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
|
@@ -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
|
+
}
|
data/registry/components.yml
CHANGED
|
@@ -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:
|
|
@@ -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
|
|
@@ -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.
|
|
8
|
-
<img src="/velvetkit/logo-dark.
|
|
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.
|
|
12
|
-
<img src="/velvetkit/mark-dark.
|
|
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.
|
|
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,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
|