Skip to content

Commit

Permalink
Toggle switch (#1321)
Browse files Browse the repository at this point in the history
* WIP

* docs: build docs

* Adding previews and controller

* Copy over tests

* Fix tests

* Fix linting issues

* Fix erb lint issues

* Remove erblint comment

* Add tests for csrf token

* Actually use form_params in controller

* Adjust comments

* build

Co-authored-by: Actions Auto Build <actions@github.com>
Co-authored-by: Jon Rohan <yes@jonrohan.codes>
  • Loading branch information
3 people authored Aug 23, 2022
1 parent b89ac4d commit f18ef0f
Show file tree
Hide file tree
Showing 25 changed files with 543 additions and 4 deletions.
5 changes: 5 additions & 0 deletions .changeset/tall-oranges-knock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@primer/view-components": patch
---

Adding Primer::Alpha::ToggleSwitch component
2 changes: 1 addition & 1 deletion app/assets/javascripts/primer_view_components.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion app/assets/javascripts/primer_view_components.js.map

Large diffs are not rendered by default.

147 changes: 147 additions & 0 deletions app/components/primer/alpha/toggle-switch-element.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/* eslint-disable custom-elements/expose-class-on-global */
/* eslint-disable custom-elements/define-tag-after-class-definition */

import {controller, target} from '@github/catalyst'
import {debounce} from '@github/mini-throttle/decorators'

@controller
export class ToggleSwitchElement extends HTMLElement {
@target switch: HTMLElement
@target loadingSpinner: HTMLElement
@target errorIcon: HTMLElement

get src(): string | null {
const src = this.getAttribute('src')
if (!src) return null

const link = this.ownerDocument.createElement('a')
link.href = src
return link.href
}

get csrf(): string | null {
const csrfElement = this.querySelector('[data-csrf]')
return this.getAttribute('csrf') || (csrfElement instanceof HTMLInputElement && csrfElement.value) || null
}

get csrfField(): string {
// the authenticity token is passed into the element and is not generated in js land

return this.getAttribute('csrf-field') || 'authenticity_token'
}

isRemote(): boolean {
return this.src != null
}

toggle() {
if (this.isRemote()) {
this.setLoadingState()
this.check()
} else {
this.performToggle()
}
}

turnOn(): void {
if (this.isDisabled()) {
return
}

this.switch.setAttribute('aria-checked', 'true')
this.classList.add('ToggleSwitch--checked')
}

turnOff(): void {
if (this.isDisabled()) {
return
}

this.switch.setAttribute('aria-checked', 'false')
this.classList.remove('ToggleSwitch--checked')
}

isOn(): boolean {
return this.switch.getAttribute('aria-checked') === 'true'
}

isOff(): boolean {
return !this.isOn()
}

isDisabled(): boolean {
return this.switch.getAttribute('aria-disabled') === 'true'
}

disable(): void {
this.switch.setAttribute('aria-disabled', 'true')
}

enable(): void {
this.switch.setAttribute('aria-disabled', 'false')
}

private performToggle(): void {
if (this.isOn()) {
this.turnOff()
} else {
this.turnOn()
}
}

private setLoadingState(): void {
this.disable()
this.errorIcon.setAttribute('hidden', 'hidden')
this.loadingSpinner.removeAttribute('hidden')
}

private setSuccessState(): void {
this.setFinishedState(false)
}

private setErrorState(): void {
this.setFinishedState(true)
}

private setFinishedState(error: boolean): void {
if (error) {
this.errorIcon.removeAttribute('hidden')
}

this.loadingSpinner.setAttribute('hidden', 'hidden')
this.enable()
}

@debounce(300)
private async check() {
const body = new FormData()

if (this.csrf) {
body.append(this.csrfField, this.csrf)
}

body.append('value', this.isOn() ? '1' : '0')

try {
const response = await fetch(this.src!, {
credentials: 'same-origin',
method: 'POST',
body
})
if (response.ok) {
this.setSuccessState()
this.performToggle()
} else {
this.setErrorState()
}
} catch (error) {
this.setErrorState()
}
}
}

declare global {
interface Window {
ToggleSwitchElement: typeof ToggleSwitchElement
}
}
44 changes: 44 additions & 0 deletions app/components/primer/alpha/toggle_switch.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<%= render(Primer::BaseComponent.new(tag: "toggle-switch", **@system_arguments)) do %>
<%= render(Primer::OcticonComponent.new(size: :small, color: :danger, icon: :alert, hidden: "true", data: { target: "toggle-switch.errorIcon" })) %>
<%= render(Primer::SpinnerComponent.new(size: :small, hidden: "true", data: { target: "toggle-switch.loadingSpinner" })) %>
<%= render(Primer::Beta::Text.new(aria: { hidden: true }, classes: "ToggleSwitch-status")) do %>
<%= render(Primer::Box.new(classes: "ToggleSwitch-statusOn").with_content("On")) %>
<%= render(Primer::Box.new(classes: "ToggleSwitch-statusOff").with_content("Off")) %>
<% end %>
<%= render(Primer::BaseComponent.new(tag: :button, classes: "ToggleSwitch-track", role: "switch", data: { target: "toggle-switch.switch", action: "click:toggle-switch#toggle" }, aria: { checked: on?, disabled: disabled?, label: "Switch" })) do %>
<%= render(Primer::Box.new(classes: "ToggleSwitch-icons", aria: { hidden: true })) do %>
<%= render(Primer::Box.new(classes: "ToggleSwitch-lineIcon")) do %>
<%= render(Primer::BaseComponent.new(
tag: :svg,
width: @size == :small ? 12 : 16,
height: @size == :small ? 12 : 16,
viewBox: "0 0 16 16",
fill: "currentColor",
xmlns: "http://www.w3.org/2000/svg",
aria: { hidden: true },
focusable: false
)) do %>
<path fill-rule="evenodd" d="M8 2a.75.75 0 0 1 .75.75v11.5a.75.75 0 0 1-1.5 0V2.75A.75.75 0 0 1 8 2Z" />
<% end %>
<% end %>
<%= render(Primer::Box.new(classes: "ToggleSwitch-circleIcon")) do %>
<%= render(Primer::BaseComponent.new(
tag: :svg,
width: @size == :small ? 12 : 16,
height: @size == :small ? 12 : 16,
viewBox: "0 0 16 16",
fill: "currentColor",
xmlns: "http://www.w3.org/2000/svg",
aria: { hidden: true },
focusable: false
)) do %>
<path fill-rule="evenodd" d="M8 12.5a4.5 4.5 0 1 0 0-9 4.5 4.5 0 0 0 0 9ZM8 14A6 6 0 1 0 8 2a6 6 0 0 0 0 12Z" />
<% end %>
<% end %>
<% end %>
<%= render(Primer::Box.new(classes: "ToggleSwitch-knob")) %>
<% end %>
<% end %>
89 changes: 89 additions & 0 deletions app/components/primer/alpha/toggle_switch.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# frozen_string_literal: true

module Primer
module Alpha
# The ToggleSwitch component is a button that toggles between two boolean states. It is meant to be used for
# settings that should cause an immediate update. If configured with a "src" attribute, the component will
# make a POST request containing data of the form "value: 0 | 1".
class ToggleSwitch < Primer::Component
SIZE_DEFAULT = :medium
SIZE_MAPPINGS = {
SIZE_DEFAULT => nil,
:small => "ToggleSwitch--small"
}.freeze
SIZE_OPTIONS = SIZE_MAPPINGS.keys.freeze

STATUS_LABEL_POSITION_DEFAULT = :start
STATUS_LABEL_POSITION_MAPPINGS = {
STATUS_LABEL_POSITION_DEFAULT => nil,
:end => "ToggleSwitch--statusAtEnd"
}.freeze
STATUS_LABEL_POSITION_OPTIONS = STATUS_LABEL_POSITION_MAPPINGS.keys.freeze

# @example Default
# <%= render(Primer::Alpha::ToggleSwitch.new(src: "/foo")) %>
#
# @example Checked
# <%= render(Primer::Alpha::ToggleSwitch.new(src: "/foo", checked: true)) %>
#
# @example Disabled
# <%= render(Primer::Alpha::ToggleSwitch.new(src: "/foo", enabled: false)) %>
#
# @example Checked and Disabled
# <%= render(Primer::Alpha::ToggleSwitch.new(src: "/foo", checked: true, enabled: false)) %>
#
# @example Small
# <%= render(Primer::Alpha::ToggleSwitch.new(src: "/foo", size: :small)) %>
#
# @example With status label positioned at the end
# <%= render(Primer::Alpha::ToggleSwitch.new(src: "/foo", status_label_position: :end)) %>
#
# @param src [String] The URL to POST to when the toggle switch is toggled. If `nil`, the toggle switch will not make any requests.
# @param csrf_token [String] A CSRF token that will be sent to the server as "authenticity_token" when the toggle switch is toggled. Unused if `src` is `nil`.
# @param checked [Boolean] Whether the toggle switch is on or off.
# @param enabled [Boolean] Whether or not the toggle switch responds to user input.
# @param size [Symbol] What size toggle switch to render. <%= one_of(Primer::Alpha::ToggleSwitch::STATUS_LABEL_POSITION_OPTIONS) %>
# @param status_label_position [Symbol] Which side of the toggle switch to render the status label. <%= one_of(Primer::Alpha::ToggleSwitch::SIZE_OPTIONS) %>
# @param system_arguments [Hash] <%= link_to_system_arguments_docs %>
def initialize(src: nil, csrf_token: nil, checked: false, enabled: true, size: SIZE_DEFAULT, status_label_position: STATUS_LABEL_POSITION_DEFAULT, **system_arguments)
@src = src
@csrf_token = csrf_token
@checked = checked
@enabled = enabled
@system_arguments = system_arguments

@size = fetch_or_fallback(SIZE_OPTIONS, size, SIZE_DEFAULT)
@status_label_position = fetch_or_fallback(
STATUS_LABEL_POSITION_OPTIONS, status_label_position, STATUS_LABEL_POSITION_DEFAULT
)

@system_arguments[:classes] = class_names(
@system_arguments.delete(:classes),
"ToggleSwitch",
on? ? "ToggleSwitch--checked" : nil,
enabled? ? nil : "ToggleSwitch--disabled",
STATUS_LABEL_POSITION_MAPPINGS[@status_label_position],
SIZE_MAPPINGS[@size]
)

@system_arguments[:src] = @src if @src

return unless @src && @csrf_token

@system_arguments[:csrf] = @csrf_token
end

def on?
@checked
end

def enabled?
@enabled
end

def disabled?
!enabled?
end
end
end
end
1 change: 1 addition & 0 deletions app/components/primer/primer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ import './time_ago_component'
import './local_time'
import './image_crop'
import './dropdown'
import './alpha/toggle-switch-element'
import './alpha/tool-tip-element'
23 changes: 23 additions & 0 deletions demo/app/controllers/toggle_switch_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# frozen_string_literal: true

# For toggle switch previews/tests
class ToggleSwitchController < ApplicationController
skip_before_action :verify_authenticity_token

def create
sleep 1 unless Rails.env.test?

if form_params[:authenticity_token] && form_params[:authenticity_token] != "let_me_in"
head :unauthorized
return
end

head :accepted
end

private

def form_params
params.permit(:value, :authenticity_token)
end
end
1 change: 1 addition & 0 deletions demo/config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@
get '/', to: redirect('/view-components/stories/')

get '/auto_complete', to: 'auto_complete_test#index'
resources :toggle_switch, only: [:create]
end
2 changes: 2 additions & 0 deletions docs/src/@primer/gatsby-theme-doctocat/nav.yml
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@
url: "/components/timeago"
- title: TimelineItem
url: "/components/timelineitem"
- title: ToggleSwitch
url: "/components/alpha/toggleswitch"
- title: Tooltip
url: "/components/alpha/tooltip"
- title: Truncate
Expand Down
2 changes: 1 addition & 1 deletion docs/static/primer_view_components.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/static/primer_view_components.js.map

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions lookbook/Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,8 @@ GEM
net-protocol
timeout
nio4r (2.5.8)
nokogiri (1.13.8-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.13.8-x86_64-linux)
racc (~> 1.4)
octicons (17.4.1)
Expand Down
23 changes: 23 additions & 0 deletions lookbook/app/controllers/toggle_switch_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# frozen_string_literal: true

# For toggle switch previews/tests
class ToggleSwitchController < ApplicationController
skip_before_action :verify_authenticity_token

def create
sleep 1 unless Rails.env.test?

if form_params[:authenticity_token] && form_params[:authenticity_token] != "let_me_in"
head :unauthorized
return
end

head :accepted
end

private

def form_params
params.permit(:value, :authenticity_token)
end
end
1 change: 1 addition & 0 deletions lookbook/config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@
mount Lookbook::Engine, at: "/lookbook"
get "/", to: redirect("/lookbook")
get "/auto_complete", to: "auto_complete_test#index"
resources :toggle_switch, only: [:create]
end
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,11 @@
},
"dependencies": {
"@github/auto-complete-element": "^3.3.4",
"@github/catalyst": "^1.6.0",
"@github/clipboard-copy-element": "^1.1.2",
"@github/details-menu-element": "^1.0.12",
"@github/image-crop-element": "^5.0.0",
"@github/mini-throttle": "^2.1.0",
"@github/tab-container-element": "^3.1.2",
"@github/time-elements": "^3.1.2",
"@primer/behaviors": "^1.1.3"
Expand Down
1 change: 1 addition & 0 deletions static/audited_at.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"Primer::Alpha::TabNav": "",
"Primer::Alpha::TabPanels": "",
"Primer::Alpha::TextField": "",
"Primer::Alpha::ToggleSwitch": "",
"Primer::Alpha::Tooltip": "",
"Primer::Alpha::UnderlineNav": "",
"Primer::Alpha::UnderlinePanels": "",
Expand Down
Loading

0 comments on commit f18ef0f

Please sign in to comment.