Skip to content

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-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-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',
}
KeyTypeRequiredDescription
socketUrlstringYes (for live)WAMP socket URL used for topic subscriptions.
socketRealmstringYes (for live)WAMP realm.
apiBaseUrlstringYesREST base URL for the sports API.
bettingApiBaseUrlstringNoREST base URL for betting offers.
ucsOperatorIdstringYesOperator ID.
localestringNoi18n locale (e.g. da, en, fr). Defaults to 'en'.
iconBaseUrlstringNoBase URL where icon SVGs are served from.
themeobjectNoCSS variable overrides — see Theming.
messagesobjectNoi18n overrides per locale — see i18n / Message overrides.
operatorIdstringNoOperator ID (alternative to ucsOperatorId).
clientIdstringNoClient identifier.
bettingApiUrlstringNoLegacy betting API URL (prefer bettingApiBaseUrl).
topicPrefixstringNoWAMP 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/'.
sportsNamespacestringNoRoot 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'.
networkobjectNoNetwork 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.
debugbooleanNoSurfaces 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)

PropertyAttributeDefaultDescription
maxAdditionalMarketsmax-additional-markets5Max number of market cards rendered after the main game card.
spaceBetweenspace-between12Pixel gap between slides.
showSeeAllButtonshow-see-all-buttontrueWhether to append a "See all" card when there are more markets.
cardTypecard-type"auto"Force a main card type: auto / prelive / live / outright.
skeletonCountskeleton-count3Number of skeleton placeholder cards shown while loading.
selectedOutcomesselected-outcomes[]Array of betting offer IDs that should appear selected. See Two-way selection sync.
followSportPickerfollow-sport-pickerfalseOpt-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.
sportPickerSelectorsport-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 shapeCard rendered
event._type === 'TOURNAMENT'OutrightRegularOdds
event.isLive === trueLiveRegularOdds
Everything elsePreLiveRegularOdds

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):

ViewportCard widthBehaviour
320px260px (min)Narrow mobile — next card peeks in swiper
375px300pxStandard mobile
500px+400px (max)Tablet and desktop

Loading & empty states

The widget shows three visual states while waiting for data:

  1. 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 (default 3).

  2. 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).

  3. Error — if a render error occurs, a themed error banner is shown for 8 seconds with a dismiss button. Connection errors emit goma:error to 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

KeyDefault (en)Used in
liveLiveLive badge
suspendedSuspendedSuspended market overlay
no_events_availableNo events availableEmpty state message
see_allSee AllSee-all card button
x_markets{count} marketsMarket count on see-all card
add_favoriteAdd to favoritesFavorite star aria-label
remove_favoriteRemove from favoritesFavorite star aria-label (active)
loadingLoadingSkeleton loading state
odds_increasedOdds increased to {value}Screen-reader live region
odds_decreasedOdds decreased to {value}Screen-reader live region
outcome_lockedOutcome lockedDisabled odds button
retryRetryError banner retry button
see_competitionSee competitionCompetition link label
see_game_detailsSee game detailsCard navigation aria-label
see_outrightsSee outrightsOutright 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.

StatusEventDetailEmitted when
Canonicalready{}Widget mounted, initial render done. (No goma:-namespaced version.)
Canonicalgoma:connection{ state, attempt?, nextRetryMs?, lastError?, attempts? }Connection state transition — see Reconnection behavior.
Canonicalgoma:error{ message: string, code: string, component?: string }Render or data error.
Canonicalgoma:outcome-select{ bettingOfferId: string, outcomeName: string, odds: number, eventId: string, marketId: string }User taps odds to add to betslip.
Canonicalgoma:outcome-deselect{ bettingOfferId: string }User taps selected odds to remove.
Canonicalgoma:event-navigate{ eventId: string, homeTeam: string, awayTeam: string, competitionName: string }User clicks card to navigate.
Canonicalgoma:favorite-toggle{ eventId: string, isFavorite: boolean }User toggles favorite star.
AliaserrorSame as goma:errorDispatched in parallel with goma:error. Prefer the namespaced form.
Aliasgoma: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 shape

Typed 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:

AttemptDelay
11 s
22 s
34 s
48 s
516 s
Max30 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 picked

The 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:

SelectorMatches
'#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:

  • topic is empty (direct-events mode — there's no topic to rewrite).
  • The current topic doesn't start with custom-matches-aggregator/ (unknown format — left untouched).
  • followSportPicker is false (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. PLUS odd, 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 on selection.odd. The original cross-widget odds flicker is prevented by updates.js no longer pushing stale isAvailable from 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 stateactiveBetslipId, stake, activeTab, acceptOddsChange. Events-horizontal calls useSharedBetslipSync({ subscribeToState: false }): it neither reads nor writes these fields (its UI doesn't render them), and when it writes selections it spreads ...shared.value so 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 cleared

Dedup 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 stress

For 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

MetricBaselineTarget
FPS (100 events, 200ms updates)~70 FPS≥ 30 FPS
Longest task (100 events, 100ms)65 ms (scripting)< 100 ms
CLS00
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 soak

Run the full 15-minute variant manually (not CI-gated):

bash
SOAK_FULL=1 pnpm run test:perf -- --grep soak

performance.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 in EventHorizontalWrapper.vue with shallow map(m => ({ ...m })) — eliminates the most expensive per-card operation on reactive updates.
  • Bundle budgets enforced via size-limit in CI — PRs that regress >5% will fail.

Accessibility

ARIA roles and attributes

ElementRoleAttributes
Odds button (RegularOdds)buttontabindex="0", aria-pressed, aria-disabled, aria-label="Name, Odds"
Card containers (Live, PreLive, Market, Outright)linktabindex="0", aria-label with match/event context
Favorite starbuttontabindex="0", aria-label="Add/Remove from favorites"
See All buttonbuttontabindex="0", aria-label="See all N markets"
Outright action buttonnative <button>Focus-visible ring
Error bannerrole="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-core runs against all card variant HTML patterns in tests/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

LayerFile
Element classpackages/events-horizontal/src/EventsHorizontalElement.js
App wrapperpackages/events-horizontal/src/EventsHorizontalApp.vue
Component treepackages/events-horizontal/src/components/EventsLists/*
Entry / registrationpackages/events-horizontal/src/index.js
Manifestpackages/events-horizontal/custom-elements.json

Shared widget building blocks live in @gomagaming/sports-domain:

LayerFile
Game cardspackages/sports-domain/src/components/GameCards/*
Card composablespackages/sports-domain/src/composables/games/gamecards/useGameCardsTallOdds.js
Topic subscriptionpackages/sports-domain/src/composables/ui/useTopicSubscription.js
Pinia storespackages/sports-domain/src/stores/{betting,betslip}.js
Wire-format mapperpackages/sports-domain/src/app/storeMapper.js
API containerpackages/sports-domain/src/plugins/api/Api.js

Framework spine pieces live in @gomagaming/core:

LayerFile
Element factorypackages/core/src/createWidgetElement.js
Emit routerpackages/core/src/routers/emit-router.js
TypeScript typespackages/events-horizontal/src/types/index.d.ts