openproject-primer_view_components 0.12.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -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 +9 -4
  11. data/app/components/primer/alpha/action_bar_element.ts +9 -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 +160 -85
  15. data/app/components/primer/alpha/action_menu/action_menu_element.ts +195 -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 +26 -93
  30. data/app/components/primer/alpha/tool_tip.ts +25 -91
  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/accessibility.rb +3 -1
  38. data/lib/primer/static/generate_info_arch.rb +86 -5
  39. data/lib/primer/view_components/version.rb +2 -2
  40. data/previews/primer/alpha/action_menu_preview/single_select_form_items.html.erb +31 -0
  41. data/previews/primer/alpha/action_menu_preview/with_actions.html.erb +6 -5
  42. data/previews/primer/alpha/action_menu_preview.rb +10 -1
  43. data/previews/primer/alpha/check_box_preview.rb +1 -0
  44. data/previews/primer/alpha/dialog_preview/autofocus_element.html.erb +8 -0
  45. data/previews/primer/alpha/dialog_preview.rb +5 -0
  46. data/previews/primer/alpha/radio_button_preview.rb +1 -0
  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 +137 -161
  57. data/static/previews.json +91 -172
  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,218 @@ 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 (
138
+ event instanceof KeyboardEvent &&
139
+ event.type === 'keydown' &&
140
+ !(event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) &&
141
+ (event.key === 'Enter' || event.key === ' ')
142
+ )
143
+ }
144
+
145
+ #isMouseActivation(event: Event): boolean {
146
+ return event instanceof MouseEvent && event.type === 'click'
147
+ }
148
+
149
+ #isActivation(event: Event): boolean {
150
+ return this.#isMouseActivation(event) || this.#isKeyboardActivation(event)
151
+ }
152
+
111
153
  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
- }
154
+ const targetIsInvoker = this.invokerElement?.contains(event.target as HTMLElement)
155
+ const eventIsActivation = this.#isActivation(event)
156
+
157
+ if (targetIsInvoker && event.type === 'mousedown') {
158
+ this.#invokerBeingClicked = true
159
+ return
160
+ }
161
+
162
+ // Prevent safari bug that dismisses menu on mousedown instead of allowing
163
+ // the click event to propagate to the button
164
+ if (event.type === 'mousedown') {
165
+ event.preventDefault()
166
+ return
120
167
  }
121
168
 
122
- // Ignore events within dialogs within menus
123
- if ((event.target as Element)?.closest('dialog') || (event.target as Element)?.closest('modal-dialog')) {
169
+ if (targetIsInvoker && eventIsActivation) {
170
+ this.#handleInvokerActivated(event)
171
+ this.#invokerBeingClicked = false
124
172
  return
125
173
  }
126
174
 
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
- }
175
+ if (event.type === 'focusout') {
176
+ if (this.#invokerBeingClicked) return
177
+
178
+ // Give the browser time to focus the next element
179
+ requestAnimationFrame(() => {
180
+ if (!this.contains(document.activeElement) || document.activeElement === this.invokerElement) {
181
+ this.#handleFocusOut()
143
182
  }
144
- dialog.addEventListener('close', handleDialogClose, {signal})
145
- dialog.addEventListener('cancel', handleDialogClose, {signal})
146
- return
147
- }
183
+ })
184
+
185
+ return
148
186
  }
149
187
 
150
- if (!this.popoverElement?.matches(':popover-open')) return
188
+ const item = (event.target as Element).closest(menuItemSelectors.join(','))
189
+ const targetIsItem = item !== null
151
190
 
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
- })
191
+ if (targetIsItem && eventIsActivation) {
192
+ const dialogInvoker = item.closest('[data-show-dialog-id]')
193
+
194
+ if (dialogInvoker) {
195
+ const dialog = this.ownerDocument.getElementById(dialogInvoker.getAttribute('data-show-dialog-id') || '')
196
+
197
+ if (dialog && this.contains(dialogInvoker) && this.contains(dialog)) {
198
+ this.#handleDialogItemActivated(event, dialog)
199
+ return
200
+ }
167
201
  }
168
202
 
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
203
+ this.#activateItem(event, item)
204
+ this.#handleItemActivated(event, item)
205
+ return
206
+ }
172
207
 
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'
208
+ if (event.type === 'include-fragment-replaced') {
209
+ this.#handleIncludeFragmentReplaced()
210
+ }
211
+ }
177
212
 
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
- }
213
+ #handleInvokerActivated(event: Event) {
214
+ event.preventDefault()
215
+ event.stopPropagation()
183
216
 
184
- for (const checkedItem of this.querySelectorAll('[aria-checked]')) {
185
- if (checkedItem !== item) {
186
- checkedItem.setAttribute('aria-checked', 'false')
187
- }
188
- }
217
+ if (this.#isOpen()) {
218
+ this.#hide()
219
+ } else {
220
+ this.#show()
221
+ this.#firstItem?.focus()
222
+ }
223
+ }
189
224
 
190
- this.#setDynamicLabel()
191
- } else {
192
- // multi-select mode allows unchecking a checked item
193
- item.setAttribute('aria-checked', `${checked}`)
225
+ #handleDialogItemActivated(event: Event, dialog: HTMLElement) {
226
+ this.querySelector<HTMLElement>('.ActionListWrap')!.style.display = 'none'
227
+ const dialog_controller = new AbortController()
228
+ const {signal} = dialog_controller
229
+ const handleDialogClose = () => {
230
+ dialog_controller.abort()
231
+ this.querySelector<HTMLElement>('.ActionListWrap')!.style.display = ''
232
+ if (this.#isOpen()) {
233
+ this.#hide()
194
234
  }
235
+ }
236
+ dialog.addEventListener('close', handleDialogClose, {signal})
237
+ dialog.addEventListener('cancel', handleDialogClose, {signal})
238
+ }
239
+
240
+ #handleItemActivated(event: Event, item: Element) {
241
+ // Hide popover after current event loop to prevent changes in focus from
242
+ // altering the target of the event. Not doing this specifically affects
243
+ // <a> tags. It causes the event to be sent to the currently focused element
244
+ // instead of the anchor, which effectively prevents navigation, i.e. it
245
+ // appears as if hitting enter does nothing. Curiously, clicking instead
246
+ // works fine.
247
+ if (this.selectVariant !== 'multiple') {
248
+ setTimeout(() => {
249
+ if (this.#isOpen()) {
250
+ this.#hide()
251
+ }
252
+ })
253
+ }
195
254
 
196
- this.#updateInput()
255
+ // The rest of the code below deals with single/multiple selection behavior, and should not
256
+ // interfere with events fired by menu items whose behavior is specified outside the library.
257
+ if (this.selectVariant !== 'multiple' && this.selectVariant !== 'single') return
197
258
 
198
- if (event instanceof KeyboardEvent && event.target instanceof HTMLButtonElement) {
199
- // prevent buttons from being clicked twice
200
- event.preventDefault()
259
+ const ariaChecked = item.getAttribute('aria-checked')
260
+ const checked = ariaChecked !== 'true'
261
+
262
+ if (this.selectVariant === 'single') {
263
+ // Only check, never uncheck here. Single-select mode does not allow unchecking a checked item.
264
+ if (checked) {
265
+ item.setAttribute('aria-checked', 'true')
266
+ }
267
+
268
+ for (const checkedItem of this.querySelectorAll('[aria-checked]')) {
269
+ if (checkedItem !== item) {
270
+ checkedItem.setAttribute('aria-checked', 'false')
271
+ }
201
272
  }
273
+
274
+ this.#setDynamicLabel()
275
+ } else {
276
+ // multi-select mode allows unchecking a checked item
277
+ item.setAttribute('aria-checked', `${checked}`)
202
278
  }
279
+
280
+ this.#updateInput()
281
+ }
282
+
283
+ #activateItem(event: Event, item: Element) {
284
+ const eventWillActivateByDefault =
285
+ (event instanceof MouseEvent && event.type === 'click') ||
286
+ (event instanceof KeyboardEvent &&
287
+ event.type === 'keydown' &&
288
+ !(event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) &&
289
+ event.key === 'Enter')
290
+
291
+ // if the event will result in activating the current item by default, i.e. is a
292
+ // mouse click or keyboard enter, bail out
293
+ if (eventWillActivateByDefault) return
294
+
295
+ // otherwise, event will not result in activation by default, so we stop it and
296
+ // simulate a click
297
+ event.stopPropagation()
298
+ const elem = item as HTMLElement
299
+ elem.click()
300
+ }
301
+
302
+ #handleIncludeFragmentReplaced() {
303
+ if (this.#firstItem) this.#firstItem.focus()
304
+ this.#softDisableItems()
305
+ }
306
+
307
+ // Close when focus leaves menu
308
+ #handleFocusOut() {
309
+ this.#hide()
310
+ }
311
+
312
+ #show() {
313
+ this.popoverElement?.showPopover()
314
+ }
315
+
316
+ #hide() {
317
+ this.popoverElement?.hidePopover()
318
+ }
319
+
320
+ #isOpen() {
321
+ return this.popoverElement?.matches(':popover-open')
203
322
  }
204
323
 
205
324
  #setDynamicLabel() {
@@ -261,18 +380,13 @@ export class ActionMenuElement extends HTMLElement {
261
380
  }
262
381
  }
263
382
 
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
383
  get #firstItem(): HTMLElement | null {
274
384
  return this.querySelector(menuItemSelectors.join(','))
275
385
  }
386
+
387
+ get #items(): HTMLElement[] {
388
+ return Array.from(this.querySelectorAll(menuItemSelectors.join(',')))
389
+ }
276
390
  }
277
391
 
278
392
  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
@@ -1 +1 @@
1
- .SegmentedControl{background-color:var(--controlTrack-bgColor-rest,var(--color-segmented-control-bg));border-radius:var(--borderRadius-medium,.375rem);display:inline-flex;list-style:none}.SegmentedControl-item{border:var(--borderWidth-thin,max(1px,.0625rem)) solid #0000;border-radius:var(--borderRadius-medium,.375rem);display:inline-flex;padding:var(--control-xsmall-paddingInline-condensed,.25rem);position:relative}.SegmentedControl-item .Button--invisible:hover:not(:disabled){background-color:var(--controlTrack-bgColor-hover,var(--color-action-list-item-default-hover-bg))}.SegmentedControl-item .Button--invisible:active:not(:disabled){background-color:var(--controlTrack-bgColor-active,var(--color-action-list-item-default-active-bg))}.SegmentedControl-item.SegmentedControl-item--selected{background-color:var(--controlKnob-bgColor-rest,var(--color-segmented-control-button-bg));border-color:var(--controlKnob-borderColor-rest,var(--color-segmented-control-button-selected-border))}.SegmentedControl-item.SegmentedControl-item--selected .Button{font-weight:var(--base-text-weight-semibold,600)}.SegmentedControl-item.SegmentedControl-item--selected .Button:hover{background-color:initial}.SegmentedControl-item.SegmentedControl-item--selected:before{border-color:#0000!important}.SegmentedControl-item.SegmentedControl-item--selected+.SegmentedControl-item:before{border-color:#0000}.SegmentedControl-item .Button-label[data-content]:before{content:attr(data-content);display:block;font-weight:var(--base-text-weight-semibold,600);height:0;visibility:hidden}.SegmentedControl-item:not(:first-child):before{border-left:var(--borderWidth-thin,max(1px,.0625rem)) solid var(--borderColor-default,var(--color-border-default));content:"";inset:0 0 0 -1px;margin-bottom:var(--control-medium-paddingBlock,.375rem);margin-top:var(--control-medium-paddingBlock,.375rem);position:absolute}.SegmentedControl-item .Button{border:0;color:var(--button-default-fgColor-rest,var(--color-btn-text));font-weight:var(--base-text-weight-normal,400);transition:none}.SegmentedControl-item .Button:focus-visible{border-radius:calc(var(--borderRadius-medium,.375rem) - 5px);outline-offset:calc(var(--control-xsmall-paddingInline-condensed,.25rem) - var(--borderWidth-thin,max(1px, .0625rem)))}.SegmentedControl-item .Button--small{height:calc(var(--control-small-size,1.75rem) - var(--control-xsmall-paddingInline-condensed,.25rem)*2 - var(--borderWidth-thin,max(1px, .0625rem))*2);padding:0 calc(var(--control-small-paddingInline-condensed,.5rem) - var(--control-xsmall-paddingInline-condensed,.25rem))}.SegmentedControl-item .Button--small.Button--iconOnly{width:calc(var(--control-medium-size,2rem) - var(--control-xsmall-paddingInline-condensed,.25rem)*2 - var(--borderWidth-thin,max(1px, .0625rem))*2)}.SegmentedControl-item .Button--small.Button--iconOnly:before{content:"";height:100%;left:50%;min-height:var(--control-medium-size,2rem);min-width:var(--control-medium-size,2rem);position:absolute;top:50%;transform:translateX(-50%) translateY(-50%);width:100%}.SegmentedControl-item .Button--medium{height:calc(var(--control-medium-size,2rem) - var(--control-xsmall-paddingInline-condensed,.25rem)*2 - var(--borderWidth-thin,max(1px, .0625rem))*2);padding:0 calc(var(--control-medium-paddingInline-normal,.75rem) - var(--control-xsmall-paddingInline-condensed,.25rem))}.SegmentedControl-item .Button--medium.Button--iconOnly{width:calc(var(--control-medium-size,2rem) - var(--control-xsmall-paddingInline-condensed,.25rem)*2 - var(--borderWidth-thin,max(1px, .0625rem))*2)}.SegmentedControl-item .Button--medium.Button--iconOnly:before{content:"";height:100%;left:50%;min-height:var(--control-medium-size,2rem);min-width:var(--control-medium-size,2rem);position:absolute;top:50%;transform:translateX(-50%) translateY(-50%);width:100%}.SegmentedControl-item .Button--large{height:calc(var(--control-large-size,2.5rem) - var(--control-xsmall-paddingInline-condensed,.25rem)*2 - var(--borderWidth-thin,max(1px, .0625rem))*2);padding:0 calc(var(--control-large-paddingInline-spacious,1rem) - var(--control-xsmall-paddingInline-condensed,.25rem))}.SegmentedControl-item .Button--large.Button--iconOnly{width:calc(var(--control-large-size,2.5rem) - var(--control-xsmall-paddingInline-condensed,.25rem)*2 - var(--borderWidth-thin,max(1px, .0625rem))*2)}.SegmentedControl-item .Button--large.Button--iconOnly:before{content:"";height:100%;left:50%;min-height:var(--control-large-size,2.5rem);min-width:var(--control-large-size,2.5rem);position:absolute;top:50%;transform:translateX(-50%) translateY(-50%);width:100%}.SegmentedControl-item .Button--iconOnly{padding:initial}.SegmentedControl-item .Button--invisible.Button--invisible-noVisuals .Button-label{color:var(--button-default-fgColor-rest,var(--color-btn-text))}.SegmentedControl--fullWidth{display:flex}.SegmentedControl--fullWidth .SegmentedControl-item{flex:1;justify-content:center}.SegmentedControl--fullWidth .Button--iconOnly,.SegmentedControl--fullWidth .Button-withTooltip{width:100%}
1
+ .SegmentedControl{--segmentedControl-item-padding:var(--control-small-paddingBlock,0.25rem);background-color:var(--controlTrack-bgColor-rest,var(--color-segmented-control-bg));border-radius:var(--borderRadius-medium,.375rem);display:inline-flex;list-style:none}.SegmentedControl--iconOnly .Button--iconOnly.Button--large,.SegmentedControl--iconOnly .Button--iconOnly.Button--medium,.SegmentedControl--iconOnly .Button--iconOnly.Button--small{padding-inline:0!important;width:100%}.SegmentedControl--small{--segmentedControl-item-padding:var(--control-xsmall-paddingBlock,0.125rem)}.SegmentedControl--small .SegmentedControl-item{height:var(--control-small-size,1.75rem)}.SegmentedControl--small .SegmentedControl-item .Button{padding-inline:calc(var(--control-xsmall-paddingInline-normal,.5rem) - var(--segmentedControl-item-padding))}.SegmentedControl--small.SegmentedControl--iconOnly .SegmentedControl-item{width:var(--control-small-size,1.75rem)}.SegmentedControl--medium .SegmentedControl-item{height:var(--control-medium-size,2rem)}.SegmentedControl--medium.SegmentedControl--iconOnly .SegmentedControl-item{width:var(--control-medium-size,2rem)}.SegmentedControl--large .SegmentedControl-item{height:var(--control-large-size,2.5rem)}.SegmentedControl--large .SegmentedControl-item .Button{padding-inline:calc(var(--control-large-paddingInline-normal,.75rem) - var(--segmentedControl-item-padding))}.SegmentedControl--large.SegmentedControl--iconOnly .SegmentedControl-item{width:var(--control-large-size,2.5rem)}.SegmentedControl-item{border:var(--borderWidth-thin,max(1px,.0625rem)) solid #0000;border-radius:var(--borderRadius-medium,.375rem);display:inline-flex;height:var(--control-medium-size,2rem);justify-content:center;padding:var(--segmentedControl-item-padding);position:relative}.SegmentedControl-item .Button-withTooltip{width:100%}.SegmentedControl-item .Button--invisible:hover:not(:disabled){background-color:var(--controlTrack-bgColor-hover,var(--color-action-list-item-default-hover-bg))}.SegmentedControl-item .Button--invisible:active:not(:disabled){background-color:var(--controlTrack-bgColor-active,var(--color-action-list-item-default-active-bg))}.SegmentedControl-item.SegmentedControl-item--selected{background-color:var(--controlKnob-bgColor-rest,var(--color-segmented-control-button-bg));border-color:var(--controlKnob-borderColor-rest,var(--color-segmented-control-button-selected-border))}.SegmentedControl-item.SegmentedControl-item--selected .Button{font-weight:var(--base-text-weight-semibold,600)}.SegmentedControl-item.SegmentedControl-item--selected .Button:hover{background-color:initial}.SegmentedControl-item.SegmentedControl-item--selected:before{border-color:#0000!important}.SegmentedControl-item.SegmentedControl-item--selected+.SegmentedControl-item:before{border-color:#0000}.SegmentedControl-item .Button-label[data-content]:before{content:attr(data-content);display:block;font-weight:var(--base-text-weight-semibold,600);height:0;visibility:hidden}.SegmentedControl-item:not(:first-child):before{border-left:var(--borderWidth-thin,max(1px,.0625rem)) solid var(--borderColor-default,var(--color-border-default));content:"";inset:0 0 0 -1px;margin-bottom:var(--control-medium-paddingBlock,.375rem);margin-top:var(--control-medium-paddingBlock,.375rem);position:absolute}.SegmentedControl-item .Button{border:0;border-radius:calc(var(--borderRadius-medium,.375rem) - var(--segmentedControl-item-padding)/2);font-weight:var(--base-text-weight-normal,400);height:100%;padding-inline:calc(var(--control-medium-paddingInline-normal,.75rem) - var(--segmentedControl-item-padding));width:100%}.SegmentedControl-item .Button:focus-visible{border-radius:calc(var(--borderRadius-medium,.375rem) - var(--segmentedControl-item-padding)/1);outline-offset:calc(var(--segmentedControl-item-padding) - var(--borderWidth-thin,max(1px, .0625rem)))}.SegmentedControl-item .Button--invisible.Button--invisible-noVisuals .Button-label{color:var(--button-default-fgColor-rest,var(--color-btn-text))}.SegmentedControl--fullWidth{display:flex}.SegmentedControl--fullWidth .SegmentedControl-item{flex:1;justify-content:center}