Skip to content

goma-sports-navigation-dialog

Self-contained sport-picker dialog: a trigger pill that, on tap, opens a modal grid of sport tiles with an optional search input. Sister widget to goma-sports-navigation-horizontal — same data sources (prop / rpc / subscribe), same selection state, same goma:sport-select event, just rendered as a popover dialog instead of a horizontal swiper.

Designed primarily for mobile layouts where the horizontal strip wouldn't fit, but the contract is identical so the two widgets are interchangeable wherever a single-selection sports picker is needed.

Live demo

Open in the playground → — toggle Sports navigation (dialog) in the widget rail, then tap the trigger pill on the stage to open the modal. The mock source drifts live-count badges on the same 4-second cadence as the horizontal sister so you can verify the dialog stays responsive while open.

Install

@gomagaming/sports-navigation-dialog is a scoped private package. See Installation for the one-time .npmrc setup required to consume it.

bash
npm install @gomagaming/sports-navigation-dialog

You also need the shared peer dependencies installed at your app level:

bash
npm install vue pinia vue-router vue-i18n @vueuse/core autobahn-browser vue-toastification

vue, pinia, vue-router, vue-i18n, @vueuse/core, autobahn-browser, and vue-toastification are declared as peerDependencies so a consumer with multiple @gomagaming/* widgets gets exactly one copy of each.

Element

html
<goma-sports-navigation-dialog id="snd"></goma-sports-navigation-dialog>

The widget renders only the trigger pill in its closed state — its host element shrinks to that footprint (the demo's theme.backgroundPrimary = 'transparent' override removes the inherited dark backdrop). Tapping the pill opens the modal.

Configuration

js
const el = document.getElementById('snd')
el.config = {
  socketUrl: 'wss://api-ndwstage.everymatrix.com/v2',
  socketRealm: 'www.yes.dk',
  apiBaseUrl: 'https://api-ndwstage.everymatrix.com',
  bettingApiBaseUrl: 'https://sports-api.example.com',
  ucsOperatorId: '2838',
  locale: 'en',
}

The shape is identical to the horizontal sibling — see its config table. All keys are optional in prop mode (no socket is opened).

Data sources (choose one)

The dataSource property selects where the sport list comes from. Mechanism is the same as the horizontal widget — the useSportsSource composable is shared.

ModeBehaviour
'prop' (default)Host feeds el.sports = […]. No socket.
'rpc'One-shot $wcall on connect populates the list once.
'subscribe'Live WAMP subscription streams updates as games go live.

See Sports Navigation Horizontal § Data sources for the full breakdown of liveStatus mappings, RPC method names, and subscription topic URLs.

Props (HTML attributes + JS properties)

Inherited from the shared sports-picker contract (identical to the horizontal sibling):

PropertyAttributeDefaultDescription
dataSourcedata-source'prop''prop' / 'rpc' / 'subscribe'.
liveStatuslive-status'LIVE''LIVE' / 'NOT_LIVE' / 'BOTH'.
sportssports[]Array of GomaSport.
selectedSportIdselected-sport-idnullCurrently selected sport id. Two-way.
showLabelsshow-labelstrueRender the sport name under each tile icon.
showLiveCountshow-live-counttrueRender the live-event count badge on each sport tile. Set false to hide it.
skeletonCountskeleton-count12Placeholder tile count until the first non-empty sports assignment.

Dialog-specific knobs:

PropertyAttributeDefaultDescription
openopenfalseProgrammatic open/close. Two-way: tapping the trigger flips it to true; selecting a sport flips it back to false.
showSearchshow-searchtrueRender the search input inside the modal. Set false to hide.
showTriggershow-triggertrueRender the built-in trigger pill. Set false when the dialog is opened externally (host control or the goma:sports-dialog-open event).
showBackButtonshow-back-buttonfalseShow a Back button (header left) that emits goma:dialog-back and closes — for returning to a previous view.
showApplyButtonshow-apply-buttonfalseShow an Apply footer. When on, tapping a tile only highlights it (no auto-close); Apply commits the highlighted sport (goma:sport-select) and closes.
gridColumnsgrid-columns0Force a fixed column count; 0 = auto (currently 4).
mobileBreakpointmobile-breakpoint'sm'Viewport width below which the dialog renders as a bottom-sheet (full-width, anchored to viewport bottom, slide-up animation) instead of a centred modal. Accepts a Tailwind token ('sm' 640 / 'md' 768 / 'lg' 1024 / 'xl' 1280 / '2xl' 1536) or a raw pixel value (number 480 or string '480px').

spaceBetween from the horizontal sibling is not applicable here — the grid uses CSS gap from the panel layout.

Bottom-sheet vs centred modal

Below mobileBreakpoint the panel renders as a bottom-sheet: full-width, anchored to the viewport bottom, rounded only at the top, with a small drag-handle pill above the header (purely visual — the sheet isn't draggable yet) and a slide-up entry/exit. At or above the breakpoint, the panel is a classic centred modal with rounded corners on all sides and a scale + fade entry/exit. The transition is live — resizing the viewport across the breakpoint while the dialog is open swaps the layout without re-mounting.

GomaSport shape

Identical to the horizontal sibling — see GomaSport.

Behaviour

Open / close

  • Tapping the trigger pill sets el.open = true and opens the modal.
  • Selecting a sport tile dispatches goma:sport-select, mutates state.selectedSportId, and auto-closes the dialog.
  • Tapping the Close button (top-right), tapping the backdrop, or pressing Escape closes the dialog.
  • Hosts can drive open/close programmatically: el.open = true / el.open = false. Each transition emits goma:dialog-open / goma:dialog-close.

Sort order

Live-first partition — same as the horizontal widget. Sports with numberOfLiveEvents > 0 float to the top, sorted by live count desc; the rest preserve original order.

When showSearch is true (default), the modal renders an input with a magnifying-glass icon and a localised placeholder (sports_navigation_dialog_search_placeholder). The query filters the sport list by case-insensitive substring against sport.name. An empty match shows a localised "no results" message (sports_navigation_dialog_no_results).

The search query resets every time the dialog reopens.

Skeleton / empty / no-results

StateTriggerRender
SkeletonFirst open before the host has fed sportsPulsing placeholder circles (count = skeletonCount).
EmptyHost fed sports = [] after at least one assignmentLocalised sports_navigation_empty message.
No resultsSearch query has no matchLocalised sports_navigation_dialog_no_results message.

Trigger pill customisation

The trigger pill is rendered inside the widget's Shadow DOM, so it is not stylable from the outer document. All customisation flows through the public surface — CSS variables for visuals, message overrides for text, and the selectedSportId property for the icon and announced selection.

Visual

The pill displays the selected sport's icon and name on an accent-coloured background; it is rounded on the right only. Before a sport is selected it shows a placeholder label instead of a name, and long sport names truncate with an ellipsis to keep the pill compact.

TokenDrives
--goma-highlightPrimaryPill background (the whole rounded surface) + focus ring.
--goma-highlightPrimaryContrastIcon glyph, sport name, and pill text colour.
js
el.theme = {
  // Accent-coloured pill surface.
  highlightPrimary: '#ff6600',
  // Icon glyph + sport name on the pill.
  highlightPrimaryContrast: '#ffffff',
}

Text / accessibility

The pill's aria-label is built from sports_navigation_dialog_title plus the currently-selected sport name. Override the base label per locale via config.messages:

js
el.config = {
  /* … */
  messages: {
    en: { sports_navigation_dialog_title: 'Pick a sport' },
    fr: { sports_navigation_dialog_title: 'Choisir un sport' },
  },
}
// → aria-label="Pick a sport: Football" once a sport is selected.

The pill's icon and name come from the currently selected sport — feed sports and selectedSportId to control which sport appears. The icon resolves from the selected sport's iconId. Until a sport is selected (e.g. while the sport list is still loading in rpc/subscribe mode) the pill shows the noicon glyph with a placeholder label (sports_navigation_dialog_placeholder, default Sport) so it is never empty. Long sport names truncate with an ellipsis.

Hide the search bar with a single switch

js
el.showSearch = false                   // hides the search input on every open
el.setAttribute('show-search', 'false') // attribute equivalent

Override the search placeholder/label per locale via config.messages:

js
el.config = {
  /* … */
  messages: {
    en: { sports_navigation_dialog_search_placeholder: 'Search sports…' },
    fr: { sports_navigation_dialog_search_placeholder: 'Rechercher un sport…' },
    pt: { sports_navigation_dialog_search_placeholder: 'Procurar desporto…' },
  },
}

Events

StatusEventDetailEmitted when
Canonicalready{}Widget mounted, initial render done.
Canonicalgoma:sport-select{ sportId, sportName, sportIconId, previousSportId }User tapped (or activated via Enter/Space) a sport tile. The widget mutates state.selectedSportId and auto-closes the modal.
Canonicalgoma:dialog-open{}state.open transitioned from false → true.
Canonicalgoma:dialog-close{}state.open transitioned from true → false.
Canonicalgoma:dialog-back{}The Back button (showBackButton) was pressed; the dialog closes.
Canonicalgoma:error{ message, code, component? }Render or data error.

Opening via an event. Any control on the page can open the dialog by dispatching a composed goma:sports-dialog-open event (window.dispatchEvent(new CustomEvent('goma:sports-dialog-open', { bubbles: true, composed: true }))). Paired with showTrigger: false, this lets another widget (e.g. <goma-filters-settings>'s "Filters applied to:" card) act as the trigger. | Alias | error | Same as goma:error | Dispatched in parallel with goma:error. |

goma:sport-select is bubbles + composed, so it crosses the shadow-DOM boundary and is observable from a window.addEventListener('goma:sport-select', …) listener — the dialog and the horizontal picker are drop-in interchangeable for any consumer wiring that expected the horizontal picker (e.g. <goma-events-horizontal>'s followSportPicker rewiring keeps working).

Embedding examples

Vanilla HTML

html
<goma-sports-navigation-dialog id="snd"></goma-sports-navigation-dialog>
<script type="module">
  import '@gomagaming/sports-navigation-dialog'

  const el = document.getElementById('snd')
  el.dataSource = 'subscribe'
  el.liveStatus = 'LIVE'
  el.config = {
    socketUrl: 'wss://example.com/v2',
    socketRealm: 'www.example.com',
    apiBaseUrl: 'https://api.example.com',
    bettingApiBaseUrl: 'https://sports-api.example.com',
    ucsOperatorId: '2838',
    locale: 'en',
  }
  el.addEventListener('goma:sport-select', (e) => {
    console.log('Selected:', e.detail.sportName, e.detail.sportId)
  })
</script>

React

jsx
import '@gomagaming/sports-navigation-dialog'
import { useEffect, useRef } from 'react'

function SportsPicker({ config }) {
  const ref = useRef(null)
  useEffect(() => {
    if (!ref.current) return
    ref.current.dataSource = 'subscribe'
    ref.current.liveStatus = 'LIVE'
    ref.current.config = config
  }, [config])
  return (
    <goma-sports-navigation-dialog
      ref={ref}
      onGoma:sport-select={(e) => console.log(e.detail)}
    />
  )
}

Vue 3

vue
<script setup>
import '@gomagaming/sports-navigation-dialog'
import { ref, onMounted } from 'vue'

const el = ref(null)
const props = defineProps(['config'])

onMounted(() => {
  el.value.dataSource = 'subscribe'
  el.value.liveStatus = 'LIVE'
  el.value.config = props.config
})
</script>

<template>
  <goma-sports-navigation-dialog
    ref="el"
    @goma:sport-select="(e) => console.log(e.detail)"
  />
</template>

Prop mode (no backend)

js
el.dataSource = 'prop'
el.sports = await fetch('/api/my-sports').then(r => r.json())
el.selectedSportId = '3'

Theming

Override CSS variables via el.theme = { … } — the prefix --goma- is added automatically.

js
el.theme = {
  // The dialog is a popover, so its host element should usually be transparent.
  // Override backgroundPrimary if you want a backdrop band behind the trigger pill.
  backgroundPrimary: 'transparent',
  // Modal panel background — decoupled from backgroundPrimary so consumers can
  // paint a transparent host while keeping the modal opaque.
  dialogBackground: '#03061b',
  // Selected-tile ring + close-button accent
  highlightPrimary: '#ff6600',
  // Live-count badge
  liveCountBackground: '#ed4f63',
  liveCountText: '#ffffff',
  textPrimary: '#ffffff',
  textSecondary: '#939dff',
}

Tokens used by this widget

TokenElement
--goma-backgroundPrimaryHost element background (consumers usually set this to transparent for popover usage).
--goma-dialogBackgroundModal panel background. Specific to this widget — defaults to #03061b in main.css.
--goma-backgroundCardsUnselected sport-tile circle background; search-input background.
--goma-highlightPrimarySelected-tile ring; Close-button text colour.
--goma-textPrimaryTitle, selected label, body text.
--goma-textSecondaryUnselected labels; placeholder text.
--goma-separatorLinePanel border, search-input border.
--goma-liveCountBackgroundLive-count badge background.
--goma-liveCountTextLive-count badge text.
--goma-iconPrimaryIcon stroke / fill (via <sb-icon>).

See the Theming guide for the complete token reference.

i18n / Message overrides

KeyDefault (en)Used in
sports_navigation_dialog_titleSports availableModal header title
sports_navigation_dialog_placeholderSportTrigger-pill label shown when no sport is selected yet
sports_navigation_dialog_search_placeholderFilter by country...Search input placeholder + label
sports_navigation_dialog_no_resultsNo sports match your searchEmpty-search-result message
sports_navigation_ariaSports navigationGrid aria-label (shared with horizontal)
sports_navigation_emptyNo sports availableEmpty-list message (shared with horizontal)
closeCloseModal close button
retryRetryError banner button

Override with config.messages:

js
el.config = {
  /* … */
  messages: {
    en: { sports_navigation_dialog_title: 'Pick a sport' },
    fr: { sports_navigation_dialog_title: 'Choisir un sport' },
  },
}

Accessibility

ElementRoleAttributes
Trigger pillbuttonaria-haspopup="dialog", aria-expanded, aria-label (announces current selection)
Modal paneldialogaria-modal="true", aria-labelledby → title id
Backdropbuttonaria-label="Close", tabindex="-1"
Sport tiletabtabindex="0", aria-selected, aria-label="{name}"
Live-count badgearia-label="N live event(s)"
Search inputaria-label matching the placeholder

Keyboard: Tab cycles focus inside the modal; Shift+Tab cycles backward; Escape closes; Enter/Space activates the focused tile. Focus returns to the trigger pill on close.

Focus trap: provided by the browser. The widget uses a native <dialog> opened via showModal(), which puts the dialog into the browser's top layer and traps Tab/Shift+Tab inside automatically. The inert attribute on the dialog wrapper keeps its descendants out of the tab order while the dialog is closed.

iOS Safari notes

An early prototype tried <div role="dialog"> with user-space sentinels, a custom focus-trap, and a host-elevation workaround to handle iOS Safari's quirks around shadow-rooted overlays. This widget shipped with a different architecture — native <dialog> + showModal() — which lets the browser's top layer escape those quirks. The table below maps each iOS-specific symptom to its current solution:

Symptom on iOSSolution in this widget
Inputs under 16 px trigger an auto-zoomSearch input has inline font-size: 16px.
Native <dialog> focus-trap leaks when used as a styled box in light DOMThe dialog opens via showModal() into the browser's top layer — the trap is browser-managed, not user-space, and works because the dialog tree is no longer anchored to the host's stacking context.
showModal() doesn't scroll-lock the page on iOSuseScrollLock from @gomagaming/core applied on every open.
Shadow-rooted z-index doesn't always cover sibling widgetsNative <dialog> top layer escapes every ancestor stacking context — the dialog paints above sibling widgets without any host-elevation workaround.
Keyboard focus tracking edge casesBrowser-managed focus trap inside the top-layer dialog handles these — no custom keydown tracking needed.
Layout flex/grid quirks on the dialog/panel rootThe dialog itself is a transparent full-viewport stage (display: grid; place-items: center) and the visible panel uses flex flex-col — layout lives on inner wrappers, not on the panel root.

Source layout

LayerFile
Element classpackages/sports-navigation-dialog/src/SportsNavigationDialogElement.js
App wrapperpackages/sports-navigation-dialog/src/SportsNavigationDialogApp.vue
Trigger pillpackages/sports-navigation-dialog/src/components/SportsNavigation/SportsNavigationDialogTrigger.vue
Modal panelpackages/sports-navigation-dialog/src/components/SportsNavigation/SportsNavigationDialogPanel.vue
Tile (copied from horizontal)packages/sports-navigation-dialog/src/components/SportsNavigation/SportItem.vue
Entry / registrationpackages/sports-navigation-dialog/src/index.js
Manifestpackages/sports-navigation-dialog/custom-elements.json
Testspackages/sports-navigation-dialog/tests/SportsNavigationDialog.test.js

Reusable primitives extracted into @gomagaming/core for any future modal widgets:

LayerFile
Focus trap composablepackages/core/src/composables/useFocusTrap.js
Scroll-lock composablepackages/core/src/composables/useScrollLock.js
Focusable walker (crosses nested shadow roots)packages/core/src/utils/focusableInside.js

Sports composable + factory (shared with the horizontal sibling):

LayerFile
useSportsSourcepackages/sports-domain/src/composables/ui/useSportsSource.js
Element factorypackages/core/src/createWidgetElement.js