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
@@ -0,0 +1,179 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // Scrollspy: marks the link whose section was scrolled to last and slides the
4
+ // indicator beside it.
5
+ export default class extends Controller<HTMLElement> {
6
+ static targets = ['link', 'indicator', 'rail'];
7
+ static values = {
8
+ offset: { type: Number, default: 80 },
9
+ container: String,
10
+ };
11
+
12
+ declare readonly linkTargets: HTMLAnchorElement[];
13
+ declare readonly indicatorTarget: HTMLElement;
14
+ declare readonly railTarget: HTMLElement;
15
+ declare offsetValue: number;
16
+ declare containerValue: string;
17
+
18
+ private scroller: HTMLElement | Window = window;
19
+ private active: HTMLAnchorElement | null = null;
20
+ private frame = 0;
21
+ private settleFrame = 0;
22
+ private locked = false;
23
+ private unlockTimer = 0;
24
+ private resizeObserver?: ResizeObserver;
25
+
26
+ connect() {
27
+ this.scroller =
28
+ (this.containerValue && document.querySelector<HTMLElement>(this.containerValue)) || window;
29
+ this.scroller.addEventListener('scroll', this.schedule, { passive: true });
30
+ this.scroller.addEventListener('scrollend', this.unlock);
31
+ this.resizeObserver = new ResizeObserver(this.schedule);
32
+ this.resizeObserver.observe(this.element);
33
+ this.update();
34
+ // The first state is drawn without transitions (data-settling); let them
35
+ // back in once it has been painted.
36
+ this.settleFrame = requestAnimationFrame(() => {
37
+ this.settleFrame = requestAnimationFrame(() => delete this.element.dataset.settling);
38
+ });
39
+ }
40
+
41
+ disconnect() {
42
+ this.scroller.removeEventListener('scroll', this.schedule);
43
+ this.scroller.removeEventListener('scrollend', this.unlock);
44
+ this.resizeObserver?.disconnect();
45
+ cancelAnimationFrame(this.frame);
46
+ cancelAnimationFrame(this.settleFrame);
47
+ clearTimeout(this.unlockTimer);
48
+ }
49
+
50
+ // A click scrolls the section to the offset line itself: left to the browser
51
+ // (or Turbo), the jump scrolls every ancestor, the window included, and lands
52
+ // wherever scroll-margin says rather than where the section counts as reached.
53
+ // The link is marked at once and held while the scroll runs, so the
54
+ // indicator doesn't stop at every section on the way.
55
+ select(event: MouseEvent) {
56
+ if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
57
+ const link = event.currentTarget as HTMLAnchorElement;
58
+ const target = this.targetOf(link);
59
+ if (!target) return;
60
+
61
+ event.preventDefault();
62
+ this.scrollToTarget(target);
63
+ if (this.scroller instanceof Window) history.replaceState(history.state, '', link.hash);
64
+ // Keyboard focus continues from the section, as it would after a jump.
65
+ if (!target.hasAttribute('tabindex')) target.setAttribute('tabindex', '-1');
66
+ target.focus({ preventScroll: true });
67
+
68
+ this.setActive(link);
69
+ this.locked = true;
70
+ clearTimeout(this.unlockTimer);
71
+ // scrollend never fires when the section is already in place, and some
72
+ // browsers don't send it at all.
73
+ this.unlockTimer = window.setTimeout(this.unlock, 1000);
74
+ }
75
+
76
+ private unlock = () => {
77
+ this.locked = false;
78
+ };
79
+
80
+ private schedule = () => {
81
+ if (!this.frame) this.frame = requestAnimationFrame(this.update);
82
+ };
83
+
84
+ private update = () => {
85
+ this.frame = 0;
86
+ if (this.locked) {
87
+ this.moveIndicator();
88
+ return;
89
+ }
90
+
91
+ const sections = this.sections();
92
+ if (sections.length === 0) return this.setActive(null);
93
+
94
+ // The last section whose top has passed the line; the first until one has.
95
+ // At the very bottom, the last section, which may be too short to reach it.
96
+ const line = this.scrollerTop() + this.offsetValue;
97
+ let current = sections[0];
98
+ if (this.atBottom()) {
99
+ current = sections[sections.length - 1];
100
+ } else {
101
+ for (const section of sections) {
102
+ if (section.top <= line) current = section;
103
+ }
104
+ }
105
+ this.setActive(current.link);
106
+ };
107
+
108
+ private targetOf(link: HTMLAnchorElement) {
109
+ const id = decodeURIComponent(link.hash.slice(1));
110
+ return id ? document.getElementById(id) : null;
111
+ }
112
+
113
+ private scrollToTarget(target: HTMLElement) {
114
+ const behavior = matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth';
115
+ const distance = target.getBoundingClientRect().top - this.scrollerTop() - this.offsetValue;
116
+ this.scroller.scrollBy({ top: distance, behavior });
117
+ }
118
+
119
+ // Links paired with the sections they point at, in the order they appear.
120
+ private sections() {
121
+ return this.linkTargets
122
+ .map((link) => {
123
+ const target = this.targetOf(link);
124
+ return target && target.getClientRects().length > 0
125
+ ? { link, top: target.getBoundingClientRect().top }
126
+ : null;
127
+ })
128
+ .filter((section): section is { link: HTMLAnchorElement; top: number } => section !== null)
129
+ .sort((a, b) => a.top - b.top);
130
+ }
131
+
132
+ private scrollerTop() {
133
+ return this.scroller instanceof Window ? 0 : this.scroller.getBoundingClientRect().top;
134
+ }
135
+
136
+ private atBottom() {
137
+ const el = this.scroller instanceof Window ? document.documentElement : this.scroller;
138
+ return el.scrollHeight > el.clientHeight && el.scrollTop + el.clientHeight >= el.scrollHeight - 2;
139
+ }
140
+
141
+ private setActive(link: HTMLAnchorElement | null) {
142
+ if (link !== this.active) {
143
+ this.active = link;
144
+ const ancestors = link ? this.ancestorsOf(link) : [];
145
+ this.linkTargets.forEach((l) => {
146
+ l.dataset.state = l === link ? 'active' : ancestors.includes(l) ? 'ancestor' : 'inactive';
147
+ if (l === link) l.setAttribute('aria-current', 'location');
148
+ else l.removeAttribute('aria-current');
149
+ });
150
+ this.dispatch('change', { detail: { href: link?.getAttribute('href') ?? null } });
151
+ }
152
+ this.moveIndicator();
153
+ }
154
+
155
+ // Links of the items this link's item is nested in.
156
+ private ancestorsOf(link: HTMLAnchorElement) {
157
+ const links: HTMLAnchorElement[] = [];
158
+ let item = link.closest('li')?.parentElement?.closest('li');
159
+ while (item && this.element.contains(item)) {
160
+ const parent = item.querySelector<HTMLAnchorElement>(':scope > a');
161
+ if (parent) links.push(parent);
162
+ item = item.parentElement?.closest('li');
163
+ }
164
+ return links;
165
+ }
166
+
167
+ private moveIndicator() {
168
+ const indicator = this.indicatorTarget;
169
+ if (!this.active) {
170
+ indicator.dataset.state = 'hidden';
171
+ return;
172
+ }
173
+ const link = this.active.getBoundingClientRect();
174
+ const rail = this.railTarget.getBoundingClientRect();
175
+ indicator.style.transform = `translateY(${link.top - rail.top}px)`;
176
+ indicator.style.height = `${link.height}px`;
177
+ indicator.dataset.state = 'visible';
178
+ }
179
+ }
@@ -43,6 +43,8 @@ components:
43
43
  description: A bordered container with header, content and footer.
44
44
  checkbox:
45
45
  description: A checkbox with label, standalone or bound to a form builder.
46
+ collapsible:
47
+ description: A section that opens and closes under its trigger, like a branch of a nav tree.
46
48
  data_grid:
47
49
  description: A sortable, filterable, server-paged table on AG Grid.
48
50
  date_picker:
@@ -51,12 +53,14 @@ components:
51
53
  description: A modal dialog on the native <dialog> element.
52
54
  divider:
53
55
  description: A horizontal or vertical rule, optionally labelled.
56
+ form_field:
57
+ description: A label, hint and error message wired to any form control.
54
58
  icon:
55
59
  description: A Lucide icon.
56
60
  image:
57
61
  description: An image with consistent sizing and corner radius.
58
62
  input:
59
- description: A text field with label, hint and error, standalone or with a form builder.
63
+ description: A text field with label, hint and error message, standalone or with a form builder.
60
64
  input_group:
61
65
  description: A text field with addons before or after it.
62
66
  label:
@@ -88,10 +92,14 @@ components:
88
92
  description: An on/off toggle.
89
93
  table:
90
94
  description: A styled table with header, rows and caption.
95
+ table_of_contents:
96
+ description: An "On this page" list that follows the reader's scroll.
91
97
  tabs:
92
98
  description: Tabbed panels with keyboard navigation.
93
99
  text:
94
100
  description: Body text in set sizes, weights and tones.
101
+ textarea:
102
+ description: A multi-line text field with label, hint and error, that can grow with its text.
95
103
  theme_toggle:
96
104
  description: A light/dark/system theme switcher.
97
105
  files:
@@ -32,6 +32,8 @@ en:
32
32
  hide: 'Hide password'
33
33
  sidebar:
34
34
  toggle: 'Toggle sidebar'
35
+ table_of_contents:
36
+ label: 'On this page'
35
37
  pagination:
36
38
  label: 'Pages'
37
39
  summary: 'Showing %{from}–%{to} of %{count}'
@@ -0,0 +1,18 @@
1
+ <%# A sidebar tree: branches open on click, and the one holding the current page starts open. %>
2
+ <nav aria-label="Documentation" class="w-56 text-sm">
3
+ <%= render(UI::Collapsible.new(open: true, trigger_class: "font-medium hover:bg-accent", content_class: "ml-3 space-y-0.5 border-l pl-2")) do |c| %>
4
+ <% c.with_trigger { "Forms" } %>
5
+ <a href="#" class="block rounded-md px-3 py-1.5 text-muted-foreground hover:bg-accent hover:text-foreground">Input</a>
6
+ <a href="#" aria-current="page" class="block rounded-md bg-primary/10 px-3 py-1.5 font-medium text-primary">Textarea</a>
7
+ <%= render(UI::Collapsible.new(trigger_class: "text-muted-foreground hover:bg-accent hover:text-foreground", content_class: "ml-3 space-y-0.5 border-l pl-2")) do |inner| %>
8
+ <% inner.with_trigger { "Choices" } %>
9
+ <a href="#" class="block rounded-md px-3 py-1.5 text-muted-foreground hover:bg-accent hover:text-foreground">Select</a>
10
+ <a href="#" class="block rounded-md px-3 py-1.5 text-muted-foreground hover:bg-accent hover:text-foreground">Checkbox</a>
11
+ <% end %>
12
+ <% end %>
13
+ <%= render(UI::Collapsible.new(trigger_class: "font-medium hover:bg-accent", content_class: "ml-3 space-y-0.5 border-l pl-2")) do |c| %>
14
+ <% c.with_trigger { "Overlays" } %>
15
+ <a href="#" class="block rounded-md px-3 py-1.5 text-muted-foreground hover:bg-accent hover:text-foreground">Dialog</a>
16
+ <a href="#" class="block rounded-md px-3 py-1.5 text-muted-foreground hover:bg-accent hover:text-foreground">Sheet</a>
17
+ <% end %>
18
+ </nav>
@@ -0,0 +1,7 @@
1
+ <div class="max-w-md rounded-lg border p-4">
2
+ <p class="text-sm">Payments are collected on the 1st of each month.</p>
3
+ <%= render(UI::Collapsible.new(size: :sm, class: "mt-2", trigger_class: "w-auto -ml-2 text-primary hover:underline")) do |c| %>
4
+ <% c.with_trigger { "How refunds work" } %>
5
+ <p class="px-0 text-sm text-muted-foreground">Cancel within 14 days for a full refund. After that, the rest of the month is credited to your next invoice.</p>
6
+ <% end %>
7
+ </div>
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ class UI::CollapsiblePreview < ViewComponent::Preview
4
+ # @label Playground
5
+ # @param open toggle
6
+ # @param size select { choices: [sm, default, lg] }
7
+ def playground(open: false, size: :default)
8
+ render(UI::Collapsible.new(open: open, size: size.to_sym, class: "max-w-xs")) do |c|
9
+ c.with_trigger { "Advanced settings" }
10
+ "Hidden until opened."
11
+ end
12
+ end
13
+
14
+ # @label Navigation tree
15
+ def default; render_with_template; end
16
+
17
+ # @label Show more
18
+ def show_more; render_with_template; end
19
+ end
@@ -0,0 +1,6 @@
1
+ <div class="max-w-md">
2
+ <%= render(UI::FormField.new(label: "Accent colour", hint: "Used for your avatar and links.")) do |field| %>
3
+ <%= tag.input(type: "color", name: "accent", value: "#4d1a7f",
4
+ class: "h-9 w-16 rounded-md border border-input bg-background p-1", **field.control_attributes) %>
5
+ <% end %>
6
+ </div>
@@ -0,0 +1,7 @@
1
+ <div class="flex flex-col gap-6 max-w-md">
2
+ <%= render(UI::Input.new(type: :email, label: "Email", hint: "We'll only use it to send receipts.", placeholder: "you@example.com")) %>
3
+ <%= render(UI::Input.new(label: "Username", value: "a", error: "Use at least 3 characters.")) %>
4
+ <%= render(UI::Textarea.new(label: "Bio", hint: "Shown on your public profile.", rows: 3)) %>
5
+ <%= render(UI::NativeSelect.new(label: "Team", options: %w[Design Engineering], prompt: "Choose a team", error: "Choose a team to continue.")) %>
6
+ <%= render(UI::Select.new(label: "Plan", options: %w[Free Pro Team], hint: "You can change plans at any time.")) %>
7
+ </div>
@@ -0,0 +1,11 @@
1
+ <%# In an app, @signup comes back from a failed save. Bound fields show its errors on their own. %>
2
+ <% signup = Struct.new(:email, :bio) do
3
+ include ActiveModel::Validations
4
+ def self.model_name = ActiveModel::Name.new(self, nil, "Signup")
5
+ validates :email, presence: true
6
+ validates :bio, length: { minimum: 20 }
7
+ end.new("", "Hi").tap(&:validate) %>
8
+ <%= form_with(model: signup, url: "#", class: "flex flex-col gap-6 max-w-md") do |f| %>
9
+ <%= render(UI::Input.new(form: f, field: :email, type: :email)) %>
10
+ <%= render(UI::Textarea.new(form: f, field: :bio, hint: "A sentence or two about you.")) %>
11
+ <% end %>
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ class UI::FormFieldPreview < ViewComponent::Preview
4
+ # @label On the built-in controls
5
+ def default; render_with_template; end
6
+
7
+ # @label Errors from the model
8
+ def model_errors; render_with_template; end
9
+
10
+ # @label Your own control
11
+ def custom_control; render_with_template; end
12
+ end
@@ -0,0 +1,4 @@
1
+ <div class="flex flex-col gap-6 max-w-sm">
2
+ <%= render(UI::Input.new(label: "Email", type: :email, hint: "We'll only use it to send receipts.")) %>
3
+ <%= render(UI::Input.new(label: "Username", value: "a", error: "Use at least 3 characters.")) %>
4
+ </div>
@@ -30,4 +30,7 @@ class UI::InputPreview < ViewComponent::Preview
30
30
 
31
31
  # @label With Labels
32
32
  def with_labels; render_with_template; end
33
+
34
+ # @label Hint and Error
35
+ def hint_and_error; render_with_template; end
33
36
  end
@@ -4,11 +4,11 @@
4
4
  the icons. The images are velvetkit.com's; in an app, put its own logo here. %>
5
5
  <div class="flex h-9 min-w-0 items-center px-2 group-data-[collapsible=icon]:px-0">
6
6
  <span class="group-data-[collapsible=icon]:hidden">
7
- <img src="/velvetkit/logo-light.png" alt="VelvetKit" class="h-8 w-auto max-w-none dark:hidden">
8
- <img src="/velvetkit/logo-dark.png" alt="VelvetKit" class="hidden h-8 w-auto max-w-none dark:block">
7
+ <img src="/velvetkit/logo-light.svg" alt="VelvetKit" class="h-8 w-auto max-w-none dark:hidden">
8
+ <img src="/velvetkit/logo-dark.svg" alt="VelvetKit" class="hidden h-8 w-auto max-w-none dark:block">
9
9
  </span>
10
10
  <span class="hidden size-8 shrink-0 items-center justify-center group-data-[collapsible=icon]:flex">
11
- <img src="/velvetkit/mark-light.png" alt="VelvetKit" class="size-7 max-w-none shrink-0 object-contain dark:hidden">
12
- <img src="/velvetkit/mark-dark.png" alt="VelvetKit" class="hidden size-7 max-w-none shrink-0 object-contain dark:block">
11
+ <img src="/velvetkit/mark-light.svg" alt="VelvetKit" class="size-7 max-w-none shrink-0 object-contain dark:hidden">
12
+ <img src="/velvetkit/mark-dark.svg" alt="VelvetKit" class="hidden size-7 max-w-none shrink-0 object-contain dark:block">
13
13
  </span>
14
14
  </div>
@@ -1,7 +1,7 @@
1
1
  <% brand = capture do %>
2
2
  <%= render(UI::Menu.new) do |menu| %>
3
3
  <% menu.with_trigger(variant: :ghost, class: "w-full justify-start px-2 h-auto py-1.5 group-data-[collapsible=icon]:size-8 group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:p-0") do %>
4
- <img src="/velvetkit/mark-light.png" alt="" class="size-8 max-w-none shrink-0 object-contain dark:hidden"><img src="/velvetkit/mark-dark.png" alt="" class="hidden size-8 max-w-none shrink-0 object-contain dark:block">
4
+ <img src="/velvetkit/mark-light.svg" alt="" class="size-8 max-w-none shrink-0 object-contain dark:hidden"><img src="/velvetkit/mark-dark.svg" alt="" class="hidden size-8 max-w-none shrink-0 object-contain dark:block">
5
5
  <div class="grid flex-1 text-left text-sm leading-tight group-data-[collapsible=icon]:hidden">
6
6
  <span class="truncate font-medium">VelvetKit</span>
7
7
  <span class="truncate text-xs text-muted-foreground">Workspace</span>
@@ -0,0 +1,41 @@
1
+ <%# The article scrolls in its own box here; on a real page leave out `container:`. %>
2
+ <div class="grid grid-cols-[1fr_14rem] gap-8 max-w-3xl">
3
+ <div id="toc-default-article" class="h-96 overflow-y-auto rounded-md border p-6 space-y-6">
4
+ <section class="space-y-2">
5
+ <h2 id="toc-default-process" class="text-lg font-semibold">Contribution process</h2>
6
+ <p class="text-sm text-muted-foreground">Every change goes through the same four steps.</p>
7
+ </section>
8
+ <% [
9
+ ["toc-default-issue", "1. Create an issue or open a merge request"],
10
+ ["toc-default-classify", "2. Classify as core or extended"],
11
+ ["toc-default-support", "3. Define level of support"],
12
+ ["toc-default-merge", "4. Merge your changes"]
13
+ ].each do |id, heading| %>
14
+ <section class="space-y-2">
15
+ <h3 id="<%= id %>" class="font-medium"><%= heading %></h3>
16
+ <p class="text-sm text-muted-foreground">Describe the change and why it matters. Link related issues, add screenshots for visual changes, and note anything reviewers should try by hand.</p>
17
+ <p class="text-sm text-muted-foreground">Keep each change focused on one thing, so it is quick to review and easy to revert.</p>
18
+ </section>
19
+ <% end %>
20
+ <section class="space-y-2">
21
+ <h2 id="toc-default-resources" class="text-lg font-semibold">Resources</h2>
22
+ <p class="text-sm text-muted-foreground">Guides, design files and the component checklist.</p>
23
+ <p class="text-sm text-muted-foreground">Ask in the discussion board when something is unclear.</p>
24
+ </section>
25
+ <section class="min-h-80 space-y-2">
26
+ <h2 id="toc-default-conduct" class="text-lg font-semibold">Code of conduct</h2>
27
+ <p class="text-sm text-muted-foreground">Be kind, assume good intent, and keep feedback about the work.</p>
28
+ </section>
29
+ </div>
30
+
31
+ <%= render(UI::TableOfContents.new(title: "On this page", container: "#toc-default-article", offset: 24)) do |toc| %>
32
+ <% toc.with_item(href: "#toc-default-process", text: "Contribution process") do |item| %>
33
+ <% item.with_item(href: "#toc-default-issue", text: "1. Create an issue or open a merge request") %>
34
+ <% item.with_item(href: "#toc-default-classify", text: "2. Classify as core or extended") %>
35
+ <% item.with_item(href: "#toc-default-support", text: "3. Define level of support") %>
36
+ <% item.with_item(href: "#toc-default-merge", text: "4. Merge your changes") %>
37
+ <% end %>
38
+ <% toc.with_item(href: "#toc-default-resources", text: "Resources") %>
39
+ <% toc.with_item(href: "#toc-default-conduct", text: "Code of conduct") %>
40
+ <% end %>
41
+ </div>
@@ -0,0 +1,17 @@
1
+ <div class="grid grid-cols-[1fr_12rem] gap-8 max-w-2xl">
2
+ <div id="toc-flat-article" class="h-72 overflow-y-auto rounded-md border p-6 space-y-6 [&>section:last-child]:min-h-60">
3
+ <% %w[Install Configure Theme Deploy].each do |heading| %>
4
+ <section class="space-y-2">
5
+ <h2 id="toc-flat-<%= heading.downcase %>" class="font-semibold"><%= heading %></h2>
6
+ <p class="text-sm text-muted-foreground">Each section starts with what you need and ends with how to check it worked.</p>
7
+ <p class="text-sm text-muted-foreground">Commands run from the root of your Rails app.</p>
8
+ </section>
9
+ <% end %>
10
+ </div>
11
+
12
+ <%= render(UI::TableOfContents.new(container: "#toc-flat-article", offset: 24)) do |toc| %>
13
+ <% %w[Install Configure Theme Deploy].each do |heading| %>
14
+ <% toc.with_item(href: "#toc-flat-#{heading.downcase}", text: heading) %>
15
+ <% end %>
16
+ <% end %>
17
+ </div>
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ class UI::TableOfContentsPreview < ViewComponent::Preview
4
+ # @label Nested sections
5
+ def default; render_with_template; end
6
+
7
+ # @label Flat
8
+ def flat; render_with_template; end
9
+ end
@@ -0,0 +1,4 @@
1
+ <div class="max-w-md">
2
+ <%= render(UI::Textarea.new(label: "Comment", autogrow: true, class: "max-h-60",
3
+ placeholder: "Type a few lines; the box grows with them, up to a limit.")) %>
4
+ </div>
@@ -0,0 +1,3 @@
1
+ <div class="max-w-md">
2
+ <%= render(UI::Textarea.new(label: "Feedback", placeholder: "What could we do better?", rows: 4)) %>
3
+ </div>
@@ -0,0 +1,5 @@
1
+ <div class="flex flex-col gap-3 max-w-md">
2
+ <%= render(UI::Textarea.new(size: :sm, rows: 2, placeholder: "Small")) %>
3
+ <%= render(UI::Textarea.new(rows: 2, placeholder: "Default")) %>
4
+ <%= render(UI::Textarea.new(size: :lg, rows: 2, placeholder: "Large")) %>
5
+ </div>
@@ -0,0 +1,7 @@
1
+ <div class="flex flex-col gap-4 max-w-md">
2
+ <%= render(UI::Textarea.new(label: "Bio", value: "Rails developer who likes accessible UI.")) %>
3
+ <%= render(UI::Textarea.new(label: "Reason", required: true, placeholder: "Required")) %>
4
+ <%= render(UI::Textarea.new(label: "Summary", error: true, value: "Too short")) %>
5
+ <%= render(UI::Textarea.new(label: "Notes", disabled: true, value: "Locked after review")) %>
6
+ <%= render(UI::Textarea.new(label: "Fixed size", resize: :none, placeholder: "Can't be resized")) %>
7
+ </div>
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ class UI::TextareaPreview < ViewComponent::Preview
4
+ # @label Playground
5
+ # @param size select { choices: [sm, default, lg] }
6
+ # @param resize select { choices: [vertical, none, both] }
7
+ # @param rows number
8
+ # @param autogrow toggle
9
+ # @param label text
10
+ # @param required toggle
11
+ # @param error toggle
12
+ # @param disabled toggle
13
+ # @param placeholder text
14
+ def playground(size: :default, resize: :vertical, rows: 3, autogrow: false,
15
+ label: "Message", required: false, error: false, disabled: false,
16
+ placeholder: "Write a message…")
17
+ render(UI::Textarea.new(size: size.to_sym, resize: resize.to_sym, rows: rows.to_i, autogrow: autogrow,
18
+ label: label, required: required, error: error, disabled: disabled,
19
+ placeholder: placeholder))
20
+ end
21
+
22
+ # @label Default
23
+ def default; render_with_template; end
24
+
25
+ # @label Grows with its text
26
+ def autogrow; render_with_template; end
27
+
28
+ # @label States
29
+ def states; render_with_template; end
30
+
31
+ # @label All Sizes
32
+ def sizes; render_with_template; end
33
+ end
@@ -233,4 +233,28 @@ class UI::CheckboxTest < ViewComponent::TestCase
233
233
  render_inline(UI::Checkbox.new(form: form_builder_for(User.new), field: :remember_me, label: false))
234
234
  assert_no_selector "label"
235
235
  end
236
+
237
+ def test_error_message_shows_under_the_box_and_describes_it
238
+ render_inline(UI::Checkbox.new(name: "terms", label: "I accept the terms", id: "terms", error: "You must accept the terms"))
239
+ box = page.find("input#terms[aria-invalid='true']")
240
+ assert_equal "terms-error", box["aria-describedby"]
241
+ assert_selector "div > label + p#terms-error.text-destructive", text: "You must accept the terms"
242
+ end
243
+
244
+ def test_model_errors_show_under_a_bound_checkbox
245
+ record = Struct.new(:terms) do
246
+ include ActiveModel::Validations
247
+ def self.model_name = ActiveModel::Name.new(self, nil, "Signup")
248
+ validates :terms, acceptance: { accept: "1" }
249
+ end.new("0").tap(&:validate)
250
+ form = ActionView::Helpers::FormBuilder.new(:signup, record, vc_test_controller.view_context, {})
251
+ render_inline(UI::Checkbox.new(form: form, field: :terms))
252
+ assert_selector "input[type='checkbox'][aria-invalid='true']"
253
+ assert_selector "p.text-destructive", text: "Terms must be accepted"
254
+ end
255
+
256
+ def test_no_messages_no_wrapper
257
+ render_inline(UI::Checkbox.new(name: "terms", label: "Terms"))
258
+ refute_selector "div"
259
+ end
236
260
  end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class UI::CollapsibleTest < ViewComponent::TestCase
6
+ def setup_fixtures(*); end
7
+ def teardown_fixtures(*); end
8
+
9
+ def render_collapsible(**opts)
10
+ render_inline(UI::Collapsible.new(**opts)) do |c|
11
+ c.with_trigger { "Forms" }
12
+ "<a href='/input'>Input</a>".html_safe
13
+ end
14
+ end
15
+
16
+ def test_renders_details_with_summary_trigger
17
+ render_collapsible
18
+ assert_selector "details > summary", text: "Forms"
19
+ assert_selector "details > summary + div > a[href='/input']", visible: :all
20
+ end
21
+
22
+ def test_closed_by_default
23
+ render_collapsible
24
+ assert_selector "details:not([open])", visible: :all
25
+ end
26
+
27
+ def test_open
28
+ render_collapsible(open: true)
29
+ assert_selector "details[open]"
30
+ assert_selector "a[href='/input']", text: "Input"
31
+ end
32
+
33
+ def test_chevron_is_decorative_and_turns_when_open
34
+ render_collapsible
35
+ icon = page.find("summary [aria-hidden='true']", visible: :all)
36
+ assert_includes icon[:class].split, "group-open/collapsible:rotate-90"
37
+ end
38
+
39
+ def test_hides_the_native_marker
40
+ render_collapsible
41
+ classes = page.find("summary")[:class].split
42
+ assert_includes classes, "list-none"
43
+ assert_includes classes, "[&::-webkit-details-marker]:hidden"
44
+ end
45
+
46
+ def test_sizes
47
+ render_collapsible(size: :sm)
48
+ assert_includes page.find("summary")[:class].split, "text-xs"
49
+ end
50
+
51
+ def test_trigger_and_content_classes
52
+ render_collapsible(trigger_class: "font-semibold", content_class: "ml-3 border-l")
53
+ assert_includes page.find("summary")[:class].split, "font-semibold"
54
+ assert_includes page.find("details > div", visible: :all)[:class].split, "border-l"
55
+ end
56
+
57
+ def test_html_attrs_and_class_on_details
58
+ render_collapsible(id: "forms-tree", class: "mt-2", data: { test_id: "tree" })
59
+ details = page.find("details#forms-tree[data-test-id='tree']", visible: :all)
60
+ assert_includes details[:class].split, "mt-2"
61
+ assert_includes details[:class].split, "group/collapsible"
62
+ end
63
+
64
+ def test_nests
65
+ render_inline(UI::Collapsible.new(open: true)) do |outer|
66
+ outer.with_trigger { "Components" }
67
+ vc_test_controller.view_context.render(UI::Collapsible.new) { |inner| inner.with_trigger { "Forms" } }
68
+ end
69
+ assert_selector "details[open] > div > details > summary", text: "Forms"
70
+ end
71
+ end
@@ -165,4 +165,18 @@ class UI::DatePickerTest < ViewComponent::TestCase
165
165
  assert_includes arrow.text(:all), I18n.t(key)
166
166
  end
167
167
  end
168
+
169
+ def test_error_message_shows_under_the_hint_and_describes_the_input
170
+ render_inline(UI::DatePicker.new(name: "starts_on", label: "Start date", error: "Pick a date in the future"))
171
+ input = page.find("input[type='text'][aria-invalid='true']")
172
+ assert_equal "#{input[:id]}-hint #{input[:id]}-error", input["aria-describedby"]
173
+ assert_selector "p##{input[:id]}-error.text-destructive", text: "Pick a date in the future"
174
+ end
175
+
176
+ def test_no_error_only_the_hint_describes
177
+ render_inline(UI::DatePicker.new(name: "starts_on"))
178
+ input = page.find("input[type='text']")
179
+ assert_equal "#{input[:id]}-hint", input["aria-describedby"]
180
+ refute input["aria-invalid"]
181
+ end
168
182
  end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class UI::FormField::MessagesTest < ViewComponent::TestCase
6
+ def setup_fixtures(*); end
7
+ def teardown_fixtures(*); end
8
+
9
+ def test_renders_hint_and_error_with_ids
10
+ render_inline(UI::FormField::Messages.new(UI::FormField.new(hint: "Hint", error: "Error", for: "f")))
11
+ assert_selector "p#f-hint", text: "Hint"
12
+ assert_selector "p#f-error", text: "Error"
13
+ end
14
+
15
+ def test_renders_nothing_without_messages
16
+ render_inline(UI::FormField::Messages.new(UI::FormField.new(error: true, for: "f")))
17
+ refute_selector "p"
18
+ end
19
+ end