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 27, 2022
1 parent bbdcc17 commit d574ac6
Show file tree
Hide file tree
Showing 16 changed files with 529 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.

22 changes: 22 additions & 0 deletions app/components/primer/alpha/dialog.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<%= 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 class="Overlay-header <%= @header_classes %>">
<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>
</header>
<div class="Overlay-body">
<%= body %>
</div>
<%= footer %>
<% end %>
</div>
138 changes: 138 additions & 0 deletions app/components/primer/alpha/dialog.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
# 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 system_arguments [Hash] <%= link_to_system_arguments_docs %>
renders_one :header, lambda { |**system_arguments|
deny_tag_argument(**system_arguments)
system_arguments[:tag] = :div
system_arguments[:classes] = class_names(
system_arguments[:classes]
)
Primer::BaseComponent.new(**system_arguments)
}

# Required body content.
#
# @param system_arguments [Hash] <%= link_to_system_arguments_docs %>
renders_one :body, lambda { |**system_arguments|
deny_tag_argument(**system_arguments)
system_arguments[:tag] = :div
system_arguments[:classes] = class_names(
system_arguments[:classes]
)
Primer::BaseComponent.new(**system_arguments)
}

# Footer content.
#
# @param system_arguments [Hash] <%= link_to_system_arguments_docs %>
renders_one :footer, lambda { |**system_arguments|
deny_tag_argument(**system_arguments)
system_arguments[:tag] = :div
system_arguments[:classes] = class_names(
"Overlay-footer",
"Overlay-footer--alignEnd",
"Overlay-footer--divided",
system_arguments[:classes]
)
Primer::BaseComponent.new(**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 show_header_divider
# @param show_footer_divider
# @paranm 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]
)

if subtitle.present?
@subtitle = subtitle
@subtitle_id = "#{id}-description"
@system_arguments[:aria].describedby ||= @description
end

@title = title
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
1 change: 1 addition & 0 deletions lib/tasks/docs.rake
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ namespace :docs do
Primer::CloseButton,
Primer::CounterComponent,
Primer::DetailsComponent,
Primer::Alpha::Dialog,
Primer::Dropdown,
Primer::DropdownMenuComponent,
Primer::Beta::Flash,
Expand Down
30 changes: 30 additions & 0 deletions static/arguments.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,36 @@
type: Hash
default: N/A
description: "[System arguments](/system-arguments)"
- component: Dialog
source: https://github.com/primer/view_components/tree/main/app/components/primer/alpha/dialog.rb
parameters:
- name: id
type: String
default: '`"dialog-#{(36**3 + rand(36**4)).to_s(36)}"`'
description: The id of the dialog.
- name: title
type: String
default: N/A
description: The title of the dialog.
- name: subtitle
type: String
default: "`nil`"
description: The subtitle of the dialog. This will also set the `aria-describedby`
attribute.
- name: role
type: String
default: '`"dialog"`'
description: The role of the dialog, defaults to `dialog`, but could also be set
to `alertdialog`.
- name: width
type: Symbol
default: "`:medium`"
description: The width of the dialog. One of `:large`, `:medium`, `:small`, `:xlarge`,
or `:xxlarge`.
- name: system_arguments
type: Hash
default: N/A
description: "[System arguments](/system-arguments)"
- component: Layout
source: https://github.com/primer/view_components/tree/main/app/components/primer/alpha/layout.rb
parameters:
Expand Down
1 change: 1 addition & 0 deletions static/audited_at.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"Primer::Alpha::BorderBox::Header": "",
"Primer::Alpha::ButtonMarketing": "",
"Primer::Alpha::Dialog": "",
"Primer::Alpha::Layout": "",
"Primer::Alpha::Layout::Main": "",
"Primer::Alpha::Layout::Sidebar": "",
Expand Down
Loading

0 comments on commit d574ac6

Please sign in to comment.