Appearance
goma-events-horizontal
Horizontal swipeable row of event cards — one main game card per event followed by additional market cards. Fully themeable, isolated Web Component with live score support, odds buttons, and betslip integration.
Live demo
Open in the playground → — toggle Events horizontal in the widget rail, then drive props, theme, and the mock / subscribe data source from the inspector's Form / JSON / Snippet tabs. Use Share setup to capture a ?s=… URL for a support ticket or repro.
Install
@gomagaming/events-horizontal is a scoped private package. See Installation for the one-time .npmrc setup required to consume it.
bash
npm install @gomagaming/events-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-events-horizontal id="sb"></goma-events-horizontal>Configuration
Assign a config object to the element's config property:
js
const el = document.getElementById('sb')
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: 'da',
iconBaseUrl: '/dist',
}| Key | Type | Required | Description |
|---|---|---|---|
socketUrl | string | Yes (for live) | WAMP socket URL used for topic subscriptions. |
socketRealm | string | Yes (for live) | WAMP realm. |
apiBaseUrl | string | Yes | REST base URL for the sports API. |
bettingApiBaseUrl | string | No | REST base URL for betting offers. |
ucsOperatorId | string | Yes | Operator ID. |
locale | string | No | i18n locale (e.g. da, en, fr). Defaults to 'en'. |
iconBaseUrl | string | No | Base URL where icon SVGs are served from. |
theme | object | No | CSS variable overrides — see Theming. |
messages | object | No | i18n overrides per locale — see i18n / Message overrides. |
operatorId | string | No | Operator ID (alternative to ucsOperatorId). |
clientId | string | No | Client identifier. |
bettingApiUrl | string | No | Legacy betting API URL (prefer bettingApiBaseUrl). |
topicPrefix | string | No | WAMP topic prefix for the matches-aggregator feed. Used when building subscription URLs and when parsing or rewriting consumer-supplied topic strings (followSportPicker). Override only when the backend exposes a renamed namespace. A trailing slash is added if missing. Defaults to 'custom-matches-aggregator/'. |
sportsNamespace | string | No | Root WAMP namespace shared by every sports RPC and subscription URL. RPCs become {sportsNamespace}#{operation} (e.g. /sports#disciplines); subscriptions become {sportsNamespace}/{ucsOperatorId}/{lang}/{suffix}. Override only when the backend exposes a renamed root. A trailing slash is stripped. Defaults to '/sports'. |
network | object | No | Network policy for connection + retry. Sub-fields default to historical values and may be omitted individually: connectTimeoutMs (default 15000), rpcTimeoutMs (default 15000), retry: { maxAttempts: 5, baseMs: 1000, capMs: 30000 }. Exponential backoff is baseMs * 2^(attempt-1), capped at capMs. |
debug | boolean | No | Surfaces verbose console logging: advisory config-validation warnings (missing apiBaseUrl, unknown keys), the "no socket configured" skip notice, network online/offline transitions, per-attempt connection-retry messages, and per-attempt connect failures. Production-fatal errors (Vue render errors, terminal failure after retry budget exhausted, invalid attribute values) log unconditionally. Defaults to false. |
Data sources (choose one)
Topic subscription — the widget subscribes to a WAMP topic and renders whatever events the feed pushes:
js
el.topic = 'custom-matches-aggregator/1/all/all/all/POPULAR/LIVE/10/5'Direct events — bypass the socket and feed events as a plain array:
js
el.events = [{ id: '123', homeShortParticipantName: 'PAC', /* ... */ }]Props (HTML attributes + JS properties)
| Property | Attribute | Default | Description |
|---|---|---|---|
maxAdditionalMarkets | max-additional-markets | 5 | Max number of market cards rendered after the main game card. |
spaceBetween | space-between | 12 | Pixel gap between slides. |
showSeeAllButton | show-see-all-button | true | Whether to append a "See all" card when there are more markets. |
cardType | card-type | "auto" | Force a main card type: auto / prelive / live / outright. |
skeletonCount | skeleton-count | 3 | Number of skeleton placeholder cards shown while loading. |
selectedOutcomes | selected-outcomes | [] | Array of betting offer IDs that should appear selected. See Two-way selection sync. |
followSportPicker | follow-sport-picker | false | Opt-in cross-widget automation — when true, listens at the window for any <goma-sports-navigation-horizontal>'s goma:sport-select events and rewrites the sportId segment of the current topic. The internal subscription rebinds transparently — no reconnect. No-op when topic is empty (direct-events mode). See Following the sport picker. |
sportPickerSelector | sport-picker-selector | '' | Optional CSS selector that scopes followSportPicker to a specific picker (e.g. '#main-picker', '[data-feed="primary"]'). Empty = match any picker. |
Card types
The wrapper automatically selects the main game card component based on the event:
| Event shape | Card rendered |
|---|---|
event._type === 'TOURNAMENT' | OutrightRegularOdds |
event.isLive === true | LiveRegularOdds |
| Everything else | PreLiveRegularOdds |
Additional markets are rendered with MarketRegularOdds. All four card types use responsive widths (clamp(260px, 80vw, 400px)) with a gradient border and transparent inner border. Colours are fully themeable via CSS variables.
Responsive layout
Card widths adapt to the viewport using clamp(260px, 80vw, 400px):
| Viewport | Card width | Behaviour |
|---|---|---|
| 320px | 260px (min) | Narrow mobile — next card peeks in swiper |
| 375px | 300px | Standard mobile |
| 500px+ | 400px (max) | Tablet and desktop |
Loading & empty states
The widget shows three visual states while waiting for data:
Loading — skeleton placeholder cards (pulsing blocks mimicking the card layout) are shown from mount until the first batch of events arrives. The number of skeletons is configurable via
skeletonCount(default3).Empty — if the API connects but returns zero events for the topic, a centred "No events available" message is shown. The message is localised (en, fr, pt).
Error — if a render error occurs, a themed error banner is shown for 8 seconds with a dismiss button. Connection errors emit
goma:errorto the host.
Events
See Custom events (v1 contract) below for the full event reference with typed detail schemas.
Listen with standard DOM APIs:
js
el.addEventListener('goma:outcome-select', (e) => {
console.log('Added to betslip:', e.detail)
})Embedding examples
Vanilla HTML
html
<goma-events-horizontal id="sb"></goma-events-horizontal>
<script type="module">
import '@gomagaming/events-horizontal'
const el = document.getElementById('sb')
el.config = { /* ... */ }
el.topic = 'custom-matches-aggregator/1/all/all/all/POPULAR/LIVE/10/5'
</script>React
jsx
import '@gomagaming/events-horizontal'
function Feed({ config, topic }) {
return (
<goma-events-horizontal
ref={(el) => {
if (!el) return
el.config = config
el.topic = topic
}}
/>
)
}Vue 3
vue
<script setup>
import '@gomagaming/events-horizontal'
import { ref, onMounted } from 'vue'
const el = ref(null)
onMounted(() => {
el.value.config = { /* ... */ }
el.value.topic = 'custom-matches-aggregator/1/all/all/all/POPULAR/LIVE/10/5'
})
</script>
<template>
<goma-events-horizontal ref="el" />
</template>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: '#1a0505',
highlightPrimary: '#dc2626',
buttonBackgroundPrimary: '#dc2626',
}See the Theming guide for the complete token reference, default values, and full theme examples.
i18n / Message overrides
Override any bundled translation key via config.messages. Consumer phrases are merged on top of bundled defaults. The fallback chain is: custom key -> bundled default -> fallback locale ('en') -> literal key string.
js
el.config = {
apiBaseUrl: 'https://api.example.com',
messages: {
en: { see_all: 'View All', no_events_available: 'Nothing to show' },
fr: { see_all: 'Tout voir' },
},
}Keys used by this widget
| Key | Default (en) | Used in |
|---|---|---|
live | Live | Live badge |
suspended | Suspended | Suspended market overlay |
no_events_available | No events available | Empty state message |
see_all | See All | See-all card button |
x_markets | {count} markets | Market count on see-all card |
add_favorite | Add to favorites | Favorite star aria-label |
remove_favorite | Remove from favorites | Favorite star aria-label (active) |
loading | Loading | Skeleton loading state |
odds_increased | Odds increased to {value} | Screen-reader live region |
odds_decreased | Odds decreased to {value} | Screen-reader live region |
outcome_locked | Outcome locked | Disabled odds button |
retry | Retry | Error banner retry button |
see_competition | See competition | Competition link label |
see_game_details | See game details | Card navigation aria-label |
see_outrights | See outrights | Outright card action button |
Post-mount overrides are also supported:
js
el.config = { messages: { en: { see_all: 'SHOW MORE' } } }Custom events (v1 contract)
The following events are the v1 public API. Their detail shapes will not change without a major version bump. All events use bubbles: true and composed: true to cross the Shadow DOM boundary.
Canonical vs alias. Bind to canonical names in new consumer code. Aliases are dispatched in parallel for backward-compat with hosts written before the namespace was introduced; they will be removed in a future major version.
| Status | Event | Detail | Emitted when |
|---|---|---|---|
| Canonical | ready | {} | Widget mounted, initial render done. (No goma:-namespaced version.) |
| Canonical | goma:connection | { state, attempt?, nextRetryMs?, lastError?, attempts? } | Connection state transition — see Reconnection behavior. |
| Canonical | goma:error | { message: string, code: string, component?: string } | Render or data error. |
| Canonical | goma:outcome-select | { bettingOfferId: string, outcomeName: string, odds: number, eventId: string, marketId: string } | User taps odds to add to betslip. |
| Canonical | goma:outcome-deselect | { bettingOfferId: string } | User taps selected odds to remove. |
| Canonical | goma:event-navigate | { eventId: string, homeTeam: string, awayTeam: string, competitionName: string } | User clicks card to navigate. |
| Canonical | goma:favorite-toggle | { eventId: string, isFavorite: boolean } | User toggles favorite star. |
| Alias | error | Same as goma:error | Dispatched in parallel with goma:error. Prefer the namespaced form. |
| Alias | goma:connected | {} | Dispatched once when the API connection is first established. Prefer goma:connection with state === 'connected'. |
TypeScript
The package ships .d.ts declarations at dist/types/index.d.ts.
querySelector type safety
ts
const el = document.querySelector('goma-events-horizontal')!
el.theme = { highlightPrimary: '#dc2626' } // autocomplete for token names
el.config = { apiBaseUrl: '...' } // typed config shapeTyped event listeners
ts
import type { GomaCustomEvent } from '@gomagaming/events-horizontal'
el.addEventListener('goma:outcome-select', (e: GomaCustomEvent<'goma:outcome-select'>) => {
console.log(e.detail.bettingOfferId) // string
})Vue GlobalComponents
The package augments Vue's GlobalComponents interface, so <goma-events-horizontal> is recognised in Vue templates with full type checking:
vue
<template>
<goma-events-horizontal ref="el" />
</template>Reconnection behavior
When config.socketUrl is set, the widget connects to the WAMP socket on mount. If the connection fails, it retries with exponential backoff. The defaults below are used unless overridden via config.network.retry:
| Attempt | Delay |
|---|---|
| 1 | 1 s |
| 2 | 2 s |
| 3 | 4 s |
| 4 | 8 s |
| 5 | 16 s |
| Max | 30 s cap |
After network.retry.maxAttempts failed attempts (default 5) the widget stops retrying and emits goma:connection { state: 'failed' }. The cadence comes from network.retry.baseMs * 2^(attempt-1), capped at network.retry.capMs. The initial WAMP handshake itself has a hard cap of network.connectTimeoutMs (default 15 s).
goma:connection event detail
ts
{
state: 'connecting' | 'connected' | 'reconnecting' | 'disconnected' | 'failed'
attempt?: number // current retry attempt (reconnecting only)
nextRetryMs?: number // ms until next retry (reconnecting only)
lastError?: string // error message (failed only)
attempts?: number // total attempts made (failed only)
}Handling connection state in the host
js
el.addEventListener('goma:connection', (e) => {
switch (e.detail.state) {
case 'connecting':
showSpinner()
break
case 'connected':
hideSpinner()
break
case 'reconnecting':
showToast(`Reconnecting (attempt ${e.detail.attempt}, next in ${e.detail.nextRetryMs}ms)`)
break
case 'disconnected':
// Widget unmounted — clean up host-side state
break
case 'failed':
showError(`Connection failed after ${e.detail.attempts} attempts: ${e.detail.lastError}`)
break
}
})Subscription replay
On reconnect, active topic subscriptions are automatically replayed. The widget's internal watch([oddsApi, topic]) fires when the API reference transitions from null back to a live object, triggering a fresh subscribeToFilteredEvents() call. Version-based deduplication in the WAMP client prevents duplicate event processing from the new subscription's initial dump.
Following the sport picker
Set followSportPicker = true to wire this widget to any<goma-sports-navigation-horizontal> on the page without any host-side glue.
js
const events = document.getElementById('feed')
events.followSportPicker = true
events.topic = 'custom-matches-aggregator/3/all/all/all/POPULAR/LIVE/10/5'When the user taps Tennis (id=3) in the picker, the picker dispatches goma:sport-select { sportId: 3, … } (bubbles + composed). This widget's window-level listener catches it and rewrites the topic in place:
custom-matches-aggregator/3/all/all/all/POPULAR/LIVE/10/5
^ sportId segment swapped to whatever the user pickedThe widget's internal useTopicSubscription watcher detects state.topic changing and rebinds the WAMP subscription transparently. No reconnect, no recreate, no host code.
The window listener attaches and detaches reactively with the followSportPicker prop — flip it to false at runtime and the listener is removed; flip it back on, the listener is re-attached. It's also removed on unmount.
Multiple events-horizontal instances on the same page each listen independently and stay in sync. Multiple pickers — each subsequent selection wins, unless you scope to a specific picker with sportPickerSelector:
html
<goma-sports-navigation-horizontal id="main-picker"></goma-sports-navigation-horizontal>
<goma-sports-navigation-horizontal id="featured"></goma-sports-navigation-horizontal>
<goma-events-horizontal
follow-sport-picker
sport-picker-selector="#main-picker">
</goma-events-horizontal>The events-horizontal will only react to selections from the picker whose host element matches the selector. Any selector valid in Element.matches() works:
| Selector | Matches |
|---|---|
'#main-picker' | Picker with id="main-picker" |
'[data-feed="primary"]' | Picker with data-feed="primary" |
'goma-sports-navigation-horizontal.featured' | Pickers carrying class="featured" |
The selector is matched against event.target after the event has been retargeted across the shadow boundary (composed: true), so it always sees the custom-element host — never internal Vue components.
The automation is a no-op when:
topicis empty (direct-events mode — there's no topic to rewrite).- The current topic doesn't start with
custom-matches-aggregator/(unknown format — left untouched). followSportPickerisfalse(default).
When debug is on, the topic rewrite is logged: [goma-events-horizontal] follow-sport-picker → topic: ….
Cross-widget integration with the betslip
When a betslip widget (<goma-betslip-floating> or <goma-betslip-sidebar>) is on the same page (or another tab on the same origin), this widget shares its selections with it via a single goma:betslip:v3 envelope in localStorage + BroadcastChannel:
- Selections (identity + last-known live state) —
outcomeId,bettingOfferId,eventId/eventName,marketId/marketName,outcomeName, etc. PLUSodd,isAvailable,isLive(last-known values, refreshed by each betslip widget's own WAMP feed on mount). Pure UI flash flags (priceUp,priceDown) are NEVER serialised — components derive their own up/down indicator from a local watch onselection.odd. The original cross-widget odds flicker is prevented byupdates.jsno longer pushing staleisAvailablefrom this widget's update pipeline (Fix #1) — leaving each betslip widget's WAMP feed as the single authoritative writer for its own live fields. - Slip-level state —
activeBetslipId,stake,activeTab,acceptOddsChange. Events-horizontal callsuseSharedBetslipSync({ subscribeToState: false }): it neither reads nor writes these fields (its UI doesn't render them), and when it writes selections it spreads...shared.valueso peer-written state fields survive.
A one-shot migration runs on first widget mount in any tab; older goma:betslip:v2 entries and the short-lived goma:betslip-selections:v1 + goma:betslip-state:v1 split are merged into v3 (transient flash flags stripped on the way in; identity + last-known live state preserved) and the legacy entries deleted.
Two-way selection sync
The host can drive which outcomes appear selected via the selectedOutcomes property. The widget diffs the incoming array against its internal betslip state and adds/removes selections to match. Changes reflect within one frame.
Sequence diagram
mermaid
sequenceDiagram
participant Host
participant Widget
participant BetslipStore
Host->>Widget: el.selectedOutcomes = ['offer-1']
Widget->>BetslipStore: diff → addSelection('offer-1')
Note over Widget: UI highlights offer-1
Note over Host: User clicks offer-2 inside widget
Widget->>BetslipStore: addSelection('offer-2')
Widget->>Host: goma:outcome-select { bettingOfferId: 'offer-2' }
Host->>Widget: el.selectedOutcomes = ['offer-1', 'offer-2']
Note over Widget: No-op (already in sync)
Host->>Widget: el.selectedOutcomes = []
Widget->>BetslipStore: diff → removeSelection('offer-1'), removeSelection('offer-2')
Note over Widget: All highlights clearedDedup guard
Rapid double-clicks on the same odds button are deduplicated — only one goma:outcome-select or goma:outcome-deselect event fires per animation frame.
Performance
Stress test
A Playwright spec at tests/perf/widget-stress.spec.ts automates the frame-budget regression check that the retired demo/perf-stress-test.html covered manually. It streams 100 mock events through a single <goma-events-horizontal> instance with 200 ms odds/score mutations and asserts both the average and 5th-percentile requestAnimationFrame-sampled FPS stay above thresholds (avg ≥ 30 / P5 ≥ 8, with CI-conditional floors) over a 10-second window.
Run it from the repo root:
bash
pnpm run test:perf -- --grep stressFor deeper profiling, boot the docs site at pnpm docs:dev and open /playground in your browser. Open DevTools → Performance, hit record while the events-horizontal widget is selected with mock data + a streaming source, and analyse the flame chart as before. The Playwright spec gates regressions in CI; the playground + DevTools workflow remains the right tool for actively chasing one.
Baseline & targets
| Metric | Baseline | Target |
|---|---|---|
| FPS (100 events, 200ms updates) | ~70 FPS | ≥ 30 FPS |
| Longest task (100 events, 100ms) | 65 ms (scripting) | < 100 ms |
| CLS | 0 | 0 |
| Main chunk (gzip) | 368 kB | < 400 kB |
| Total bundle (gzip) | 506 kB | < 550 kB |
Soak test (memory)
A Playwright spec at tests/perf/widget-soak.spec.ts automates the memory-leak regression check that the retired demo/soak-test.html covered manually. Default mode (CI) runs a 120-second soak with 50 events under 200 ms mutation streaming, snapshots performance.memory.usedJSHeapSize every 15 s, and asserts final-snapshot growth stays under 10 MB. The original 15-minute / 50 MB protocol is preserved verbatim under an opt-in env flag for manual verification before invasive changes. V8's sawtooth GC pattern means peak values will be higher — the verdict uses the final snapshot, not the peak.
Run the short variant from the repo root:
bash
pnpm run test:perf -- --grep soakRun the full 15-minute variant manually (not CI-gated):
bash
SOAK_FULL=1 pnpm run test:perf -- --grep soakperformance.memory is Chromium-only; the spec skips its growth assertions on browsers where the API is unavailable.
Mitigations applied
- Replaced
JSON.parse(JSON.stringify(...))deep clone inEventHorizontalWrapper.vuewith shallowmap(m => ({ ...m }))— eliminates the most expensive per-card operation on reactive updates. - Bundle budgets enforced via
size-limitin CI — PRs that regress >5% will fail.
Accessibility
ARIA roles and attributes
| Element | Role | Attributes |
|---|---|---|
Odds button (RegularOdds) | button | tabindex="0", aria-pressed, aria-disabled, aria-label="Name, Odds" |
| Card containers (Live, PreLive, Market, Outright) | link | tabindex="0", aria-label with match/event context |
| Favorite star | button | tabindex="0", aria-label="Add/Remove from favorites" |
| See All button | button | tabindex="0", aria-label="See all N markets" |
| Outright action button | native <button> | Focus-visible ring |
| Error banner | — | role="alert" for screen-reader announcement |
Keyboard navigation
All interactive elements are reachable via Tab. Enter activates cards and buttons; Space activates odds buttons and the See All button. :focus-visible ring styling uses the --goma-highlightPrimary token.
Screen-reader support
- Odds price changes: A visually-hidden
aria-live="polite"region announces "Odds increased to X" or "Odds decreased to X" when odds change. - Error state: The error banner has
role="alert", which causes screen readers to announce it immediately.
Testing
Automated
axe-coreruns against all card variant HTML patterns intests/a11y.test.js— zero violations expected on every PR.
Manual smoke procedures
- VoiceOver (macOS) — enable with Cmd+F5. Tab through cards and odds; verify announcements. Listen for odds-change announcements during live streaming.
- NVDA (Windows) — enable NVDA, navigate with Tab/arrow keys. Verify card labels, odds announcements, and error-banner
role="alert"read-out.
Source layout
| Layer | File |
|---|---|
| Element class | packages/events-horizontal/src/EventsHorizontalElement.js |
| App wrapper | packages/events-horizontal/src/EventsHorizontalApp.vue |
| Component tree | packages/events-horizontal/src/components/EventsLists/* |
| Entry / registration | packages/events-horizontal/src/index.js |
| Manifest | packages/events-horizontal/custom-elements.json |
Shared widget building blocks live in @gomagaming/sports-domain:
| Layer | File |
|---|---|
| Game cards | packages/sports-domain/src/components/GameCards/* |
| Card composables | packages/sports-domain/src/composables/games/gamecards/useGameCardsTallOdds.js |
| Topic subscription | packages/sports-domain/src/composables/ui/useTopicSubscription.js |
| Pinia stores | packages/sports-domain/src/stores/{betting,betslip}.js |
| Wire-format mapper | packages/sports-domain/src/app/storeMapper.js |
| API container | packages/sports-domain/src/plugins/api/Api.js |
Framework spine pieces live in @gomagaming/core:
| Layer | File |
|---|---|
| Element factory | packages/core/src/createWidgetElement.js |
| Emit router | packages/core/src/routers/emit-router.js |
| TypeScript types | packages/events-horizontal/src/types/index.d.ts |