From 74c075e1870c11539e089878013ca4ad638806d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Mon, 9 Feb 2026 13:29:38 -0300 Subject: [PATCH 1/5] 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. --- lib/ruby_ui/native_select/native_select.rb | 55 ++++++++++++++ .../native_select/native_select_docs.rb | 74 +++++++++++++++++++ .../native_select/native_select_group.rb | 15 ++++ .../native_select/native_select_option.rb | 15 ++++ test/ruby_ui/native_select_test.rb | 70 ++++++++++++++++++ 5 files changed, 229 insertions(+) create mode 100644 lib/ruby_ui/native_select/native_select.rb create mode 100644 lib/ruby_ui/native_select/native_select_docs.rb create mode 100644 lib/ruby_ui/native_select/native_select_group.rb create mode 100644 lib/ruby_ui/native_select/native_select_option.rb create mode 100644 test/ruby_ui/native_select_test.rb diff --git a/lib/ruby_ui/native_select/native_select.rb b/lib/ruby_ui/native_select/native_select.rb new file mode 100644 index 00000000..478015cb --- /dev/null +++ b/lib/ruby_ui/native_select/native_select.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module RubyUI + class NativeSelect < Base + def initialize(size: :default, **attrs) + @size = size + super(**attrs) + end + + def view_template(&block) + div( + class: "group/native-select relative w-fit has-[select:disabled]:opacity-50" + ) do + select(**attrs, &block) + chevron_icon + end + end + + private + + def chevron_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + viewbox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "text-muted-foreground pointer-events-none absolute top-1/2 right-2.5 size-4 -translate-y-1/2 select-none", + aria_hidden: "true" + ) do |s| + s.path(d: "m6 9 6 6 6-6") + end + end + + def default_attrs + { + data: { + ruby_ui__form_field_target: "input", + action: "change->ruby-ui--form-field#onChange invalid->ruby-ui--form-field#onInvalid" + }, + class: [ + "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", + "placeholder:text-muted-foreground", + "selection:bg-primary selection:text-primary-foreground", + "focus-visible:outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-2", + "disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50", + "aria-invalid:ring-destructive/20 aria-invalid:border-destructive aria-invalid:ring-2", + @size == :sm ? "h-7 rounded-md py-0.5" : "h-9" + ] + } + end + end +end diff --git a/lib/ruby_ui/native_select/native_select_docs.rb b/lib/ruby_ui/native_select/native_select_docs.rb new file mode 100644 index 00000000..b7881491 --- /dev/null +++ b/lib/ruby_ui/native_select/native_select_docs.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +class Views::Docs::NativeSelect < Views::Base + def view_template + component = "NativeSelect" + + div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do + render Docs::Header.new(title: "Native Select", description: "A styled native HTML select element with consistent design system integration.") + + Heading(level: 2) { "Usage" } + + render Docs::VisualCodeExample.new(title: "Default", context: self) do + <<~RUBY + div(class: "grid w-full max-w-sm items-center gap-1.5") do + NativeSelect do + NativeSelectOption(value: "") { "Select a fruit" } + NativeSelectOption(value: "apple") { "Apple" } + NativeSelectOption(value: "banana") { "Banana" } + NativeSelectOption(value: "blueberry") { "Blueberry" } + NativeSelectOption(value: "pineapple") { "Pineapple" } + end + end + RUBY + end + + render Docs::VisualCodeExample.new(title: "With groups", context: self) do + <<~RUBY + div(class: "grid w-full max-w-sm items-center gap-1.5") do + NativeSelect do + NativeSelectOption(value: "") { "Select a department" } + NativeSelectGroup(label: "Engineering") do + NativeSelectOption(value: "frontend") { "Frontend" } + NativeSelectOption(value: "backend") { "Backend" } + NativeSelectOption(value: "devops") { "DevOps" } + end + NativeSelectGroup(label: "Sales") do + NativeSelectOption(value: "account_executive") { "Account Executive" } + NativeSelectOption(value: "sales_development") { "Sales Development" } + end + end + end + RUBY + end + + render Docs::VisualCodeExample.new(title: "Small", context: self) do + <<~RUBY + div(class: "grid w-full max-w-sm items-center gap-1.5") do + NativeSelect(size: :sm) do + NativeSelectOption(value: "") { "Select a fruit" } + NativeSelectOption(value: "apple") { "Apple" } + NativeSelectOption(value: "banana") { "Banana" } + end + end + RUBY + end + + render Docs::VisualCodeExample.new(title: "Disabled", context: self) do + <<~RUBY + div(class: "grid w-full max-w-sm items-center gap-1.5") do + NativeSelect(disabled: true) do + NativeSelectOption(value: "") { "Select a fruit" } + NativeSelectOption(value: "apple") { "Apple" } + NativeSelectOption(value: "banana") { "Banana" } + end + end + RUBY + end + + render Components::ComponentSetup::Tabs.new(component_name: component) + + render Docs::ComponentsTable.new(component_files(component)) + end + end +end diff --git a/lib/ruby_ui/native_select/native_select_group.rb b/lib/ruby_ui/native_select/native_select_group.rb new file mode 100644 index 00000000..d3fae6d7 --- /dev/null +++ b/lib/ruby_ui/native_select/native_select_group.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module RubyUI + class NativeSelectGroup < Base + def view_template(&) + optgroup(**attrs, &) + end + + private + + def default_attrs + {} + end + end +end diff --git a/lib/ruby_ui/native_select/native_select_option.rb b/lib/ruby_ui/native_select/native_select_option.rb new file mode 100644 index 00000000..bf4dd3e3 --- /dev/null +++ b/lib/ruby_ui/native_select/native_select_option.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module RubyUI + class NativeSelectOption < Base + def view_template(&) + option(**attrs, &) + end + + private + + def default_attrs + {} + end + end +end diff --git a/test/ruby_ui/native_select_test.rb b/test/ruby_ui/native_select_test.rb new file mode 100644 index 00000000..e2323b1b --- /dev/null +++ b/test/ruby_ui/native_select_test.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require "test_helper" + +class RubyUI::NativeSelectTest < ComponentTest + def test_render_with_options + output = phlex do + RubyUI.NativeSelect do + RubyUI.NativeSelectOption(value: "") { "Select a fruit" } + RubyUI.NativeSelectOption(value: "apple") { "Apple" } + RubyUI.NativeSelectOption(value: "banana") { "Banana" } + end + end + + assert_match(/Select a fruit/, output) + assert_match(/Apple/, output) + assert_match(/Banana/, output) + assert_match(/ Date: Mon, 9 Feb 2026 13:39:01 -0300 Subject: [PATCH 3/5] test: simplify NativeSelect test to match team conventions --- test/ruby_ui/native_select_test.rb | 68 +++++------------------------- 1 file changed, 10 insertions(+), 58 deletions(-) diff --git a/test/ruby_ui/native_select_test.rb b/test/ruby_ui/native_select_test.rb index e36e306b..85d7c73c 100644 --- a/test/ruby_ui/native_select_test.rb +++ b/test/ruby_ui/native_select_test.rb @@ -3,69 +3,21 @@ require "test_helper" class RubyUI::NativeSelectTest < ComponentTest - def test_render_with_options + def test_render_with_all_items output = phlex do - RubyUI.NativeSelect do - RubyUI.NativeSelectOption(value: "") { "Select a fruit" } - RubyUI.NativeSelectOption(value: "apple") { "Apple" } - RubyUI.NativeSelectOption(value: "banana") { "Banana" } - end - end - - assert_match(/Select a fruit/, output) - assert_match(/Apple/, output) - assert_match(/Banana/, output) - assert_match(/