openproject-primer_view_components 0.12.1 → 0.13.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -0
  3. data/app/assets/javascripts/app/components/primer/alpha/tool_tip.d.ts +1 -0
  4. data/app/assets/javascripts/primer_view_components.js +1 -1
  5. data/app/assets/javascripts/primer_view_components.js.map +1 -1
  6. data/app/assets/styles/primer_view_components.css +1 -1
  7. data/app/assets/styles/primer_view_components.css.map +1 -1
  8. data/app/components/primer/alpha/action_bar/item.rb +7 -4
  9. data/app/components/primer/alpha/action_bar.rb +2 -2
  10. data/app/components/primer/alpha/action_bar_element.js +11 -4
  11. data/app/components/primer/alpha/action_bar_element.ts +11 -2
  12. data/app/components/primer/alpha/action_list/form_wrapper.html.erb +4 -2
  13. data/app/components/primer/alpha/action_list/form_wrapper.rb +20 -9
  14. data/app/components/primer/alpha/action_menu/action_menu_element.js +174 -84
  15. data/app/components/primer/alpha/action_menu/action_menu_element.ts +218 -81
  16. data/app/components/primer/alpha/action_menu/list.rb +0 -2
  17. data/app/components/primer/alpha/action_menu.rb +120 -3
  18. data/app/components/primer/alpha/modal_dialog.js +10 -13
  19. data/app/components/primer/alpha/modal_dialog.ts +10 -13
  20. data/app/components/primer/alpha/segmented_control/item.html.erb +1 -8
  21. data/app/components/primer/alpha/segmented_control/item.rb +38 -4
  22. data/app/components/primer/alpha/segmented_control.css +1 -1
  23. data/app/components/primer/alpha/segmented_control.css.json +14 -13
  24. data/app/components/primer/alpha/segmented_control.css.map +1 -1
  25. data/app/components/primer/alpha/segmented_control.pcss +75 -66
  26. data/app/components/primer/alpha/segmented_control.rb +10 -0
  27. data/app/components/primer/alpha/text_field.css +1 -1
  28. data/app/components/primer/alpha/tool_tip.d.ts +1 -0
  29. data/app/components/primer/alpha/tool_tip.js +34 -28
  30. data/app/components/primer/alpha/tool_tip.ts +33 -26
  31. data/app/components/primer/beta/base_button.rb +4 -0
  32. data/app/components/primer/beta/button.css +1 -1
  33. data/app/components/primer/beta/button.css.map +1 -1
  34. data/app/components/primer/beta/button.pcss +6 -2
  35. data/app/components/primer/focus_group.js +28 -3
  36. data/app/components/primer/focus_group.ts +27 -1
  37. data/lib/primer/static/generate_info_arch.rb +86 -5
  38. data/lib/primer/view_components/version.rb +1 -1
  39. data/previews/primer/alpha/action_menu_preview/single_select_form_items.html.erb +31 -0
  40. data/previews/primer/alpha/action_menu_preview/with_actions.html.erb +6 -5
  41. data/previews/primer/alpha/action_menu_preview.rb +10 -1
  42. data/previews/primer/alpha/check_box_preview.rb +0 -2
  43. data/previews/primer/alpha/dialog_preview/autofocus_element.html.erb +8 -0
  44. data/previews/primer/alpha/dialog_preview/with_text_input.html.erb +2 -1
  45. data/previews/primer/alpha/dialog_preview.rb +5 -0
  46. data/previews/primer/alpha/radio_button_preview.rb +0 -2
  47. data/previews/primer/alpha/segmented_control_preview.rb +35 -0
  48. data/previews/primer/alpha/tooltip_preview/tooltip_with_dialog_moving_focus_to_input.html.erb +23 -0
  49. data/previews/primer/alpha/tooltip_preview.rb +6 -1
  50. data/previews/primer/beta/button_group_preview.rb +6 -6
  51. data/previews/primer/beta/button_preview.rb +20 -2
  52. data/previews/primer/beta/icon_button_preview.rb +3 -0
  53. data/static/arguments.json +18 -1
  54. data/static/classes.json +12 -0
  55. data/static/constants.json +12 -1
  56. data/static/info_arch.json +135 -5
  57. data/static/previews.json +91 -0
  58. metadata +5 -310
@@ -20,6 +20,7 @@ export class ActionMenuElement extends HTMLElement {
20
20
  #abortController: AbortController
21
21
  #originalLabel = ''
22
22
  #inputName = ''
23
+ #invokerBeingClicked = false
23
24
 
24
25
  get selectVariant(): SelectVariant {
25
26
  return this.getAttribute('data-select-variant') as SelectVariant
@@ -52,7 +53,7 @@ export class ActionMenuElement extends HTMLElement {
52
53
  }
53
54
 
54
55
  get popoverElement(): HTMLElement | null {
55
- return this.invokerElement?.popoverTargetElement || null
56
+ return (this.invokerElement?.popoverTargetElement as HTMLElement) || null
56
57
  }
57
58
 
58
59
  get invokerElement(): HTMLButtonElement | null {
@@ -94,8 +95,10 @@ export class ActionMenuElement extends HTMLElement {
94
95
  this.addEventListener('click', this, {signal})
95
96
  this.addEventListener('mouseover', this, {signal})
96
97
  this.addEventListener('focusout', this, {signal})
98
+ this.addEventListener('mousedown', this, {signal})
97
99
  this.#setDynamicLabel()
98
100
  this.#updateInput()
101
+ this.#softDisableItems()
99
102
 
100
103
  if (this.includeFragment) {
101
104
  this.includeFragment.addEventListener('include-fragment-replaced', this, {
@@ -104,102 +107,241 @@ export class ActionMenuElement extends HTMLElement {
104
107
  }
105
108
  }
106
109
 
110
+ #softDisableItems() {
111
+ const {signal} = this.#abortController
112
+
113
+ for (const item of this.#items) {
114
+ item.addEventListener('click', this.#potentiallyDisallowActivation.bind(this), {signal})
115
+ item.addEventListener('keydown', this.#potentiallyDisallowActivation.bind(this), {signal})
116
+ }
117
+ }
118
+
119
+ #potentiallyDisallowActivation(event: Event) {
120
+ if (!this.#isActivation(event)) return
121
+
122
+ const item = (event.target as HTMLElement).closest(menuItemSelectors.join(','))
123
+ if (!item) return
124
+
125
+ if (item.getAttribute('aria-disabled')) {
126
+ event.preventDefault()
127
+ event.stopPropagation()
128
+ event.stopImmediatePropagation()
129
+ }
130
+ }
131
+
107
132
  disconnectedCallback() {
108
133
  this.#abortController.abort()
109
134
  }
110
135
 
136
+ #isKeyboardActivation(event: Event): boolean {
137
+ return this.#isKeyboardActivationViaEnter(event) || this.#isKeyboardActivationViaSpace(event)
138
+ }
139
+
140
+ #isKeyboardActivationViaEnter(event: Event): boolean {
141
+ return (
142
+ event instanceof KeyboardEvent &&
143
+ event.type === 'keydown' &&
144
+ !(event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) &&
145
+ event.key === 'Enter'
146
+ )
147
+ }
148
+
149
+ #isKeyboardActivationViaSpace(event: Event): boolean {
150
+ return (
151
+ event instanceof KeyboardEvent &&
152
+ event.type === 'keydown' &&
153
+ !(event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) &&
154
+ event.key === ' '
155
+ )
156
+ }
157
+
158
+ #isMouseActivation(event: Event): boolean {
159
+ return event instanceof MouseEvent && event.type === 'click'
160
+ }
161
+
162
+ #isActivation(event: Event): boolean {
163
+ return this.#isMouseActivation(event) || this.#isKeyboardActivation(event)
164
+ }
165
+
111
166
  handleEvent(event: Event) {
112
- const activation = this.#isActivationKeydown(event)
113
- if (event.target === this.invokerElement && activation) {
114
- if (this.#firstItem) {
115
- event.preventDefault()
116
- this.popoverElement?.showPopover()
117
- this.#firstItem.focus()
118
- return
119
- }
167
+ const targetIsInvoker = this.invokerElement?.contains(event.target as HTMLElement)
168
+ const eventIsActivation = this.#isActivation(event)
169
+
170
+ if (targetIsInvoker && event.type === 'mousedown') {
171
+ this.#invokerBeingClicked = true
172
+ return
173
+ }
174
+
175
+ // Prevent safari bug that dismisses menu on mousedown instead of allowing
176
+ // the click event to propagate to the button
177
+ if (event.type === 'mousedown') {
178
+ event.preventDefault()
179
+ return
120
180
  }
121
181
 
122
- // Ignore events within dialogs within menus
123
- if ((event.target as Element)?.closest('dialog') || (event.target as Element)?.closest('modal-dialog')) {
182
+ if (targetIsInvoker && eventIsActivation) {
183
+ this.#handleInvokerActivated(event)
184
+ this.#invokerBeingClicked = false
124
185
  return
125
186
  }
126
187
 
127
- // If a dialog has been rendered within the menu, we do not want to hide
128
- // the entire menu, as that will also hide the Dialog. Instead we want to
129
- // show the Dialog while hiding just the visible part of the menu.
130
- if ((activation || event.type === 'click') && (event.target as HTMLElement)?.closest('[data-show-dialog-id]')) {
131
- const dialogInvoker = (event.target as HTMLElement)!.closest('[data-show-dialog-id]')
132
- const dialog = this.ownerDocument.getElementById(dialogInvoker?.getAttribute('data-show-dialog-id') || '')
133
- if (dialogInvoker && dialog && this.contains(dialogInvoker) && this.contains(dialog)) {
134
- this.querySelector<HTMLElement>('.ActionListWrap')!.style.display = 'none'
135
- const dialog_controller = new AbortController()
136
- const {signal} = dialog_controller
137
- const handleDialogClose = () => {
138
- dialog_controller.abort()
139
- this.querySelector<HTMLElement>('.ActionListWrap')!.style.display = ''
140
- if (this.popoverElement?.matches(':popover-open')) {
141
- this.popoverElement?.hidePopover()
142
- }
188
+ if (event.type === 'focusout') {
189
+ if (this.#invokerBeingClicked) return
190
+
191
+ // Give the browser time to focus the next element
192
+ requestAnimationFrame(() => {
193
+ if (!this.contains(document.activeElement) || document.activeElement === this.invokerElement) {
194
+ this.#handleFocusOut()
143
195
  }
144
- dialog.addEventListener('close', handleDialogClose, {signal})
145
- dialog.addEventListener('cancel', handleDialogClose, {signal})
146
- return
147
- }
196
+ })
197
+
198
+ return
148
199
  }
149
200
 
150
- if (!this.popoverElement?.matches(':popover-open')) return
201
+ const item = (event.target as Element).closest(menuItemSelectors.join(','))
202
+ const targetIsItem = item !== null
151
203
 
152
- if (event.type === 'include-fragment-replaced') {
153
- if (this.#firstItem) this.#firstItem.focus()
154
- } else if (activation || (event instanceof MouseEvent && event.type === 'click')) {
155
- // Hide popover after current event loop to prevent changes in focus from
156
- // altering the target of the event. Not doing this specifically affects
157
- // <a> tags. It causes the event to be sent to the currently focused element
158
- // instead of the anchor, which effectively prevents navigation, i.e. it
159
- // appears as if hitting enter does nothing. Curiously, clicking instead
160
- // works fine.
161
- if (this.selectVariant !== 'multiple') {
162
- setTimeout(() => {
163
- if (this.popoverElement?.matches(':popover-open')) {
164
- this.popoverElement?.hidePopover()
165
- }
166
- })
204
+ if (targetIsItem && eventIsActivation) {
205
+ const dialogInvoker = item.closest('[data-show-dialog-id]')
206
+
207
+ if (dialogInvoker) {
208
+ const dialog = this.ownerDocument.getElementById(dialogInvoker.getAttribute('data-show-dialog-id') || '')
209
+
210
+ if (dialog && this.contains(dialogInvoker) && this.contains(dialog)) {
211
+ this.#handleDialogItemActivated(event, dialog)
212
+ return
213
+ }
167
214
  }
168
215
 
169
- // The rest of the code below deals with single/multiple selection behavior, and should not
170
- // interfere with events fired by menu items whose behavior is specified outside the library.
171
- if (this.selectVariant !== 'multiple' && this.selectVariant !== 'single') return
216
+ this.#activateItem(event, item)
217
+ this.#handleItemActivated(event, item)
172
218
 
173
- const item = (event.target as Element).closest(menuItemSelectors.join(','))
174
- if (!item) return
175
- const ariaChecked = item.getAttribute('aria-checked')
176
- const checked = ariaChecked !== 'true'
219
+ // Pressing the space key on a button will cause the page to scroll unless preventDefault()
220
+ // is called. Unfortunately, calling preventDefault() will also skip form submission. The
221
+ // code below therefore only calls preventDefault() if the button submits a form and the
222
+ // button is being activated by the space key.
223
+ if (item.getAttribute('type') === 'submit' && this.#isKeyboardActivationViaSpace(event)) {
224
+ event.preventDefault()
225
+ item.closest('form')?.submit()
226
+ }
177
227
 
178
- if (this.selectVariant === 'single') {
179
- // Only check, never uncheck here. Single-select mode does not allow unchecking a checked item.
180
- if (checked) {
181
- item.setAttribute('aria-checked', 'true')
182
- }
228
+ return
229
+ }
183
230
 
184
- for (const checkedItem of this.querySelectorAll('[aria-checked]')) {
185
- if (checkedItem !== item) {
186
- checkedItem.setAttribute('aria-checked', 'false')
187
- }
188
- }
231
+ if (event.type === 'include-fragment-replaced') {
232
+ this.#handleIncludeFragmentReplaced()
233
+ }
234
+ }
189
235
 
190
- this.#setDynamicLabel()
191
- } else {
192
- // multi-select mode allows unchecking a checked item
193
- item.setAttribute('aria-checked', `${checked}`)
236
+ #handleInvokerActivated(event: Event) {
237
+ event.preventDefault()
238
+ event.stopPropagation()
239
+
240
+ if (this.#isOpen()) {
241
+ this.#hide()
242
+ } else {
243
+ this.#show()
244
+ this.#firstItem?.focus()
245
+ }
246
+ }
247
+
248
+ #handleDialogItemActivated(event: Event, dialog: HTMLElement) {
249
+ this.querySelector<HTMLElement>('.ActionListWrap')!.style.display = 'none'
250
+ const dialog_controller = new AbortController()
251
+ const {signal} = dialog_controller
252
+ const handleDialogClose = () => {
253
+ dialog_controller.abort()
254
+ this.querySelector<HTMLElement>('.ActionListWrap')!.style.display = ''
255
+ if (this.#isOpen()) {
256
+ this.#hide()
194
257
  }
258
+ }
259
+ dialog.addEventListener('close', handleDialogClose, {signal})
260
+ dialog.addEventListener('cancel', handleDialogClose, {signal})
261
+ }
262
+
263
+ #handleItemActivated(event: Event, item: Element) {
264
+ // Hide popover after current event loop to prevent changes in focus from
265
+ // altering the target of the event. Not doing this specifically affects
266
+ // <a> tags. It causes the event to be sent to the currently focused element
267
+ // instead of the anchor, which effectively prevents navigation, i.e. it
268
+ // appears as if hitting enter does nothing. Curiously, clicking instead
269
+ // works fine.
270
+ if (this.selectVariant !== 'multiple') {
271
+ setTimeout(() => {
272
+ if (this.#isOpen()) {
273
+ this.#hide()
274
+ }
275
+ })
276
+ }
195
277
 
196
- this.#updateInput()
278
+ // The rest of the code below deals with single/multiple selection behavior, and should not
279
+ // interfere with events fired by menu items whose behavior is specified outside the library.
280
+ if (this.selectVariant !== 'multiple' && this.selectVariant !== 'single') return
197
281
 
198
- if (event instanceof KeyboardEvent && event.target instanceof HTMLButtonElement) {
199
- // prevent buttons from being clicked twice
200
- event.preventDefault()
282
+ const ariaChecked = item.getAttribute('aria-checked')
283
+ const checked = ariaChecked !== 'true'
284
+
285
+ if (this.selectVariant === 'single') {
286
+ // Only check, never uncheck here. Single-select mode does not allow unchecking a checked item.
287
+ if (checked) {
288
+ item.setAttribute('aria-checked', 'true')
289
+ }
290
+
291
+ for (const checkedItem of this.querySelectorAll('[aria-checked]')) {
292
+ if (checkedItem !== item) {
293
+ checkedItem.setAttribute('aria-checked', 'false')
294
+ }
201
295
  }
296
+
297
+ this.#setDynamicLabel()
298
+ } else {
299
+ // multi-select mode allows unchecking a checked item
300
+ item.setAttribute('aria-checked', `${checked}`)
202
301
  }
302
+
303
+ this.#updateInput()
304
+ }
305
+
306
+ #activateItem(event: Event, item: Element) {
307
+ const eventWillActivateByDefault =
308
+ (event instanceof MouseEvent && event.type === 'click') ||
309
+ (event instanceof KeyboardEvent &&
310
+ event.type === 'keydown' &&
311
+ !(event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) &&
312
+ event.key === 'Enter')
313
+
314
+ // if the event will result in activating the current item by default, i.e. is a
315
+ // mouse click or keyboard enter, bail out
316
+ if (eventWillActivateByDefault) return
317
+
318
+ // otherwise, event will not result in activation by default, so we stop it and
319
+ // simulate a click
320
+ event.stopPropagation()
321
+ const elem = item as HTMLElement
322
+ elem.click()
323
+ }
324
+
325
+ #handleIncludeFragmentReplaced() {
326
+ if (this.#firstItem) this.#firstItem.focus()
327
+ this.#softDisableItems()
328
+ }
329
+
330
+ // Close when focus leaves menu
331
+ #handleFocusOut() {
332
+ this.#hide()
333
+ }
334
+
335
+ #show() {
336
+ this.popoverElement?.showPopover()
337
+ }
338
+
339
+ #hide() {
340
+ this.popoverElement?.hidePopover()
341
+ }
342
+
343
+ #isOpen() {
344
+ return this.popoverElement?.matches(':popover-open')
203
345
  }
204
346
 
205
347
  #setDynamicLabel() {
@@ -261,18 +403,13 @@ export class ActionMenuElement extends HTMLElement {
261
403
  }
262
404
  }
263
405
 
264
- #isActivationKeydown(event: Event): boolean {
265
- return (
266
- event instanceof KeyboardEvent &&
267
- event.type === 'keydown' &&
268
- !(event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) &&
269
- (event.key === 'Enter' || event.key === ' ')
270
- )
271
- }
272
-
273
406
  get #firstItem(): HTMLElement | null {
274
407
  return this.querySelector(menuItemSelectors.join(','))
275
408
  }
409
+
410
+ get #items(): HTMLElement[] {
411
+ return Array.from(this.querySelectorAll(menuItemSelectors.join(',')))
412
+ }
276
413
  }
277
414
 
278
415
  if (!window.customElements.get('action-menu')) {
@@ -111,8 +111,6 @@ module Primer
111
111
  system_arguments,
112
112
  { aria: { disabled: true } }
113
113
  )
114
-
115
- content_arguments[:disabled] = "" if content_arguments[:tag] == :button
116
114
  end
117
115
 
118
116
  { data: data, **system_arguments, content_arguments: content_arguments }
@@ -3,14 +3,131 @@
3
3
 
4
4
  module Primer
5
5
  module Alpha
6
- # ActionMenu is used for actions, navigation, to display secondary options, or single/multi select lists. They appear when users interact with buttons, actions, or other controls.
6
+ # ActionMenu is used for actions, navigation, to display secondary options, or single/multi select lists. They appear when
7
+ # users interact with buttons, actions, or other controls.
7
8
  #
8
9
  # The only allowed elements for the `Item` components are: `:a`, `:button`, and `:clipboard-copy`. The default is `:button`.
9
10
  #
11
+ # ### Select variants
12
+ #
13
+ # While `ActionMenu`s default to a list of buttons that can link to other pages, copy text to the clipboard, etc, they also support
14
+ # `single` and `multiple` select variants. The single select variant allows a single item to be "selected" (i.e. marked "active")
15
+ # when clicked, which will cause a check mark to appear to the left of the item text. When the `multiple` select variant is chosen,
16
+ # multiple items may be selected and check marks will appear next to each selected item.
17
+ #
18
+ # Use the `select_variant:` option to control which variant the `ActionMenu` uses. For more information, see the documentation on
19
+ # supported arguments below.
20
+ #
21
+ # ### Dynamic labels
22
+ #
23
+ # When using the `single` select variant, an optional label indicating the selected item can be displayed inside the menu button.
24
+ # Dynamic labels can also be prefixed with custom text.
25
+ #
26
+ # Pass `dynamic_label: true` to enable dynamic label behavior, and pass `dynamic_label_prefix: "<string>"` to set a custom prefix.
27
+ # For more information, see the documentation on supported arguments below.
28
+ #
29
+ # ### `ActionMenu`s as form inputs
30
+ #
31
+ # When using either the `single` or `multiple` select variants, `ActionMenu`s can be used as form inputs. They behave very
32
+ # similarly to how HTML `<select>` boxes behave, and play nicely with Rails' built-in form mechanisms. Pass arguments via the
33
+ # `form_arguments:` argument, including the Rails form builder object and the name of the field:
34
+ #
35
+ # ```erb
36
+ # <% form_with(url: update_merge_strategy_path) do |f| %>
37
+ # <%= render(Primer::Alpha::ActionMenu.new(form_arguments: { builder: f, name: "merge_strategy" })) do |menu| %>
38
+ # <% menu.with_item(label: "Fast forward", data: { value: "fast_forward" }) %>
39
+ # <% menu.with_item(label: "Recursive", data: { value: "recursive" }) %>
40
+ # <% menu.with_item(label: "Ours", data: { value: "ours" }) %>
41
+ # <% menu.with_item(label: "Theirs", data: { value: "theirs" }) %>
42
+ # <% end %>
43
+ # <% end %>
44
+ # ```
45
+ #
46
+ # The value of the `data: { value: ... }` argument is sent to the server on submit, keyed using the name provided above
47
+ # (eg. `"merge_strategy"`). If no value is provided for an item, the value of that item is the item's label. Here's the
48
+ # corresponding `MergeStrategyController` that might be written to handle the form above:
49
+ #
50
+ # ```ruby
51
+ # class MergeStrategyController < ApplicationController
52
+ # def update
53
+ # puts "You chose #{merge_strategy_params[:merge_strategy]}"
54
+ # end
55
+ #
56
+ # private
57
+ #
58
+ # def merge_strategy_params
59
+ # params.permit(:merge_strategy)
60
+ # end
61
+ # end
62
+ # ```
63
+ #
64
+ # ### `ActionMenu` items that submit forms
65
+ #
66
+ # Whereas `ActionMenu` items normally permit navigation via `<a>` tags which make HTTP `get` requests, `ActionMenu` items
67
+ # also permit navigation via `POST` requests. To enable this behavior, include the `href:` argument as normal, but also pass
68
+ # the `form_arguments:` argument to the appropriate item:
69
+ #
70
+ # ```erb
71
+ # <%= render(Primer::Alpha::ActionMenu.new) do |menu| %>
72
+ # <% menu.with_item(
73
+ # label: "Repository",
74
+ # href: update_repo_grouping_path,
75
+ # form_arguments: {
76
+ # method: :post,
77
+ # name: "group_by",
78
+ # value: "repository"
79
+ # }
80
+ # ) %>
81
+ # <% end %>
82
+ # ```
83
+ #
84
+ # Make sure to specify `method: :post`, as the default is `:get`. When clicked, the list item will submit a POST request to
85
+ # the URL passed in the `href:` argument, including a parameter named `"group_by"` with a value of `"repository"`. If no value
86
+ # is given, the name, eg. `"group_by"`, will be used as the value.
87
+ #
88
+ # It is possible to include multiple fields on submit. Instead of passing the `name:` and `value:` arguments, pass an array via
89
+ # the `inputs:` argument:
90
+ #
91
+ # ```erb
92
+ # <%= render(Primer::Alpha::ActionMenu.new) do |menu| %>
93
+ # <% menu.with_show_button { "Group By" } %>
94
+ # <% menu.with_item(
95
+ # label: "Repository",
96
+ # href: update_repo_grouping_path,
97
+ # form_arguments: {
98
+ # method: :post,
99
+ # inputs: [{
100
+ # name: "group_by",
101
+ # value: "repository"
102
+ # }, {
103
+ # name: "some_other_field",
104
+ # value: "some value",
105
+ # }],
106
+ # }
107
+ # ) %>
108
+ # <% end %>
109
+ # ```
110
+ #
111
+ # ### Form arguments
112
+ #
113
+ # The following table summarizes the arguments allowed in the `form_arguments:` hash mentioned above.
114
+ #
115
+ # |Name |Type |Default|Description|
116
+ # |:----------------|:-------------|:------|:----------|
117
+ # |`method` |`Symbol` |`:get` |The HTTP request method to use to submit the form. One of `:get`, `:post`, `:patch`, `:put`, `:delete`, or `:head`|
118
+ # |`name` |`String` |`nil` |The name of the field that will be sent to the server on submit.|
119
+ # |`value` |`String` |`nil` |The value of the field that will be sent to the server on submit.|
120
+ # |`input_arguments`|`Hash` |`{}` |Additional key/value pairs to emit as HTML attributes on the `<input type="hidden">` element.|
121
+ # |`inputs` |`Array<Hash>` |`[]` |An array of hashes representing HTML `<input type="hidden">` elements. Must contain at least `name:` and `value:` keys. If additional key/value pairs are provided, they are emitted as HTML attributes on the `<input>` element. This argument supercedes the `name:`, `value:`, and `:input_arguments` arguments listed above.|
122
+ #
123
+ # The elements of the `inputs:` array will be emitted as HTML `<input type="hidden">` elements.
124
+ #
10
125
  # @accessibility
11
- # The action for the menu item needs to be on the element with `role="menuitem"`. Semantics are removed for everything nested inside of it. When a menu item is selected, the menu will close immediately.
126
+ # The action for the menu item needs to be on the element with `role="menuitem"`. Semantics are removed for everything
127
+ # nested inside of it. When a menu item is selected, the menu will close immediately.
12
128
  #
13
- # 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).
129
+ # Additional information around the keyboard functionality and implementation can be found on the
130
+ # [WAI-ARIA Authoring Practices](https://www.w3.org/TR/wai-aria-practices-1.2/#menu).
14
131
  class ActionMenu < Primer::Component
15
132
  status :alpha
16
133
 
@@ -38,19 +38,16 @@ function clickHandler(event) {
38
38
  return;
39
39
  }
40
40
  }
41
- // Find the top level dialog that is open.
42
- const topLevelDialog = overlayStack[overlayStack.length - 1];
43
- if (!topLevelDialog)
41
+ if (!overlayStack.length)
44
42
  return;
45
- dialogId = button.getAttribute('data-close-dialog-id');
46
- if (dialogId === topLevelDialog.id) {
47
- overlayStack.pop();
48
- topLevelDialog.close();
49
- }
50
- dialogId = button.getAttribute('data-submit-dialog-id');
51
- if (dialogId === topLevelDialog.id) {
52
- overlayStack.pop();
53
- topLevelDialog.close(true);
43
+ dialogId = button.getAttribute('data-close-dialog-id') || button.getAttribute('data-submit-dialog-id');
44
+ if (dialogId) {
45
+ const dialog = document.getElementById(dialogId);
46
+ if (dialog instanceof ModalDialogElement) {
47
+ const dialogIndex = overlayStack.findIndex(ele => ele.id === dialogId);
48
+ overlayStack.splice(dialogIndex, 1);
49
+ dialog.close(button.hasAttribute('data-submit-dialog-id'));
50
+ }
54
51
  }
55
52
  }
56
53
  function keydownHandler(event) {
@@ -107,7 +104,7 @@ export class ModalDialogElement extends HTMLElement {
107
104
  if (__classPrivateFieldGet(this, _ModalDialogElement_focusAbortController, "f").signal.aborted) {
108
105
  __classPrivateFieldSet(this, _ModalDialogElement_focusAbortController, new AbortController(), "f");
109
106
  }
110
- focusTrap(this, undefined, __classPrivateFieldGet(this, _ModalDialogElement_focusAbortController, "f").signal);
107
+ focusTrap(this, this.querySelector('[autofocus]'), __classPrivateFieldGet(this, _ModalDialogElement_focusAbortController, "f").signal);
111
108
  overlayStack.push(this);
112
109
  }
113
110
  else {
@@ -30,20 +30,17 @@ function clickHandler(event: Event) {
30
30
  return
31
31
  }
32
32
  }
33
- // Find the top level dialog that is open.
34
- const topLevelDialog = overlayStack[overlayStack.length - 1]
35
- if (!topLevelDialog) return
36
33
 
37
- dialogId = button.getAttribute('data-close-dialog-id')
38
- if (dialogId === topLevelDialog.id) {
39
- overlayStack.pop()
40
- topLevelDialog.close()
41
- }
34
+ if (!overlayStack.length) return
42
35
 
43
- dialogId = button.getAttribute('data-submit-dialog-id')
44
- if (dialogId === topLevelDialog.id) {
45
- overlayStack.pop()
46
- topLevelDialog.close(true)
36
+ dialogId = button.getAttribute('data-close-dialog-id') || button.getAttribute('data-submit-dialog-id')
37
+ if (dialogId) {
38
+ const dialog = document.getElementById(dialogId)
39
+ if (dialog instanceof ModalDialogElement) {
40
+ const dialogIndex = overlayStack.findIndex(ele => ele.id === dialogId)
41
+ overlayStack.splice(dialogIndex, 1)
42
+ dialog.close(button.hasAttribute('data-submit-dialog-id'))
43
+ }
47
44
  }
48
45
  }
49
46
 
@@ -107,7 +104,7 @@ export class ModalDialogElement extends HTMLElement {
107
104
  if (this.#focusAbortController.signal.aborted) {
108
105
  this.#focusAbortController = new AbortController()
109
106
  }
110
- focusTrap(this, undefined, this.#focusAbortController.signal)
107
+ focusTrap(this, this.querySelector('[autofocus]') as HTMLElement, this.#focusAbortController.signal)
111
108
  overlayStack.push(this)
112
109
  } else {
113
110
  if (!this.open) return
@@ -2,12 +2,5 @@
2
2
  "SegmentedControl-item",
3
3
  "SegmentedControl-item--selected": @selected
4
4
  ) %>" role="listitem" data-targets="segmented-control.items">
5
- <% if @hide_labels %>
6
- <%= render Primer::Beta::IconButton.new(icon: @icon, "aria-label": @label, **@system_arguments) %>
7
- <% else %>
8
- <%= render Primer::Beta::Button.new(**@system_arguments) do |button| %>
9
- <% button.with_leading_visual_icon(icon: @icon) unless @icon.nil? %>
10
- <%= @label %>
11
- <% end %>
12
- <% end %>
5
+ <%= render @button %>
13
6
  </li>
@@ -13,16 +13,50 @@ module Primer
13
13
  # @param selected [Boolean] Whether the item is selected
14
14
  # @param icon [Symbol] The icon to use
15
15
  # @param hide_labels [Symbol] Whether to only show the icon
16
- def initialize(label:, selected: false, icon: nil, hide_labels: false, **system_arguments)
17
- @icon = icon
18
- @hide_labels = hide_labels
19
- @label = label
16
+ def initialize(
17
+ label:,
18
+ selected: false,
19
+ icon: nil,
20
+ hide_labels: false,
21
+ **system_arguments
22
+ )
20
23
  @selected = selected
21
24
 
22
25
  @system_arguments = system_arguments
23
26
  @system_arguments[:"data-action"] = "click:segmented-control#select" if system_arguments[:href].nil?
24
27
  @system_arguments[:"aria-current"] = selected
25
28
  @system_arguments[:scheme] = :invisible
29
+
30
+ if hide_labels
31
+ @button = Primer::Beta::IconButton.new(
32
+ icon: icon,
33
+ "aria-label": label,
34
+ **@system_arguments
35
+ )
36
+ else
37
+ @button = Primer::Beta::Button.new(**@system_arguments)
38
+ @button.with_leading_visual_icon(icon: icon) if icon
39
+ @button.with_content(label)
40
+ end
41
+ end
42
+
43
+ # @!parse
44
+ # # Optional trailing Label
45
+ # #
46
+ # # @param system_arguments [Hash] The arguments accepted by <%= link_to_component(Primer::Beta::Button) %>'s `with_trailing_visual_label` slot.
47
+ # renders_one(:trailing_visual_label)
48
+
49
+ # Optional trailing label.
50
+ #
51
+ # @param system_arguments [Hash] The arguments accepted by <%= link_to_component(Primer::Beta::Button) %>'s `with_trailing_visual_label` slot.
52
+ def with_trailing_visual_label(**system_arguments, &block)
53
+ @button.with_trailing_visual_label(**system_arguments, &block)
54
+ end
55
+
56
+ private
57
+
58
+ def before_render
59
+ content
26
60
  end
27
61
  end
28
62
  end