Skip to content

Commit

Permalink
Don't allow previously hidden items to be checkable; add ActionMenu j…
Browse files Browse the repository at this point in the history
…s API (#2393)
  • Loading branch information
camertron authored Nov 22, 2023
1 parent 542eeb3 commit 745eae0
Show file tree
Hide file tree
Showing 9 changed files with 331 additions and 75 deletions.
5 changes: 5 additions & 0 deletions .changeset/strange-rings-confess.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/view-components': minor
---

[ActionMenu] Don't allow previously hidden items to be checkable; add JavaScript API
14 changes: 13 additions & 1 deletion app/components/primer/alpha/action_list/item.rb
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ class Item < Primer::Component
# @private
renders_one :private_content

attr_reader :id, :list, :href, :active, :disabled, :parent
attr_reader :id, :item_id, :list, :href, :active, :disabled, :parent

# Whether or not this item is active.
#
Expand All @@ -143,6 +143,7 @@ class Item < Primer::Component
# @param list [Primer::Alpha::ActionList] The list that contains this item. Used internally.
# @param parent [Primer::Alpha::ActionList::Item] This item's parent item. `nil` if this item is at the root. Used internally.
# @param label [String] Item label. If no label is provided, content is used.
# @param item_id [String] An ID that will be attached to the item's `<li>` element as `data-item-id` for distinguishing between items, perhaps in JavaScript.
# @param label_classes [String] CSS classes that will be added to the label.
# @param label_arguments [Hash] <%= link_to_system_arguments_docs %> used to construct the label.
# @param content_arguments [Hash] <%= link_to_system_arguments_docs %> used to construct the item's anchor or button tag.
Expand All @@ -161,6 +162,7 @@ class Item < Primer::Component
def initialize(
list:,
label: nil,
item_id: nil,
label_classes: nil,
label_arguments: {},
content_arguments: {},
Expand All @@ -181,6 +183,7 @@ def initialize(
@list = list
@parent = parent
@label = label
@item_id = item_id
@href = href || content_arguments[:href]
@truncate_label = truncate_label
@disabled = disabled
Expand All @@ -206,6 +209,15 @@ def initialize(
@system_arguments[:data] ||= {}
@system_arguments[:data][:targets] = "#{list_class.custom_element_name}.items"

@system_arguments[:data] = merge_data(
@system_arguments, {
data: {
targets: "#{list_class.custom_element_name}.items",
**(@item_id ? { item_id: @item_id } : {})
}
}
)

@label_arguments = {
**label_arguments,
classes: class_names(
Expand Down
33 changes: 33 additions & 0 deletions app/components/primer/alpha/action_menu.rb
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,39 @@ module Alpha
#
# Additional information around the keyboard functionality and implementation can be found on the
# [WAI-ARIA Authoring Practices](https://www.w3.org/TR/wai-aria-practices-1.2/#menu).
#
# ### JavaScript API
#
# `ActionList`s render an `<action-list>` custom element that exposes behavior to the client. For all these methods,
# `itemId` refers to the value of the `item_id:` argument (see below) that is used to populate the `data-item-id` HTML
# attribute.
#
# #### Query methods
#
# * `getItemById(itemId: string): Element`: Returns the item's HTML `<li>` element. The return value can be passed as the `item` argument to the other methods listed below.
# * `isItemChecked(item: Element): boolean`: Returns `true` if the item is checked, `false` otherwise.
# * `isItemHidden(item: Element): boolean`: Returns `true` if the item is hidden, `false` otherwise.
# * `isItemDisabled(item: Element): boolean`: Returns `true` if the item is disabled, `false` otherwise.
#
# #### State methods
#
# * `showItem(item: Element)`: Shows the item, i.e. makes it visible.
# * `hideItem(item: Element)`: Hides the item, i.e. makes it invisible.
# * `enableItem(item: Element)`: Enables the item, i.e. makes it clickable by the mouse and keyboard.
# * `disableItem(item: Element)`: Disables the item, i.e. makes it unclickable by the mouse and keyboard.
# * `checkItem(item: Element)`: Checks the item. Only has an effect in single- and multi-select modes.
# * `uncheckItem(item: Element)`: Unchecks the item. Only has an effect in multi-select mode, since items cannot be unchecked in single-select mode.
#
# #### Events
#
# The `<action-menu>` element fires an `itemActivated` event whenever an item is activated (eg. clicked) via the mouse or keyboard.
#
# ```typescript
# document.querySelector("action-menu").addEventListener("itemActivated", (event: ItemActivatedEvent) => {
# event.item // Element: the <li> item that was activated
# event.checked // boolean: whether or not the result of the activation checked the item
# })
# ```
class ActionMenu < Primer::Component
status :alpha

Expand Down
122 changes: 110 additions & 12 deletions app/components/primer/alpha/action_menu/action_menu_element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,17 @@ type SelectedItem = {
const validSelectors = ['[role="menuitem"]', '[role="menuitemcheckbox"]', '[role="menuitemradio"]']
const menuItemSelectors = validSelectors.map(selector => `:not([hidden]) > ${selector}`)

export type ItemActivatedEvent = {
item: Element
checked: boolean
}

declare global {
interface HTMLElementEventMap {
itemActivated: CustomEvent<ItemActivatedEvent>
}
}

@controller
export class ActionMenuElement extends HTMLElement {
@target
Expand Down Expand Up @@ -82,7 +93,7 @@ export class ActionMenuElement extends HTMLElement {
results.push({
label: labelEl?.textContent,
value: selectedItem?.getAttribute('data-value'),
element: selectedItem
element: selectedItem,
})
}

Expand All @@ -102,35 +113,39 @@ export class ActionMenuElement extends HTMLElement {

if (this.includeFragment) {
this.includeFragment.addEventListener('include-fragment-replaced', this, {
signal
signal,
})
}
}

disconnectedCallback() {
this.#abortController.abort()
}

#softDisableItems() {
const {signal} = this.#abortController

for (const item of this.#items) {
for (const item of this.querySelectorAll(validSelectors.join(','))) {
item.addEventListener('click', this.#potentiallyDisallowActivation.bind(this), {signal})
item.addEventListener('keydown', this.#potentiallyDisallowActivation.bind(this), {signal})
}
}

#potentiallyDisallowActivation(event: Event) {
if (!this.#isActivation(event)) return
// returns true if activation was prevented
#potentiallyDisallowActivation(event: Event): boolean {
if (!this.#isActivation(event)) return false

const item = (event.target as HTMLElement).closest(menuItemSelectors.join(','))
if (!item) return
if (!item) return false

if (item.getAttribute('aria-disabled')) {
event.preventDefault()
event.stopPropagation()
event.stopImmediatePropagation()
return true
}
}

disconnectedCallback() {
this.#abortController.abort()
return false
}

#isKeyboardActivation(event: Event): boolean {
Expand Down Expand Up @@ -202,6 +217,8 @@ export class ActionMenuElement extends HTMLElement {
const targetIsItem = item !== null

if (targetIsItem && eventIsActivation) {
if (this.#potentiallyDisallowActivation(event)) return

const dialogInvoker = item.closest('[data-show-dialog-id]')

if (dialogInvoker) {
Expand All @@ -214,7 +231,7 @@ export class ActionMenuElement extends HTMLElement {
}

this.#activateItem(event, item)
this.#handleItemActivated(event, item)
this.#handleItemActivated(item)

// Pressing the space key on a button or link will cause the page to scroll unless preventDefault()
// is called. While calling preventDefault() appears to have no effect on link navigation, it skips
Expand Down Expand Up @@ -263,7 +280,7 @@ export class ActionMenuElement extends HTMLElement {
dialog.addEventListener('cancel', handleDialogClose, {signal})
}

#handleItemActivated(event: Event, item: Element) {
#handleItemActivated(item: Element) {
// Hide popover after current event loop to prevent changes in focus from
// altering the target of the event. Not doing this specifically affects
// <a> tags. It causes the event to be sent to the currently focused element
Expand Down Expand Up @@ -304,6 +321,11 @@ export class ActionMenuElement extends HTMLElement {
}

this.#updateInput()
this.dispatchEvent(
new CustomEvent('itemActivated', {
detail: {item: item.parentElement, checked: this.isItemChecked(item.parentElement)},
}),
)
}

#activateItem(event: Event, item: Element) {
Expand Down Expand Up @@ -410,9 +432,85 @@ export class ActionMenuElement extends HTMLElement {
return this.querySelector(menuItemSelectors.join(','))
}

get #items(): HTMLElement[] {
get items(): HTMLElement[] {
return Array.from(this.querySelectorAll(menuItemSelectors.join(',')))
}

getItemById(itemId: string): HTMLElement | null {
return this.querySelector(`li[data-item-id="${itemId}"`)
}

isItemDisabled(item: Element | null): boolean {
if (item) {
return item.classList.contains('ActionListItem--disabled')
} else {
return false
}
}

disableItem(item: Element | null) {
if (item) {
item.classList.add('ActionListItem--disabled')
item.querySelector('.ActionListContent')!.setAttribute('aria-disabled', 'true')
}
}

enableItem(item: Element | null) {
if (item) {
item.classList.remove('ActionListItem--disabled')
item.querySelector('.ActionListContent')!.removeAttribute('aria-disabled')
}
}

isItemHidden(item: Element | null): boolean {
if (item) {
return item.hasAttribute('hidden')
} else {
return false
}
}

hideItem(item: Element | null) {
if (item) {
item.setAttribute('hidden', 'hidden')
}
}

showItem(item: Element | null) {
if (item) {
item.removeAttribute('hidden')
}
}

isItemChecked(item: Element | null) {
if (item) {
return item.querySelector('.ActionListContent')!.getAttribute('aria-checked') === 'true'
} else {
return false
}
}

checkItem(item: Element | null) {
if (item && (this.selectVariant === 'single' || this.selectVariant === 'multiple')) {
const itemContent = item.querySelector('.ActionListContent')!
const ariaChecked = itemContent.getAttribute('aria-checked') === 'true'

if (!ariaChecked) {
this.#handleItemActivated(itemContent)
}
}
}

uncheckItem(item: Element | null) {
if (item && (this.selectVariant === 'single' || this.selectVariant === 'multiple')) {
const itemContent = item.querySelector('.ActionListContent')!
const ariaChecked = itemContent.getAttribute('aria-checked') === 'true'

if (ariaChecked) {
this.#handleItemActivated(itemContent)
}
}
}
}

if (!window.customElements.get('action-menu')) {
Expand Down
2 changes: 1 addition & 1 deletion app/components/primer/alpha/action_menu/list.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ class List < Primer::Component

attr_reader :items

# @param system_arguments [Hash] The arguments accepted by <%= link_to_component(Primer::Alpha::ActionMenu::List) %>
# @param system_arguments [Hash] The arguments accepted by <%= link_to_component(Primer::Alpha::ActionList) %>
def initialize(**system_arguments)
@items = []
@has_group = false
Expand Down
Loading

0 comments on commit 745eae0

Please sign in to comment.