Appearance
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-horizontalYou 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-toastificationvue, 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',
}| Key | Type | Required | Description |
|---|---|---|---|
socketUrl | string | Yes (rpc/subscribe) | WAMP socket URL. Only consulted when dataSource is 'rpc' or 'subscribe'. |
socketRealm | string | Yes (rpc/subscribe) | WAMP realm. |
apiBaseUrl | string | Yes (rpc/subscribe) | REST base URL. |
bettingApiBaseUrl | string | No | REST base URL for betting offers. |
ucsOperatorId | string | Yes (rpc/subscribe) | Operator ID — used to build the per-tenant subscription URL. |
locale | string | No | i18n locale. Defaults to 'en'. |
iconBaseUrl | string | No | Base URL where icon SVGs are served from (${iconBaseUrl}/icons/sports/dark_mode/{iconId}.svg). |
theme | object | No | CSS variable overrides — see Theming. |
messages | object | No | i18n overrides per locale. |
debug | boolean | No | Verbose 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: '...',
}liveStatus | Underlying RPC method | Equivalent 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 */ }liveStatus | Underlying subscription | Topic 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)
| Property | Attribute | Default | Description |
|---|---|---|---|
dataSource | data-source | 'prop' | 'prop' / 'rpc' / 'subscribe'. See Data sources. |
liveStatus | live-status | 'LIVE' | 'LIVE' / 'NOT_LIVE' / 'BOTH'. Selects which RPC method or subscription URL to use. |
sports | sports | [] | Array of GomaSport objects. Driven by the host in 'prop' mode; populated from the WAMP API in 'rpc'/'subscribe' mode. |
selectedSportId | selected-sport-id | null | Id of the currently-selected sport. Two-way: a tile click also updates this value. |
spaceBetween | space-between | 12 | Pixel gap between tiles. |
showLabels | show-labels | true | Render the sport name beneath each icon. |
skeletonCount | skeleton-count | 8 | Number 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:
- Sports with
numberOfLiveEvents > 0— sorted descending by live count. - 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
- Skeleton — pulsing placeholder circles render from mount until the first non-empty
sportsassignment. Count is configurable viaskeletonCount(default8). Once data has arrived, the widget never falls back to skeleton — even if the host clears the list. - Empty — once data has been received and the list is empty, a centred localised "No sports available" message is shown (key:
sports_navigation_empty). - Error — render errors surface in a themed banner for 8 seconds with a Retry button. Connection failures emit
goma:errorto 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:
| Token | Default | Used for |
|---|---|---|
--goma-liveCountBackground | #ed4f63 | Pill background |
--goma-liveCountText | #ffffff | Number 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
| Status | Event | Detail | Emitted when |
|---|---|---|---|
| Canonical | ready | {} | Widget mounted, initial render done. |
| Canonical | goma:connection | { state, attempt?, nextRetryMs?, lastError?, attempts? } | Connection state transition ('rpc' / 'subscribe' modes only). |
| Canonical | goma:error | { message, code, component? } | Render or data error. |
| Canonical | goma: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. |
| Alias | error | Same as goma:error | Dispatched in parallel with goma:error. |
| Alias | goma: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
| Token | Element |
|---|---|
--goma-backgroundPrimary | Container background and the badge ring offset |
--goma-backgroundCards | Unselected icon circle background |
--goma-highlightPrimary | Selected-tile ring |
--goma-textPrimary | Selected label colour |
--goma-textSecondary | Unselected label colour |
--goma-liveCountBackground | Live-count badge background |
--goma-liveCountText | Live-count badge text |
--goma-iconPrimary | Icon stroke / fill (via <sb-icon>) |
See the Theming guide for the complete token reference.
i18n / Message overrides
| Key | Default (en) | Used in |
|---|---|---|
sports_navigation_aria | Sports navigation | <swiper aria-label> |
sports_navigation_empty | No sports available | Empty state message |
retry | Retry | Error 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 thegetXxxSports()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
| Element | Role | Attributes |
|---|---|---|
| Swiper container | tablist | aria-label="Sports navigation" (i18n key sports_navigation_aria) |
| Sport tile | tab | tabindex="0", aria-selected, aria-label="{name}" |
| Live-count badge | — | aria-label="N live event(s)" |
| Error banner | — | role="alert" for screen-reader announcement |
Keyboard: Tab to focus tiles, Enter / Space to select. :focus-visible ring uses the --goma-highlightPrimary token.
Source layout
| Layer | File |
|---|---|
| Element class | packages/sports-navigation-horizontal/src/SportsNavigationHorizontalElement.js |
| App wrapper | packages/sports-navigation-horizontal/src/SportsNavigationHorizontalApp.vue |
| Component tree | packages/sports-navigation-horizontal/src/components/SportsNavigation/* |
| Source composable | packages/sports-navigation-horizontal/src/composables/useSportsSource.js |
| Entry / registration | packages/sports-navigation-horizontal/src/index.js |
| Manifest | packages/sports-navigation-horizontal/custom-elements.json |
Shared sports primitives live in @gomagaming/sports-domain:
| Layer | File |
|---|---|
| Sports API module | packages/sports-domain/src/api/everymatrix/modules/betting.js |
| Sports mapper | packages/sports-domain/src/api/everymatrix/mappers/sports.mapper.js |
| Sports store | packages/sports-domain/src/stores/sports.js |
Framework spine pieces live in @gomagaming/core (factory, swiper composable, client-asset stub):
| Layer | File |
|---|---|
| Element factory | packages/core/src/createWidgetElement.js |
| Swiper composable | packages/core/src/composables/useSwiper.js |
| Client-assets stub | packages/core/src/stubs/useClientAssets.stub.js |
| TypeScript types | packages/sports-navigation-horizontal/src/types/index.d.ts |