diff --git a/.changeset/lovely-boats-hang.md b/.changeset/lovely-boats-hang.md
new file mode 100644
index 0000000000..3e47be7966
--- /dev/null
+++ b/.changeset/lovely-boats-hang.md
@@ -0,0 +1,5 @@
+---
+'@primer/view-components': patch
+---
+
+Add Primer::Forms::ToggleSwitchForm
diff --git a/app/forms/example_toggle_switch_form.rb b/app/forms/example_toggle_switch_form.rb
new file mode 100644
index 0000000000..0636457d71
--- /dev/null
+++ b/app/forms/example_toggle_switch_form.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+# :nodoc:
+class ExampleToggleSwitchForm < Primer::Forms::ToggleSwitchForm
+ def initialize(**system_arguments)
+ super(name: :example_field, label: "Example", **system_arguments)
+ end
+end
diff --git a/app/forms/example_toggle_switch_form/example_field_caption.html.erb b/app/forms/example_toggle_switch_form/example_field_caption.html.erb
new file mode 100644
index 0000000000..3dd8a8a9fa
--- /dev/null
+++ b/app/forms/example_toggle_switch_form/example_field_caption.html.erb
@@ -0,0 +1 @@
+My favorite caption.
diff --git a/lib/primer/forms/dsl/toggle_switch_input.rb b/lib/primer/forms/dsl/toggle_switch_input.rb
new file mode 100644
index 0000000000..a574bbfd3e
--- /dev/null
+++ b/lib/primer/forms/dsl/toggle_switch_input.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+module Primer
+ module Forms
+ module Dsl
+ # :nodoc:
+ class ToggleSwitchInput < Input
+ attr_reader :name, :label, :src, :csrf
+
+ def initialize(
+ name:,
+ label:,
+ src:,
+ csrf: nil,
+ **system_arguments
+ )
+ @name = name
+ @label = label
+ @src = src
+ @csrf = csrf
+
+ super(**system_arguments)
+ end
+
+ def to_component
+ ToggleSwitch.new(input: self)
+ end
+
+ def type
+ :toggle_switch
+ end
+ end
+ end
+ end
+end
diff --git a/lib/primer/forms/toggle_switch.html.erb b/lib/primer/forms/toggle_switch.html.erb
new file mode 100644
index 0000000000..1b49692a94
--- /dev/null
+++ b/lib/primer/forms/toggle_switch.html.erb
@@ -0,0 +1,17 @@
+<%= content_tag(:div, **@form_group_arguments) do %>
+
+ <%= builder.label(@input.name, **@input.label_arguments) do %>
+ <%= @input.label %>
+ <% end %>
+ <%= render(Caption.new(input: @input)) %>
+
+ <%
+ csrf = @input.csrf || @view_context.form_authenticity_token(
+ form_options: {
+ method: :post,
+ action: @input.src
+ }
+ )
+ %>
+ <%= render(Primer::Alpha::ToggleSwitch.new(src: @input.src, csrf: csrf)) %>
+<% end %>
diff --git a/lib/primer/forms/toggle_switch.rb b/lib/primer/forms/toggle_switch.rb
new file mode 100644
index 0000000000..6ff843dba1
--- /dev/null
+++ b/lib/primer/forms/toggle_switch.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Primer
+ module Forms
+ # :nodoc:
+ class ToggleSwitch < BaseComponent
+ delegate :builder, :form, to: :@input
+
+ def initialize(input:)
+ @input = input
+ @input.add_label_classes("FormControl-label")
+
+ @form_group_arguments = { class: "d-flex" }
+
+ @form_group_arguments[:hidden] = "hidden" if @input.hidden?
+ end
+ end
+ end
+end
diff --git a/lib/primer/forms/toggle_switch_form.rb b/lib/primer/forms/toggle_switch_form.rb
new file mode 100644
index 0000000000..ab703a94c2
--- /dev/null
+++ b/lib/primer/forms/toggle_switch_form.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+module Primer
+ module Forms
+ # Toggle switches are designed to submit an on/off value to the server immediately
+ # upon click. For that reason they are not designed to be used in "regular" forms
+ # that have other fields, etc. Instead they should be used independently via this
+ # class.
+ #
+ # ToggleSwitchForm can be used directly or via inheritance.
+ #
+ # Via inheritance:
+ #
+ # # app/forms/my_toggle_form.rb
+ # class MyToggleForm < Primer::Forms::ToggleSwitchForm
+ # def initialize(**system_arguments)
+ # super(name: :foo, label: "Foo", src: "/foo", **system_arguments)
+ # end
+ # end
+ #
+ # # app/views/some_view.html.erb
+ # <%= render(MyToggleForm.new) %>
+ #
+ # Directly:
+ #
+ # # app/views/some_view.html.erb
+ # <%= render(
+ # Primer::Forms::ToggleSwitchForm.new(
+ # name: :foo, label: "Foo", src: "/foo"
+ # )
+ # ) %>
+ #
+ class ToggleSwitchForm < Primer::Forms::Base
+ # Define the form on subclasses so render(Subclass.new) works as expected.
+ def self.inherited(base)
+ base.form do |toggle_switch_form|
+ input = Dsl::ToggleSwitchInput.new(
+ builder: toggle_switch_form.builder, form: self, **@system_arguments
+ )
+
+ toggle_switch_form.send(:add_input, input)
+ end
+ end
+
+ # Define the form on self so render(ToggleSwitchForm.new) works as expected.
+ inherited(self)
+
+ # Override to avoid accepting a builder argument. We create our own builder
+ # on render. See the implementation of render_in below.
+ def self.new(**options)
+ allocate.tap { |obj| obj.send(:initialize, **options) }
+ end
+
+ def initialize(**system_arguments)
+ @system_arguments = system_arguments
+ end
+
+ # Unlike other instances of Base, ToggleSwitchForm defines its own form and
+ # is not given a Rails form builder on instantiation. We do this mostly for
+ # ergonomic reasons; it's much less verbose than if you were required to
+ # call form_with/form_for, etc. That said, the rest of the forms framework
+ # assumes the presence of a builder so we create our own here. A builder
+ # cannot be constructed without a corresponding view context, which is why
+ # we have to override render_in and can't create it in the initializer.
+ def render_in(view_context, &block)
+ @builder = Primer::Forms::Builder.new(
+ nil, nil, view_context, {}
+ )
+
+ super
+ end
+ end
+ end
+end
diff --git a/previews/primer/forms/forms_preview.rb b/previews/primer/forms/forms_preview.rb
index 004c0cf02d..c5f4e1822e 100644
--- a/previews/primer/forms/forms_preview.rb
+++ b/previews/primer/forms/forms_preview.rb
@@ -39,6 +39,8 @@ def multi_input_form; end
def name_with_question_mark_form; end
def immediate_validation_form; end
+
+ def example_toggle_switch_form; end
end
end
end
diff --git a/previews/primer/forms/forms_preview/example_toggle_switch_form.html.erb b/previews/primer/forms/forms_preview/example_toggle_switch_form.html.erb
new file mode 100644
index 0000000000..f9a1aab130
--- /dev/null
+++ b/previews/primer/forms/forms_preview/example_toggle_switch_form.html.erb
@@ -0,0 +1 @@
+<%= render(ExampleToggleSwitchForm.new(csrf: "let_me_in", src: toggle_switch_index_path)) %>
diff --git a/test/lib/primer/forms/toggle_switch_form_test.rb b/test/lib/primer/forms/toggle_switch_form_test.rb
new file mode 100644
index 0000000000..da8afd403a
--- /dev/null
+++ b/test/lib/primer/forms/toggle_switch_form_test.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require "lib/test_helper"
+
+class Primer::Forms::ToggleSwitchFormTest < Minitest::Test
+ include Primer::ComponentTestHelpers
+
+ def test_it_renders_with_a_name
+ bogus_csrf = "let me in"
+ render_inline(ExampleToggleSwitchForm.new(csrf: bogus_csrf, src: "/toggle_switch"))
+
+ assert_selector "toggle-switch[src='/toggle_switch'][csrf='#{bogus_csrf}']"
+ end
+
+ def test_can_render_without_subclass
+ render_inline(
+ Primer::Forms::ToggleSwitchForm.new(
+ name: :example_field,
+ label: "Example",
+ src: "/toggle_switch"
+ )
+ )
+
+ assert_selector "toggle-switch[src='/toggle_switch']"
+ end
+end