Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Toggle switch #1321

Merged
merged 15 commits into from
Aug 23, 2022
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