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.
Files changed (134) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +110 -0
  3. data/app/assets/javascripts/app/components/primer/alpha/tool_tip.d.ts +1 -0
  4. data/app/assets/javascripts/app/components/primer/primer.d.ts +1 -1
  5. data/app/assets/javascripts/primer_view_components.js +1 -1
  6. data/app/assets/javascripts/primer_view_components.js.map +1 -1
  7. data/app/assets/styles/primer_view_components.css +1 -1
  8. data/app/assets/styles/primer_view_components.css.map +1 -1
  9. data/app/components/primer/alpha/action_bar/item.rb +7 -4
  10. data/app/components/primer/alpha/action_bar.rb +2 -2
  11. data/app/components/primer/alpha/action_bar_element.js +9 -4
  12. data/app/components/primer/alpha/action_bar_element.ts +9 -2
  13. data/app/components/primer/alpha/action_list/form_wrapper.html.erb +4 -2
  14. data/app/components/primer/alpha/action_list/form_wrapper.rb +20 -9
  15. data/app/components/primer/alpha/action_menu/action_menu_element.js +162 -86
  16. data/app/components/primer/alpha/action_menu/action_menu_element.ts +197 -82
  17. data/app/components/primer/alpha/action_menu/list.rb +0 -2
  18. data/app/components/primer/alpha/action_menu.rb +120 -3
  19. data/app/components/primer/alpha/check_box_group.rb +2 -0
  20. data/app/components/primer/alpha/dialog/header.rb +12 -0
  21. data/app/components/primer/alpha/dialog.rb +1 -1
  22. data/app/components/primer/alpha/modal_dialog.js +10 -13
  23. data/app/components/primer/alpha/modal_dialog.ts +10 -13
  24. data/app/components/primer/alpha/nav_list/divider.rb +2 -5
  25. data/app/components/primer/alpha/nav_list/group.rb +2 -98
  26. data/app/components/primer/alpha/nav_list/heading.rb +2 -27
  27. data/app/components/primer/alpha/nav_list/item.rb +2 -147
  28. data/app/components/primer/alpha/nav_list.rb +2 -205
  29. data/app/components/primer/alpha/overlay.css +1 -1
  30. data/app/components/primer/alpha/overlay.css.map +1 -1
  31. data/app/components/primer/alpha/overlay.pcss +1 -7
  32. data/app/components/primer/alpha/overlay.rb +6 -4
  33. data/app/components/primer/alpha/radio_button_group.rb +2 -0
  34. data/app/components/primer/alpha/segmented_control/item.html.erb +1 -8
  35. data/app/components/primer/alpha/segmented_control/item.rb +38 -4
  36. data/app/components/primer/alpha/segmented_control.css +1 -1
  37. data/app/components/primer/alpha/segmented_control.css.json +14 -13
  38. data/app/components/primer/alpha/segmented_control.css.map +1 -1
  39. data/app/components/primer/alpha/segmented_control.pcss +75 -66
  40. data/app/components/primer/alpha/segmented_control.rb +10 -0
  41. data/app/components/primer/alpha/text_field.css +1 -1
  42. data/app/components/primer/alpha/text_field.css.json +4 -1
  43. data/app/components/primer/alpha/text_field.css.map +1 -1
  44. data/app/components/primer/alpha/text_field.pcss +18 -3
  45. data/app/components/primer/alpha/tool_tip.d.ts +1 -0
  46. data/app/components/primer/alpha/tool_tip.js +26 -93
  47. data/app/components/primer/alpha/tool_tip.ts +25 -91
  48. data/app/components/primer/alpha/tooltip.rb +3 -1
  49. data/app/components/primer/beta/base_button.rb +4 -0
  50. data/app/components/primer/beta/button.css +1 -1
  51. data/app/components/primer/beta/button.css.json +2 -0
  52. data/app/components/primer/beta/button.css.map +1 -1
  53. data/app/components/primer/beta/button.pcss +17 -5
  54. data/app/components/primer/beta/icon_button.html.erb +1 -1
  55. data/app/components/primer/beta/icon_button.rb +8 -1
  56. data/app/components/primer/beta/link.css +1 -1
  57. data/app/components/primer/beta/link.css.json +1 -0
  58. data/app/components/primer/beta/link.css.map +1 -1
  59. data/app/components/primer/beta/link.pcss +5 -0
  60. data/app/components/primer/beta/link.rb +2 -2
  61. data/app/components/primer/beta/nav_list/divider.rb +14 -0
  62. data/app/components/primer/beta/nav_list/group.rb +107 -0
  63. data/app/components/primer/beta/nav_list/heading.rb +36 -0
  64. data/app/components/primer/beta/nav_list/item.rb +156 -0
  65. data/app/components/primer/beta/nav_list.rb +212 -0
  66. data/app/components/primer/focus_group.js +30 -4
  67. data/app/components/primer/focus_group.ts +29 -2
  68. data/app/components/primer/open_project/flex_layout.html.erb +23 -0
  69. data/app/components/primer/open_project/flex_layout.rb +52 -0
  70. data/app/components/primer/open_project/grid_layout/area.rb +38 -0
  71. data/app/components/primer/open_project/grid_layout.html.erb +11 -0
  72. data/app/components/primer/open_project/grid_layout.rb +34 -0
  73. data/app/components/primer/open_project/page_header.css +1 -1
  74. data/app/components/primer/open_project/page_header.css.map +1 -1
  75. data/app/components/primer/open_project/page_header.pcss +4 -0
  76. data/app/components/primer/primer.d.ts +1 -1
  77. data/app/components/primer/primer.js +1 -1
  78. data/app/components/primer/primer.ts +1 -1
  79. data/app/helpers/primer/form_helper.rb +10 -0
  80. data/lib/primer/accessibility.rb +3 -1
  81. data/lib/primer/deprecations.yml +20 -0
  82. data/lib/primer/forms/check_box_group.html.erb +3 -0
  83. data/lib/primer/forms/dsl/check_box_group_input.rb +1 -5
  84. data/lib/primer/forms/dsl/check_box_input.rb +5 -0
  85. data/lib/primer/forms/dsl/radio_button_input.rb +5 -0
  86. data/lib/primer/forms/form_control.html.erb +1 -4
  87. data/lib/primer/forms/radio_button_group.html.erb +3 -0
  88. data/lib/primer/forms/utils.rb +2 -0
  89. data/lib/primer/forms/validation_message.html.erb +4 -0
  90. data/lib/primer/forms/validation_message.rb +14 -0
  91. data/lib/primer/forms.rb +16 -0
  92. data/lib/primer/static/generate_info_arch.rb +86 -5
  93. data/lib/primer/view_components/version.rb +1 -1
  94. data/lib/primer/yard/component_manifest.rb +4 -0
  95. data/previews/primer/alpha/action_menu_preview/single_select_form_items.html.erb +31 -0
  96. data/previews/primer/alpha/action_menu_preview/with_actions.html.erb +6 -5
  97. data/previews/primer/alpha/action_menu_preview.rb +10 -1
  98. data/previews/primer/alpha/check_box_group_preview.rb +13 -0
  99. data/previews/primer/alpha/check_box_preview.rb +1 -0
  100. data/previews/primer/alpha/dialog_preview/autofocus_element.html.erb +8 -0
  101. data/previews/primer/alpha/dialog_preview/with_header.html.erb +5 -0
  102. data/previews/primer/alpha/dialog_preview.rb +22 -0
  103. data/previews/primer/alpha/overlay_preview.rb +1 -1
  104. data/previews/primer/alpha/radio_button_group_preview.rb +13 -0
  105. data/previews/primer/alpha/radio_button_preview.rb +2 -1
  106. data/previews/primer/alpha/segmented_control_preview.rb +35 -0
  107. data/previews/primer/alpha/text_field_preview/input_group_leading_action_menu.html.erb +21 -0
  108. data/previews/primer/alpha/text_field_preview/input_group_leading_button.html.erb +18 -0
  109. data/previews/primer/alpha/text_field_preview/input_group_trailing_button.html.erb +18 -0
  110. data/previews/primer/alpha/text_field_preview.rb +21 -0
  111. data/previews/primer/alpha/tooltip_preview/tooltip_with_dialog_moving_focus_to_input.html.erb +23 -0
  112. data/previews/primer/alpha/tooltip_preview.rb +6 -1
  113. data/previews/primer/beta/button_group_preview.rb +6 -6
  114. data/previews/primer/beta/button_preview.rb +21 -3
  115. data/previews/primer/beta/icon_button_preview.rb +3 -0
  116. data/previews/primer/{alpha → beta}/nav_list_preview/trailing_action.html.erb +1 -1
  117. data/previews/primer/{alpha → beta}/nav_list_preview.rb +5 -5
  118. data/previews/primer/open_project/flex_layout_preview.rb +73 -0
  119. data/previews/primer/open_project/grid_layout_preview.rb +37 -0
  120. data/static/arguments.json +278 -7
  121. data/static/audited_at.json +8 -0
  122. data/static/classes.json +15 -0
  123. data/static/constants.json +47 -1
  124. data/static/info_arch.json +1338 -632
  125. data/static/previews.json +271 -167
  126. data/static/statuses.json +13 -5
  127. metadata +33 -319
  128. /data/app/assets/javascripts/app/components/primer/{alpha → beta}/nav_list.d.ts +0 -0
  129. /data/app/components/primer/{alpha → beta}/nav_list/group.html.erb +0 -0
  130. /data/app/components/primer/{alpha → beta}/nav_list/item.html.erb +0 -0
  131. /data/app/components/primer/{alpha → beta}/nav_list.d.ts +0 -0
  132. /data/app/components/primer/{alpha → beta}/nav_list.html.erb +0 -0
  133. /data/app/components/primer/{alpha → beta}/nav_list.js +0 -0
  134. /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 menuItemSelectors = ['[role="menuitem"]', '[role="menuitemcheckbox"]', '[role="menuitemradio"]']
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 activation = this.#isActivationKeydown(event)
112
- if (event.target === this.invokerElement && activation) {
113
- if (this.#firstItem) {
114
- event.preventDefault()
115
- this.popoverElement?.showPopover()
116
- this.#firstItem.focus()
117
- return
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
- // Ignore events within dialogs within menus
122
- 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
123
172
  return
124
173
  }
125
174
 
126
- // If a dialog has been rendered within the menu, we do not want to hide
127
- // the entire menu, as that will also hide the Dialog. Instead we want to
128
- // show the Dialog while hiding just the visible part of the menu.
129
- if ((activation || event.type === 'click') && (event.target as HTMLElement)?.closest('[data-show-dialog-id]')) {
130
- const dialogInvoker = (event.target as HTMLElement)!.closest('[data-show-dialog-id]')
131
- const dialog = this.ownerDocument.getElementById(dialogInvoker?.getAttribute('data-show-dialog-id') || '')
132
- if (dialogInvoker && dialog && this.contains(dialogInvoker) && this.contains(dialog)) {
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
- dialog.addEventListener('close', handleDialogClose, {signal})
144
- dialog.addEventListener('cancel', handleDialogClose, {signal})
145
- return
146
- }
183
+ })
184
+
185
+ return
147
186
  }
148
187
 
149
- if (!this.popoverElement?.matches(':popover-open')) return
188
+ const item = (event.target as Element).closest(menuItemSelectors.join(','))
189
+ const targetIsItem = item !== null
150
190
 
151
- if (event.type === 'include-fragment-replaced') {
152
- if (this.#firstItem) this.#firstItem.focus()
153
- } else if (activation || (event instanceof MouseEvent && event.type === 'click')) {
154
- // Hide popover after current event loop to prevent changes in focus from
155
- // altering the target of the event. Not doing this specifically affects
156
- // <a> tags. It causes the event to be sent to the currently focused element
157
- // instead of the anchor, which effectively prevents navigation, i.e. it
158
- // appears as if hitting enter does nothing. Curiously, clicking instead
159
- // works fine.
160
- if (this.selectVariant !== 'multiple') {
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
- // The rest of the code below deals with single/multiple selection behavior, and should not
169
- // interfere with events fired by menu items whose behavior is specified outside the library.
170
- if (this.selectVariant !== 'multiple' && this.selectVariant !== 'single') return
203
+ this.#activateItem(event, item)
204
+ this.#handleItemActivated(event, item)
205
+ return
206
+ }
171
207
 
172
- const item = (event.target as Element).closest(menuItemSelectors.join(','))
173
- if (!item) return
174
- const ariaChecked = item.getAttribute('aria-checked')
175
- const checked = ariaChecked !== 'true'
208
+ if (event.type === 'include-fragment-replaced') {
209
+ this.#handleIncludeFragmentReplaced()
210
+ }
211
+ }
176
212
 
177
- if (this.selectVariant === 'single') {
178
- // Only check, never uncheck here. Single-select mode does not allow unchecking a checked item.
179
- if (checked) {
180
- item.setAttribute('aria-checked', 'true')
181
- }
213
+ #handleInvokerActivated(event: Event) {
214
+ event.preventDefault()
215
+ event.stopPropagation()
182
216
 
183
- for (const checkedItem of this.querySelectorAll('[aria-checked]')) {
184
- if (checkedItem !== item) {
185
- checkedItem.setAttribute('aria-checked', 'false')
186
- }
187
- }
217
+ if (this.#isOpen()) {
218
+ this.#hide()
219
+ } else {
220
+ this.#show()
221
+ this.#firstItem?.focus()
222
+ }
223
+ }
188
224
 
189
- this.#setDynamicLabel()
190
- } else {
191
- // multi-select mode allows unchecking a checked item
192
- 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()
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
- 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
196
258
 
197
- if (event instanceof KeyboardEvent && event.target instanceof HTMLButtonElement) {
198
- // prevent buttons from being clicked twice
199
- 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
+ }
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')) {
@@ -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
 
@@ -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] <%= link_to_system_arguments_docs %>
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
- // 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
@@ -3,11 +3,8 @@
3
3
  module Primer
4
4
  module Alpha
5
5
  class NavList
6
- # Separator with optional text rendered above groups or between individual items.
7
- class Divider < Primer::Alpha::ActionList::Divider
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