Skip to content

Commit

Permalink
initial dialog implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
keithamus committed Jul 28, 2022
1 parent bbdcc17 commit fe6e1c5
Show file tree
Hide file tree
Showing 20 changed files with 569 additions and 3 deletions.
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.

8 changes: 8 additions & 0 deletions app/components/primer/alpha/dialog.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<%= show_button %>
<div class="Overlay--hidden Overlay-backdrop--center Overlay-backdrop--full-whenNarrow" data-modal-dialog-overlay>
<%= render Primer::BaseComponent.new(**@system_arguments) do %>
<%= header %>
<%= body %>
<%= footer %>
<% end %>
</div>
127 changes: 127 additions & 0 deletions app/components/primer/alpha/dialog.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# frozen_string_literal: true

module Primer
module Alpha
# A `Dialog` is used to remove the user from the main application flow,
# to confirm actions, ask for disambiguation or to present small forms.
#
# @accessibility
# - **Dialog Accessible Name**: A dialog should have an accessible name,
# so screen readers are aware of the purpose of the dialog when it opens.
# Give an accessible name setting `:title`. The accessible name will be
# used as the main heading inside the dialog.
# - **Dialog unique id**: A dialog should be unique. Give a unique id
# setting `:dialog_id`. If no `:dialog_id` is given, a default randomize
# hex id is generated.
#
# The combination of both `:title` and `:dialog_id` establishes an
# `aria-labelledby` relationship between the title and the unique id of
# the dialog.
class Dialog < Primer::Component
DEFAULT_WIDTH = :medium
WIDTH_MAPPINGS = {
:small => "Overlay--width-small",
DEFAULT_WIDTH => "Overlay--width-medium",
:large => "Overlay--width-large",
:xlarge => "Overlay--width-xlarge",
:xxlarge => "Overlay--width-xxlarge"
}.freeze
WIDTH_OPTIONS = WIDTH_MAPPINGS.keys

# Optional button to open the dialog.
#
# @param system_arguments [Hash] The same arguments as <%= link_to_component(Primer::ButtonComponent) %>.
renders_one :show_button, lambda { |**system_arguments|
system_arguments[:classes] = class_names(
system_arguments[:classes]
)
system_arguments[:id] = "dialog-show-#{@system_arguments[:id]}"
system_arguments["data-show-dialog-id"] = @system_arguments[:id]
system_arguments[:data] = (system_arguments[:data] || {}).merge({ "show-dialog-id": @system_arguments[:id] })
Primer::ButtonComponent.new(**system_arguments)
}

# Header content.
#
# @param hide_divider [Boolean] If true the visual dividing line between the header and body will be hidden
# @param system_arguments [Hash] <%= link_to_system_arguments_docs %>
renders_one :header, lambda { |hide_divider: false, **system_arguments|
if @subtitle.present?
subtitle_id = "#{id}-description"
end
Primer::Alpha::Dialog::Header.new(
title: @title,
subtitle: @subtitle,
subtitle_id: subtitle_id,
hide_divider: hide_divider,
**system_arguments
)
}

# Required body content.
#
# @param system_arguments [Hash] <%= link_to_system_arguments_docs %>
renders_one :body, "Body"

# Footer content.
#
# @param hide_divider [Boolean] If true the visual dividing line between the body and footer will be hidden
# @param system_arguments [Hash] <%= link_to_system_arguments_docs %>
renders_one :footer, lambda { |hide_divider: false, **system_arguments|
Primer::Alpha::Dialog::Footer.new(hide_divider: hide_divider, **system_arguments)
}

# @example Dialog with Cancel and Submit buttons
# @description
# An ID is provided which enables wiring of the open and close buttons to the dialog.
# @code
# <%= render(Primer::Alpha::Dialog.new(
# title: "Dialog Example",
# )) do |d| %>
# <% d.show_button { "Show Dialog" } %>
# <% d.body do %>
# <p>Some content</p>
# <% end %>
# <% d.footer do %>
# <%= render(Primer::ButtonComponent.new(data: { "close-dialog-id": "my-dialog" })) { "Cancel" } %>
# <%= render(Primer::ButtonComponent.new(scheme: :primary)) { "Submit" } %>
# <% end %>
# <% end %>
# @param id [String] The id of the dialog.
# @param title [String] The title of the dialog.
# @param subtitle [String] The subtitle of the dialog. This will also set the `aria-describedby` attribute.
# @param width [Symbol] The width of the dialog. <%= one_of(Primer::Alpha::Dialog::WIDTH_OPTIONS) %>
# @param system_arguments [Hash] <%= link_to_system_arguments_docs %>
# @param body_padding_variant??
def initialize(
title:,
subtitle: nil,
width: DEFAULT_WIDTH,
id: "dialog-#{(36**3 + rand(36**4)).to_s(36)}",
**system_arguments
)
@system_arguments = deny_tag_argument(**system_arguments)

@system_arguments[:tag] = "modal-dialog"
@system_arguments[:role] = "dialog"
@system_arguments[:id] = id.to_s
@system_arguments[:aria] = { modal: true }
@system_arguments[:classes] = class_names(
"Overlay",
WIDTH_MAPPINGS[fetch_or_fallback(WIDTH_OPTIONS, width, DEFAULT_WIDTH)],
"Overlay--height-auto",
"Overlay--motion-scaleFade",
system_arguments[:classes]
)

@subtitle = subtitle
if subtitle.present?
@system_arguments[:aria].describedby ||= @description
end

@id = id.to_s
@title = title
end
end
end
end
22 changes: 22 additions & 0 deletions app/components/primer/alpha/dialog/body.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true

module Primer
module Alpha
class Dialog
# A `Dialog::Body` is a compositional component, used to render the
# Body of a dialog. See <%= link_to_component(Primer::Alpha::Dialog) %>.
class Body < Primer::Component
# @param system_arguments [Hash] <%= link_to_system_arguments_docs %>
def initialize(**system_arguments)
deny_tag_argument(**system_arguments)
system_arguments[:tag] = :header
system_arguments[:classes] = class_names(
"Overlay-body",
system_arguments[:classes]
)
render(Primer::BaseComponent.new(**system_arguments))
end
end
end
end
end
25 changes: 25 additions & 0 deletions app/components/primer/alpha/dialog/footer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# frozen_string_literal: true

module Primer
module Alpha
class Dialog
# A `Dialog::Footer` is a compositional component, used to render the
# Footer of a dialog. See <%= link_to_component(Primer::Alpha::Dialog) %>.
class Footer < Primer::Component
# @param hide_divider [Boolean] If true the visual dividing line between the body and footer will be hidden
# @param system_arguments [Hash] <%= link_to_system_arguments_docs %>
def initialize(hide_divider: false, **system_arguments)
deny_tag_argument(**system_arguments)
system_arguments[:tag] = :div
system_arguments[:classes] = class_names(
"Overlay-footer",
"Overlay-footer--alignEnd",
{ "Overlay-footer--divided": !hide_divider },
system_arguments[:classes]
)
render(Primer::BaseComponent.new(**system_arguments))
end
end
end
end
end
13 changes: 13 additions & 0 deletions app/components/primer/alpha/dialog/header.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<%= render Primer::BaseComponent.new(**@system_arguments) do %>
<div class="Overlay-headerContentWrap">
<div class="Overlay-titleWrap">
<h1 class="Overlay-title"><%= @title %></h1>
<% if @subtitle.present? %>
<h2 id="<%= @subtitle_id %>" class="Overlay-description"><%= @subtitle %></h2>
<% end %>
</div>
<div class="Overlay-actionWrap">
<%= render Primer::CloseButton.new(classes: "Overlay-closeButton", "data-close-dialog-id": @system_arguments[:id]) %>
</div>
</div>
<% end %>
32 changes: 32 additions & 0 deletions app/components/primer/alpha/dialog/header.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# frozen_string_literal: true

module Primer
module Alpha
class Dialog
# A `Dialog::Header` is a compositional component, used to render the
# Header of a dialog. See <%= link_to_component(Primer::Alpha::Dialog) %>.
class Header < Primer::Component
# @param title [String] The title of the dialog.
# @param subtitle [String] The subtitle of the dialog. This will also set the `aria-describedby` attribute.
# @param hide_divider [Boolean] If true the visual dividing line between the body and footer will be hidden
# @param system_arguments [Hash] <%= link_to_system_arguments_docs %>
def initialize(
title:,
subtitle: nil,
sbutitle_id: nil,
hide_divider: false,
**system_arguments
)
deny_tag_argument(**system_arguments)
system_arguments[:tag] = :header
system_arguments[:classes] = class_names(
"Overlay-header",
{ "Overlay-header--divided": !hide_divider },
system_arguments[:classes]
)
render(Primer::BaseComponent.new(**system_arguments))
end
end
end
end
end
150 changes: 150 additions & 0 deletions app/components/primer/alpha/modal-dialog-element.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import {focusTrap} from '@primer/behaviors'
import {getFocusableChild} from '@primer/behaviors/utils'

function focusIfNeeded(elem?: HTMLElement) {
if (document.activeElement !== elem) {
elem?.focus()
}
}

export class ModalDialogElement extends HTMLElement {
//TODO: Do we remove the abortController from focusTrap?
#focusAbortController = new AbortController()
#abortController: AbortController | null = null
#openButton: HTMLButtonElement | undefined
#shouldTryLoadingFragment = true

get open() {
return this.hasAttribute('open')
}
set open(value: boolean) {
if (value) {
if (this.open) return
this.setAttribute('open', '')
this.#overlayBackdrop?.classList.remove('Overlay--hidden')
document.body.style.overflow = 'hidden'
if (this.#focusAbortController.signal.aborted) {
this.#focusAbortController = new AbortController()
}
focusTrap(this, undefined, this.#focusAbortController.signal)
} else {
if (!this.open) return
this.removeAttribute('open')
this.#overlayBackdrop?.classList.add('Overlay--hidden')
document.body.style.overflow = 'initial'
this.#focusAbortController.abort()
// if #openButton is a child of a menu, we need to focus a suitable child of the menu
// element since it is expected for the menu to close on click
const menu = this.#openButton?.closest('details') || this.#openButton?.closest('action-menu')
if (menu) {
focusIfNeeded(getFocusableChild(menu))
} else {
focusIfNeeded(this.#openButton)
}
this.#openButton = undefined
}
}

get #overlayBackdrop(): HTMLElement | null {
if (this.parentElement?.hasAttribute('data-modal-dialog-overlay')) {
return this.parentElement
}

return null
}

get showButtons(): NodeList {
// Dialogs may also be opened from any arbitrary button with a matching show-dialog-id data attribute
return document.querySelectorAll(`button[data-show-dialog-id='${this.id}']`)
}

connectedCallback(): void {
if (!this.hasAttribute('role')) this.setAttribute('role', 'dialog')

const {signal} = (this.#abortController = new AbortController())

this.ownerDocument.addEventListener(
'click',
event => {
const target = event.target as HTMLElement
const clickOutsideDialog = target.closest(this.tagName) !== this
const button = target?.closest('button')
// go over this logic:
if (!button) {
if (clickOutsideDialog) {
// This click is outside the dialog
this.close()
}
return
}

let dialogId = button.getAttribute('data-close-dialog-id')
if (dialogId === this.id) {
this.close()
}

dialogId = button.getAttribute('data-submit-dialog-id')
if (dialogId === this.id) {
this.close(true)
}

dialogId = button.getAttribute('data-show-dialog-id')
if (dialogId === this.id) {
//TODO: see if I can remove this
event.stopPropagation()
this.#openButton = button
this.show()
}
},
{signal}
)

this.addEventListener('keydown', e => this.#keydown(e))
}

disconnectedCallback(): void {
this.#abortController?.abort()
}

show() {
this.open = true
}

close(closed = false) {
if (this.open === false) return
const eventType = closed ? 'close' : 'cancel'
const dialogEvent = new Event(eventType)
this.dispatchEvent(dialogEvent)
this.open = false
}

#keydown(event: Event) {
if (!(event instanceof KeyboardEvent)) return
if (event.isComposing) return

switch (event.key) {
case 'Escape':
if (this.open) {
this.close()
event.preventDefault()
event.stopPropagation()
}
break
}
}
}

declare global {
interface Window {
ModalDialogElement: typeof ModalDialogElement
}
interface HTMLElementTagNameMap {
'modal-dialog': ModalDialogElement
}
}

if (!window.customElements.get('modal-dialog')) {
window.ModalDialogElement = ModalDialogElement
window.customElements.define('modal-dialog', ModalDialogElement)
}

1 change: 1 addition & 0 deletions app/components/primer/primer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ import './local_time'
import './image_crop'
import './dropdown'
import './alpha/tool-tip-element'
import './alpha/modal-dialog-element'
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 @@ -45,6 +45,8 @@
url: "/components/counter"
- title: Details
url: "/components/details"
- title: Dialog
url: "/components/alpha/dialog"
- title: Dropdown
url: "/components/dropdown"
- title: DropdownMenu
Expand Down
Loading

0 comments on commit fe6e1c5

Please sign in to comment.