openproject-primer_view_components 0.11.0 → 0.13.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +110 -0
- data/app/assets/javascripts/app/components/primer/alpha/tool_tip.d.ts +1 -0
- data/app/assets/javascripts/app/components/primer/primer.d.ts +1 -1
- 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 +9 -4
- data/app/components/primer/alpha/action_bar_element.ts +9 -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 +162 -86
- data/app/components/primer/alpha/action_menu/action_menu_element.ts +197 -82
- 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/check_box_group.rb +2 -0
- data/app/components/primer/alpha/dialog/header.rb +12 -0
- data/app/components/primer/alpha/dialog.rb +1 -1
- 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/nav_list/divider.rb +2 -5
- data/app/components/primer/alpha/nav_list/group.rb +2 -98
- data/app/components/primer/alpha/nav_list/heading.rb +2 -27
- data/app/components/primer/alpha/nav_list/item.rb +2 -147
- data/app/components/primer/alpha/nav_list.rb +2 -205
- data/app/components/primer/alpha/overlay.css +1 -1
- data/app/components/primer/alpha/overlay.css.map +1 -1
- data/app/components/primer/alpha/overlay.pcss +1 -7
- data/app/components/primer/alpha/overlay.rb +6 -4
- data/app/components/primer/alpha/radio_button_group.rb +2 -0
- 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/text_field.css.json +4 -1
- data/app/components/primer/alpha/text_field.css.map +1 -1
- data/app/components/primer/alpha/text_field.pcss +18 -3
- data/app/components/primer/alpha/tool_tip.d.ts +1 -0
- data/app/components/primer/alpha/tool_tip.js +26 -93
- data/app/components/primer/alpha/tool_tip.ts +25 -91
- data/app/components/primer/alpha/tooltip.rb +3 -1
- 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.json +2 -0
- data/app/components/primer/beta/button.css.map +1 -1
- data/app/components/primer/beta/button.pcss +17 -5
- data/app/components/primer/beta/icon_button.html.erb +1 -1
- data/app/components/primer/beta/icon_button.rb +8 -1
- data/app/components/primer/beta/link.css +1 -1
- data/app/components/primer/beta/link.css.json +1 -0
- data/app/components/primer/beta/link.css.map +1 -1
- data/app/components/primer/beta/link.pcss +5 -0
- data/app/components/primer/beta/link.rb +2 -2
- data/app/components/primer/beta/nav_list/divider.rb +14 -0
- data/app/components/primer/beta/nav_list/group.rb +107 -0
- data/app/components/primer/beta/nav_list/heading.rb +36 -0
- data/app/components/primer/beta/nav_list/item.rb +156 -0
- data/app/components/primer/beta/nav_list.rb +212 -0
- data/app/components/primer/focus_group.js +30 -4
- data/app/components/primer/focus_group.ts +29 -2
- data/app/components/primer/open_project/flex_layout.html.erb +23 -0
- data/app/components/primer/open_project/flex_layout.rb +52 -0
- data/app/components/primer/open_project/grid_layout/area.rb +38 -0
- data/app/components/primer/open_project/grid_layout.html.erb +11 -0
- data/app/components/primer/open_project/grid_layout.rb +34 -0
- data/app/components/primer/open_project/page_header.css +1 -1
- data/app/components/primer/open_project/page_header.css.map +1 -1
- data/app/components/primer/open_project/page_header.pcss +4 -0
- data/app/components/primer/primer.d.ts +1 -1
- data/app/components/primer/primer.js +1 -1
- data/app/components/primer/primer.ts +1 -1
- data/app/helpers/primer/form_helper.rb +10 -0
- data/lib/primer/accessibility.rb +3 -1
- data/lib/primer/deprecations.yml +20 -0
- data/lib/primer/forms/check_box_group.html.erb +3 -0
- data/lib/primer/forms/dsl/check_box_group_input.rb +1 -5
- data/lib/primer/forms/dsl/check_box_input.rb +5 -0
- data/lib/primer/forms/dsl/radio_button_input.rb +5 -0
- data/lib/primer/forms/form_control.html.erb +1 -4
- data/lib/primer/forms/radio_button_group.html.erb +3 -0
- data/lib/primer/forms/utils.rb +2 -0
- data/lib/primer/forms/validation_message.html.erb +4 -0
- data/lib/primer/forms/validation_message.rb +14 -0
- data/lib/primer/forms.rb +16 -0
- data/lib/primer/static/generate_info_arch.rb +86 -5
- data/lib/primer/view_components/version.rb +1 -1
- data/lib/primer/yard/component_manifest.rb +4 -0
- 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_group_preview.rb +13 -0
- data/previews/primer/alpha/check_box_preview.rb +1 -0
- data/previews/primer/alpha/dialog_preview/autofocus_element.html.erb +8 -0
- data/previews/primer/alpha/dialog_preview/with_header.html.erb +5 -0
- data/previews/primer/alpha/dialog_preview.rb +22 -0
- data/previews/primer/alpha/overlay_preview.rb +1 -1
- data/previews/primer/alpha/radio_button_group_preview.rb +13 -0
- data/previews/primer/alpha/radio_button_preview.rb +2 -1
- data/previews/primer/alpha/segmented_control_preview.rb +35 -0
- data/previews/primer/alpha/text_field_preview/input_group_leading_action_menu.html.erb +21 -0
- data/previews/primer/alpha/text_field_preview/input_group_leading_button.html.erb +18 -0
- data/previews/primer/alpha/text_field_preview/input_group_trailing_button.html.erb +18 -0
- data/previews/primer/alpha/text_field_preview.rb +21 -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 +21 -3
- data/previews/primer/beta/icon_button_preview.rb +3 -0
- data/previews/primer/{alpha → beta}/nav_list_preview/trailing_action.html.erb +1 -1
- data/previews/primer/{alpha → beta}/nav_list_preview.rb +5 -5
- data/previews/primer/open_project/flex_layout_preview.rb +73 -0
- data/previews/primer/open_project/grid_layout_preview.rb +37 -0
- data/static/arguments.json +278 -7
- data/static/audited_at.json +8 -0
- data/static/classes.json +15 -0
- data/static/constants.json +47 -1
- data/static/info_arch.json +1338 -632
- data/static/previews.json +271 -167
- data/static/statuses.json +13 -5
- metadata +33 -319
- /data/app/assets/javascripts/app/components/primer/{alpha → beta}/nav_list.d.ts +0 -0
- /data/app/components/primer/{alpha → beta}/nav_list/group.html.erb +0 -0
- /data/app/components/primer/{alpha → beta}/nav_list/item.html.erb +0 -0
- /data/app/components/primer/{alpha → beta}/nav_list.d.ts +0 -0
- /data/app/components/primer/{alpha → beta}/nav_list.html.erb +0 -0
- /data/app/components/primer/{alpha → beta}/nav_list.js +0 -0
- /data/app/components/primer/{alpha → beta}/nav_list.ts +0 -0
@@ -9,7 +9,8 @@ type SelectedItem = {
|
|
9
9
|
element: Element
|
10
10
|
}
|
11
11
|
|
12
|
-
const
|
12
|
+
const validSelectors = ['[role="menuitem"]', '[role="menuitemcheckbox"]', '[role="menuitemradio"]']
|
13
|
+
const menuItemSelectors = validSelectors.map(selector => `:not([hidden]) > ${selector}`)
|
13
14
|
|
14
15
|
@controller
|
15
16
|
export class ActionMenuElement extends HTMLElement {
|
@@ -19,6 +20,7 @@ export class ActionMenuElement extends HTMLElement {
|
|
19
20
|
#abortController: AbortController
|
20
21
|
#originalLabel = ''
|
21
22
|
#inputName = ''
|
23
|
+
#invokerBeingClicked = false
|
22
24
|
|
23
25
|
get selectVariant(): SelectVariant {
|
24
26
|
return this.getAttribute('data-select-variant') as SelectVariant
|
@@ -51,7 +53,7 @@ export class ActionMenuElement extends HTMLElement {
|
|
51
53
|
}
|
52
54
|
|
53
55
|
get popoverElement(): HTMLElement | null {
|
54
|
-
return this.invokerElement?.popoverTargetElement || null
|
56
|
+
return (this.invokerElement?.popoverTargetElement as HTMLElement) || null
|
55
57
|
}
|
56
58
|
|
57
59
|
get invokerElement(): HTMLButtonElement | null {
|
@@ -93,8 +95,10 @@ export class ActionMenuElement extends HTMLElement {
|
|
93
95
|
this.addEventListener('click', this, {signal})
|
94
96
|
this.addEventListener('mouseover', this, {signal})
|
95
97
|
this.addEventListener('focusout', this, {signal})
|
98
|
+
this.addEventListener('mousedown', this, {signal})
|
96
99
|
this.#setDynamicLabel()
|
97
100
|
this.#updateInput()
|
101
|
+
this.#softDisableItems()
|
98
102
|
|
99
103
|
if (this.includeFragment) {
|
100
104
|
this.includeFragment.addEventListener('include-fragment-replaced', this, {
|
@@ -103,102 +107,218 @@ export class ActionMenuElement extends HTMLElement {
|
|
103
107
|
}
|
104
108
|
}
|
105
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
|
+
|
106
132
|
disconnectedCallback() {
|
107
133
|
this.#abortController.abort()
|
108
134
|
}
|
109
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
|
+
|
110
153
|
handleEvent(event: Event) {
|
111
|
-
const
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
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
|
119
167
|
}
|
120
168
|
|
121
|
-
|
122
|
-
|
169
|
+
if (targetIsInvoker && eventIsActivation) {
|
170
|
+
this.#handleInvokerActivated(event)
|
171
|
+
this.#invokerBeingClicked = false
|
123
172
|
return
|
124
173
|
}
|
125
174
|
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
this.querySelector<HTMLElement>('.ActionListWrap')!.style.display = 'none'
|
134
|
-
const dialog_controller = new AbortController()
|
135
|
-
const {signal} = dialog_controller
|
136
|
-
const handleDialogClose = () => {
|
137
|
-
dialog_controller.abort()
|
138
|
-
this.querySelector<HTMLElement>('.ActionListWrap')!.style.display = ''
|
139
|
-
if (this.popoverElement?.matches(':popover-open')) {
|
140
|
-
this.popoverElement?.hidePopover()
|
141
|
-
}
|
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()
|
142
182
|
}
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
}
|
183
|
+
})
|
184
|
+
|
185
|
+
return
|
147
186
|
}
|
148
187
|
|
149
|
-
|
188
|
+
const item = (event.target as Element).closest(menuItemSelectors.join(','))
|
189
|
+
const targetIsItem = item !== null
|
150
190
|
|
151
|
-
if (
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
setTimeout(() => {
|
162
|
-
if (this.popoverElement?.matches(':popover-open')) {
|
163
|
-
this.popoverElement?.hidePopover()
|
164
|
-
}
|
165
|
-
})
|
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
|
+
}
|
166
201
|
}
|
167
202
|
|
168
|
-
|
169
|
-
|
170
|
-
|
203
|
+
this.#activateItem(event, item)
|
204
|
+
this.#handleItemActivated(event, item)
|
205
|
+
return
|
206
|
+
}
|
171
207
|
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
208
|
+
if (event.type === 'include-fragment-replaced') {
|
209
|
+
this.#handleIncludeFragmentReplaced()
|
210
|
+
}
|
211
|
+
}
|
176
212
|
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
item.setAttribute('aria-checked', 'true')
|
181
|
-
}
|
213
|
+
#handleInvokerActivated(event: Event) {
|
214
|
+
event.preventDefault()
|
215
|
+
event.stopPropagation()
|
182
216
|
|
183
|
-
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
|
217
|
+
if (this.#isOpen()) {
|
218
|
+
this.#hide()
|
219
|
+
} else {
|
220
|
+
this.#show()
|
221
|
+
this.#firstItem?.focus()
|
222
|
+
}
|
223
|
+
}
|
188
224
|
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
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()
|
193
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
|
+
}
|
194
254
|
|
195
|
-
|
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
|
196
258
|
|
197
|
-
|
198
|
-
|
199
|
-
|
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
|
+
}
|
200
272
|
}
|
273
|
+
|
274
|
+
this.#setDynamicLabel()
|
275
|
+
} else {
|
276
|
+
// multi-select mode allows unchecking a checked item
|
277
|
+
item.setAttribute('aria-checked', `${checked}`)
|
201
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')
|
202
322
|
}
|
203
323
|
|
204
324
|
#setDynamicLabel() {
|
@@ -260,18 +380,13 @@ export class ActionMenuElement extends HTMLElement {
|
|
260
380
|
}
|
261
381
|
}
|
262
382
|
|
263
|
-
#isActivationKeydown(event: Event): boolean {
|
264
|
-
return (
|
265
|
-
event instanceof KeyboardEvent &&
|
266
|
-
event.type === 'keydown' &&
|
267
|
-
!(event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) &&
|
268
|
-
(event.key === 'Enter' || event.key === ' ')
|
269
|
-
)
|
270
|
-
}
|
271
|
-
|
272
383
|
get #firstItem(): HTMLElement | null {
|
273
384
|
return this.querySelector(menuItemSelectors.join(','))
|
274
385
|
}
|
386
|
+
|
387
|
+
get #items(): HTMLElement[] {
|
388
|
+
return Array.from(this.querySelectorAll(menuItemSelectors.join(',')))
|
389
|
+
}
|
275
390
|
}
|
276
391
|
|
277
392
|
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
|
|
@@ -23,6 +23,8 @@ module Primer
|
|
23
23
|
# @param label [String] Label text displayed above the input.
|
24
24
|
# @param hidden [Boolean] When set to `true`, visually hides the group.
|
25
25
|
# @param caption [String] A string describing the field and what sorts of input it expects. Displayed below the group.
|
26
|
+
# @param invalid [Boolean] If set to `true`, the input will be marked as invalid. Implied if `validation_message` is truthy. This option is set to `true` automatically if the model object associated with the form reports that the input is invalid via Rails validations. It is provided for cases where the form does not have an associated model. If the input is invalid as determined by Rails validations, setting `invalid` to `false` will have no effect.
|
27
|
+
# @param validation_message [String] A string displayed between the caption and the input indicating the input's contents are invalid. This option is, by default, set to the first Rails validation message for the input (assuming the form is associated with a model object). Use `validation_message` to override the default or to provide a validation message in case there is no associated model object.
|
26
28
|
# @param label_arguments [Hash] Attributes that will be passed to Rails' `builder.label` method. These can be HTML attributes or any of the other label options Rails supports. They will appear as HTML attributes on the `<label>` tag.
|
27
29
|
|
28
30
|
# @!method check_box
|
@@ -9,10 +9,19 @@ module Primer
|
|
9
9
|
status :alpha
|
10
10
|
audited_at "2022-10-10"
|
11
11
|
|
12
|
+
DEFAULT_VARIANT = :medium
|
13
|
+
VARIANT_MAPPINGS = {
|
14
|
+
DEFAULT_VARIANT => "",
|
15
|
+
:large => "Overlay-header--large"
|
16
|
+
}.freeze
|
17
|
+
VARIANT_OPTIONS = VARIANT_MAPPINGS.keys
|
18
|
+
|
19
|
+
# @param id [String] The HTML element's ID value.
|
12
20
|
# @param title [String] Describes the content of the dialog.
|
13
21
|
# @param subtitle [String] Provides dditional context for the dialog, also setting the `aria-describedby` attribute.
|
14
22
|
# @param show_divider [Boolean] Show a divider between the header and body.
|
15
23
|
# @param visually_hide_title [Boolean] Visually hide the `title` while maintaining a label for assistive technologies.
|
24
|
+
# @param variant [Symbol] <%= one_of(Primer::Alpha::Dialog::Header::VARIANT_OPTIONS) %>
|
16
25
|
# @param system_arguments [Hash] <%= link_to_system_arguments_docs %>
|
17
26
|
def initialize(
|
18
27
|
id:,
|
@@ -20,6 +29,7 @@ module Primer
|
|
20
29
|
subtitle: nil,
|
21
30
|
show_divider: false,
|
22
31
|
visually_hide_title: false,
|
32
|
+
variant: DEFAULT_VARIANT,
|
23
33
|
**system_arguments
|
24
34
|
)
|
25
35
|
@id = id
|
@@ -28,8 +38,10 @@ module Primer
|
|
28
38
|
@visually_hide_title = visually_hide_title
|
29
39
|
@system_arguments = deny_tag_argument(**system_arguments)
|
30
40
|
@system_arguments[:tag] = :div
|
41
|
+
|
31
42
|
@system_arguments[:classes] = class_names(
|
32
43
|
"Overlay-header",
|
44
|
+
VARIANT_MAPPINGS[fetch_or_fallback(VARIANT_OPTIONS, variant, DEFAULT_VARIANT)],
|
33
45
|
{ "Overlay-header--divided": show_divider },
|
34
46
|
system_arguments[:classes]
|
35
47
|
)
|
@@ -74,7 +74,7 @@ module Primer
|
|
74
74
|
#
|
75
75
|
# @param show_divider [Boolean] Show a divider between the header and body.
|
76
76
|
# @param visually_hide_title [Boolean] Visually hide the `title` while maintaining a label for assistive technologies.
|
77
|
-
# @param system_arguments [Hash] <%=
|
77
|
+
# @param system_arguments [Hash] The arguments accepted by <%= link_to_component(Primer::Alpha::Dialog::Header) %>.
|
78
78
|
renders_one :header, lambda { |show_divider: false, visually_hide_title: @visually_hide_title, **system_arguments|
|
79
79
|
Primer::Alpha::Dialog::Header.new(
|
80
80
|
id: @id,
|
@@ -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
|
@@ -3,11 +3,8 @@
|
|
3
3
|
module Primer
|
4
4
|
module Alpha
|
5
5
|
class NavList
|
6
|
-
|
7
|
-
|
8
|
-
def kind
|
9
|
-
:divider
|
10
|
-
end
|
6
|
+
class Divider < Beta::NavList::Divider
|
7
|
+
status :deprecated
|
11
8
|
end
|
12
9
|
end
|
13
10
|
end
|