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

Add details-toggle Catalyst element to improve accessibility of Details component #3292

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
5 changes: 5 additions & 0 deletions .changeset/smooth-files-remember.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/view-components': minor
---

Improve Details component accessibility by setting aria-label and aria-expanded attributes correctly on initial render and when toggled open/closed.
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
- group:
- button "Dropdown"
- button "Close" [expanded]: Dropdown
- menu:
- menuitem "Item 1"
- menuitem "Item 2"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
- group:
- button "Dropdown"
- button "Open": Dropdown
8 changes: 8 additions & 0 deletions app/components/primer/alpha/dropdown.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,20 @@ module Alpha
class Dropdown < Primer::Component
status :alpha

ARIA_LABEL_OPEN_DEFAULT = "Close"
ARIA_LABEL_CLOSED_DEFAULT = "Open"

# Required trigger for the dropdown. Has the same arguments as <%= link_to_component(Primer::ButtonComponent) %>,
# but it is locked as a `summary` tag.
#
# @param aria_label_open [String] Defaults to "Close". Value to announce when menu is open.
# @param aria_label_closed [String] Defaults to "Open". Value to announce when menu is closed.
renders_one :button, lambda { |**system_arguments|
@button_arguments = system_arguments
@button_arguments[:button] = true
@button_arguments[:dropdown] = @with_caret
@button_arguments[:aria_label_open] = system_arguments[:aria_label_open] || ARIA_LABEL_OPEN_DEFAULT
@button_arguments[:aria_label_closed] = system_arguments[:aria_label_closed] || ARIA_LABEL_CLOSED_DEFAULT

Primer::Content.new
}
Expand Down
14 changes: 8 additions & 6 deletions app/components/primer/beta/details.html.erb
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
<% if disabled? %>
<%= summary %>
<% else %>
<%= render(Primer::BaseComponent.new(**@system_arguments)) do %>
<details-toggle>
<% if disabled? %>
<%= summary %>
<%= body %>
<% else %>
<%= render(Primer::BaseComponent.new(**@system_arguments)) do %>
<%= summary %>
<%= body %>
<% end %>
<% end %>
<% end %>
</details-toggle>
42 changes: 42 additions & 0 deletions app/components/primer/beta/details.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,47 @@ class Details < Primer::Component
:default => "details-overlay",
:dark => "details-overlay details-overlay-dark"
}.freeze
ARIA_LABEL_OPEN_DEFAULT = "Collapse"
ARIA_LABEL_CLOSED_DEFAULT = "Expand"

attr_reader :disabled
alias disabled? disabled

attr_reader :open
alias open? open

# Use the Summary slot as the target for toggling the Details content open/closed.
#
# @param button [Boolean] Whether or not to render the summary element as a button.
# @param aria_label_open [String] Defaults to "Collapse". Value to announce when details element is open.
# @param aria_label_closed [String] Defaults to "Expand". Value to announce when details element is closed.
renders_one :summary, lambda { |button: true, **system_arguments|
system_arguments[:tag] = :summary
system_arguments[:role] = "button"

aria_label_closed = system_arguments[:aria_label_closed] || ARIA_LABEL_CLOSED_DEFAULT
aria_label_open = system_arguments[:aria_label_open] || ARIA_LABEL_OPEN_DEFAULT

system_arguments[:data] = merge_data(
system_arguments, {
data: {
target: "details-toggle.summaryTarget",
action: "click:details-toggle#toggle",
aria_label_closed: aria_label_closed,
aria_label_open: aria_label_open,
}
}
)

system_arguments[:aria] = merge_aria(
system_arguments, {
aria: {
label: open? ? aria_label_open : aria_label_closed,
expanded: open?,
}
}
)

if disabled?
# rubocop:disable Primer/ComponentNameMigration
Primer::ButtonComponent.new(**system_arguments, disabled: true)
Expand Down Expand Up @@ -57,6 +90,15 @@ def initialize(overlay: NO_OVERLAY, reset: false, disabled: false, **system_argu
OVERLAY_MAPPINGS[fetch_or_fallback(OVERLAY_MAPPINGS.keys, overlay, NO_OVERLAY)],
"details-reset" => reset
)
@system_arguments[:data] = merge_data(
@system_arguments, {
data: {
target: "details-toggle.detailsTarget",
}
}
)
# https://developer.mozilla.org/en-US/docs/Web/HTML/Element/details#open
@open = !!@system_arguments[:open]
@disabled = disabled
@summary_info = nil
end
Expand Down
57 changes: 57 additions & 0 deletions app/components/primer/beta/details_toggle_element.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import {controller, target} from '@github/catalyst'

/**
* A companion Catalyst element for the Details view component. This element
* ensures the <details> and <summary> elements markup is properly accessible by
* updating the aria-label and aria-expanded attributes on click.
*
* aria-label values default to "Expand" and "Collapse". To override those
* values, use the `data-aria-label-open` and `data-aria-label-closed`
* attributes on the summary target.
*
* @example
* ```html
* <details-toggle>
* <details open=true data-target="details-toggle.detailsTarget">
* <summary
* aria-expanded="true"
* aria-label="Collapse me"
* data-target="details-toggle.summaryTarget"
* data-action="click:details-toggle#toggle"
* data-aria-label-closed="Expand me"
* data-aria-label-open="Collapse me"
* >
* Click me
* </summary>
* <div>Contents</div>
* </details>
* </details-toggle>
* ```
*/

@controller
class DetailsToggleElement extends HTMLElement {
@target detailsTarget!: HTMLDetailsElement
@target summaryTarget!: HTMLElement

toggle() {
const detailsIsOpen = this.detailsTarget.hasAttribute('open')
if (detailsIsOpen) {
const ariaLabelClosed = this.summaryTarget.getAttribute('data-aria-label-closed') || 'Expand'
this.summaryTarget.setAttribute('aria-label', ariaLabelClosed)
this.summaryTarget.setAttribute('aria-expanded', 'false')
} else {
const ariaLabelOpen = this.summaryTarget.getAttribute('data-aria-label-open') || 'Collapse'
this.summaryTarget.setAttribute('aria-label', ariaLabelOpen)
this.summaryTarget.setAttribute('aria-expanded', 'true')
}
}
}

declare global {
interface Window {
DetailsToggleElement: typeof DetailsToggleElement
}
}

window.DetailsToggleElement = DetailsToggleElement
1 change: 1 addition & 0 deletions app/components/primer/primer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ import '../../lib/primer/forms/primer_text_field'
import '../../lib/primer/forms/toggle_switch_input'
import './alpha/action_menu/action_menu_element'
import './alpha/select_panel_element'
import './beta/details_toggle_element'
12 changes: 12 additions & 0 deletions previews/primer/beta/details_preview.rb
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,18 @@ def without_button(reset: false, overlay: :default, disabled: false)
component.with_body { "Body" }
end
end

# @label Open
#
# @param overlay [Symbol] select [none, default, dark]
# @param reset [Boolean] toggle
# @param disabled [Boolean] toggle
def open(reset: false, overlay: :default, disabled: false)
render Primer::Beta::Details.new(reset: reset, overlay: overlay, disabled: disabled, open: true) do |component|
component.with_summary { "Click me" }
component.with_body { "Body" }
end
end
end
end
end
67 changes: 64 additions & 3 deletions static/info_arch.json
Original file line number Diff line number Diff line change
Expand Up @@ -3843,7 +3843,18 @@
"name": "button",
"description": "Required trigger for the dropdown. Has the same arguments as {{#link_to_component}}Primer::ButtonComponent{{/link_to_component}},\nbut it is locked as a `summary` tag.",
"parameters": [

{
"name": "aria_label_open",
"type": "String",
"default": "N/A",
"description": "Defaults to \"Close\". Value to announce when menu is open."
},
{
"name": "aria_label_closed",
"type": "String",
"default": "N/A",
"description": "Defaults to \"Open\". Value to announce when menu is closed."
}
]
},
{
Expand Down Expand Up @@ -13410,9 +13421,26 @@
"slots": [
{
"name": "summary",
"description": null,
"description": "Use the Summary slot as the target for toggling the Details content open/closed.",
"parameters": [

{
"name": "button",
"type": "Boolean",
"default": "N/A",
"description": "Whether or not to render the summary element as a button."
},
{
"name": "aria_label_open",
"type": "String",
"default": "N/A",
"description": "Defaults to \"Collapse\". Value to announce when details element is open."
},
{
"name": "aria_label_closed",
"type": "String",
"default": "N/A",
"description": "Defaults to \"Expand\". Value to announce when details element is closed."
}
]
},
{
Expand Down Expand Up @@ -13453,6 +13481,26 @@
],
"return_types": [

]
},
{
"name": "open",
"description": "Returns the value of attribute open.",
"parameters": [

],
"return_types": [

]
},
{
"name": "open?",
"description": "Returns the value of attribute open.",
"parameters": [

],
"return_types": [

]
}
],
Expand Down Expand Up @@ -13508,6 +13556,19 @@
"color-contrast"
]
}
},
{
"preview_path": "primer/beta/details/open",
"name": "open",
"snapshot": "false",
"skip_rules": {
"wont_fix": [
"region"
],
"will_fix": [
"color-contrast"
]
}
}
],
"subcomponents": [
Expand Down
13 changes: 13 additions & 0 deletions static/previews.json
Original file line number Diff line number Diff line change
Expand Up @@ -3118,6 +3118,19 @@
"color-contrast"
]
}
},
{
"preview_path": "primer/beta/details/open",
"name": "open",
"snapshot": "false",
"skip_rules": {
"wont_fix": [
"region"
],
"will_fix": [
"color-contrast"
]
}
}
]
},
Expand Down
17 changes: 17 additions & 0 deletions test/components/alpha/dropdown_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ def test_renders_dropdown

assert_selector("details.dropdown") do
assert_selector("summary.btn", text: "Button")
assert_selector("summary.btn[aria-label='Open']")
assert_selector("details-menu.dropdown-menu", visible: false) do
assert_selector(".dropdown-item", text: "Item", visible: false)
end
Expand Down Expand Up @@ -98,4 +99,20 @@ def test_renders_caret
end
end
end

def test_accepts_custom_values_for_button_aria_label
render_inline(Primer::Alpha::Dropdown.new(with_caret: true)) do |component|
component.with_button(aria_label_closed: "Open me", aria_label_open: "Close me") { "Button" }
component.with_menu do |menu|
menu.with_item { "Item" }
end
end

assert_selector("details.dropdown") do
assert_selector("summary.btn", text: "Button")
assert_selector("summary.btn[aria-label='Open me']")
assert_selector("summary[data-aria-label-open='Close me']")
assert_selector("summary[data-aria-label-closed='Open me']")
end
Comment on lines +111 to +116
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think assert_selector with an implicit receiver searches the entire page. Calling it on the yielded element instead should have the nesting effect you're after:

Suggested change
assert_selector("details.dropdown") do
assert_selector("summary.btn", text: "Button")
assert_selector("summary.btn[aria-label='Open me']")
assert_selector("summary[data-aria-label-open='Close me']")
assert_selector("summary[data-aria-label-closed='Open me']")
end
assert_selector("details.dropdown") do |dropdown|
dropdown.assert_selector("summary.btn", text: "Button")
dropdown.assert_selector("summary.btn[aria-label='Open me']")
dropdown.assert_selector("summary[data-aria-label-open='Close me']")
dropdown.assert_selector("summary[data-aria-label-closed='Open me']")
end

end
end
Loading
Loading