Skip to content

Commit 74c075e

Browse files
committed
feat: add NativeSelect component
Add a styled native HTML select component inspired by shadcn/ui's native-select. Includes NativeSelect, NativeSelectOption, and NativeSelectGroup sub-components with support for size variants (:default, :sm), disabled state, aria-invalid styling, and form field integration.
1 parent 4e4fd14 commit 74c075e

5 files changed

Lines changed: 229 additions & 0 deletions

File tree

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# frozen_string_literal: true
2+
3+
module RubyUI
4+
class NativeSelect < Base
5+
def initialize(size: :default, **attrs)
6+
@size = size
7+
super(**attrs)
8+
end
9+
10+
def view_template(&block)
11+
div(
12+
class: "group/native-select relative w-fit has-[select:disabled]:opacity-50"
13+
) do
14+
select(**attrs, &block)
15+
chevron_icon
16+
end
17+
end
18+
19+
private
20+
21+
def chevron_icon
22+
svg(
23+
xmlns: "http://www.w3.org/2000/svg",
24+
viewbox: "0 0 24 24",
25+
fill: "none",
26+
stroke: "currentColor",
27+
stroke_width: "2",
28+
stroke_linecap: "round",
29+
stroke_linejoin: "round",
30+
class: "text-muted-foreground pointer-events-none absolute top-1/2 right-2.5 size-4 -translate-y-1/2 select-none",
31+
aria_hidden: "true"
32+
) do |s|
33+
s.path(d: "m6 9 6 6 6-6")
34+
end
35+
end
36+
37+
def default_attrs
38+
{
39+
data: {
40+
ruby_ui__form_field_target: "input",
41+
action: "change->ruby-ui--form-field#onChange invalid->ruby-ui--form-field#onInvalid"
42+
},
43+
class: [
44+
"border-border bg-transparent text-sm w-full min-w-0 appearance-none rounded-md border py-1 pr-8 pl-2.5 shadow-xs transition-[color,box-shadow] outline-none select-none ring-0 ring-ring/0",
45+
"placeholder:text-muted-foreground",
46+
"selection:bg-primary selection:text-primary-foreground",
47+
"focus-visible:outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-2",
48+
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
49+
"aria-invalid:ring-destructive/20 aria-invalid:border-destructive aria-invalid:ring-2",
50+
@size == :sm ? "h-7 rounded-md py-0.5" : "h-9"
51+
]
52+
}
53+
end
54+
end
55+
end
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# frozen_string_literal: true
2+
3+
class Views::Docs::NativeSelect < Views::Base
4+
def view_template
5+
component = "NativeSelect"
6+
7+
div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do
8+
render Docs::Header.new(title: "Native Select", description: "A styled native HTML select element with consistent design system integration.")
9+
10+
Heading(level: 2) { "Usage" }
11+
12+
render Docs::VisualCodeExample.new(title: "Default", context: self) do
13+
<<~RUBY
14+
div(class: "grid w-full max-w-sm items-center gap-1.5") do
15+
NativeSelect do
16+
NativeSelectOption(value: "") { "Select a fruit" }
17+
NativeSelectOption(value: "apple") { "Apple" }
18+
NativeSelectOption(value: "banana") { "Banana" }
19+
NativeSelectOption(value: "blueberry") { "Blueberry" }
20+
NativeSelectOption(value: "pineapple") { "Pineapple" }
21+
end
22+
end
23+
RUBY
24+
end
25+
26+
render Docs::VisualCodeExample.new(title: "With groups", context: self) do
27+
<<~RUBY
28+
div(class: "grid w-full max-w-sm items-center gap-1.5") do
29+
NativeSelect do
30+
NativeSelectOption(value: "") { "Select a department" }
31+
NativeSelectGroup(label: "Engineering") do
32+
NativeSelectOption(value: "frontend") { "Frontend" }
33+
NativeSelectOption(value: "backend") { "Backend" }
34+
NativeSelectOption(value: "devops") { "DevOps" }
35+
end
36+
NativeSelectGroup(label: "Sales") do
37+
NativeSelectOption(value: "account_executive") { "Account Executive" }
38+
NativeSelectOption(value: "sales_development") { "Sales Development" }
39+
end
40+
end
41+
end
42+
RUBY
43+
end
44+
45+
render Docs::VisualCodeExample.new(title: "Small", context: self) do
46+
<<~RUBY
47+
div(class: "grid w-full max-w-sm items-center gap-1.5") do
48+
NativeSelect(size: :sm) do
49+
NativeSelectOption(value: "") { "Select a fruit" }
50+
NativeSelectOption(value: "apple") { "Apple" }
51+
NativeSelectOption(value: "banana") { "Banana" }
52+
end
53+
end
54+
RUBY
55+
end
56+
57+
render Docs::VisualCodeExample.new(title: "Disabled", context: self) do
58+
<<~RUBY
59+
div(class: "grid w-full max-w-sm items-center gap-1.5") do
60+
NativeSelect(disabled: true) do
61+
NativeSelectOption(value: "") { "Select a fruit" }
62+
NativeSelectOption(value: "apple") { "Apple" }
63+
NativeSelectOption(value: "banana") { "Banana" }
64+
end
65+
end
66+
RUBY
67+
end
68+
69+
render Components::ComponentSetup::Tabs.new(component_name: component)
70+
71+
render Docs::ComponentsTable.new(component_files(component))
72+
end
73+
end
74+
end
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# frozen_string_literal: true
2+
3+
module RubyUI
4+
class NativeSelectGroup < Base
5+
def view_template(&)
6+
optgroup(**attrs, &)
7+
end
8+
9+
private
10+
11+
def default_attrs
12+
{}
13+
end
14+
end
15+
end
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# frozen_string_literal: true
2+
3+
module RubyUI
4+
class NativeSelectOption < Base
5+
def view_template(&)
6+
option(**attrs, &)
7+
end
8+
9+
private
10+
11+
def default_attrs
12+
{}
13+
end
14+
end
15+
end

test/ruby_ui/native_select_test.rb

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# frozen_string_literal: true
2+
3+
require "test_helper"
4+
5+
class RubyUI::NativeSelectTest < ComponentTest
6+
def test_render_with_options
7+
output = phlex do
8+
RubyUI.NativeSelect do
9+
RubyUI.NativeSelectOption(value: "") { "Select a fruit" }
10+
RubyUI.NativeSelectOption(value: "apple") { "Apple" }
11+
RubyUI.NativeSelectOption(value: "banana") { "Banana" }
12+
end
13+
end
14+
15+
assert_match(/Select a fruit/, output)
16+
assert_match(/Apple/, output)
17+
assert_match(/Banana/, output)
18+
assert_match(/<select/, output)
19+
end
20+
21+
def test_render_with_groups
22+
output = phlex do
23+
RubyUI.NativeSelect do
24+
RubyUI.NativeSelectGroup(label: "Fruits") do
25+
RubyUI.NativeSelectOption(value: "apple") { "Apple" }
26+
RubyUI.NativeSelectOption(value: "banana") { "Banana" }
27+
end
28+
RubyUI.NativeSelectGroup(label: "Vegetables") do
29+
RubyUI.NativeSelectOption(value: "carrot") { "Carrot" }
30+
end
31+
end
32+
end
33+
34+
assert_match(/<optgroup/, output)
35+
assert_match(/Fruits/, output)
36+
assert_match(/Vegetables/, output)
37+
assert_match(/Carrot/, output)
38+
end
39+
40+
def test_render_with_small_size
41+
output = phlex do
42+
RubyUI.NativeSelect(size: :sm) do
43+
RubyUI.NativeSelectOption(value: "apple") { "Apple" }
44+
end
45+
end
46+
47+
assert_match(/Apple/, output)
48+
assert_match(/<select/, output)
49+
end
50+
51+
def test_render_with_disabled
52+
output = phlex do
53+
RubyUI.NativeSelect(disabled: true) do
54+
RubyUI.NativeSelectOption(value: "apple") { "Apple" }
55+
end
56+
end
57+
58+
assert_match(/disabled/, output)
59+
end
60+
61+
def test_render_with_name
62+
output = phlex do
63+
RubyUI.NativeSelect(name: "fruit") do
64+
RubyUI.NativeSelectOption(value: "apple") { "Apple" }
65+
end
66+
end
67+
68+
assert_match(/name="fruit"/, output)
69+
end
70+
end

0 commit comments

Comments
 (0)