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 26, 2022
1 parent 1034692 commit 9489471
Show file tree
Hide file tree
Showing 17 changed files with 659 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>
137 changes: 137 additions & 0 deletions app/components/primer/alpha/dialog.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# 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 role [String] The role of the dialog, defaults to `dialog`, but could also be set to `alertdialog`.
# @param width [Symbol] The width of the dialog. <%= one_of(Primer::Alpha::Dialog::WIDTH_OPTIONS) %>
# @param system_arguments [Hash] <%= link_to_system_arguments_docs %>
def initialize(
title:,
subtitle: nil,
width: DEFAULT_WIDTH,
role: "dialog",
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] = role
@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
143 changes: 143 additions & 0 deletions app/components/primer/alpha/iterate-focusable-elements.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/**
* Options to the focusable elements iterator
*/
interface IterateFocusableElements {
/**
* (Default: false) Iterate through focusable elements in reverse-order
*/
reverse?: boolean

/**
* (Default: false) Perform additional checks to determine tabbability
* which may adversely affect app performance.
*/
strict?: boolean

/**
* (Default: false) Only iterate tabbable elements, which is the subset
* of focusable elements that are part of the page's tab sequence.
*/
onlyTabbable?: boolean
}

/**
* Returns an iterator over all of the focusable elements within `container`.
* Note: If `container` is itself focusable it will be included in the results.
* @param container The container over which to find focusable elements.
* @param reverse If true, iterate backwards through focusable elements.
*/
function* iterateFocusableElements(
container: HTMLElement,
options: IterateFocusableElements = {}
): Generator<HTMLElement, undefined, undefined> {
const strict = options.strict ?? false
const acceptFn = options.onlyTabbable ?? false ? isTabbable : isFocusable
const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, {
acceptNode: node =>
node instanceof HTMLElement && acceptFn(node, strict) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP
})
let nextNode: Node | null = null

// Allow the container to participate
if (!options.reverse && acceptFn(container, strict)) {
yield container
}

// If iterating in reverse, continue traversing down into the last child until we reach
// a leaf DOM node
if (options.reverse) {
let lastChild = walker.lastChild()
while (lastChild) {
nextNode = lastChild
lastChild = walker.lastChild()
}
} else {
nextNode = walker.firstChild()
}
while (nextNode instanceof HTMLElement) {
yield nextNode
nextNode = options.reverse ? walker.previousNode() : walker.nextNode()
}

// Allow the container to participate (in reverse)
if (options.reverse && acceptFn(container, strict)) {
yield container
}

return undefined
}

/**
* Focuses the `elem` element if it doesn't already have focus.
* @param elem
*/
export function focusIfNeeded(elem?: HTMLElement) {
if (document.activeElement !== elem) {
elem?.focus()
}
}

/**
* Returns the first focusable child of `container`. If `lastChild` is true,
* returns the last focusable child of `container`.
* @param container
* @param lastChild
*/
export function getFocusableChild(container: HTMLElement, lastChild = false) {
return iterateFocusableElements(container, {reverse: lastChild, strict: true, onlyTabbable: true}).next().value
}

/**
* Determines whether the given element is focusable. If `strict` is true, we may
* perform additional checks that require a reflow (less performant).
* @param elem
* @param strict
*/
function isFocusable(elem: HTMLElement, strict = false): boolean {
// Certain conditions cause an element to never be focusable, even if they have tabindex="0"
const disabledAttrInert =
['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'OPTGROUP', 'OPTION', 'FIELDSET'].includes(elem.tagName) &&
(elem as HTMLElement & {disabled: boolean}).disabled
const hiddenInert = elem.hidden
const hiddenInputInert = elem instanceof HTMLInputElement && elem.type === 'hidden'
const sentinelInert = elem.classList.contains('sentinel')
if (disabledAttrInert || hiddenInert || hiddenInputInert || sentinelInert) {
return false
}

// Each of the conditions checked below require a reflow, thus are gated by the `strict`
// argument. If any are true, the element is not focusable, even if tabindex is set.
if (strict) {
const sizeInert = elem.offsetWidth === 0 || elem.offsetHeight === 0
const visibilityInert = ['hidden', 'collapse'].includes(getComputedStyle(elem).visibility)
const clientRectsInert = elem.getClientRects().length === 0
if (sizeInert || visibilityInert || clientRectsInert) {
return false
}
}

// Any element with `tabindex` explicitly set can be focusable, even if it's set to "-1"
if (elem.getAttribute('tabindex') != null) {
return true
}

// One last way `elem.tabIndex` can be wrong.
if (elem instanceof HTMLAnchorElement && elem.getAttribute('href') == null) {
return false
}

return elem.tabIndex !== -1
}

/**
* Determines whether the given element is tabbable. If `strict` is true, we may
* perform additional checks that require a reflow (less performant). This check
* ensures that the element is focusable and that its tabindex is not explicitly
* set to "-1" (which makes it focusable, but removes it from the tab order).
* @param elem
* @param strict
*/
export function isTabbable(elem: HTMLElement, strict = false): boolean {
return isFocusable(elem, strict) && elem.getAttribute('tabindex') !== '-1'
}

Loading

0 comments on commit 9489471

Please sign in to comment.