Skip to content

goma-sports-navigation-horizontal

Horizontal swipeable strip of sports — circular icon + label per tile, single-selection state with a themed ring around the selected tile, optional live-event count badge. Sources its sport list from a host-fed array, a one-shot WAMP RPC call, or a live WAMP subscription.

Live demo

Open in the playground → — toggle Sports navigation (horizontal) in the widget rail, then switch between prop / rpc / subscribe sources in the inspector to see how each feed shape renders. Live-count badges drift on the mock source so you can verify the badge UX without a real WAMP feed.

Install

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

bash
npm install @gomagaming/sports-navigation-horizontal

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 swiper vue-toastification

vue, pinia, vue-router, vue-i18n, @vueuse/core, autobahn-browser, swiper, 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-horizontal id="nav"></goma-sports-navigation-horizontal>

Configuration

Assign a config object to the element's config property:

js
const el = document.getElementById('nav')
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',
  iconBaseUrl: '/dist',
}
KeyTypeRequiredDescription
socketUrlstringYes (rpc/subscribe)WAMP socket URL. Only consulted when dataSource is 'rpc' or 'subscribe'.
socketRealmstringYes (rpc/subscribe)WAMP realm.
apiBaseUrlstringYes (rpc/subscribe)REST base URL.
bettingApiBaseUrlstringNoREST base URL for betting offers.
ucsOperatorIdstringYes (rpc/subscribe)Operator ID — used to build the per-tenant subscription URL.
localestringNoi18n locale. Defaults to 'en'.
iconBaseUrlstringNoBase URL where icon SVGs are served from (${iconBaseUrl}/icons/sports/dark_mode/{iconId}.svg).
themeobjectNoCSS variable overrides — see Theming.
messagesobjectNoi18n overrides per locale.
debugbooleanNoVerbose console logging (config-validation warnings, connection-retry messages, etc.).

In prop mode only iconBaseUrl, locale, theme, messages, and debug are consulted — no socket is opened.

Data sources (choose one)

The dataSource property selects where the sport list comes from. All three modes flow through the same render path; only the upstream changes.

1. 'prop' — host-fed array (default)

Drive the list from outside. The widget never opens a socket.

js
el.dataSource = 'prop'
el.sports = [
  { id: '1', name: 'Football',   iconId: 1,  numberOfLiveEvents: 12 },
  { id: '2', name: 'Basketball', iconId: 2,  numberOfLiveEvents: 0 },
  { id: '3', name: 'Tennis',     iconId: 3,  numberOfLiveEvents: 64 },
]

numberOfLiveEvents is optional but enables the live-count badge and the live-first ordering (see Sort order).

2. 'rpc' — one-shot WAMP call on connect

The widget calls apiSession.$wcall('/sports#disciplines', …) once the WAMP connection is up, populates its internal store, then renders. No live updates after the dump — call el.config = el.config (or change liveStatus) to re-fetch.

js
el.dataSource = 'rpc'
el.liveStatus = 'LIVE'   // 'LIVE' | 'NOT_LIVE' | 'BOTH'
el.config = {
  socketUrl: '...',
  socketRealm: '...',
  apiBaseUrl: '...',
  ucsOperatorId: '...',
}
liveStatusUnderlying RPC methodEquivalent upstream call
'LIVE'getLiveSports()$wcall('/sports#disciplines', { liveStatus: 'LIVE', virtualStatus: 'BOTH', sortByPopularity: true })
'NOT_LIVE'getPreLiveSports()$wcall('/sports#disciplines', { liveStatus: 'NOT_LIVE', virtualStatus: 'BOTH', sortByPopularity: true })
'BOTH'getAllSports()$wcall('/sports#disciplines', { liveStatus: 'BOTH', virtualStatus: 'BOTH', sortByPopularity: true })

3. 'subscribe' — live WAMP subscription

The widget registers to the per-tenant sports topic and renders updates as they stream in. numberOfLiveEvents (and the badge / ordering that uses it) stay accurate as games go live.

js
el.dataSource = 'subscribe'
el.liveStatus = 'LIVE'   // 'LIVE' | 'NOT_LIVE' (BOTH falls back to LIVE)
el.config = { /* same as rpc */ }
liveStatusUnderlying subscriptionTopic URL
'LIVE'subscribeToLiveSports({ afterInitialDump })/sports/{ucsOperatorId}/{lang}/sports/LIVE/BOTH
'NOT_LIVE'subscribeToPreLiveSports({ afterInitialDump })/sports/{ucsOperatorId}/{lang}/sports/NOT_LIVE/BOTH
'BOTH'Falls back to subscribeToLiveSports/sports/{ucsOperatorId}/{lang}/sports/LIVE/BOTH (no dedicated BOTH topic exists upstream)

Subscriptions are torn down and replayed automatically when dataSource or liveStatus change, or when the WAMP connection drops and reconnects.

Props (HTML attributes + JS properties)

PropertyAttributeDefaultDescription
dataSourcedata-source'prop''prop' / 'rpc' / 'subscribe'. See Data sources.
liveStatuslive-status'LIVE''LIVE' / 'NOT_LIVE' / 'BOTH'. Selects which RPC method or subscription URL to use.
sportssports[]Array of GomaSport objects. Driven by the host in 'prop' mode; populated from the WAMP API in 'rpc'/'subscribe' mode.
selectedSportIdselected-sport-idnullId of the currently-selected sport. Two-way: a tile click also updates this value.
spaceBetweenspace-between12Pixel gap between tiles.
showLabelsshow-labelstrueRender the sport name beneath each icon.
skeletonCountskeleton-count8Number of placeholder circles shown until the first non-empty sports assignment.

GomaSport shape

ts
interface GomaSport {
  id: string | number
  name: string
  iconId?: string | number       // resolves to /icons/sports/dark_mode/{iconId}.svg
  numberOfEvents?: number
  numberOfLiveEvents?: number    // > 0 → renders the live-count badge
  isVirtual?: boolean
  isTopSport?: boolean
}

When the WAMP modes populate the list, the entries are produced by mapSport() and include the full set of optional fields. The widget only reads id, name, iconId, and numberOfLiveEvents.

Sort order

Tiles are partitioned at render time:

  1. Sports with numberOfLiveEvents > 0 — sorted descending by live count.
  2. All other sports — preserve their original feed order at the tail.

This applies in all three data-source modes. If the host sorts its sports array a particular way for prop mode, that order is preserved within the non-live group.

Click-to-centre scroll

Selecting a tile (whether by user click, keyboard activation, or host setting el.selectedSportId = X) triggers a smooth 300ms scroll that centres the selected tile in the visible swiper viewport. The scroll also runs once on first paint if the host pre-selects a sport before mount.

The animation clamps to the swiper edges, so a selection at the very start or end of the list never over-scrolls past the first/last tile. No-op if the selected sport isn't currently in the rendered list (e.g. during a liveStatus switch where the sport hasn't arrived yet) or if the swiper hasn't initialised.

Loading & empty states

  1. Skeleton — pulsing placeholder circles render from mount until the first non-empty sports assignment. Count is configurable via skeletonCount (default 8). Once data has arrived, the widget never falls back to skeleton — even if the host clears the list.
  2. Empty — once data has been received and the list is empty, a centred localised "No sports available" message is shown (key: sports_navigation_empty).
  3. Error — render errors surface in a themed banner for 8 seconds with a Retry button. Connection failures emit goma:error to the host.

Live-count badge

When a sport has numberOfLiveEvents > 0, a small pill renders on the top-right of the icon circle showing the count (capped at 99+). The badge is themed via:

TokenDefaultUsed for
--goma-liveCountBackground#ed4f63Pill background
--goma-liveCountText#ffffffNumber colour

Both have -dark variants. The badge is wrapped with a 2px ring in --goma-backgroundPrimary so it pops cleanly off the surrounding surface.

Events

StatusEventDetailEmitted when
Canonicalready{}Widget mounted, initial render done.
Canonicalgoma:connection{ state, attempt?, nextRetryMs?, lastError?, attempts? }Connection state transition ('rpc' / 'subscribe' modes only).
Canonicalgoma:error{ message, code, component? }Render or data error.
Canonicalgoma:sport-select{ sportId, sportName, sportIconId, previousSportId }User taps (or activates via Enter/Space) a sport tile. The widget also mutates state.selectedSportId so the ring follows immediately.
AliaserrorSame as goma:errorDispatched in parallel with goma:error.
Aliasgoma:connected{}Dispatched once when the API connection is first established. Prefer goma:connection with state === 'connected'.

Embedding examples

Vanilla HTML

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

  const el = document.getElementById('nav')
  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-horizontal'
import { useEffect, useRef } from 'react'

function SportsNav({ 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-horizontal
      ref={ref}
      onGoma:sport-select={(e) => console.log(e.detail)}
    />
  )
}

Vue 3

vue
<script setup>
import '@gomagaming/sports-navigation-horizontal'
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
})

function onSportSelect(e) {
  console.log('Selected:', e.detail.sportId)
}
</script>

<template>
  <goma-sports-navigation-horizontal
    ref="el"
    @goma:sport-select="onSportSelect"
  />
</template>

Prop mode (no backend)

For storefronts, design previews, or hosts that already have sport data in scope:

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

Theming

Override any CSS variable by passing a theme object. All tokens are namespaced with --goma-* and applied as inline styles on the host element. The prefix is added automatically — pass just the token name:

js
el.theme = {
  backgroundPrimary: '#03061b',
  backgroundCards: '#1f2147',
  highlightPrimary: '#ff6600',     // selected-tile ring colour
  liveCountBackground: '#ef4444',  // live-count pill
  liveCountText: '#ffffff',
  textPrimary: '#ffffff',          // selected label colour
  textSecondary: '#939dff',        // unselected label colour
}

Tokens used by this widget

TokenElement
--goma-backgroundPrimaryContainer background and the badge ring offset
--goma-backgroundCardsUnselected icon circle background
--goma-highlightPrimarySelected-tile ring
--goma-textPrimarySelected label colour
--goma-textSecondaryUnselected label colour
--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_ariaSports navigation<swiper aria-label>
sports_navigation_emptyNo sports availableEmpty state message
retryRetryError banner button

Override with config.messages:

js
el.config = {
  /* … */
  messages: {
    en: { sports_navigation_empty: 'Nothing live right now' },
    fr: { sports_navigation_empty: 'Rien en direct' },
  },
}

TypeScript

ts
import type { GomaSport } from '@gomagaming/sports-navigation-horizontal'

const el = document.querySelector('goma-sports-navigation-horizontal')!
el.dataSource = 'subscribe'      // 'prop' | 'rpc' | 'subscribe'
el.liveStatus = 'LIVE'           // 'LIVE' | 'NOT_LIVE' | 'BOTH'
el.theme = { highlightPrimary: '#dc2626' }
el.sports = [] satisfies GomaSport[]

Reconnection behavior

In 'rpc' and 'subscribe' modes, the widget connects to the WAMP socket on mount. Same exponential-backoff schedule as goma-events-horizontal (1s, 2s, 4s, 8s, 16s, 30s cap; 5 attempts before goma:connection { state: 'failed' }). On reconnect:

  • 'rpc' mode re-runs the getXxxSports() call automatically.
  • 'subscribe' mode re-registers the topic; the initial dump from the new subscription rehydrates the list, and version-based deduplication in the WAMP client prevents double-processing.

Toggling dataSource or liveStatus at runtime cleanly unregisters the previous subscription / cancels the previous RPC before issuing the new one — no leaks.

Accessibility

ElementRoleAttributes
Swiper containertablistaria-label="Sports navigation" (i18n key sports_navigation_aria)
Sport tiletabtabindex="0", aria-selected, aria-label="{name}"
Live-count badgearia-label="N live event(s)"
Error bannerrole="alert" for screen-reader announcement

Keyboard: Tab to focus tiles, Enter / Space to select. :focus-visible ring uses the --goma-highlightPrimary token.

Source layout

LayerFile
Element classpackages/sports-navigation-horizontal/src/SportsNavigationHorizontalElement.js
App wrapperpackages/sports-navigation-horizontal/src/SportsNavigationHorizontalApp.vue
Component treepackages/sports-navigation-horizontal/src/components/SportsNavigation/*
Source composablepackages/sports-navigation-horizontal/src/composables/useSportsSource.js
Entry / registrationpackages/sports-navigation-horizontal/src/index.js
Manifestpackages/sports-navigation-horizontal/custom-elements.json

Shared sports primitives live in @gomagaming/sports-domain:

LayerFile
Sports API modulepackages/sports-domain/src/api/everymatrix/modules/betting.js
Sports mapperpackages/sports-domain/src/api/everymatrix/mappers/sports.mapper.js
Sports storepackages/sports-domain/src/stores/sports.js

Framework spine pieces live in @gomagaming/core (factory, swiper composable, client-asset stub):

LayerFile
Element factorypackages/core/src/createWidgetElement.js
Swiper composablepackages/core/src/composables/useSwiper.js
Client-assets stubpackages/core/src/stubs/useClientAssets.stub.js
TypeScript typespackages/sports-navigation-horizontal/src/types/index.d.ts