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://sportsapi-yesgamingdk-stage.everymatrix.com/v2',
  socketRealm: 'www.yes.dk',
  apiBaseUrl: 'https://yesgamingdk-api.stage.norway.everymatrix.com',
  bettingApiBaseUrl: 'https://sports-api-stage.everymatrix.com',
  ucsOperatorId: '4313',
  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.
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.
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

TokenDrives
--goma-backgroundCardsPill background (the whole rounded surface).
--goma-separatorLinePill border.
--goma-textPrimaryPill text colour (caret column).
--goma-highlightPrimarySelected-sport icon-circle background + focus ring.
--goma-highlightPrimaryContrastIcon glyph inside the icon-circle.
js
el.theme = {
  // Match the host page's surface — keep the pill flush instead of opaque.
  backgroundCards: '#1f2147',
  separatorLine: 'rgba(255,255,255,0.08)',
  textPrimary: '#ffffff',
  // Brand-coloured icon circle.
  highlightPrimary: '#ff6600',
  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 comes from the currently selected sport's iconId — feed sports and selectedSportId to control which icon appears. With no selection, the pill renders the noicon glyph.

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:error{ message, code, component? }Render or data error.
AliaserrorSame as goma:errorDispatched 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: '4313',
    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_search_placeholderSearch…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