Class: Primer::Alpha::SelectPanel
- Includes:
- Utils
- Defined in:
- app/components/primer/alpha/select_panel.rb
Overview
Select panels allow for selecting from a large number of options and can be thought of as a more capable version of the traditional HTML ‘<select>` element.
Select panels:
-
feature an input field at the top that allows an end user to filter the list of results.
-
can render their items statically or dynamically by fetching results from the server.
-
allow selecting a single item or multiple items.
-
permit leading visuals like Octicons, avatars, and custom SVGs.
-
can be used as form inputs in Rails forms.
## Static list items
The Rails ‘SelectPanel` component allows items to be provided statically or loaded dynamically from the server. Providing items statically is done using a fetch strategy of `:local` in combination with the `item` slot:
“‘erb <%= render(Primer::Alpha::SelectPanel.new(fetch_strategy: :local))) do |panel| %>
<% panel.with_show_button { "Select item" } %>
<% panel.with_item(label: "Item 1") %>
<% panel.with_item(label: "Item 2") %>
<% end %> “‘
## Dynamic list items
List items can also be fetched dynamically from the server and will require creating a Rails controller action to respond with the list of items in addition to rendering the ‘SelectPanel` instance. Render the instance as normal, providing your desired [fetch strategy](#fetch-strategies):
“‘erb <%= render(
Primer::Alpha::SelectPanel.new(
fetch_strategy: :remote,
src: search_items_path # perhaps a Rails URL helper
)
) %> “‘
Define a controller action to serve the list of items. The ‘SelectPanel` component passes any filter text in the `q=` URL parameter.
“‘ruby class SearchItemsController < ApplicationController
def show
# NOTE: params[:q] may be nil since there is no filter string available
# when the panel is first opened
@results = SomeModel.search(params[:q] || "")
end
end “‘
Responses must be HTML fragments, eg. have a content type of ‘text/html+fragment`. This content type isn’t available by default in Rails, so you may have to register it eg. in an initializer:
“‘ruby Mime::Type.register(“text/fragment+html”, :html_fragment) “`
Render a ‘Primer::Alpha::SelectPanel::ItemList` in the action’s template, search_items/show.html_fragment.erb:
“‘erb <%= render(Primer::Alpha::SelectPanel::ItemList.new) do |list| %>
<% @results.each do |result| %>
<% list.with_item(label: result.title) do |item| %>
<% item.with_description(result.description) %>
<% end %>
<% end %>
<% end %> “‘
### Selection consistency
The ‘SelectPanel` component automatically “remembers” which items have been selected across item fetch requests, meaning the controller that renders dynamic list items does not (and should not) remember these selections or persist them until the user has confirmed them, either by submitting the form or otherwise indicating completion. The `SelectPanel` component does not include unconfirmed selection data in requests.
## Fetch strategies
The list of items can be fetched from a remote URL, or provided as a static list, configured using the ‘fetch_strategy` attribute. Fetch strategies are summarized below.
-
‘:remote`: a query is made to the URL in the `src` attribute every time the input field changes.
-
‘:eventually_local`: a query is made to the URL in the `src` attribute when the panel is first opened. The
results are "remembered" and filtered in-memory for all subsequent filter operations, i.e. when the input field changes.
-
‘:local`: the list of items is provided statically ahead of time and filtered in-memory. No requests are made
to the server.
## Customizing filter behavior
If the fetch strategy is ‘:remote`, then filtering is handled server-side. The server should render a `Primer::Alpha::SelectPanel::ItemList` (an alias of <%= link_to_component(Primer::Alpha::ActionList) %>) in the response containing the filtered list of items. The component achieves remote fetching via the [remote-input-element](github.com/github/remote-input-element), which sends a request to the server with the filter string in the `q=` parameter. Responses must be HTML fragments, eg. have a content type of `text/html+fragment`.
### Local filtering
If the fetch strategy is ‘:local` or `:eventually_local`, filtering is performed client-side. Filter behavior can be customized in JavaScript by setting the `filterFn` attribute on the instance of `SelectPanelElement`, eg:
“‘javascript document.querySelector(“select-panel”).filterFn = (item: HTMLElement, query: string): boolean =>
// return true if the item should be displayed, false otherwise
“‘
The element’s default filter function uses the value of the ‘data-filter-string` attribute, falling back to the element’s ‘innerText` property. It performs a case-insensitive substring match against the filter string.
### ‘SelectPanel`s as form inputs
‘SelectPanel`s can be used as form inputs. They behave very similarly to how HTML `<select>` boxes behave, and play nicely with Rails’ built-in form mechanisms. Pass arguments via the ‘form_arguments:` argument, including the Rails form builder object and the name of the field. Each list item must also have a value specified in `content_arguments: { data: { value: } }`.
“‘erb <% form_with(model: Address.new) do |f| %>
<%= render(Primer::Alpha::SelectPanel.new(form_arguments: { builder: f, name: "country" })) do |menu| %>
<% countries.each do |country|
<% menu.with_item(label: country.name, content_arguments: { data: { value: country.code } }) %>
<% end %>
<% end %>
<% end %> “‘
The value of the ‘data: { value: … }` argument is sent to the server on submit, keyed using the name provided above (eg. `“country”`). If no value is provided for an item, the value of that item is the item’s label. Here’s the corresponding ‘AddressesController` that might be written to handle the form above:
“‘ruby class AddressesController < ApplicationController
def create
puts "You chose #{address_params[:country]} as your country"
end
private
def address_params
params.require(:address).permit(:country)
end
end “‘
If items are provided dynamically, things become a bit more complicated. The ‘form_for` or `form_with` method call happens in the view that renders the `SelectPanel`, which means the form builder object but isn’t available in the view that renders the list items. In such a case, it can be useful to create an instance of the form builder maually:
“‘erb <% builder = ActionView::Helpers::FormBuilder.new(
"address", # the name of the model, used to wrap input names, eg 'address[country]'
nil, # object (eg. the Address instance, which we can omit)
self, # template
{} # options
) %> <%= render(Primer::Alpha::SelectPanel::ItemList.new(
form_arguments: { builder: builder, name: "country" }
)) do |list| %>
<% countries.each do |country| %>
<% menu.with_item(label: country.name, content_arguments: { data: { value: country.code } }) %>
<% end %>
<% end %> “‘
### JavaScript API
‘SelectPanel`s render a `<select-panel>` custom element that exposes behavior to the client.
#### Utility methods
-
‘show()`: Manually open the panel. Under normal circumstances, a show button is used to show the panel, but this method exists to support unusual use-cases.
-
‘hide()`: Manually hides (closes) the panel.
#### Query methods
-
‘getItemById(itemId: string): Element`: Returns the item’s HTML ‘<li>` element. The return value can be passed as the `item` argument to the other methods listed below.
-
‘isItemChecked(item: Element): boolean`: Returns `true` if the item is checked, `false` otherwise.
-
‘isItemHidden(item: Element): boolean`: Returns `true` if the item is hidden, `false` otherwise.
-
‘isItemDisabled(item: Element): boolean`: Returns `true` if the item is disabled, `false` otherwise.
NOTE: Item IDs are special values provided by the user that are attached to ‘SelectPanel` list items as the `data-item-id` HTML attribute. Item IDs can be provided by passing an `item_id:` attribute when adding items to the panel, eg:
“‘erb <%= render(Primer::Alpha::SelectPanel.new) do |panel| %>
<% panel.with_item(item_id: "my-id") %>
<% end %> “‘
The same is true when rendering ‘ItemList`s:
“‘erb <%= render(Primer::Alpha::SelectPanel::ItemList.new) do |list| %>
<% list.with_item(item_id: "my-id") %>
<% end %> “‘
#### State methods
-
‘enableItem(item: Element)`: Enables the item, i.e. makes it clickable by the mouse and keyboard.
-
‘disableItem(item: Element)`: Disables the item, i.e. makes it unclickable by the mouse and keyboard.
-
‘checkItem(item: Element)`: Checks the item. Only has an effect in single- and multi-select modes.
-
‘uncheckItem(item: Element)`: Unchecks the item. Only has an effect in multi-select mode, since items cannot be unchecked in single-select mode.
#### Events
|Name |Type |Bubbles |Cancelable | |:——————–|:——————————————|:——-|:———-| |‘itemActivated` |`CustomEvent<ItemActivatedEvent>` |Yes |No | |`beforeItemActivated`|`CustomEvent<ItemActivatedEvent>` |Yes |Yes | |`dialog:open` |`CustomEvent<HTMLDialogElement>` |No |No | |`panelClosed` |`CustomEvent<SelectPanelElement>` |Yes |No |
_Item activation_
The ‘<select-panel>` element fires an `itemActivated` event whenever an item is activated (eg. clicked) via the mouse or keyboard.
“‘typescript document.querySelector(“select-panel”).addEventListener(
"itemActivated",
(event: CustomEvent<ItemActivatedEvent>) => {
event.detail.item // Element: the <li> item that was activated
event.detail.checked // boolean: whether or not the result of the activation checked the item
}
) “‘
The ‘beforeItemActivated` event fires before an item is activated. Canceling this event will prevent the item from being activated.
“‘typescript document.querySelector(“select-panel”).addEventListener(
"beforeItemActivated",
(event: CustomEvent<ItemActivatedEvent>) => {
event.detail.item // Element: the <li> item that was activated
event.detail.checked // boolean: whether or not the result of the activation checked the item
event.preventDefault() // Cancel the event to prevent activation (eg. checking/unchecking)
}
) “‘
Defined Under Namespace
Modules: Utils Classes: ItemList
Constant Summary collapse
- DEFAULT_PRELOAD =
false
- DEFAULT_FETCH_STRATEGY =
:remote
- FETCH_STRATEGIES =
[ DEFAULT_FETCH_STRATEGY, :eventually_local, :local ]
- DEFAULT_SELECT_VARIANT =
:single
- SELECT_VARIANT_OPTIONS =
[ DEFAULT_SELECT_VARIANT, :multiple, :none, ].freeze
- DEFAULT_BANNER_SCHEME =
:danger
- BANNER_SCHEME_OPTIONS =
[ DEFAULT_BANNER_SCHEME, :warning ].freeze
Constants inherited from Component
Component::INVALID_ARIA_LABEL_TAGS
Constants included from Status::Dsl
Constants included from ViewHelper
Constants included from TestSelectorHelper
TestSelectorHelper::TEST_SELECTOR_TAG
Constants included from FetchOrFallbackHelper
FetchOrFallbackHelper::InvalidValueError
Constants included from Primer::AttributesHelper
Primer::AttributesHelper::PLURAL_ARIA_ATTRIBUTES, Primer::AttributesHelper::PLURAL_DATA_ATTRIBUTES
Instance Attribute Summary collapse
-
#banner_scheme ⇒ Symbol
readonly
<%= one_of(Primer::Alpha::SelectPanel::BANNER_SCHEME_OPTIONS) %>.
-
#body_id ⇒ String
readonly
The unique ID of the panel body.
-
#fetch_strategy ⇒ Symbol
readonly
<%= one_of(Primer::Alpha::SelectPanel::FETCH_STRATEGIES) %>.
-
#panel_id ⇒ String
readonly
The unique ID of the panel.
-
#preload ⇒ Boolean
(also: #preload?)
readonly
Whether to preload search results when the page loads.
-
#select_variant ⇒ Symbol
readonly
<%= one_of(Primer::Alpha::ActionMenu::SELECT_VARIANT_OPTIONS) %>.
-
#show_filter ⇒ Boolean
(also: #show_filter?)
readonly
Whether or not to show the filter input.
-
#src ⇒ String
readonly
The URL to fetch search results from.
Instance Method Summary collapse
-
#initialize(src: nil, title: "Menu", id: self.class.generate_id, size: :small, select_variant: DEFAULT_SELECT_VARIANT, fetch_strategy: DEFAULT_FETCH_STRATEGY, no_results_label: "No results found", preload: DEFAULT_PRELOAD, dynamic_label: false, dynamic_label_prefix: nil, dynamic_aria_label_prefix: nil, body_id: nil, list_arguments: {}, form_arguments: {}, show_filter: true, open_on_load: false, anchor_align: Primer::Alpha::Overlay::DEFAULT_ANCHOR_ALIGN, anchor_side: Primer::Alpha::Overlay::DEFAULT_ANCHOR_SIDE, loading_label: "Loading content...", loading_description: nil, banner_scheme: DEFAULT_BANNER_SCHEME, **system_arguments) ⇒ SelectPanel
constructor
A new instance of SelectPanel.
- #with_avatar_item ⇒ Object
-
#with_item(**system_arguments) ⇒ Object
Adds an item to the list.
Methods included from Utils
Methods inherited from Component
Methods included from JoinStyleArgumentsHelper
Methods included from TestSelectorHelper
Methods included from FetchOrFallbackHelper
#fetch_or_fallback, #fetch_or_fallback_boolean, #silence_deprecations?
Methods included from ClassNameHelper
Methods included from Primer::AttributesHelper
#aria, #data, #extract_data, #merge_aria, #merge_data, #merge_prefixed_attribute_hashes
Methods included from ExperimentalSlotHelpers
Methods included from ExperimentalRenderHelpers
Constructor Details
#initialize(src: nil, title: "Menu", id: self.class.generate_id, size: :small, select_variant: DEFAULT_SELECT_VARIANT, fetch_strategy: DEFAULT_FETCH_STRATEGY, no_results_label: "No results found", preload: DEFAULT_PRELOAD, dynamic_label: false, dynamic_label_prefix: nil, dynamic_aria_label_prefix: nil, body_id: nil, list_arguments: {}, form_arguments: {}, show_filter: true, open_on_load: false, anchor_align: Primer::Alpha::Overlay::DEFAULT_ANCHOR_ALIGN, anchor_side: Primer::Alpha::Overlay::DEFAULT_ANCHOR_SIDE, loading_label: "Loading content...", loading_description: nil, banner_scheme: DEFAULT_BANNER_SCHEME, **system_arguments) ⇒ SelectPanel
Returns a new instance of SelectPanel.
387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 |
# File 'app/components/primer/alpha/select_panel.rb', line 387 def initialize( src: nil, title: "Menu", id: self.class.generate_id, size: :small, select_variant: DEFAULT_SELECT_VARIANT, fetch_strategy: DEFAULT_FETCH_STRATEGY, no_results_label: "No results found", preload: DEFAULT_PRELOAD, dynamic_label: false, dynamic_label_prefix: nil, dynamic_aria_label_prefix: nil, body_id: nil, list_arguments: {}, form_arguments: {}, show_filter: true, open_on_load: false, anchor_align: Primer::Alpha::Overlay::DEFAULT_ANCHOR_ALIGN, anchor_side: Primer::Alpha::Overlay::DEFAULT_ANCHOR_SIDE, loading_label: "Loading content...", loading_description: nil, banner_scheme: DEFAULT_BANNER_SCHEME, **system_arguments ) raise_if_role_given!(**system_arguments) if src.present? url = URI(src) query = url.query || "" url.query = query.split("&").push("experimental=1").join("&") @src = url end @panel_id = id @body_id = body_id || "#{@panel_id}-body" @preload = fetch_or_fallback_boolean(preload, DEFAULT_PRELOAD) @select_variant = fetch_or_fallback(SELECT_VARIANT_OPTIONS, select_variant, DEFAULT_SELECT_VARIANT) @fetch_strategy = fetch_or_fallback(FETCH_STRATEGIES, fetch_strategy, DEFAULT_FETCH_STRATEGY) @no_results_label = no_results_label @show_filter = show_filter @dynamic_label = dynamic_label @dynamic_label_prefix = dynamic_label_prefix @dynamic_aria_label_prefix = dynamic_aria_label_prefix @loading_label = loading_label @loading_description_id = nil if loading_description.present? @loading_description_id = "#{@panel_id}-loading-description" end @loading_description = loading_description @banner_scheme = fetch_or_fallback(BANNER_SCHEME_OPTIONS, , DEFAULT_BANNER_SCHEME) @system_arguments = deny_tag_argument(**system_arguments) @system_arguments[:id] = @panel_id @system_arguments[:"anchor-align"] = fetch_or_fallback(Primer::Alpha::Overlay::ANCHOR_ALIGN_OPTIONS, anchor_align, Primer::Alpha::Overlay::DEFAULT_ANCHOR_ALIGN) @system_arguments[:"anchor-side"] = Primer::Alpha::Overlay::ANCHOR_SIDE_MAPPINGS[fetch_or_fallback(Primer::Alpha::Overlay::ANCHOR_SIDE_OPTIONS, anchor_side, Primer::Alpha::Overlay::DEFAULT_ANCHOR_SIDE)] @title = title @system_arguments[:tag] = :"select-panel" @system_arguments[:preload] = true if @src.present? && preload? @system_arguments[:data] = merge_data( system_arguments, { data: { select_variant: @select_variant, fetch_strategy: @fetch_strategy, open_on_load: open_on_load }.tap do |data| data[:dynamic_label] = dynamic_label if dynamic_label data[:dynamic_label_prefix] = dynamic_label_prefix if dynamic_label_prefix.present? data[:dynamic_aria_label_prefix] = dynamic_aria_label_prefix if dynamic_aria_label_prefix.present? end } ) @dialog = Primer::BaseComponent.new( id: "#{@panel_id}-dialog", "aria-labelledby": "#{@panel_id}-dialog-title", tag: :dialog, data: { target: "select-panel.dialog" }, classes: class_names( "Overlay", "Overlay-whenNarrow", Primer::Alpha::Dialog::SIZE_MAPPINGS[ fetch_or_fallback(Primer::Alpha::Dialog::SIZE_OPTIONS, size, Primer::Alpha::Dialog::DEFAULT_SIZE) ], ), style: "position: absolute;", ) @list = Primer::Alpha::SelectPanel::ItemList.new( **list_arguments, form_arguments: form_arguments, id: "#{@panel_id}-list", select_variant: @select_variant, aria: { label: "#{title} options" } ) return if @show_filter || @fetch_strategy != :remote return if shouldnt_raise_error? raise( "Hiding the filter input with a remote fetch strategy is not permitted, "\ "since such a combinaton of options will cause the component to only "\ "fetch items from the server once when the panel opens for the first time; "\ "this is what the `:eventually_local` fetch strategy is designed to do. "\ "Consider passing `show_filter: true` or use the `:eventually_local` fetch "\ "strategy instead." ) end |
Instance Attribute Details
#banner_scheme ⇒ Symbol (readonly)
<%= one_of(Primer::Alpha::SelectPanel::BANNER_SCHEME_OPTIONS) %>
344 345 346 |
# File 'app/components/primer/alpha/select_panel.rb', line 344 def @banner_scheme end |
#body_id ⇒ String (readonly)
The unique ID of the panel body.
334 335 336 |
# File 'app/components/primer/alpha/select_panel.rb', line 334 def body_id @body_id end |
#fetch_strategy ⇒ Symbol (readonly)
<%= one_of(Primer::Alpha::SelectPanel::FETCH_STRATEGIES) %>
349 350 351 |
# File 'app/components/primer/alpha/select_panel.rb', line 349 def fetch_strategy @fetch_strategy end |
#panel_id ⇒ String (readonly)
The unique ID of the panel.
329 330 331 |
# File 'app/components/primer/alpha/select_panel.rb', line 329 def panel_id @panel_id end |
#preload ⇒ Boolean (readonly) Also known as: preload?
Whether to preload search results when the page loads. If this option is false, results are loaded when the panel is opened.
354 355 356 |
# File 'app/components/primer/alpha/select_panel.rb', line 354 def preload @preload end |
#select_variant ⇒ Symbol (readonly)
<%= one_of(Primer::Alpha::ActionMenu::SELECT_VARIANT_OPTIONS) %>
339 340 341 |
# File 'app/components/primer/alpha/select_panel.rb', line 339 def select_variant @select_variant end |
#show_filter ⇒ Boolean (readonly) Also known as: show_filter?
Whether or not to show the filter input.
361 362 363 |
# File 'app/components/primer/alpha/select_panel.rb', line 361 def show_filter @show_filter end |
#src ⇒ String (readonly)
The URL to fetch search results from.
324 325 326 |
# File 'app/components/primer/alpha/select_panel.rb', line 324 def src @src end |
Instance Method Details
#with_avatar_item ⇒ Object
10 11 |
# File 'app/components/primer/alpha/select_panel.rb', line 10 def with_avatar_item end |
#with_item(**system_arguments) ⇒ Object
Adds an item to the list. Note that this method only has an effect for the local fetch strategy.
4 5 |
# File 'app/components/primer/alpha/select_panel.rb', line 4 def with_item(**system_arguments) end |