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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -0
- data/app/assets/javascripts/app/components/primer/alpha/tool_tip.d.ts +1 -0
- data/app/assets/javascripts/primer_view_components.js +1 -1
- data/app/assets/javascripts/primer_view_components.js.map +1 -1
- data/app/assets/styles/primer_view_components.css +1 -1
- data/app/assets/styles/primer_view_components.css.map +1 -1
- data/app/components/primer/alpha/action_bar/item.rb +7 -4
- data/app/components/primer/alpha/action_bar.rb +2 -2
- data/app/components/primer/alpha/action_bar_element.js +11 -4
- data/app/components/primer/alpha/action_bar_element.ts +11 -2
- data/app/components/primer/alpha/action_list/form_wrapper.html.erb +4 -2
- data/app/components/primer/alpha/action_list/form_wrapper.rb +20 -9
- data/app/components/primer/alpha/action_menu/action_menu_element.js +174 -84
- data/app/components/primer/alpha/action_menu/action_menu_element.ts +218 -81
- data/app/components/primer/alpha/action_menu/list.rb +0 -2
- data/app/components/primer/alpha/action_menu.rb +120 -3
- data/app/components/primer/alpha/modal_dialog.js +10 -13
- data/app/components/primer/alpha/modal_dialog.ts +10 -13
- data/app/components/primer/alpha/segmented_control/item.html.erb +1 -8
- data/app/components/primer/alpha/segmented_control/item.rb +38 -4
- data/app/components/primer/alpha/segmented_control.css +1 -1
- data/app/components/primer/alpha/segmented_control.css.json +14 -13
- data/app/components/primer/alpha/segmented_control.css.map +1 -1
- data/app/components/primer/alpha/segmented_control.pcss +75 -66
- data/app/components/primer/alpha/segmented_control.rb +10 -0
- data/app/components/primer/alpha/text_field.css +1 -1
- data/app/components/primer/alpha/tool_tip.d.ts +1 -0
- data/app/components/primer/alpha/tool_tip.js +34 -28
- data/app/components/primer/alpha/tool_tip.ts +33 -26
- data/app/components/primer/beta/base_button.rb +4 -0
- data/app/components/primer/beta/button.css +1 -1
- data/app/components/primer/beta/button.css.map +1 -1
- data/app/components/primer/beta/button.pcss +6 -2
- data/app/components/primer/focus_group.js +28 -3
- data/app/components/primer/focus_group.ts +27 -1
- data/lib/primer/static/generate_info_arch.rb +86 -5
- data/lib/primer/view_components/version.rb +1 -1
- data/previews/primer/alpha/action_menu_preview/single_select_form_items.html.erb +31 -0
- data/previews/primer/alpha/action_menu_preview/with_actions.html.erb +6 -5
- data/previews/primer/alpha/action_menu_preview.rb +10 -1
- data/previews/primer/alpha/check_box_preview.rb +0 -2
- data/previews/primer/alpha/dialog_preview/autofocus_element.html.erb +8 -0
- data/previews/primer/alpha/dialog_preview/with_text_input.html.erb +2 -1
- data/previews/primer/alpha/dialog_preview.rb +5 -0
- data/previews/primer/alpha/radio_button_preview.rb +0 -2
- data/previews/primer/alpha/segmented_control_preview.rb +35 -0
- data/previews/primer/alpha/tooltip_preview/tooltip_with_dialog_moving_focus_to_input.html.erb +23 -0
- data/previews/primer/alpha/tooltip_preview.rb +6 -1
- data/previews/primer/beta/button_group_preview.rb +6 -6
- data/previews/primer/beta/button_preview.rb +20 -2
- data/previews/primer/beta/icon_button_preview.rb +3 -0
- data/static/arguments.json +18 -1
- data/static/classes.json +12 -0
- data/static/constants.json +12 -1
- data/static/info_arch.json +135 -5
- data/static/previews.json +91 -0
- 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
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
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
|
-
|
123
|
-
|
182
|
+
if (targetIsInvoker && eventIsActivation) {
|
183
|
+
this.#handleInvokerActivated(event)
|
184
|
+
this.#invokerBeingClicked = false
|
124
185
|
return
|
125
186
|
}
|
126
187
|
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
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
|
-
|
145
|
-
|
146
|
-
|
147
|
-
}
|
196
|
+
})
|
197
|
+
|
198
|
+
return
|
148
199
|
}
|
149
200
|
|
150
|
-
|
201
|
+
const item = (event.target as Element).closest(menuItemSelectors.join(','))
|
202
|
+
const targetIsItem = item !== null
|
151
203
|
|
152
|
-
if (
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
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
|
-
|
170
|
-
|
171
|
-
if (this.selectVariant !== 'multiple' && this.selectVariant !== 'single') return
|
216
|
+
this.#activateItem(event, item)
|
217
|
+
this.#handleItemActivated(event, item)
|
172
218
|
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
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
|
-
|
179
|
-
|
180
|
-
if (checked) {
|
181
|
-
item.setAttribute('aria-checked', 'true')
|
182
|
-
}
|
228
|
+
return
|
229
|
+
}
|
183
230
|
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
}
|
231
|
+
if (event.type === 'include-fragment-replaced') {
|
232
|
+
this.#handleIncludeFragmentReplaced()
|
233
|
+
}
|
234
|
+
}
|
189
235
|
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
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
|
-
|
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
|
-
|
199
|
-
|
200
|
-
|
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')) {
|
@@ -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
|
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
|
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
|
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
|
-
|
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
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
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,
|
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
|
-
|
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
|
45
|
-
|
46
|
-
|
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,
|
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
|
-
|
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(
|
17
|
-
|
18
|
-
|
19
|
-
|
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
|