velvetkit 0.1.0.beta → 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 +4 -4
- data/README.md +143 -75
- data/lib/velvetkit/version.rb +1 -1
- data/registry/app/components/ui/checkbox.html.erb +2 -0
- data/registry/app/components/ui/checkbox.rb +11 -5
- data/registry/app/components/ui/collapsible.html.erb +7 -0
- data/registry/app/components/ui/collapsible.rb +67 -0
- data/registry/app/components/ui/date_picker.html.erb +4 -1
- data/registry/app/components/ui/date_picker.rb +10 -2
- data/registry/app/components/ui/form_field/messages.html.erb +6 -0
- data/registry/app/components/ui/form_field/messages.rb +11 -0
- data/registry/app/components/ui/form_field.html.erb +9 -0
- data/registry/app/components/ui/form_field.rb +105 -0
- data/registry/app/components/ui/input.html.erb +1 -8
- data/registry/app/components/ui/input.rb +12 -36
- data/registry/app/components/ui/input_group/addon.rb +4 -1
- data/registry/app/components/ui/input_group/field.rb +4 -1
- data/registry/app/components/ui/input_group.rb +12 -10
- data/registry/app/components/ui/native_select.html.erb +2 -9
- data/registry/app/components/ui/native_select.rb +17 -31
- data/registry/app/components/ui/password_input.html.erb +1 -8
- data/registry/app/components/ui/password_input.rb +9 -27
- data/registry/app/components/ui/select.html.erb +4 -0
- data/registry/app/components/ui/select.rb +12 -29
- data/registry/app/components/ui/table_of_contents/item.html.erb +8 -0
- data/registry/app/components/ui/table_of_contents/item.rb +41 -0
- data/registry/app/components/ui/table_of_contents.html.erb +13 -0
- data/registry/app/components/ui/table_of_contents.rb +58 -0
- data/registry/app/components/ui/textarea.html.erb +2 -0
- data/registry/app/components/ui/textarea.rb +101 -0
- data/registry/app/javascript/controllers/ui/table_of_contents_controller.ts +179 -0
- data/registry/components.yml +9 -1
- data/registry/config/locales/velvetkit.en.yml +2 -0
- data/registry/test/components/previews/ui/collapsible_preview/default.html.erb +18 -0
- data/registry/test/components/previews/ui/collapsible_preview/show_more.html.erb +7 -0
- data/registry/test/components/previews/ui/collapsible_preview.rb +19 -0
- data/registry/test/components/previews/ui/form_field_preview/custom_control.html.erb +6 -0
- data/registry/test/components/previews/ui/form_field_preview/default.html.erb +7 -0
- data/registry/test/components/previews/ui/form_field_preview/model_errors.html.erb +11 -0
- data/registry/test/components/previews/ui/form_field_preview.rb +12 -0
- data/registry/test/components/previews/ui/input_preview/hint_and_error.html.erb +4 -0
- data/registry/test/components/previews/ui/input_preview.rb +3 -0
- data/registry/test/components/previews/ui/sidebar_preview/_brand.html.erb +4 -4
- data/registry/test/components/previews/ui/sidebar_preview/with_dropdown_header.html.erb +1 -1
- data/registry/test/components/previews/ui/table_of_contents_preview/default.html.erb +41 -0
- data/registry/test/components/previews/ui/table_of_contents_preview/flat.html.erb +17 -0
- data/registry/test/components/previews/ui/table_of_contents_preview.rb +9 -0
- data/registry/test/components/previews/ui/textarea_preview/autogrow.html.erb +4 -0
- data/registry/test/components/previews/ui/textarea_preview/default.html.erb +3 -0
- data/registry/test/components/previews/ui/textarea_preview/sizes.html.erb +5 -0
- data/registry/test/components/previews/ui/textarea_preview/states.html.erb +7 -0
- data/registry/test/components/previews/ui/textarea_preview.rb +33 -0
- data/registry/test/components/ui/checkbox_test.rb +24 -0
- data/registry/test/components/ui/collapsible_test.rb +71 -0
- data/registry/test/components/ui/date_picker_test.rb +14 -0
- data/registry/test/components/ui/form_field/messages_test.rb +19 -0
- data/registry/test/components/ui/form_field_test.rb +122 -0
- data/registry/test/components/ui/input_group_test.rb +30 -2
- data/registry/test/components/ui/input_test.rb +37 -0
- data/registry/test/components/ui/native_select_test.rb +25 -0
- data/registry/test/components/ui/password_input_test.rb +9 -0
- data/registry/test/components/ui/select_test.rb +14 -0
- data/registry/test/components/ui/table_of_contents/item_test.rb +38 -0
- data/registry/test/components/ui/table_of_contents_test.rb +120 -0
- data/registry/test/components/ui/textarea_test.rb +178 -0
- metadata +39 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 783fb129530172b7add0378661a6542b52014dfb663b558b625aa5fef0489bdd
|
|
4
|
+
data.tar.gz: 9cb5e73f8e4ae8a94b3be1cb7ddbed5e8dbfd9ff36d9fbdddbfed317ba576769
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3b399bf5d193fa63a3b92f7e686622761c56b453e14137bb6b8783ee95d703103ab6ca455dcade1f5695e868c44a8f6faa54eb652e0c4a141d0a6b4895553ed8
|
|
7
|
+
data.tar.gz: 4a7b4fb856f8645f7ed4ece0f8eeb5de9228d71ced91ae9ecd908814b51a59c8f1b9744bc40f99ca3af2443fb894014bdc548e7cc23074ec59b3ecb652fee1b5
|
data/README.md
CHANGED
|
@@ -1,15 +1,67 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
<div align="center">
|
|
2
|
+
<!-- One image on its own dark tile, so it reads in light and dark themes alike. -->
|
|
3
|
+
<a href="https://velvetkit.com"><img alt="VelvetKit by KitForge" src=".github/assets/readme-logo.svg" width="600"></a>
|
|
4
|
+
|
|
5
|
+
<p><strong>Rails UI components you own.</strong><br>
|
|
6
|
+
Accessible ViewComponents styled with Tailwind CSS 4 and wired up with Stimulus,<br>
|
|
7
|
+
copied into your app one at a time, under your namespace.</p>
|
|
8
|
+
|
|
9
|
+
<p>
|
|
10
|
+
<a href="https://rubygems.org/gems/velvetkit"><img alt="Gem version" src="https://img.shields.io/gem/v/velvetkit?include_prereleases&color=4D1A7F"></a>
|
|
11
|
+
<a href="https://github.com/kitforge-tech/velvetkit/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/kitforge-tech/velvetkit/actions/workflows/ci.yml/badge.svg"></a>
|
|
12
|
+
<img alt="Rails 7.1+" src="https://img.shields.io/badge/Rails-7.1%2B-CC0000?logo=rubyonrails&logoColor=white">
|
|
13
|
+
<img alt="Tailwind CSS 4" src="https://img.shields.io/badge/Tailwind_CSS-4-38BDF8?logo=tailwindcss&logoColor=white">
|
|
14
|
+
<a href="LICENSE.txt"><img alt="MIT licence" src="https://img.shields.io/badge/licence-MIT-blue"></a>
|
|
15
|
+
</p>
|
|
16
|
+
|
|
17
|
+
<p>
|
|
18
|
+
<a href="https://velvetkit.com"><strong>Website</strong></a> ·
|
|
19
|
+
<a href="https://velvetkit.com/docs">Docs</a> ·
|
|
20
|
+
<a href="https://velvetkit.com/docs/components">Components</a> ·
|
|
21
|
+
<a href="https://velvetkit.com/docs/ai">Build with AI</a> ·
|
|
22
|
+
<a href="CHANGELOG.md">Changelog</a>
|
|
23
|
+
</p>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
<br>
|
|
27
|
+
|
|
28
|
+
<a href="https://velvetkit.com">
|
|
29
|
+
<img alt="The velvetkit.com home page: a sign-in form, a team list, notification switches and a plan picker, all built from VelvetKit components" src="https://github.com/user-attachments/assets/ffafd932-34d5-4dfe-b314-ca3692fc54bf">
|
|
30
|
+
</a>
|
|
31
|
+
|
|
32
|
+
## Why VelvetKit
|
|
5
33
|
|
|
6
34
|
VelvetKit doesn't load a component library at runtime. A generator copies the
|
|
7
35
|
components you ask for, plus the ones they depend on, into your app. After
|
|
8
|
-
that they're your code,
|
|
9
|
-
|
|
36
|
+
that they're your code, and you can change them however you like.
|
|
37
|
+
|
|
38
|
+
- **Only what you use.** `velvetkit:add dialog` brings Dialog and the Button,
|
|
39
|
+
Icon and Spinner it renders, and nothing else.
|
|
40
|
+
- **Your namespace.** Components land under `UI::`, `Kit::` or any module you
|
|
41
|
+
choose, with their Stimulus identifiers, translations and DOM ids renamed to match.
|
|
42
|
+
- **Accessible by default.** Keyboard support, focus management and ARIA
|
|
43
|
+
wiring are built in, and every component ships with its Minitest tests.
|
|
44
|
+
- **Previews included.** Each component comes with Lookbook previews, copied
|
|
45
|
+
alongside it.
|
|
46
|
+
- **Themeable.** Everything styles itself from CSS variables, with dark mode
|
|
47
|
+
and no flash on load.
|
|
48
|
+
- **Ready for AI agents.** Your coding agent gets a reference of the
|
|
49
|
+
components you've installed, so it uses them instead of inventing new ones.
|
|
50
|
+
|
|
51
|
+
## Quick start
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
# Gemfile
|
|
55
|
+
gem "velvetkit", group: :development
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
bundle install
|
|
60
|
+
bin/rails generate velvetkit:install # components under UI::
|
|
61
|
+
bin/rails generate velvetkit:add dialog # Dialog, and what it uses
|
|
62
|
+
```
|
|
10
63
|
|
|
11
64
|
```bash
|
|
12
|
-
bin/rails generate velvetkit:add dialog
|
|
13
65
|
# create app/components/ui/base.rb
|
|
14
66
|
# create app/components/ui/icon.rb
|
|
15
67
|
# create app/components/ui/spinner.rb
|
|
@@ -18,22 +70,69 @@ bin/rails generate velvetkit:add dialog
|
|
|
18
70
|
# create app/javascript/controllers/ui/dialog_controller.ts
|
|
19
71
|
```
|
|
20
72
|
|
|
21
|
-
|
|
73
|
+
```erb
|
|
74
|
+
<%= render(UI::Button.new(variant: :outline)) { "Save draft" } %>
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Requirements
|
|
22
78
|
|
|
23
79
|
- Rails 7.1+, Ruby 3.2+
|
|
24
80
|
- Tailwind CSS 4
|
|
25
81
|
- A JavaScript bundler that compiles TypeScript (Bun or esbuild through
|
|
26
82
|
`jsbundling-rails`). The Stimulus controllers are `.ts` files.
|
|
27
83
|
|
|
28
|
-
##
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
84
|
+
## Components
|
|
85
|
+
|
|
86
|
+
38 components, each with live examples on [velvetkit.com](https://velvetkit.com/docs/components):
|
|
87
|
+
|
|
88
|
+
[Accordion](https://velvetkit.com/docs/components/accordion) ·
|
|
89
|
+
[Alert](https://velvetkit.com/docs/components/alert) ·
|
|
90
|
+
[Avatar](https://velvetkit.com/docs/components/avatar) ·
|
|
91
|
+
[AvatarGroup](https://velvetkit.com/docs/components/avatar_group) ·
|
|
92
|
+
[Badge](https://velvetkit.com/docs/components/badge) ·
|
|
93
|
+
[Banner](https://velvetkit.com/docs/components/banner) ·
|
|
94
|
+
[Breadcrumb](https://velvetkit.com/docs/components/breadcrumb) ·
|
|
95
|
+
[Button](https://velvetkit.com/docs/components/button) ·
|
|
96
|
+
[ButtonTo](https://velvetkit.com/docs/components/button_to) ·
|
|
97
|
+
[Card](https://velvetkit.com/docs/components/card) ·
|
|
98
|
+
[Checkbox](https://velvetkit.com/docs/components/checkbox) ·
|
|
99
|
+
[Collapsible](https://velvetkit.com/docs/components/collapsible) ·
|
|
100
|
+
[DataGrid](https://velvetkit.com/docs/components/data_grid) ·
|
|
101
|
+
[DatePicker](https://velvetkit.com/docs/components/date_picker) ·
|
|
102
|
+
[Dialog](https://velvetkit.com/docs/components/dialog) ·
|
|
103
|
+
[Divider](https://velvetkit.com/docs/components/divider) ·
|
|
104
|
+
[FormField](https://velvetkit.com/docs/components/form_field) ·
|
|
105
|
+
[Icon](https://velvetkit.com/docs/components/icon) ·
|
|
106
|
+
[Image](https://velvetkit.com/docs/components/image) ·
|
|
107
|
+
[Input](https://velvetkit.com/docs/components/input) ·
|
|
108
|
+
[InputGroup](https://velvetkit.com/docs/components/input_group) ·
|
|
109
|
+
[Label](https://velvetkit.com/docs/components/label) ·
|
|
110
|
+
[Link](https://velvetkit.com/docs/components/link) ·
|
|
111
|
+
[Menu](https://velvetkit.com/docs/components/menu) ·
|
|
112
|
+
[NativeSelect](https://velvetkit.com/docs/components/native_select) ·
|
|
113
|
+
[Pagination](https://velvetkit.com/docs/components/pagination) ·
|
|
114
|
+
[PasswordInput](https://velvetkit.com/docs/components/password_input) ·
|
|
115
|
+
[Popover](https://velvetkit.com/docs/components/popover) ·
|
|
116
|
+
[RadioGroup](https://velvetkit.com/docs/components/radio_group) ·
|
|
117
|
+
[Select](https://velvetkit.com/docs/components/select) ·
|
|
118
|
+
[Sheet](https://velvetkit.com/docs/components/sheet) ·
|
|
119
|
+
[Sidebar](https://velvetkit.com/docs/components/sidebar) ·
|
|
120
|
+
[Spinner](https://velvetkit.com/docs/components/spinner) ·
|
|
121
|
+
[Switch](https://velvetkit.com/docs/components/switch) ·
|
|
122
|
+
[Table](https://velvetkit.com/docs/components/table) ·
|
|
123
|
+
[TableOfContents](https://velvetkit.com/docs/components/table_of_contents) ·
|
|
124
|
+
[Tabs](https://velvetkit.com/docs/components/tabs) ·
|
|
125
|
+
[Text](https://velvetkit.com/docs/components/text) ·
|
|
126
|
+
[Textarea](https://velvetkit.com/docs/components/textarea) ·
|
|
127
|
+
[ThemeToggle](https://velvetkit.com/docs/components/theme_toggle) ·
|
|
128
|
+
[Tooltip](https://velvetkit.com/docs/components/tooltip) ·
|
|
129
|
+
[Typography](https://velvetkit.com/docs/components/typography)
|
|
130
|
+
|
|
131
|
+
## Usage
|
|
132
|
+
|
|
133
|
+
### Install
|
|
34
134
|
|
|
35
135
|
```bash
|
|
36
|
-
bundle install
|
|
37
136
|
bin/rails generate velvetkit:install # components under UI::
|
|
38
137
|
bin/rails generate velvetkit:install --namespace=Kit # or under Kit::
|
|
39
138
|
```
|
|
@@ -50,11 +149,12 @@ bin/rails generate velvetkit:install --namespace=Kit # or under Kit::
|
|
|
50
149
|
- installs `@hotwired/stimulus`, `lucide` and `tw-animate-css` with your
|
|
51
150
|
package manager, which it picks from your lockfile
|
|
52
151
|
|
|
53
|
-
|
|
152
|
+
### Add components
|
|
54
153
|
|
|
55
154
|
```bash
|
|
56
155
|
bin/rails generate velvetkit:add # list what's available
|
|
57
156
|
bin/rails generate velvetkit:add date_picker menu # add these and what they use
|
|
157
|
+
bin/rails generate velvetkit:add --all # everything
|
|
58
158
|
```
|
|
59
159
|
|
|
60
160
|
`add` does the following for each component:
|
|
@@ -73,7 +173,7 @@ Options: `--namespace=Kit`, `--skip-tests`, `--skip-previews`,
|
|
|
73
173
|
`--skip-packages` (list the packages instead of installing them), and the usual
|
|
74
174
|
`--force`/`--skip` for files that already exist.
|
|
75
175
|
|
|
76
|
-
|
|
176
|
+
### AI agents
|
|
77
177
|
|
|
78
178
|
`install` also writes three files so coding agents use your components
|
|
79
179
|
instead of inventing new ones:
|
|
@@ -90,7 +190,7 @@ Run `bin/rails generate velvetkit:ai` to regenerate them, or install with
|
|
|
90
190
|
[llms.txt](https://velvetkit.com/llms.txt), and every component page has a
|
|
91
191
|
Markdown version.
|
|
92
192
|
|
|
93
|
-
|
|
193
|
+
### Namespacing
|
|
94
194
|
|
|
95
195
|
Every component is written once, against `UI`. The generators rewrite each file
|
|
96
196
|
as they copy it, so with `--namespace=Kit`:
|
|
@@ -105,7 +205,7 @@ as they copy it, so with `--namespace=Kit`:
|
|
|
105
205
|
|
|
106
206
|
The namespace must be a single constant name, like `UI`, `Kit` or `VelvetKit`.
|
|
107
207
|
|
|
108
|
-
|
|
208
|
+
### Theming
|
|
109
209
|
|
|
110
210
|
Components style themselves from the CSS variables in `velvetkit.css`
|
|
111
211
|
(`--primary`, `--radius`, `--sidebar`, …), with a `.dark` set alongside. To
|
|
@@ -118,13 +218,18 @@ partial in your layout's `<head>`:
|
|
|
118
218
|
<%= render "layouts/theme_script" %>
|
|
119
219
|
```
|
|
120
220
|
|
|
121
|
-
##
|
|
221
|
+
## Contributing
|
|
222
|
+
|
|
223
|
+
Bug reports and pull requests are welcome on
|
|
224
|
+
[GitHub](https://github.com/kitforge-tech/velvetkit/issues).
|
|
122
225
|
|
|
123
226
|
```bash
|
|
124
227
|
bundle install
|
|
228
|
+
bundle exec rake test # all three suites below
|
|
125
229
|
bundle exec rake test:gem # namespace rewriting, registry, generators
|
|
126
230
|
bundle exec rake test:components # every component, from the registry, as UI::
|
|
127
231
|
bundle exec rake test:namespaced # every component copied out as Kit::, then tested
|
|
232
|
+
bundle exec rubocop
|
|
128
233
|
```
|
|
129
234
|
|
|
130
235
|
`registry/` holds the components, laid out like a Rails app.
|
|
@@ -132,66 +237,29 @@ bundle exec rake test:namespaced # every component copied out as Kit::, then te
|
|
|
132
237
|
(descriptions, package and gem versions). Dependencies, Stimulus controllers
|
|
133
238
|
and npm imports are read from the source, so they can't drift.
|
|
134
239
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
from `registry/` the way any app would, with `bin/sync-kit` (it runs
|
|
139
|
-
`velvetkit:install` and `velvetkit:add --all`). The copied files are
|
|
140
|
-
gitignored, so the registry is the only source.
|
|
141
|
-
|
|
142
|
-
The pages build themselves from the registry:
|
|
143
|
-
|
|
144
|
-
- each component page renders that component's Lookbook preview templates
|
|
145
|
-
live, next to their source
|
|
146
|
-
- the "Comes with" list comes from the same dependency scan `add` uses
|
|
147
|
-
- the arguments and slots are read from the component's Ruby
|
|
148
|
-
|
|
149
|
-
To document a component, write or improve its preview. The site picks it up.
|
|
240
|
+
To document a component, write or improve its Lookbook preview: the docs
|
|
241
|
+
site renders previews live. `site/` is the Rails app behind velvetkit.com;
|
|
242
|
+
see [site/README.md](site/README.md) to run and deploy it.
|
|
150
243
|
|
|
151
|
-
|
|
152
|
-
cd site
|
|
153
|
-
bun install && bundle install
|
|
154
|
-
bin/dev # sync the kit, then run Rails, JS and CSS watchers
|
|
155
|
-
bin/rails test # every page renders, every live example, SEO and AI endpoints
|
|
156
|
-
bin/ux-check # real clicks, keys and clipboard in headless Chrome (site on :3000)
|
|
157
|
-
```
|
|
158
|
-
|
|
159
|
-
### Deploying
|
|
160
|
-
|
|
161
|
-
The image is built from the `Dockerfile` at the repository root. It needs
|
|
162
|
-
the gem and the registry as well as `site/`.
|
|
244
|
+
### Releasing
|
|
163
245
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
246
|
+
1. Bump `VERSION` in `lib/velvetkit/version.rb` and add a `CHANGELOG.md`
|
|
247
|
+
entry, in a pull request.
|
|
248
|
+
2. Once it's merged, tag the merge commit and push the tag:
|
|
167
249
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
- Domains: `velvetkit.com` on container port **80**, with HTTPS on. Traefik
|
|
172
|
-
terminates TLS and the app trusts it (`assume_ssl`).
|
|
173
|
-
- Health check: the image declares one against `GET /up`.
|
|
250
|
+
```bash
|
|
251
|
+
git tag v0.1.1.beta && git push origin v0.1.1.beta
|
|
252
|
+
```
|
|
174
253
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
**Anywhere else:**
|
|
179
|
-
|
|
180
|
-
```bash
|
|
181
|
-
docker build -t velvetkit-site .
|
|
182
|
-
docker run -p 8080:80 -e SECRET_KEY_BASE=$(openssl rand -hex 64) -e FORCE_SSL=false velvetkit-site
|
|
183
|
-
```
|
|
254
|
+
The [release workflow](.github/workflows/release.yml) checks the tag matches
|
|
255
|
+
the version, runs the tests and publishes the gem to RubyGems.
|
|
184
256
|
|
|
185
|
-
|
|
257
|
+
## Licence
|
|
186
258
|
|
|
187
|
-
|
|
188
|
-
and Twitter cards, using `public/og.png`. The home page carries JSON-LD for
|
|
189
|
-
`SoftwareApplication` and `WebSite`; docs pages carry `TechArticle` and
|
|
190
|
-
`BreadcrumbList`. `/sitemap.xml` and `/robots.txt` are built from the
|
|
191
|
-
routes, and robots explicitly welcomes AI crawlers and points them at
|
|
192
|
-
`/llms.txt`. Component pages link their Markdown version with
|
|
193
|
-
`rel="alternate"`.
|
|
259
|
+
VelvetKit is released under the [MIT licence](LICENSE.txt).
|
|
194
260
|
|
|
195
|
-
|
|
261
|
+
<br>
|
|
196
262
|
|
|
197
|
-
|
|
263
|
+
<div align="center">
|
|
264
|
+
<sub>Built by <a href="https://kitforge.tech">KitForge</a></sub>
|
|
265
|
+
</div>
|
data/lib/velvetkit/version.rb
CHANGED
|
@@ -20,9 +20,9 @@ class UI::Checkbox < UI::Base
|
|
|
20
20
|
)
|
|
21
21
|
|
|
22
22
|
def initialize(name: nil, value: "1", unchecked_value: "0", checked: false,
|
|
23
|
-
label: nil, size: :default,
|
|
23
|
+
label: nil, hint: nil, size: :default,
|
|
24
24
|
form: nil, field: nil,
|
|
25
|
-
required: false, error:
|
|
25
|
+
required: false, error: nil, disabled: false,
|
|
26
26
|
class: nil, **html_attrs)
|
|
27
27
|
@form = form
|
|
28
28
|
@field = field
|
|
@@ -37,10 +37,15 @@ class UI::Checkbox < UI::Base
|
|
|
37
37
|
@disabled = disabled
|
|
38
38
|
@user_class = binding.local_variable_get(:class)
|
|
39
39
|
@html_attrs = html_attrs
|
|
40
|
-
|
|
40
|
+
# The label sits beside the box, so FormField only adds the messages below.
|
|
41
|
+
@wrapper = UI::FormField.new(label: false, hint: hint, error: error, form: form, field: field)
|
|
42
|
+
@id = @html_attrs[:id] || ("ui-cb-#{SecureRandom.hex(4)}" if label? || @wrapper.wrap?)
|
|
41
43
|
@html_attrs[:id] = @id if @id
|
|
44
|
+
@wrapper.for = @id
|
|
42
45
|
end
|
|
43
46
|
|
|
47
|
+
attr_reader :wrapper
|
|
48
|
+
|
|
44
49
|
def form_field?
|
|
45
50
|
@form.present? && @field.present?
|
|
46
51
|
end
|
|
@@ -87,7 +92,8 @@ class UI::Checkbox < UI::Base
|
|
|
87
92
|
opts[:checked] = true if @checked
|
|
88
93
|
opts[:disabled] = true if @disabled
|
|
89
94
|
opts[:required] = true if @required
|
|
90
|
-
opts[:"aria-invalid"] = "true" if
|
|
95
|
+
opts[:"aria-invalid"] = "true" if wrapper.error?
|
|
96
|
+
opts[:"aria-describedby"] = wrapper.describedby if wrapper.describedby
|
|
91
97
|
opts[:class] = classes
|
|
92
98
|
# Add accessible name when no visual label is present
|
|
93
99
|
if !label? && opts[:"aria-label"].blank?
|
|
@@ -107,7 +113,7 @@ class UI::Checkbox < UI::Base
|
|
|
107
113
|
def classes
|
|
108
114
|
cn(
|
|
109
115
|
variant_classes(size: @size),
|
|
110
|
-
{ "border-destructive aria-invalid:focus:ring-destructive" =>
|
|
116
|
+
{ "border-destructive aria-invalid:focus:ring-destructive" => wrapper.error? },
|
|
111
117
|
@user_class
|
|
112
118
|
)
|
|
113
119
|
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<%= tag.details(**details_options) do %>
|
|
2
|
+
<%= tag.summary(class: trigger_classes) do %>
|
|
3
|
+
<span class="min-w-0 flex-1 truncate"><%= trigger %></span>
|
|
4
|
+
<%= render(UI::Icon.new(name: "chevron-right", size: :sm, class: "shrink-0 transition-transform duration-200 group-open/collapsible:rotate-90 motion-reduce:transition-none", aria: { hidden: true })) %>
|
|
5
|
+
<% end %>
|
|
6
|
+
<%= tag.div(class: content_classes) { content } %>
|
|
7
|
+
<% end %>
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# A section that opens and closes under its trigger: a tree of links in a
|
|
4
|
+
# sidebar, "Show more" details, an advanced-settings block.
|
|
5
|
+
#
|
|
6
|
+
# Built on <details> and <summary>, so it works before any JavaScript loads,
|
|
7
|
+
# Enter and Space toggle it, screen readers announce it as expanded or
|
|
8
|
+
# collapsed, and find-in-page opens it to show a match. Browsers that can
|
|
9
|
+
# animate to auto height slide it open; the rest open it at once.
|
|
10
|
+
#
|
|
11
|
+
# <%= render(UI::Collapsible.new(open: current_page_is_a_form?)) do |c| %>
|
|
12
|
+
# <% c.with_trigger { "Forms" } %>
|
|
13
|
+
# <%= link_to "Input", input_path %>
|
|
14
|
+
# <% end %>
|
|
15
|
+
#
|
|
16
|
+
# Collapsibles nest, so a trigger inside the content makes a deeper branch.
|
|
17
|
+
class UI::Collapsible < UI::Base
|
|
18
|
+
variants(
|
|
19
|
+
base: "flex w-full cursor-pointer list-none items-center gap-2 rounded-md " \
|
|
20
|
+
"[&::-webkit-details-marker]:hidden " \
|
|
21
|
+
"#{UI::Base::FOCUS_RING}",
|
|
22
|
+
variants: {
|
|
23
|
+
size: {
|
|
24
|
+
sm: "px-2 py-1 text-xs",
|
|
25
|
+
default: "px-3 py-1.5 text-sm",
|
|
26
|
+
lg: "px-3 py-2 text-base"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
defaults: { size: :default }
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
renders_one :trigger
|
|
33
|
+
|
|
34
|
+
def initialize(open: false, size: :default, trigger_class: nil, content_class: nil,
|
|
35
|
+
class: nil, **html_attrs)
|
|
36
|
+
@open = open
|
|
37
|
+
@size = size
|
|
38
|
+
@trigger_class = trigger_class
|
|
39
|
+
@content_class = content_class
|
|
40
|
+
@user_class = binding.local_variable_get(:class)
|
|
41
|
+
@html_attrs = html_attrs
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def details_options
|
|
45
|
+
opts = @html_attrs.dup
|
|
46
|
+
opts[:open] = true if @open
|
|
47
|
+
# interpolate-size lets the content's height transition to and from auto.
|
|
48
|
+
opts[:class] = cn(
|
|
49
|
+
"group/collapsible [interpolate-size:allow-keywords] " \
|
|
50
|
+
"[&::details-content]:[block-size:0] [&::details-content]:overflow-hidden " \
|
|
51
|
+
"[&::details-content]:transition-[block-size,content-visibility] " \
|
|
52
|
+
"[&::details-content]:duration-200 [&::details-content]:[transition-behavior:allow-discrete] " \
|
|
53
|
+
"open:[&::details-content]:[block-size:auto] " \
|
|
54
|
+
"motion-reduce:[&::details-content]:transition-none",
|
|
55
|
+
@user_class
|
|
56
|
+
)
|
|
57
|
+
opts
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def trigger_classes
|
|
61
|
+
cn(variant_classes(size: @size), @trigger_class)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def content_classes
|
|
65
|
+
cn("pt-1", @content_class)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
placeholder="<%= placeholder %>" class="<%= input_classes %>"
|
|
18
18
|
value="<%= typed_value %>"
|
|
19
19
|
<%= "required" if @required %> <%= "disabled" if @disabled %>
|
|
20
|
-
<%= "aria-describedby
|
|
20
|
+
<%= tag.attributes("aria-describedby": describedby, "aria-invalid": ("true" if wrapper.error?)) %>
|
|
21
21
|
data-ui--date-picker-target="input"
|
|
22
22
|
data-action="input->ui--date-picker#typed">
|
|
23
23
|
|
|
@@ -41,6 +41,9 @@
|
|
|
41
41
|
<%= t("ui.date_picker.hint", pattern: placeholder) %>
|
|
42
42
|
</p>
|
|
43
43
|
<% end %>
|
|
44
|
+
<% if wrapper.error_message %>
|
|
45
|
+
<%= tag.p(wrapper.error_message, id: wrapper.error_id, class: "mt-1 text-xs text-destructive") %>
|
|
46
|
+
<% end %>
|
|
44
47
|
|
|
45
48
|
<div id="<%= panel_id %>" role="dialog" hidden
|
|
46
49
|
class="absolute z-30 mt-1 rounded-md border bg-popover p-3 text-popover-foreground shadow-md"
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
# <%= render(UI::DatePicker.new(name: "event[date]", value: @date, label: "Date")) %>
|
|
27
27
|
class UI::DatePicker < UI::Base
|
|
28
28
|
def initialize(name: nil, value: nil, form: nil, field: nil, label: nil,
|
|
29
|
-
required: false, error:
|
|
29
|
+
required: false, error: nil, disabled: false, hint: true,
|
|
30
30
|
min: nil, max: nil, class: nil, **html_attrs)
|
|
31
31
|
@name = name
|
|
32
32
|
@value = value
|
|
@@ -44,8 +44,12 @@ class UI::DatePicker < UI::Base
|
|
|
44
44
|
@max = max
|
|
45
45
|
@user_class = binding.local_variable_get(:class)
|
|
46
46
|
@html_attrs = html_attrs
|
|
47
|
+
# Only for the error: the label and the format hint are laid out here.
|
|
48
|
+
@wrapper = UI::FormField.new(label: false, error: error, form: form, field: field, for: input_id)
|
|
47
49
|
end
|
|
48
50
|
|
|
51
|
+
attr_reader :wrapper
|
|
52
|
+
|
|
49
53
|
def field_name = @name || (@form && @field && "#{@form.object_name}[#{@field}]")
|
|
50
54
|
|
|
51
55
|
# The value the form submits, always ISO, whatever was typed.
|
|
@@ -65,6 +69,10 @@ class UI::DatePicker < UI::Base
|
|
|
65
69
|
def render_hint? = @hint != false
|
|
66
70
|
|
|
67
71
|
def input_id = @input_id ||= "ui-date-#{SecureRandom.hex(4)}"
|
|
72
|
+
|
|
73
|
+
def describedby
|
|
74
|
+
[ ("#{input_id}-hint" if render_hint?), (wrapper.error_id if wrapper.error_message) ].compact.join(" ").presence
|
|
75
|
+
end
|
|
68
76
|
def panel_id = "#{input_id}-panel"
|
|
69
77
|
|
|
70
78
|
def classes = cn("relative", @user_class)
|
|
@@ -78,7 +86,7 @@ class UI::DatePicker < UI::Base
|
|
|
78
86
|
# aria-invalid rather than a Ruby flag, so a date the user mistypes is
|
|
79
87
|
# marked without a round trip.
|
|
80
88
|
"aria-invalid:border-destructive aria-invalid:ring-destructive",
|
|
81
|
-
|
|
89
|
+
wrapper.error? && "border-destructive"
|
|
82
90
|
)
|
|
83
91
|
end
|
|
84
92
|
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<% if @field.hint.present? %>
|
|
2
|
+
<%= tag.p(@field.hint, id: @field.hint_id, class: "text-xs text-muted-foreground") %>
|
|
3
|
+
<% end %>
|
|
4
|
+
<% if @field.error_message.present? %>
|
|
5
|
+
<%= tag.p(@field.error_message, id: @field.error_id, class: "text-xs text-destructive") %>
|
|
6
|
+
<% end %>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The hint and error under a control, with the ids its aria-describedby names.
|
|
4
|
+
# For controls that lay out their own label, as UI::Select does.
|
|
5
|
+
class UI::FormField::Messages < UI::Base
|
|
6
|
+
def initialize(field)
|
|
7
|
+
@field = field
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def render? = @field.hint.present? || @field.error_message.present?
|
|
11
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<% if wrap? %>
|
|
2
|
+
<%= tag.div(class: classes, **@html_attrs) do %>
|
|
3
|
+
<% if render_label? %><%= render(label_component) { label_text } %><% end %>
|
|
4
|
+
<%= content %>
|
|
5
|
+
<%= render(UI::FormField::Messages.new(self)) %>
|
|
6
|
+
<% end %>
|
|
7
|
+
<% else %>
|
|
8
|
+
<%= content %>
|
|
9
|
+
<% end %>
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
# A label, a control, a hint and an error message, wired together: the label
|
|
6
|
+
# points at the control, and the control is described by the hint and error.
|
|
7
|
+
#
|
|
8
|
+
# Input, Textarea, NativeSelect and Select render through it, so they take
|
|
9
|
+
# `hint:` and `error:` directly. Wrap any other control yourself and spread
|
|
10
|
+
# `field.control_attributes` onto it:
|
|
11
|
+
#
|
|
12
|
+
# <%= render(UI::FormField.new(label: "Colour", hint: "Used for your avatar")) do |field| %>
|
|
13
|
+
# <%= tag.input(type: "color", name: "colour", **field.control_attributes) %>
|
|
14
|
+
# <% end %>
|
|
15
|
+
#
|
|
16
|
+
# `error:` takes a message, or `true` to mark the control invalid without one.
|
|
17
|
+
# Bound to a form (`form:` + `field:`), the model's errors on that attribute
|
|
18
|
+
# are the message (for `team_id`, the `team` association's too, where
|
|
19
|
+
# belongs_to puts "must exist"), and the attribute's human name is the label
|
|
20
|
+
# (`label:` overrides it, `label: false` opts out). Nothing to show means no
|
|
21
|
+
# wrapper: just the control.
|
|
22
|
+
class UI::FormField < UI::Base
|
|
23
|
+
def initialize(label: nil, hint: nil, error: nil, required: false, disabled: false,
|
|
24
|
+
label_size: :default, label_weight: :medium,
|
|
25
|
+
form: nil, field: nil, for: nil,
|
|
26
|
+
class: nil, **html_attrs)
|
|
27
|
+
@label = label
|
|
28
|
+
@hint = hint
|
|
29
|
+
@error = error
|
|
30
|
+
@required = required
|
|
31
|
+
@disabled = disabled
|
|
32
|
+
@label_size = label_size
|
|
33
|
+
@label_weight = label_weight
|
|
34
|
+
@form = form
|
|
35
|
+
@field = field
|
|
36
|
+
@for = binding.local_variable_get(:for)
|
|
37
|
+
@user_class = binding.local_variable_get(:class)
|
|
38
|
+
@html_attrs = html_attrs
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
attr_reader :hint
|
|
42
|
+
attr_writer :for
|
|
43
|
+
|
|
44
|
+
def form_field? = @form.present? && @field.present?
|
|
45
|
+
|
|
46
|
+
def wrap? = render_label? || hint.present? || error_message.present?
|
|
47
|
+
|
|
48
|
+
# The id the label points at and the messages hang off. Controls set their
|
|
49
|
+
# own (`for:`); otherwise one is made the first time it's asked for.
|
|
50
|
+
def control_id
|
|
51
|
+
@for ||= "ui-field-#{SecureRandom.hex(4)}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def hint_id = "#{control_id}-hint"
|
|
55
|
+
def error_id = "#{control_id}-error"
|
|
56
|
+
|
|
57
|
+
def error_message
|
|
58
|
+
return @error if @error.is_a?(String)
|
|
59
|
+
return if @error == false || !form_field?
|
|
60
|
+
|
|
61
|
+
errors = @form.object.respond_to?(:errors) ? @form.object.errors : nil
|
|
62
|
+
return unless errors
|
|
63
|
+
|
|
64
|
+
attributes = [ @field.to_sym, @field.to_s.delete_suffix("_id").to_sym ].uniq
|
|
65
|
+
attributes.flat_map { |attribute| errors.full_messages_for(attribute) }.uniq.to_sentence.presence
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def error? = @error == true || error_message.present?
|
|
69
|
+
|
|
70
|
+
def describedby
|
|
71
|
+
[ (hint_id if hint.present?), (error_id if error_message.present?) ].compact.join(" ").presence
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Spread onto a control you render yourself.
|
|
75
|
+
def control_attributes
|
|
76
|
+
{ id: control_id, "aria-describedby": describedby, "aria-invalid": ("true" if error?) }.compact
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def render_label?
|
|
80
|
+
return false if @label == false
|
|
81
|
+
|
|
82
|
+
@label.present? || form_field?
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def label_text
|
|
86
|
+
return @label if @label.is_a?(String) && @label.present?
|
|
87
|
+
return nil unless form_field?
|
|
88
|
+
|
|
89
|
+
object = @form.object
|
|
90
|
+
if object.respond_to?(:class) && object.class.respond_to?(:human_attribute_name)
|
|
91
|
+
object.class.human_attribute_name(@field)
|
|
92
|
+
else
|
|
93
|
+
@field.to_s.humanize
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def label_component
|
|
98
|
+
UI::Label.new(for: control_id, size: @label_size, weight: @label_weight,
|
|
99
|
+
required: @required, disabled: @disabled)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def classes
|
|
103
|
+
cn("flex flex-col gap-2 w-full", @user_class)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -1,9 +1,2 @@
|
|
|
1
1
|
<%# frozen_string_literal: true %>
|
|
2
|
-
|
|
3
|
-
<div class="flex flex-col gap-2 w-full">
|
|
4
|
-
<%= render(label_component) { label_text } %>
|
|
5
|
-
<% if form_field? %><%= @form.public_send(form_builder_method, @field, **form_field_options) %><% else %><%= tag.input(**html_options) %><% end %>
|
|
6
|
-
</div>
|
|
7
|
-
<% else %>
|
|
8
|
-
<% if form_field? %><%= @form.public_send(form_builder_method, @field, **form_field_options) %><% else %><%= tag.input(**html_options) %><% end %>
|
|
9
|
-
<% end %>
|
|
2
|
+
<%= render(wrapper) do %><% if form_field? %><%= @form.public_send(form_builder_method, @field, **form_field_options) %><% else %><%= tag.input(**html_options) %><% end %><% end %>
|