Appearance
goma-sports-navigation-dialog
Self-contained sport-picker dialog: a trigger pill that, on tap, opens a modal grid of sport tiles with an optional search input. Sister widget to goma-sports-navigation-horizontal — same data sources (prop / rpc / subscribe), same selection state, same goma:sport-select event, just rendered as a popover dialog instead of a horizontal swiper.
Designed primarily for mobile layouts where the horizontal strip wouldn't fit, but the contract is identical so the two widgets are interchangeable wherever a single-selection sports picker is needed.
Live demo
Open in the playground → — toggle Sports navigation (dialog) in the widget rail, then tap the trigger pill on the stage to open the modal. The mock source drifts live-count badges on the same 4-second cadence as the horizontal sister so you can verify the dialog stays responsive while open.
Install
@gomagaming/sports-navigation-dialog is a scoped private package. See Installation for the one-time .npmrc setup required to consume it.
bash
npm install @gomagaming/sports-navigation-dialogYou also need the shared peer dependencies installed at your app level:
bash
npm install vue pinia vue-router vue-i18n @vueuse/core autobahn-browser vue-toastificationvue, pinia, vue-router, vue-i18n, @vueuse/core, autobahn-browser, 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-dialog id="snd"></goma-sports-navigation-dialog>The widget renders only the trigger pill in its closed state — its host element shrinks to that footprint (the demo's theme.backgroundPrimary = 'transparent' override removes the inherited dark backdrop). Tapping the pill opens the modal.
Configuration
js
const el = document.getElementById('snd')
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',
}The shape is identical to the horizontal sibling — see its config table. All keys are optional in prop mode (no socket is opened).
Data sources (choose one)
The dataSource property selects where the sport list comes from. Mechanism is the same as the horizontal widget — the useSportsSource composable is shared.
| Mode | Behaviour |
|---|---|
'prop' (default) | Host feeds el.sports = […]. No socket. |
'rpc' | One-shot $wcall on connect populates the list once. |
'subscribe' | Live WAMP subscription streams updates as games go live. |
See Sports Navigation Horizontal § Data sources for the full breakdown of liveStatus mappings, RPC method names, and subscription topic URLs.
Props (HTML attributes + JS properties)
Inherited from the shared sports-picker contract (identical to the horizontal sibling):
| Property | Attribute | Default | Description |
|---|---|---|---|
dataSource | data-source | 'prop' | 'prop' / 'rpc' / 'subscribe'. |
liveStatus | live-status | 'LIVE' | 'LIVE' / 'NOT_LIVE' / 'BOTH'. |
sports | sports | [] | Array of GomaSport. |
selectedSportId | selected-sport-id | null | Currently selected sport id. Two-way. |
showLabels | show-labels | true | Render the sport name under each tile icon. |
skeletonCount | skeleton-count | 12 | Placeholder tile count until the first non-empty sports assignment. |
Dialog-specific knobs:
| Property | Attribute | Default | Description |
|---|---|---|---|
open | open | false | Programmatic open/close. Two-way: tapping the trigger flips it to true; selecting a sport flips it back to false. |
showSearch | show-search | true | Render the search input inside the modal. Set false to hide. |
gridColumns | grid-columns | 0 | Force a fixed column count; 0 = auto (currently 4). |
mobileBreakpoint | mobile-breakpoint | 'sm' | Viewport width below which the dialog renders as a bottom-sheet (full-width, anchored to viewport bottom, slide-up animation) instead of a centred modal. Accepts a Tailwind token ('sm' 640 / 'md' 768 / 'lg' 1024 / 'xl' 1280 / '2xl' 1536) or a raw pixel value (number 480 or string '480px'). |
spaceBetween from the horizontal sibling is not applicable here — the grid uses CSS gap from the panel layout.
Bottom-sheet vs centred modal
Below mobileBreakpoint the panel renders as a bottom-sheet: full-width, anchored to the viewport bottom, rounded only at the top, with a small drag-handle pill above the header (purely visual — the sheet isn't draggable yet) and a slide-up entry/exit. At or above the breakpoint, the panel is a classic centred modal with rounded corners on all sides and a scale + fade entry/exit. The transition is live — resizing the viewport across the breakpoint while the dialog is open swaps the layout without re-mounting.
GomaSport shape
Identical to the horizontal sibling — see GomaSport.
Behaviour
Open / close
- Tapping the trigger pill sets
el.open = trueand opens the modal. - Selecting a sport tile dispatches
goma:sport-select, mutatesstate.selectedSportId, and auto-closes the dialog. - Tapping the Close button (top-right), tapping the backdrop, or pressing Escape closes the dialog.
- Hosts can drive open/close programmatically:
el.open = true/el.open = false. Each transition emitsgoma:dialog-open/goma:dialog-close.
Sort order
Live-first partition — same as the horizontal widget. Sports with numberOfLiveEvents > 0 float to the top, sorted by live count desc; the rest preserve original order.
Search
When showSearch is true (default), the modal renders an input with a magnifying-glass icon and a localised placeholder (sports_navigation_dialog_search_placeholder). The query filters the sport list by case-insensitive substring against sport.name. An empty match shows a localised "no results" message (sports_navigation_dialog_no_results).
The search query resets every time the dialog reopens.
Skeleton / empty / no-results
| State | Trigger | Render |
|---|---|---|
| Skeleton | First open before the host has fed sports | Pulsing placeholder circles (count = skeletonCount). |
| Empty | Host fed sports = [] after at least one assignment | Localised sports_navigation_empty message. |
| No results | Search query has no match | Localised sports_navigation_dialog_no_results message. |
Trigger pill customisation
The trigger pill is rendered inside the widget's Shadow DOM, so it is not stylable from the outer document. All customisation flows through the public surface — CSS variables for visuals, message overrides for text, and the selectedSportId property for the icon and announced selection.
Visual
| Token | Drives |
|---|---|
--goma-backgroundCards | Pill background (the whole rounded surface). |
--goma-separatorLine | Pill border. |
--goma-textPrimary | Pill text colour (caret column). |
--goma-highlightPrimary | Selected-sport icon-circle background + focus ring. |
--goma-highlightPrimaryContrast | Icon glyph inside the icon-circle. |
js
el.theme = {
// Match the host page's surface — keep the pill flush instead of opaque.
backgroundCards: '#1f2147',
separatorLine: 'rgba(255,255,255,0.08)',
textPrimary: '#ffffff',
// Brand-coloured icon circle.
highlightPrimary: '#ff6600',
highlightPrimaryContrast: '#ffffff',
}Text / accessibility
The pill's aria-label is built from sports_navigation_dialog_title plus the currently-selected sport name. Override the base label per locale via config.messages:
js
el.config = {
/* … */
messages: {
en: { sports_navigation_dialog_title: 'Pick a sport' },
fr: { sports_navigation_dialog_title: 'Choisir un sport' },
},
}
// → aria-label="Pick a sport: Football" once a sport is selected.The pill's icon comes from the currently selected sport's iconId — feed sports and selectedSportId to control which icon appears. With no selection, the pill renders the noicon glyph.
Hide the search bar with a single switch
js
el.showSearch = false // hides the search input on every open
el.setAttribute('show-search', 'false') // attribute equivalentOverride the search placeholder/label per locale via config.messages:
js
el.config = {
/* … */
messages: {
en: { sports_navigation_dialog_search_placeholder: 'Search sports…' },
fr: { sports_navigation_dialog_search_placeholder: 'Rechercher un sport…' },
pt: { sports_navigation_dialog_search_placeholder: 'Procurar desporto…' },
},
}Events
| Status | Event | Detail | Emitted when |
|---|---|---|---|
| Canonical | ready | {} | Widget mounted, initial render done. |
| Canonical | goma:sport-select | { sportId, sportName, sportIconId, previousSportId } | User tapped (or activated via Enter/Space) a sport tile. The widget mutates state.selectedSportId and auto-closes the modal. |
| Canonical | goma:dialog-open | {} | state.open transitioned from false → true. |
| Canonical | goma:dialog-close | {} | state.open transitioned from true → false. |
| Canonical | goma:error | { message, code, component? } | Render or data error. |
| Alias | error | Same as goma:error | Dispatched in parallel with goma:error. |
goma:sport-select is bubbles + composed, so it crosses the shadow-DOM boundary and is observable from a window.addEventListener('goma:sport-select', …) listener — the dialog and the horizontal picker are drop-in interchangeable for any consumer wiring that expected the horizontal picker (e.g. <goma-events-horizontal>'s followSportPicker rewiring keeps working).
Embedding examples
Vanilla HTML
html
<goma-sports-navigation-dialog id="snd"></goma-sports-navigation-dialog>
<script type="module">
import '@gomagaming/sports-navigation-dialog'
const el = document.getElementById('snd')
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-dialog'
import { useEffect, useRef } from 'react'
function SportsPicker({ 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-dialog
ref={ref}
onGoma:sport-select={(e) => console.log(e.detail)}
/>
)
}Vue 3
vue
<script setup>
import '@gomagaming/sports-navigation-dialog'
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
})
</script>
<template>
<goma-sports-navigation-dialog
ref="el"
@goma:sport-select="(e) => console.log(e.detail)"
/>
</template>Prop mode (no backend)
js
el.dataSource = 'prop'
el.sports = await fetch('/api/my-sports').then(r => r.json())
el.selectedSportId = '3'Theming
Override CSS variables via el.theme = { … } — the prefix --goma- is added automatically.
js
el.theme = {
// The dialog is a popover, so its host element should usually be transparent.
// Override backgroundPrimary if you want a backdrop band behind the trigger pill.
backgroundPrimary: 'transparent',
// Modal panel background — decoupled from backgroundPrimary so consumers can
// paint a transparent host while keeping the modal opaque.
dialogBackground: '#03061b',
// Selected-tile ring + close-button accent
highlightPrimary: '#ff6600',
// Live-count badge
liveCountBackground: '#ed4f63',
liveCountText: '#ffffff',
textPrimary: '#ffffff',
textSecondary: '#939dff',
}Tokens used by this widget
| Token | Element |
|---|---|
--goma-backgroundPrimary | Host element background (consumers usually set this to transparent for popover usage). |
--goma-dialogBackground | Modal panel background. Specific to this widget — defaults to #03061b in main.css. |
--goma-backgroundCards | Unselected sport-tile circle background; search-input background. |
--goma-highlightPrimary | Selected-tile ring; Close-button text colour. |
--goma-textPrimary | Title, selected label, body text. |
--goma-textSecondary | Unselected labels; placeholder text. |
--goma-separatorLine | Panel border, search-input border. |
--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_dialog_title | Sports available | Modal header title |
sports_navigation_dialog_search_placeholder | Search… | Search input placeholder + label |
sports_navigation_dialog_no_results | No sports match your search | Empty-search-result message |
sports_navigation_aria | Sports navigation | Grid aria-label (shared with horizontal) |
sports_navigation_empty | No sports available | Empty-list message (shared with horizontal) |
close | Close | Modal close button |
retry | Retry | Error banner button |
Override with config.messages:
js
el.config = {
/* … */
messages: {
en: { sports_navigation_dialog_title: 'Pick a sport' },
fr: { sports_navigation_dialog_title: 'Choisir un sport' },
},
}Accessibility
| Element | Role | Attributes |
|---|---|---|
| Trigger pill | button | aria-haspopup="dialog", aria-expanded, aria-label (announces current selection) |
| Modal panel | dialog | aria-modal="true", aria-labelledby → title id |
| Backdrop | button | aria-label="Close", tabindex="-1" |
| Sport tile | tab | tabindex="0", aria-selected, aria-label="{name}" |
| Live-count badge | — | aria-label="N live event(s)" |
| Search input | — | aria-label matching the placeholder |
Keyboard: Tab cycles focus inside the modal; Shift+Tab cycles backward; Escape closes; Enter/Space activates the focused tile. Focus returns to the trigger pill on close.
Focus trap: provided by the browser. The widget uses a native <dialog> opened via showModal(), which puts the dialog into the browser's top layer and traps Tab/Shift+Tab inside automatically. The inert attribute on the dialog wrapper keeps its descendants out of the tab order while the dialog is closed.
iOS Safari notes
An early prototype tried <div role="dialog"> with user-space sentinels, a custom focus-trap, and a host-elevation workaround to handle iOS Safari's quirks around shadow-rooted overlays. This widget shipped with a different architecture — native <dialog> + showModal() — which lets the browser's top layer escape those quirks. The table below maps each iOS-specific symptom to its current solution:
| Symptom on iOS | Solution in this widget |
|---|---|
| Inputs under 16 px trigger an auto-zoom | Search input has inline font-size: 16px. |
Native <dialog> focus-trap leaks when used as a styled box in light DOM | The dialog opens via showModal() into the browser's top layer — the trap is browser-managed, not user-space, and works because the dialog tree is no longer anchored to the host's stacking context. |
showModal() doesn't scroll-lock the page on iOS | useScrollLock from @gomagaming/core applied on every open. |
Shadow-rooted z-index doesn't always cover sibling widgets | Native <dialog> top layer escapes every ancestor stacking context — the dialog paints above sibling widgets without any host-elevation workaround. |
| Keyboard focus tracking edge cases | Browser-managed focus trap inside the top-layer dialog handles these — no custom keydown tracking needed. |
| Layout flex/grid quirks on the dialog/panel root | The dialog itself is a transparent full-viewport stage (display: grid; place-items: center) and the visible panel uses flex flex-col — layout lives on inner wrappers, not on the panel root. |
Source layout
| Layer | File |
|---|---|
| Element class | packages/sports-navigation-dialog/src/SportsNavigationDialogElement.js |
| App wrapper | packages/sports-navigation-dialog/src/SportsNavigationDialogApp.vue |
| Trigger pill | packages/sports-navigation-dialog/src/components/SportsNavigation/SportsNavigationDialogTrigger.vue |
| Modal panel | packages/sports-navigation-dialog/src/components/SportsNavigation/SportsNavigationDialogPanel.vue |
| Tile (copied from horizontal) | packages/sports-navigation-dialog/src/components/SportsNavigation/SportItem.vue |
| Entry / registration | packages/sports-navigation-dialog/src/index.js |
| Manifest | packages/sports-navigation-dialog/custom-elements.json |
| Tests | packages/sports-navigation-dialog/tests/SportsNavigationDialog.test.js |
Reusable primitives extracted into @gomagaming/core for any future modal widgets:
| Layer | File |
|---|---|
| Focus trap composable | packages/core/src/composables/useFocusTrap.js |
| Scroll-lock composable | packages/core/src/composables/useScrollLock.js |
| Focusable walker (crosses nested shadow roots) | packages/core/src/utils/focusableInside.js |
Sports composable + factory (shared with the horizontal sibling):
| Layer | File |
|---|---|
useSportsSource | packages/sports-domain/src/composables/ui/useSportsSource.js |
| Element factory | packages/core/src/createWidgetElement.js |