Appearance
goma-game-details
Full details for a single sporting event: header (teams, score, clock), market-category tabs, and a lazy-loaded list of collapsible market cards. Page-shaped — fills its parent. The host owns presentation context (their own route slot, drawer, modal, or sized column); <goma-game-details> owns contents.
Drop a <goma-events-horizontal> and a <goma-game-details> on the same page and card taps drive the details widget out of the box — goma:event-navigate is already emitted by every card today.
Live demo
Open in the playground → — toggle Game details in the widget rail and pick the mock source in the inspector to render a self-contained game with drifting odds and a live clock. Mount Events horizontal alongside it to verify the goma:event-navigate hand-off — tapping a card swaps the details view.
Install
bash
pnpm add @gomagaming/game-details vue pinia vue-router vue-i18n @vueuse/coreElement
html
<goma-game-details id="gd" event-id="12345"></goma-game-details>The widget fills its parent (:host { display: block; width: 100% }). Give it a sized container (route slot, drawer, sidebar column, modal body — your call).
Configuration
js
const el = document.getElementById('gd')
el.config = {
// Required — the WAMP server hosting matches + market data
apiBaseUrl: 'wss://sports-api.example.com',
// The event to render. Equivalent to setting el.eventId directly.
eventId: '12345',
// Optional — locale propagates to bundled i18n
locale: 'en',
// Cross-widget cooperation: when true (default), listen on the window
// for sibling card widgets' `goma:event-navigate` events and patch
// eventId. Drop-in cooperation with <goma-events-horizontal>.
followEventNavigate: true,
// Batching for the market list — IntersectionObserver-driven
initialBatchSize: 5, // markets rendered before first scroll
batchSize: 10, // markets added per sentinel hit
intersectionRootMargin: '200px',
// Surface toggles
showBackButton: true,
showCompetition: true,
showLiveClock: true,
}The only required key is apiBaseUrl. The widget is inert until eventId is set (either directly or via the watcher).
How the widget loads data
Three subscriptions, each owned by the component it serves:
| Subscription | Owner | Fires on |
|---|---|---|
subscribeToMatch(eventId) | GameDetailsHeader | eventId change |
subscribeToMarketGroups(eventId) | GameDetailsHeader | eventId change |
subscribeToMarketsByMarketGroupKey({ matchId, groupKey }) | GameDetailsMarketsList | (eventId, activeTab) change |
Each composable cleans up its own subscription via onScopeDispose. Switching eventId tears down the previous subscriptions before registering new ones. Switching the active market-category tab swaps only the markets-by-group subscription — the match + market-groups subscriptions stay attached.
Default tab auto-selection runs once per eventId when the market groups arrive: the group with isDefault: true wins, falling back to the first group. If the user (or a prior interaction) has moved the tab off 'allMarkets', the auto-select respects the current choice.
Trigger contract — goma:event-navigate
When followEventNavigate: true (default), the widget listens on the window for goma:event-navigate events with payload { eventId, homeTeam, awayTeam, competitionName } and patches its own eventId. All four <goma-events-horizontal> card components and the horizontal wrapper emit this event today on card tap.
To opt out:
js
el.config = { ...el.config, followEventNavigate: false }Hosts that want to mediate (e.g., route through their own URL before switching) can also event.stopPropagation() on goma:event-navigate at a higher DOM level.
Props (HTML attributes + JS properties)
| HTML attribute | JS property | Type | Default | Notes |
|---|---|---|---|---|
event-id | eventId | string | null | null | Primary contract. Setting it re-mounts the subscriptions. |
locale | locale | string | 'en' | Mirrored. |
| — | config | GameDetailsConfig | {} | JS-only; full config object. |
| — | theme | Record<string, string> | {} | Standard --goma-* token bag. |
follow-event-navigate | followEventNavigate | boolean | true | Toggle the navigate watcher. |
initial-batch-size | initialBatchSize | number | 5 | Markets in the first batch. |
batch-size | batchSize | number | 10 | Markets added per sentinel hit. |
intersection-root-margin | intersectionRootMargin | string | '200px' | Passed to IntersectionObserver. |
show-back-button | showBackButton | boolean | true | Toggle the header back affordance. |
show-competition | showCompetition | boolean | true | Toggle the competition row. |
show-live-clock | showLiveClock | boolean | true | Toggle the live clock (effective only for live events). |
Events
All events use { bubbles: true, composed: true }.
| Event | Detail | When |
|---|---|---|
goma:back | { eventId: string } | User taps the back affordance in the header. Host decides what "back" means in its routing. |
goma:tab-select | { eventId, groupKey, groupName } | User changes the active market-group tab — or the auto-select default fires. |
goma:event-not-found | { eventId: string } | Match subscription resolves and the event is missing or empty. |
goma:outcome-select | { bettingOfferId, outcomeName, odds, eventId, marketId } | User taps an outcome to add to the betslip (reused from the shared RegularOdds button). |
goma:outcome-deselect | { bettingOfferId } | User taps an active outcome to remove. |
goma:retry | {} | User taps the retry button on the error boundary. |
Empty / loading / error states
- Inert — no
eventIdset: nothing renders. - Loading —
eventIdset, subscription in flight: skeleton (header outline + 3 placeholder market cards). - Not found — subscription resolved, no event in the store: empty state with
empty_state_match_not_available_*strings, plus agoma:event-not-foundemit. - No markets — match loaded but the active tab has no markets: empty state with
empty_state_match_no_markets_*strings. - Render error — caught by the in-shadow error boundary: red banner with a retry button that emits
goma:retry.
Embedding examples
Vanilla HTML
html
<div style="height: 100vh; display: grid; grid-template-rows: auto 1fr;">
<goma-events-horizontal id="evts"></goma-events-horizontal>
<goma-game-details id="gd"></goma-game-details>
</div>
<script type="module">
import '@gomagaming/events-horizontal'
import '@gomagaming/game-details'
const cfg = {
apiBaseUrl: 'wss://sports-api.example.com',
locale: 'en',
}
document.getElementById('evts').config = { ...cfg, topic: 'live/1' }
document.getElementById('gd').config = cfg
// No glue needed — tapping a card emits goma:event-navigate
// and game-details patches its eventId via the watcher.
</script>React
jsx
import '@gomagaming/game-details'
function MatchPage({ eventId, apiBaseUrl }) {
return (
<goma-game-details
ref={(el) => {
if (!el) return
el.config = { apiBaseUrl, locale: 'en' }
el.eventId = eventId
}}
/>
)
}Vue
vue
<script setup>
import '@gomagaming/game-details'
import { ref, onMounted, watch } from 'vue'
const props = defineProps({ eventId: String })
const el = ref(null)
const config = { apiBaseUrl: 'wss://sports-api.example.com', locale: 'en' }
onMounted(() => {
el.value.config = config
el.value.eventId = props.eventId
})
watch(() => props.eventId, (id) => { if (el.value) el.value.eventId = id })
</script>
<template>
<goma-game-details ref="el" @goma:back="$emit('back')" />
</template>Theming
Reuses the existing --goma-* token set; five new tokens are specific to this widget and default to existing tokens if you don't override them. See Theming → Game details for the full table:
--goma-tabPillBackground/--goma-tabPillBackgroundActive/--goma-tabPillText/--goma-tabPillTextActive— market-tab pills--goma-marketCardHeaderBackground— market card title strip
js
el.theme = {
tabPillBackgroundActive: '#7c4dff',
tabPillTextActive: '#ffffff',
marketCardHeaderBackground: '#1a1d3a',
}i18n / Message overrides
The widget bundles its strings via @gomagaming/core's shared messages. Pass config.messages to override per-locale phrases:
js
el.config = {
apiBaseUrl: '…',
messages: {
en: {
empty_state_match_not_available_title: 'Match unavailable',
empty_state_match_no_markets_title: 'No markets yet',
market_categories: 'Categories',
},
},
}Keys used by the widget:
back,live,expand,collapse,retrymarket_categories(the ARIA label of the tablist)market_group_<key>(per-category labels — fall back to the server-providedtranslatedNamewhen present)empty_state_match_not_available_title/empty_state_match_not_available_descriptionempty_state_match_no_markets_title/empty_state_match_no_markets_description
Accessibility
- The market-tab strip is an ARIA
tablist; each pill is atabwitharia-selected; the markets list is the correspondingtabpanelwitharia-labelledbypointing to the active tab. - The back affordance is a
<button>(not an anchor) — the widget doesn't know the host's route. - Each market card's collapse toggle exposes
aria-expandedandaria-controls. - Outcome buttons (shared
RegularOddscomponent) carryaria-pressedfor the selected state andaria-disabledwhen suspended.
Source layout
| Layer | File |
|---|---|
| Element class | packages/game-details/src/GameDetailsElement.js |
| App wrapper | packages/game-details/src/GameDetailsApp.vue |
| Per-instance state | packages/game-details/src/composables/useGameDetails.js |
| Header | packages/game-details/src/components/GameDetailsHeader/GameDetailsHeader.vue |
| Header subscription | packages/game-details/src/components/GameDetailsHeader/GameDetailsHeader.subscription.js |
| Market tabs | packages/game-details/src/components/GameDetailsMarketTabs/GameDetailsMarketTabs.vue |
| Markets list | packages/game-details/src/components/GameDetailsMarketsList/GameDetailsMarketsList.vue |
| Markets list subscription | packages/game-details/src/components/GameDetailsMarketsList/GameDetailsMarketsList.subscription.js |
| Market card | packages/game-details/src/components/GameDetailsMarketsList/GameDetailsMarketCard.vue |
| Empty state | packages/game-details/src/components/EmptyState/EmptyState.vue |
| Shared score machinery | packages/sports-domain/src/composables/games/gamecards/{useGameCardsTallOdds,useGameScoresTallOdds,useGamePartsAndTimesTallOdds}.js |
| WAMP actions | packages/sports-domain/src/api/everymatrix/actions.js (createMatchDetailsActions) |
What's deferred
These are recognised next-version surfaces, not omissions:
- BetBoost tab — a
bet_boostsynthetic tab that wraps the existingBetBoostListwidget concern. Source ships it but the gomawt port lands separately. - Share button +
goma:event-shareevent contract — the source sportsbook ships a header share affordance; gomawt has no share contract yet. - Favorite toggle in the header.
- Stats / lineups / H2H / commentary — these aren't implemented in the source
MatchDetailspage either (dead-code references inuseMatchDetailsonly). <goma-game-details-dialog>twin — a modal variant for mobile-overlay UX patterns. Will land if a real product need shows up.