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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +143 -75
  3. data/lib/velvetkit/version.rb +1 -1
  4. data/registry/app/components/ui/checkbox.html.erb +2 -0
  5. data/registry/app/components/ui/checkbox.rb +11 -5
  6. data/registry/app/components/ui/collapsible.html.erb +7 -0
  7. data/registry/app/components/ui/collapsible.rb +67 -0
  8. data/registry/app/components/ui/date_picker.html.erb +4 -1
  9. data/registry/app/components/ui/date_picker.rb +10 -2
  10. data/registry/app/components/ui/form_field/messages.html.erb +6 -0
  11. data/registry/app/components/ui/form_field/messages.rb +11 -0
  12. data/registry/app/components/ui/form_field.html.erb +9 -0
  13. data/registry/app/components/ui/form_field.rb +105 -0
  14. data/registry/app/components/ui/input.html.erb +1 -8
  15. data/registry/app/components/ui/input.rb +12 -36
  16. data/registry/app/components/ui/input_group/addon.rb +4 -1
  17. data/registry/app/components/ui/input_group/field.rb +4 -1
  18. data/registry/app/components/ui/input_group.rb +12 -10
  19. data/registry/app/components/ui/native_select.html.erb +2 -9
  20. data/registry/app/components/ui/native_select.rb +17 -31
  21. data/registry/app/components/ui/password_input.html.erb +1 -8
  22. data/registry/app/components/ui/password_input.rb +9 -27
  23. data/registry/app/components/ui/select.html.erb +4 -0
  24. data/registry/app/components/ui/select.rb +12 -29
  25. data/registry/app/components/ui/table_of_contents/item.html.erb +8 -0
  26. data/registry/app/components/ui/table_of_contents/item.rb +41 -0
  27. data/registry/app/components/ui/table_of_contents.html.erb +13 -0
  28. data/registry/app/components/ui/table_of_contents.rb +58 -0
  29. data/registry/app/components/ui/textarea.html.erb +2 -0
  30. data/registry/app/components/ui/textarea.rb +101 -0
  31. data/registry/app/javascript/controllers/ui/table_of_contents_controller.ts +179 -0
  32. data/registry/components.yml +9 -1
  33. data/registry/config/locales/velvetkit.en.yml +2 -0
  34. data/registry/test/components/previews/ui/collapsible_preview/default.html.erb +18 -0
  35. data/registry/test/components/previews/ui/collapsible_preview/show_more.html.erb +7 -0
  36. data/registry/test/components/previews/ui/collapsible_preview.rb +19 -0
  37. data/registry/test/components/previews/ui/form_field_preview/custom_control.html.erb +6 -0
  38. data/registry/test/components/previews/ui/form_field_preview/default.html.erb +7 -0
  39. data/registry/test/components/previews/ui/form_field_preview/model_errors.html.erb +11 -0
  40. data/registry/test/components/previews/ui/form_field_preview.rb +12 -0
  41. data/registry/test/components/previews/ui/input_preview/hint_and_error.html.erb +4 -0
  42. data/registry/test/components/previews/ui/input_preview.rb +3 -0
  43. data/registry/test/components/previews/ui/sidebar_preview/_brand.html.erb +4 -4
  44. data/registry/test/components/previews/ui/sidebar_preview/with_dropdown_header.html.erb +1 -1
  45. data/registry/test/components/previews/ui/table_of_contents_preview/default.html.erb +41 -0
  46. data/registry/test/components/previews/ui/table_of_contents_preview/flat.html.erb +17 -0
  47. data/registry/test/components/previews/ui/table_of_contents_preview.rb +9 -0
  48. data/registry/test/components/previews/ui/textarea_preview/autogrow.html.erb +4 -0
  49. data/registry/test/components/previews/ui/textarea_preview/default.html.erb +3 -0
  50. data/registry/test/components/previews/ui/textarea_preview/sizes.html.erb +5 -0
  51. data/registry/test/components/previews/ui/textarea_preview/states.html.erb +7 -0
  52. data/registry/test/components/previews/ui/textarea_preview.rb +33 -0
  53. data/registry/test/components/ui/checkbox_test.rb +24 -0
  54. data/registry/test/components/ui/collapsible_test.rb +71 -0
  55. data/registry/test/components/ui/date_picker_test.rb +14 -0
  56. data/registry/test/components/ui/form_field/messages_test.rb +19 -0
  57. data/registry/test/components/ui/form_field_test.rb +122 -0
  58. data/registry/test/components/ui/input_group_test.rb +30 -2
  59. data/registry/test/components/ui/input_test.rb +37 -0
  60. data/registry/test/components/ui/native_select_test.rb +25 -0
  61. data/registry/test/components/ui/password_input_test.rb +9 -0
  62. data/registry/test/components/ui/select_test.rb +14 -0
  63. data/registry/test/components/ui/table_of_contents/item_test.rb +38 -0
  64. data/registry/test/components/ui/table_of_contents_test.rb +120 -0
  65. data/registry/test/components/ui/textarea_test.rb +178 -0
  66. metadata +39 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9f176f98bff0013d64b619054845556e1323b6c285e374297238d9e13896c444
4
- data.tar.gz: 37703180175c2b19f0630f92801e5831843531a97b1e1dfe3cd9f47f5995e6ad
3
+ metadata.gz: 783fb129530172b7add0378661a6542b52014dfb663b558b625aa5fef0489bdd
4
+ data.tar.gz: 9cb5e73f8e4ae8a94b3be1cb7ddbed5e8dbfd9ff36d9fbdddbfed317ba576769
5
5
  SHA512:
6
- metadata.gz: e4432fd886028e08a31f840bebc3e9128fa84f19da9ab4d8bb1f40e78f623580279228c248bd21f98e6d9e1fe56adaa951cdadc508958386b0db4cdd652d53c6
7
- data.tar.gz: b2be00dac1a8d5e8682b0ae02dae3670511644c92703397ad153a8934b5843087c9dfb0c88c4ca58ed81b36dbb5b441ed6021d69db2a3a45060712bac14884e9
6
+ metadata.gz: 3b399bf5d193fa63a3b92f7e686622761c56b453e14137bb6b8783ee95d703103ab6ca455dcade1f5695e868c44a8f6faa54eb652e0c4a141d0a6b4895553ed8
7
+ data.tar.gz: 4a7b4fb856f8645f7ed4ece0f8eeb5de9228d71ced91ae9ecd908814b51a59c8f1b9744bc40f99ca3af2443fb894014bdc548e7cc23074ec59b3ecb652fee1b5
data/README.md CHANGED
@@ -1,15 +1,67 @@
1
- # VelvetKit
2
-
3
- Accessible UI components for Rails, built on [ViewComponent](https://viewcomponent.org),
4
- Tailwind CSS 4 and Stimulus. Website: **https://velvetkit.com**
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, under whatever module name you choose, and you can
9
- change them however you like.
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
- ## Requirements
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
- ## Install
29
-
30
- ```ruby
31
- # Gemfile
32
- gem "velvetkit", group: :development
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
- ## Add components
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
- ## AI agents
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
- ## Namespacing
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
- ## Theming
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
- ## Developing VelvetKit
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
- ## The docs site (velvetkit.com)
136
-
137
- `site/` is a Rails app that serves https://velvetkit.com. It installs the kit
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
- ```bash
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
- **Dokploy:** create an Application from this repository with build type
165
- **Dockerfile**. The defaults fit: Dockerfile path `Dockerfile`, context `.`.
166
- Then:
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
- - Environment:
169
- - `SECRET_KEY_BASE`, required: generate one with `openssl rand -hex 64`.
170
- - `SITE_URL`, if the site isn't served at https://velvetkit.com.
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
- **Kamal:** fill in the TODOs in `site/config/deploy.yml`, then run
176
- `bin/kamal deploy` from `site/`. It uses the same Dockerfile.
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
- ### SEO and AI
257
+ ## Licence
186
258
 
187
- Every page has its own title, description, canonical URL and Open Graph
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
- ## License
261
+ <br>
196
262
 
197
- MIT
263
+ <div align="center">
264
+ <sub>Built by <a href="https://kitforge.tech">KitForge</a></sub>
265
+ </div>
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Velvetkit
4
- VERSION = "0.1.0.beta"
4
+ VERSION = "0.1.0"
5
5
  end
@@ -1,3 +1,4 @@
1
+ <%= render(wrapper) do %>
1
2
  <% if label.present? %>
2
3
  <%= tag.label for: @id, class: wrapper_classes do %>
3
4
  <%= tag.span class: "relative inline-flex" do %>
@@ -16,3 +17,4 @@
16
17
  <% end %>
17
18
  <% end %>
18
19
  <% end %>
20
+ <% end %>
@@ -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: false, disabled: false,
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
- @id = @html_attrs[:id] || ("ui-cb-#{SecureRandom.hex(4)}" if label?)
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 @error
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" => @error },
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=\"#{input_id}-hint\"".html_safe if render_hint? %>
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: false, disabled: false, hint: true,
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
- @error && "border-destructive"
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
- <% if render_label? %>
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 %>