primer_view_components 0.0.67 → 0.0.70

Sign up to get free protection for your applications and to get access to all the features.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +133 -2
  3. data/README.md +1 -1
  4. data/app/assets/javascripts/primer_view_components.js +1 -1
  5. data/app/assets/javascripts/primer_view_components.js.map +1 -1
  6. data/app/components/primer/alpha/tooltip.d.ts +24 -0
  7. data/app/components/primer/alpha/tooltip.js +381 -0
  8. data/app/components/primer/alpha/tooltip.rb +103 -0
  9. data/app/components/primer/alpha/tooltip.ts +383 -0
  10. data/app/components/primer/base_component.rb +2 -2
  11. data/app/components/primer/beta/auto_complete/auto_complete.html.erb +22 -4
  12. data/app/components/primer/beta/auto_complete.rb +86 -99
  13. data/app/components/primer/beta/blankslate.html.erb +6 -2
  14. data/app/components/primer/beta/blankslate.rb +4 -9
  15. data/app/components/primer/beta/truncate.rb +1 -0
  16. data/app/components/primer/button_component.html.erb +1 -0
  17. data/app/components/primer/button_component.rb +29 -0
  18. data/app/components/primer/component.rb +9 -2
  19. data/app/components/primer/details_component.rb +1 -1
  20. data/app/components/primer/icon_button.rb +1 -1
  21. data/app/components/primer/link_component.erb +4 -0
  22. data/app/components/primer/link_component.rb +29 -4
  23. data/app/components/primer/markdown.rb +1 -1
  24. data/app/components/primer/popover_component.rb +5 -9
  25. data/app/components/primer/primer.d.ts +1 -0
  26. data/app/components/primer/primer.js +1 -0
  27. data/app/components/primer/primer.ts +1 -0
  28. data/app/components/primer/subhead_component.html.erb +1 -1
  29. data/app/components/primer/subhead_component.rb +1 -1
  30. data/app/components/primer/tooltip.rb +1 -1
  31. data/app/lib/primer/test_selector_helper.rb +1 -1
  32. data/lib/primer/classify/utilities.yml +28 -0
  33. data/lib/primer/view_components/linters/button_component_migration_counter.rb +1 -1
  34. data/lib/primer/view_components/version.rb +1 -1
  35. data/lib/rubocop/cop/primer/component_name_migration.rb +35 -0
  36. data/lib/rubocop/cop/primer/primer_octicon.rb +1 -1
  37. data/lib/tasks/docs.rake +12 -7
  38. data/lib/tasks/utilities.rake +1 -1
  39. data/static/arguments.yml +52 -1
  40. data/static/audited_at.json +1 -1
  41. data/static/classes.yml +9 -4
  42. data/static/constants.json +18 -8
  43. data/static/statuses.json +2 -2
  44. metadata +13 -9
  45. data/app/components/primer/auto_complete/auto_complete.d.ts +0 -1
  46. data/app/components/primer/auto_complete/auto_complete.js +0 -1
@@ -0,0 +1,383 @@
1
+ import type {AnchorAlignment, AnchorSide} from '@primer/behaviors'
2
+ import {getAnchoredPosition} from '@primer/behaviors'
3
+
4
+ const TOOLTIP_OPEN_CLASS = 'tooltip-open'
5
+ const TOOLTIP_ARROW_EDGE_OFFSET = 10
6
+
7
+ type Direction = 'n' | 's' | 'e' | 'w' | 'ne' | 'se' | 'nw' | 'sw'
8
+
9
+ const DIRECTION_CLASSES = [
10
+ 'tooltip-n',
11
+ 'tooltip-s',
12
+ 'tooltip-e',
13
+ 'tooltip-w',
14
+ 'tooltip-ne',
15
+ 'tooltip-se',
16
+ 'tooltip-nw',
17
+ 'tooltip-sw'
18
+ ]
19
+
20
+ class TooltipElement extends HTMLElement {
21
+ styles() {
22
+ return `
23
+ :host {
24
+ position: absolute;
25
+ z-index: 1000000;
26
+ padding: .5em .75em;
27
+ font: normal normal 11px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
28
+ -webkit-font-smoothing: subpixel-antialiased;
29
+ color: var(--color-fg-on-emphasis);
30
+ text-align: center;
31
+ text-decoration: none;
32
+ text-shadow: none;
33
+ text-transform: none;
34
+ letter-spacing: normal;
35
+ word-wrap: break-word;
36
+ white-space: pre;
37
+ background: var(--color-neutral-emphasis-plus);
38
+ border-radius: 6px;
39
+ opacity: 0;
40
+ max-width: 250px;
41
+ word-wrap: break-word;
42
+ white-space: normal;
43
+ width: max-content;
44
+ }
45
+
46
+ :host:before{
47
+ position: absolute;
48
+ z-index: 1000001;
49
+ color: var(--color-neutral-emphasis-plus);
50
+ content: "";
51
+ border: 6px solid transparent;
52
+ opacity: 0
53
+ }
54
+
55
+ @keyframes tooltip-appear {
56
+ from {
57
+ opacity: 0
58
+ }
59
+ to {
60
+ opacity: 1
61
+ }
62
+ }
63
+
64
+ :host:after{
65
+ position: absolute;
66
+ display: block;
67
+ right: 0;
68
+ left: 0;
69
+ height: 12px;
70
+ content: ""
71
+ }
72
+
73
+ :host(.${TOOLTIP_OPEN_CLASS}),
74
+ :host(.${TOOLTIP_OPEN_CLASS}):before {
75
+ animation-name: tooltip-appear;
76
+ animation-duration: .1s;
77
+ animation-fill-mode: forwards;
78
+ animation-timing-function: ease-in;
79
+ animation-delay: .4s
80
+ }
81
+
82
+ :host(.tooltip-s):before,
83
+ :host(.tooltip-n):before {
84
+ right: 50%;
85
+ }
86
+
87
+ :host(.tooltip-s):before,
88
+ :host(.tooltip-se):before,
89
+ :host(.tooltip-sw):before {
90
+ bottom: 100%;
91
+ margin-right: -${TOOLTIP_ARROW_EDGE_OFFSET}px;
92
+ border-bottom-color: var(--color-neutral-emphasis-plus)
93
+ }
94
+
95
+ :host(.tooltip-s):after,
96
+ :host(.tooltip-se):after,
97
+ :host(.tooltip-sw):after {
98
+ bottom: 100%
99
+ }
100
+
101
+ :host(.tooltip-n):before,
102
+ :host(.tooltip-ne):before,
103
+ :host(.tooltip-nw):before {
104
+ top: 100%;
105
+ margin-right: -${TOOLTIP_ARROW_EDGE_OFFSET}px;
106
+ border-top-color: var(--color-neutral-emphasis-plus)
107
+ }
108
+
109
+ :host(.tooltip-n):after,
110
+ :host(.tooltip-ne):after,
111
+ :host(.tooltip-nw):after {
112
+ top: 100%
113
+ }
114
+
115
+ :host(.tooltip-se):before,
116
+ :host(.tooltip-ne):before {
117
+ left: 0;
118
+ margin-left: ${TOOLTIP_ARROW_EDGE_OFFSET}px;
119
+ }
120
+
121
+ :host(.tooltip-sw):before,
122
+ :host(.tooltip-nw):before {
123
+ right: 0;
124
+ margin-right: ${TOOLTIP_ARROW_EDGE_OFFSET}px;
125
+ }
126
+
127
+ :host(.tooltip-w):before {
128
+ top: 50%;
129
+ bottom: 50%;
130
+ left: 100%;
131
+ margin-top: -6px;
132
+ border-left-color: var(--color-neutral-emphasis-plus)
133
+ }
134
+
135
+ :host(.tooltip-e):before {
136
+ top: 50%;
137
+ right: 100%;
138
+ bottom: 50%;
139
+ margin-top: -6px;
140
+ border-right-color: var(--color-neutral-emphasis-plus)
141
+ }
142
+ `
143
+ }
144
+
145
+ #abortController: AbortController | undefined
146
+ #align: AnchorAlignment = 'center'
147
+ #side: AnchorSide = 'outside-bottom'
148
+ #allowUpdatePosition = false
149
+
150
+ get htmlFor(): string {
151
+ return this.getAttribute('for') || ''
152
+ }
153
+
154
+ set htmlFor(value: string) {
155
+ this.setAttribute('for', value)
156
+ }
157
+
158
+ get type(): 'description' | 'label' {
159
+ const type = this.getAttribute('data-type')
160
+ return type === 'label' ? 'label' : 'description'
161
+ }
162
+
163
+ set type(value: 'description' | 'label') {
164
+ this.setAttribute('data-type', value)
165
+ }
166
+
167
+ get direction(): Direction {
168
+ return (this.getAttribute('data-direction') || 's') as Direction
169
+ }
170
+
171
+ set direction(value: Direction) {
172
+ this.setAttribute('data-direction', value)
173
+ }
174
+
175
+ get control(): HTMLElement | null {
176
+ return this.ownerDocument.getElementById(this.htmlFor)
177
+ }
178
+
179
+ constructor() {
180
+ super()
181
+ const shadow = this.attachShadow({mode: 'open'})
182
+ shadow.innerHTML = `
183
+ <style>
184
+ ${this.styles()}
185
+ </style>
186
+ <slot></slot>
187
+ `
188
+ }
189
+
190
+ connectedCallback() {
191
+ this.hidden = true
192
+ this.#allowUpdatePosition = true
193
+
194
+ if (!this.id) {
195
+ this.id = `tooltip-${Date.now()}-${(Math.random() * 10000).toFixed(0)}`
196
+ }
197
+
198
+ if (!this.control) return
199
+
200
+ this.setAttribute('role', 'tooltip')
201
+
202
+ this.#abortController?.abort()
203
+ this.#abortController = new AbortController()
204
+ const {signal} = this.#abortController
205
+
206
+ this.addEventListener('mouseleave', this, {signal})
207
+ this.control.addEventListener('mouseenter', this, {signal})
208
+ this.control.addEventListener('mouseleave', this, {signal})
209
+ this.control.addEventListener('focus', this, {signal})
210
+ this.control.addEventListener('blur', this, {signal})
211
+ this.ownerDocument.addEventListener('keydown', this, {signal})
212
+ }
213
+
214
+ disconnectedCallback() {
215
+ this.#abortController?.abort()
216
+ }
217
+
218
+ handleEvent(event: Event) {
219
+ if (!this.control) return
220
+
221
+ // Ensures that tooltip stays open when hovering between tooltip and element
222
+ // WCAG Success Criterion 1.4.13 Hoverable
223
+ if ((event.type === 'mouseenter' || event.type === 'focus') && this.hidden) {
224
+ this.hidden = false
225
+ } else if (event.type === 'blur') {
226
+ this.hidden = true
227
+ } else if (
228
+ event.type === 'mouseleave' &&
229
+ (event as MouseEvent).relatedTarget !== this.control &&
230
+ (event as MouseEvent).relatedTarget !== this
231
+ ) {
232
+ this.hidden = true
233
+ } else if (event.type === 'keydown' && (event as KeyboardEvent).key === 'Escape' && !this.hidden) {
234
+ this.hidden = true
235
+ }
236
+ }
237
+
238
+ static observedAttributes = ['data-type', 'data-direction', 'id', 'hidden']
239
+
240
+ attributeChangedCallback(name: string) {
241
+ if (name === 'id' || name === 'data-type') {
242
+ if (!this.id || !this.control) return
243
+ if (this.type === 'label') {
244
+ this.control.setAttribute('aria-labelledby', this.id)
245
+ } else {
246
+ let describedBy = this.control.getAttribute('aria-describedby')
247
+ describedBy ? (describedBy = `${describedBy} ${this.id}`) : (describedBy = this.id)
248
+ this.control.setAttribute('aria-describedby', describedBy)
249
+ }
250
+ } else if (name === 'hidden') {
251
+ if (this.hidden) {
252
+ this.classList.remove(TOOLTIP_OPEN_CLASS, ...DIRECTION_CLASSES)
253
+ } else {
254
+ this.classList.add(TOOLTIP_OPEN_CLASS)
255
+ for (const tooltip of this.ownerDocument.querySelectorAll<HTMLElement>(this.tagName)) {
256
+ if (tooltip !== this) tooltip.hidden = true
257
+ }
258
+ this.#updatePosition()
259
+ }
260
+ } else if (name === 'data-direction') {
261
+ this.classList.remove(...DIRECTION_CLASSES)
262
+ const direction = this.direction
263
+ if (direction === 'n') {
264
+ this.#align = 'center'
265
+ this.#side = 'outside-top'
266
+ } else if (direction === 'ne') {
267
+ this.#align = 'start'
268
+ this.#side = 'outside-top'
269
+ } else if (direction === 'e') {
270
+ this.#align = 'center'
271
+ this.#side = 'outside-right'
272
+ } else if (direction === 'se') {
273
+ this.#align = 'start'
274
+ this.#side = 'outside-bottom'
275
+ } else if (direction === 's') {
276
+ this.#align = 'center'
277
+ this.#side = 'outside-bottom'
278
+ } else if (direction === 'sw') {
279
+ this.#align = 'end'
280
+ this.#side = 'outside-bottom'
281
+ } else if (direction === 'w') {
282
+ this.#align = 'center'
283
+ this.#side = 'outside-left'
284
+ } else if (direction === 'nw') {
285
+ this.#align = 'end'
286
+ this.#side = 'outside-top'
287
+ }
288
+ }
289
+ }
290
+
291
+ // `getAnchoredPosition` may calibrate `anchoredSide` but does not recalibrate `align`.
292
+ // Therefore, we need to determine which `align` is best based on the initial `getAnchoredPosition` calcluation.
293
+ // Related: https://github.com/primer/behaviors/issues/63
294
+ #adjustedAnchorAlignment(anchorSide: AnchorSide): AnchorAlignment | undefined {
295
+ if (!this.control) return
296
+
297
+ const tooltipPosition = this.getBoundingClientRect()
298
+ const targetPosition = this.control.getBoundingClientRect()
299
+ const tooltipWidth = tooltipPosition.width
300
+
301
+ const tooltipCenter = tooltipPosition.left + tooltipWidth / 2
302
+ const targetCenter = targetPosition.x + targetPosition.width / 2
303
+
304
+ if (Math.abs(tooltipCenter - targetCenter) < 2 || anchorSide === 'outside-left' || anchorSide === 'outside-right') {
305
+ return 'center'
306
+ } else if (tooltipPosition.left === targetPosition.left) {
307
+ return 'start'
308
+ } else if (tooltipPosition.right === targetPosition.right) {
309
+ return 'end'
310
+ } else if (tooltipCenter < targetCenter) {
311
+ if (tooltipPosition.left === 0) return 'start'
312
+ return 'end'
313
+ } else {
314
+ if (tooltipPosition.right === 0) return 'end'
315
+ return 'start'
316
+ }
317
+ }
318
+
319
+ #updatePosition() {
320
+ if (!this.control) return
321
+ if (!this.#allowUpdatePosition || this.hidden) return
322
+
323
+ const TOOLTIP_OFFSET = 10
324
+
325
+ this.style.left = `0px` // Ensures we have reliable tooltip width in `getAnchoredPosition`
326
+ let position = getAnchoredPosition(this, this.control, {
327
+ side: this.#side,
328
+ align: this.#align,
329
+ anchorOffset: TOOLTIP_OFFSET
330
+ })
331
+ let anchorSide = position.anchorSide
332
+
333
+ // We need to set tooltip position in order to determine ideal align.
334
+ this.style.top = `${position.top}px`
335
+ this.style.left = `${position.left}px`
336
+ let direction: Direction = 's'
337
+
338
+ const align = this.#adjustedAnchorAlignment(anchorSide)
339
+ if (!align) return
340
+
341
+ this.style.left = `0px` // Reset tooltip position again to ensure accurate width in `getAnchoredPosition`
342
+ position = getAnchoredPosition(this, this.control, {side: anchorSide, align, anchorOffset: TOOLTIP_OFFSET})
343
+ anchorSide = position.anchorSide
344
+
345
+ this.style.top = `${position.top}px`
346
+ this.style.left = `${position.left}px`
347
+
348
+ if (anchorSide === 'outside-left') {
349
+ direction = 'w'
350
+ } else if (anchorSide === 'outside-right') {
351
+ direction = 'e'
352
+ } else if (anchorSide === 'outside-top') {
353
+ if (align === 'center') {
354
+ direction = 'n'
355
+ } else if (align === 'start') {
356
+ direction = 'ne'
357
+ } else {
358
+ direction = 'nw'
359
+ }
360
+ } else {
361
+ if (align === 'center') {
362
+ direction = 's'
363
+ } else if (align === 'start') {
364
+ direction = 'se'
365
+ } else {
366
+ direction = 'sw'
367
+ }
368
+ }
369
+
370
+ this.classList.add(`tooltip-${direction}`)
371
+ }
372
+ }
373
+
374
+ if (!window.customElements.get('tool-tip')) {
375
+ window.TooltipElement = TooltipElement
376
+ window.customElements.define('tool-tip', TooltipElement)
377
+ }
378
+
379
+ declare global {
380
+ interface Window {
381
+ TooltipElement: typeof TooltipElement
382
+ }
383
+ }
@@ -5,7 +5,7 @@ require "primer/classify"
5
5
  module Primer
6
6
  # All Primer ViewComponents accept a standard set of options called system arguments, mimicking the [styled-system API](https://styled-system.com/table) used by [Primer React](https://primer.style/components/system-props).
7
7
  #
8
- # Under the hood, system arguments are [mapped](https://github.com/primer/view_components/blob/main/app/lib/primer/classify.rb) to Primer CSS classes, with any remaining options passed to Rails' [`content_tag`](https://api.rubyonrails.org/classes/ActionView/Helpers/TagHelper.html#method-i-content_tag).
8
+ # Under the hood, system arguments are [mapped](https://github.com/primer/view_components/blob/main/lib/primer/classify.rb) to Primer CSS classes, with any remaining options passed to Rails' [`content_tag`](https://api.rubyonrails.org/classes/ActionView/Helpers/TagHelper.html#method-i-content_tag).
9
9
  #
10
10
  # ## Responsive values
11
11
  #
@@ -85,7 +85,7 @@ module Primer
85
85
  #
86
86
  # | Name | Type | Description |
87
87
  # | :- | :- | :- |
88
- # | `clearfix` | Boolean | Wether to assign the `clearfix` class. |
88
+ # | `clearfix` | Boolean | Whether to assign the `clearfix` class. |
89
89
  # | `col` | Integer | Number of columns. <%= one_of(Primer::Classify::Utilities.mappings(:col)) %> |
90
90
  # | `container` | Symbol | Size of the container. <%= one_of(Primer::Classify::Utilities.mappings(:container)) %> |
91
91
  #
@@ -1,6 +1,24 @@
1
- <%= label %>
2
1
  <%= render Primer::BaseComponent.new(**@system_arguments) do %>
3
- <%= input %>
4
- <%= icon %>
5
- <%= results %>
2
+ <label for="<%= @input_id %>" class="<%= @label_classes %>">
3
+ <% if @is_label_visible %>
4
+ <%= @label_text %>
5
+ <% else %>
6
+ <span class="sr-only"><%= @label_text %></span>
7
+ <% end %>
8
+ </label>
9
+ <span class="autocomplete-body">
10
+ <% if @with_icon %>
11
+ <div class="form-control autocomplete-embedded-icon-wrap">
12
+ <%= render Primer::OcticonComponent.new(:search) %>
13
+ <% end %>
14
+ <%= input %>
15
+ <% if @is_clearable %>
16
+ <button id="<%= @input_id %>-clear" class="btn-octicon" aria-label="Clear"><%= primer_octicon "x" %></button>
17
+ <% end %>
18
+ <% if @with_icon %>
19
+ </div>
20
+ <% end %>
21
+ <%= results %>
22
+ </span>
23
+ <div id="<%= @list_id %>-feedback" class="sr-only"></div>
6
24
  <% end %>
@@ -7,43 +7,13 @@ module Primer
7
7
  # @accessibility
8
8
  # Always set an accessible label to help the user interact with the component.
9
9
  #
10
- # * Set the `label` slot to render a visible label. Alternatively, associate an existing visible text element
11
- # as a label by setting `aria-labelledby`.
12
- # * If you must use a non-visible label, set `:"aria-label"` on `AutoComplete` and Primer
13
- # will apply it to the correct elements. However, please note that a visible label should almost
14
- # always be used unless there is compelling reason not to. A placeholder is not a label.
10
+ # * `label_text` is required and visible by default.
11
+ # * If you must use a non-visible label, set `is_label_visible` to `false`.
12
+ # However, please note that a visible label should almost always
13
+ # be used unless there is compelling reason not to. A placeholder is not a label.
15
14
  class AutoComplete < Primer::Component
16
15
  status :beta
17
-
18
- # Optionally render a visible label. See <%= link_to_accessibility %>
19
16
  #
20
- # @param system_arguments [Hash] <%= link_to_system_arguments_docs %>
21
- renders_one :label, lambda { |**system_arguments|
22
- deny_tag_argument(**system_arguments)
23
- system_arguments[:for] = @input_id
24
- system_arguments[:tag] = :label
25
- Primer::BaseComponent.new(**system_arguments)
26
- }
27
-
28
- # Required input used to search for results
29
- #
30
- # @param type [Symbol] <%= one_of(Primer::Beta::AutoComplete::Input::TYPE_OPTIONS) %>
31
- # @param system_arguments [Hash] <%= link_to_system_arguments_docs %>
32
- renders_one :input, lambda { |**system_arguments|
33
- aria_label = aria("label", system_arguments) || @aria_label
34
- if aria_label.present?
35
- system_arguments[:"aria-label"] = aria_label
36
- system_arguments[:aria]&.delete(:label)
37
- end
38
-
39
- name = system_arguments[:name] || @input_id
40
- Input.new(id: @input_id, name: name, **system_arguments)
41
- }
42
-
43
- # Optional icon to be rendered before the input. Has the same arguments as <%= link_to_component(Primer::OcticonComponent) %>.
44
- #
45
- renders_one :icon, Primer::OcticonComponent
46
-
47
17
  # Customizable results list.
48
18
  #
49
19
  # @param system_arguments [Hash] <%= link_to_system_arguments_docs %>
@@ -56,34 +26,82 @@ module Primer
56
26
  system_arguments[:classes]
57
27
  )
58
28
 
59
- aria_label = system_arguments[:"aria-label"] || system_arguments.dig(:aria, :label) || @aria_label
60
- system_arguments[:"aria-label"] = aria_label if aria_label.present?
61
- system_arguments[:aria]&.delete(:label)
62
-
63
29
  Primer::BaseComponent.new(**system_arguments)
64
30
  }
65
31
 
32
+ # Customizable input used to search for results.
33
+ # It is preferred to use this slot sparingly - it will be created by default if not explicity added.
34
+ #
35
+ # @param system_arguments [Hash] <%= link_to_system_arguments_docs %>
36
+ renders_one :input, lambda { |**system_arguments|
37
+ sanitized_args = deny_tag_argument(**system_arguments)
38
+ sanitized_args = deny_single_argument(:autofocus, "autofocus is not allowed for accessibility reasons. See https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/autofocus#accessibility_considerations for more information.", **sanitized_args)
39
+ deny_aria_key(
40
+ :label,
41
+ "instead of `aria-label`, include `label_text` and set `is_label_visible` to `false` on the component initializer.",
42
+ **sanitized_args
43
+ )
44
+ deny_single_argument(
45
+ :id,
46
+ "`id` will always be set to @input_id.",
47
+ **sanitized_args
48
+ )
49
+ deny_single_argument(
50
+ :name,
51
+ "Set @input_name on the component initializer instead with `input_name`.",
52
+ **sanitized_args
53
+ )
54
+ sanitized_args[:id] = @input_id
55
+ sanitized_args[:name] = @input_name
56
+ sanitized_args[:tag] = :input
57
+ sanitized_args[:autocomplete] = "off"
58
+
59
+ sanitized_args[:type] = :text
60
+ sanitized_args[:classes] = class_names(
61
+ "form-control",
62
+ sanitized_args[:classes]
63
+ )
64
+
65
+ Primer::BaseComponent.new(**sanitized_args)
66
+ }
67
+
66
68
  # @example Default
67
- # <%= render(Primer::Beta::AutoComplete.new(src: "/auto_complete", input_id: "fruits-input-1", list_id: "fruits-popup-1", position: :relative)) do |c| %>
68
- # <% c.label(classes:"").with_content("Fruits") %>
69
- # <% c.input(type: :text) %>
70
- # <% end %>
69
+ # @description
70
+ # Labels are stacked by default.
71
+ # @code
72
+ # <%= render(Primer::Beta::AutoComplete.new(label_text: "Fruits", src: "/auto_complete", input_id: "fruits-input--default", list_id: "fruits-popup--default")) %>
71
73
  #
72
- # @example With `aria-label`
73
- # <%= render(Primer::Beta::AutoComplete.new("aria-label": "Fruits", src: "/auto_complete", input_id: "fruits-input-2", list_id: "fruits-popup-2", position: :relative)) do |c| %>
74
- # <% c.input(type: :text) %>
75
- # <% end %>
74
+ # @example With inline label
75
+ # @description
76
+ # Labels can be inline by setting `is_label_inline: true`. However, labels will always become stacked on smaller screen sizes.
77
+ # @code
78
+ # <%= render(Primer::Beta::AutoComplete.new(label_text: "Fruits", src: "/auto_complete", is_label_inline: true, input_id: "fruits-input--inline-label", list_id: "fruits-popup--inline-label")) %>
76
79
  #
77
- # @example With `aria-labelledby`
78
- # <%= render(Primer::HeadingComponent.new(tag: :h2, id: "search-1")) { "Search" } %>
79
- # <%= render(Primer::Beta::AutoComplete.new(src: "/auto_complete", input_id: "fruits-input-3", list_id: "fruits-popup-2", position: :relative)) do |c| %>
80
- # <% c.input("aria-labelledby": "search-1") %>
80
+ # @example With non-visible label
81
+ # @description
82
+ # A non-visible label may be rendered with `is_label_visible: false`, but it is highly discouraged. See <%= link_to_accessibility %>.
83
+ # @code
84
+ # <%= render(Primer::Beta::AutoComplete.new(label_text: "Fruits", src: "/auto_complete", input_id: "fruits-input--non-visible-label", list_id: "fruits-popup--non-visible-label", is_label_visible: false)) %>
85
+ #
86
+ # @example With icon
87
+ # @description
88
+ # To display a search icon, set `with_icon` to `true`.
89
+ # @code
90
+ # <%= render(Primer::Beta::AutoComplete.new(label_text: "Fruits", src: "/auto_complete", list_id: "fruits-popup--icon", input_id: "fruits-input--icon", with_icon: true)) %>
91
+ #
92
+ # @example With icon and non-visible label
93
+ # <%= render(Primer::Beta::AutoComplete.new(label_text: "Fruits", src: "/auto_complete", list_id: "fruits-popup--icon-no-label", input_id: "fruits-input--icon-no-label", with_icon: true, is_label_visible: false)) %>
94
+ #
95
+ # @example With clear button
96
+ # <%= render(Primer::Beta::AutoComplete.new(label_text: "Fruits", src: "/auto_complete", input_id: "fruits-input--clear", list_id: "fruits-popup--clear", is_clearable: true)) %>
97
+ #
98
+ # @example With custom classes for the input
99
+ # <%= render(Primer::Beta::AutoComplete.new(label_text: "Fruits", src: "/auto_complete", input_id: "fruits-input--custom-input", list_id: "fruits-popup--custom-input")) do |c| %>
100
+ # <% c.input(classes: "custom-class") %>
81
101
  # <% end %>
82
102
  #
83
103
  # @example With custom classes for the results
84
- # <%= render(Primer::Beta::AutoComplete.new(src: "/auto_complete", input_id: "fruits-input-4", list_id: "fruits-popup-3", position: :relative)) do |c| %>
85
- # <% c.label(classes:"").with_content("Fruits") %>
86
- # <% c.input(type: :text) %>
104
+ # <%= render(Primer::Beta::AutoComplete.new(label_text: "Fruits", src: "/auto_complete", input_id: "fruits-input--custom-results", list_id: "fruits-popup--custom-results")) do |c| %>
87
105
  # <% c.results(classes: "custom-class") do %>
88
106
  # <%= render(Primer::Beta::AutoComplete::Item.new(selected: true, value: "apple")) do |c| %>
89
107
  # Apple
@@ -94,67 +112,36 @@ module Primer
94
112
  # <% end %>
95
113
  # <% end %>
96
114
  #
97
- # @example With Icon
98
- # <%= render(Primer::Beta::AutoComplete.new(src: "/auto_complete", list_id: "fruits-popup-4", input_id: "fruits-input-4", position: :relative)) do |c| %>
99
- # <% c.label(classes:"").with_content("Fruits") %>
100
- # <% c.input(type: :text) %>
101
- # <% c.icon(icon: :search) %>
102
- # <% end %>
103
- #
115
+ # @param label_text [String] The label of the input.
104
116
  # @param src [String] The route to query.
105
117
  # @param input_id [String] Id of the input element.
118
+ # @param input_name [String] Optional name of the input element, defaults to `input_id` when not set.
106
119
  # @param list_id [String] Id of the list element.
120
+ # @param with_icon [Boolean] Controls if a search icon is visible, defaults to `false`.
121
+ # @param is_label_visible [Boolean] Controls if the label is visible. If `false`, screen reader only text will be added.
122
+ # @param is_clearable [Boolean] Adds optional clear button.
123
+ # @param is_label_inline [Boolean] Controls if the label is inline. On smaller screens, label will always become stacked.
107
124
  # @param system_arguments [Hash] <%= link_to_system_arguments_docs %>
108
- def initialize(src:, list_id:, input_id:, **system_arguments)
125
+ def initialize(label_text:, src:, list_id:, input_id:, input_name: nil, is_label_visible: true, is_label_inline: false, with_icon: false, is_clearable: false, **system_arguments)
126
+ @label_text = label_text
109
127
  @list_id = list_id
110
128
  @input_id = input_id
111
- @aria_label = aria("label", system_arguments)
112
-
113
- system_arguments.delete(:"aria-label") && system_arguments[:aria]&.delete(:label)
129
+ @input_name = input_name || input_id
130
+ @is_label_visible = is_label_visible
131
+ @with_icon = with_icon
132
+ @is_clearable = is_clearable
114
133
 
134
+ @label_classes = is_label_inline ? "autocomplete-label-inline" : "autocomplete-label-stacked"
115
135
  @system_arguments = deny_tag_argument(**system_arguments)
116
136
  @system_arguments[:tag] = "auto-complete"
117
137
  @system_arguments[:src] = src
118
138
  @system_arguments[:for] = list_id
119
139
  end
120
140
 
121
- # add `results` without needing to explicitly call it in the view
141
+ # add `input` and `results` without needing to explicitly call them in the view
122
142
  def before_render
123
- raise ArgumentError, "Missing `input` slot" if input.blank?
124
- raise ArgumentError, "Accessible label is required." if label.blank? && input.missing_label?
125
-
126
143
  results(classes: "") unless results
127
- end
128
-
129
- # This component is part of `Primer::Beta::AutoCompleteComponent` and should not be
130
- # used as a standalone component.
131
- class Input < Primer::Component
132
- DEFAULT_TYPE = :text
133
- TYPE_OPTIONS = [DEFAULT_TYPE, :search].freeze
134
-
135
- # @param type [Symbol] <%= one_of(Primer::Beta::AutoComplete::Input::TYPE_OPTIONS) %>
136
- # @param system_arguments [Hash] <%= link_to_system_arguments_docs %>
137
- def initialize(type: DEFAULT_TYPE, **system_arguments)
138
- @system_arguments = deny_tag_argument(**system_arguments)
139
- @system_arguments[:tag] = :input
140
-
141
- @aria_label = system_arguments[:"aria-label"]
142
- @aria_labelledby = system_arguments[:"aria-labelledby"] || system_arguments.dig(:aria, :labelledby)
143
-
144
- @system_arguments[:type] = fetch_or_fallback(TYPE_OPTIONS, type, DEFAULT_TYPE)
145
- @system_arguments[:classes] = class_names(
146
- "form-control",
147
- system_arguments[:classes]
148
- )
149
- end
150
-
151
- def missing_label?
152
- @aria_label.blank? && @aria_labelledby.blank?
153
- end
154
-
155
- def call
156
- render(Primer::BaseComponent.new(**@system_arguments))
157
- end
144
+ input(classes: "") unless input
158
145
  end
159
146
  end
160
147
  end
@@ -5,9 +5,13 @@
5
5
  <%= heading %>
6
6
  <%= description %>
7
7
 
8
- <%= primary_action %>
8
+ <% if primary_action.present? %>
9
+ <div class="blankslate-action">
10
+ <%= primary_action %>
11
+ </div>
12
+ <% end %>
9
13
  <% if secondary_action.present? %>
10
- <div class="mt-3">
14
+ <div class="blankslate-action">
11
15
  <%= secondary_action %>
12
16
  </div>
13
17
  <% end %>