Version: 4.0
Date: February 14, 2026
Status: Declarative-First Architecture
Need to create a bundle quickly? Hereβs all you need:
1. Create bundles/my-site/manifest.json:
{
"name": "my-site",
"displayName": "My Site",
"version": "1.0.0",
"description": "TV support for My Site",
"author": "Your Name"
}
2. Create bundles/my-site/style.css:
/* Viewport lock for TV */
html, body { width: 1920px !important; overflow-x: hidden; }
/* Focus visibility */
*:focus { outline: 3px solid #00a8ff !important; }
3. Create bundles/my-site/main.js:
import myStyles from './style.css';
export default {
style: myStyles,
onActivate(window, card) {
// Make interactive elements focusable
window.TizenPortal.elements.register({ selector: 'a, button', operation: 'focusable' });
}
};
4. Run npm run build β thatβs it! The bundle is automatically discovered, registered, and compiled in.
π‘ See Section 3 for the full step-by-step guide and Section 5 for all the things the core can do for you automatically.
A bundle is a collection of CSS and JavaScript that fixes or enhances a specific website for Tizen TV browsing. Bundles are compiled into the runtime and activated based on the cardβs configuration.
TizenPortal v1.0+ uses a declarative-first architecture. Instead of writing imperative DOM manipulation code, you declare what you want and the core handles the implementation:
Benefits:
The TizenPortal core handles a lot automatically β you donβt need to implement these yourself:
| Feature | How to use it |
|---|---|
| Spatial navigation | Core provides D-pad navigation between focusable elements automatically |
| DOM observation | Once you register elements/cards, core watches for new DOM nodes and applies registrations automatically |
| CSS injection | Set style: myStyles in main.js β core injects it at the right time |
| Viewport locking | Set "viewportLock": true in manifest.json |
| Navigation mode | Optional: set "navigationMode": "geometric" (or directional) only when needed |
| Tabindex injection | Set "features": { "tabindexInjection": true } in manifest.json |
| Scroll-into-view | Set "features": { "scrollIntoView": true } in manifest.json |
| Focus outline | Always active; style configurable via the Focus Outline preference (5 modes: Subtle Blue, Blue Ring, Yellow Ring, Portal Style, White Ring) |
| Custom navigable selectors | Call TizenPortal.features.addNavigableSelector('.my-selector') β core observer picks them up for new DOM nodes automatically |
| Card two-level nav | Register cards via TizenPortal.cards.register() β Enter/Back handled automatically |
| Cleanup on exit | Registered elements/cards are automatically cleaned up on deactivation |
| Safe-area inset | Set "features": { "safeArea": true } in manifest.json |
| Bundle options UI | Declare options in manifest.json β the site editor renders the UI automatically |
| Payload passing | Bundle name, options, and settings are passed to the site automatically via URL hash |
addNavigableSelector() so site-specific interactive elements are automatically covered by the global tabindex injection and focus ring systemBundles are compiled into tizenportal.js via Rollup. When a user navigates to a site:
bundleName passed via URL hash<style> elementEach bundle is a folder in bundles/:
bundles/
βββ registry.js # Central bundle registration
βββ my-bundle/
βββ main.js # Bundle logic (required)
βββ style.css # Bundle styles (optional)
βββ manifest.json # Bundle metadata (required)
Single source of truth for bundle metadata and configuration.
The manifest defines all bundle metadata, options, and configuration. See Manifest Schema for complete documentation.
Minimal example:
{
"name": "my-bundle",
"displayName": "My Bundle",
"version": "1.0.0",
"description": "Brief description",
"author": "Your Name"
}
With options and configuration:
{
"name": "my-bundle",
"displayName": "My Bundle",
"version": "1.0.0",
"description": "Bundle description for site selection",
"author": "Your Name",
"homepage": "https://example.com/",
"navigationMode": "geometric",
"viewportLock": true,
"provides": ["focus-styling", "navigation"],
"options": [
{
"key": "strict",
"label": "Strict Mode",
"type": "toggle",
"default": false,
"description": "Enable stricter behavior"
},
{
"key": "allowlistUrl",
"label": "Allowlist URL",
"type": "url",
"placeholder": "https://example.com/allowlist.txt",
"description": "URL to custom allowlist"
}
],
"features": {
"tabindexInjection": true,
"scrollIntoView": true
}
}
Key Points:
bundle.manifest or this.manifest in lifecycle hooksmanifest.name is mirrored to bundle.name for backward compatibility; all other manifest fields remain under bundle.manifestdisplayName, description, options, etc. must be read from bundle.manifest (for example, bundle.manifest.displayName)The main entry point that exports the bundle object with lifecycle hooks:
import myStyles from './style.css';
export default {
// CSS to inject (required if style.css exists)
style: myStyles,
// Lifecycle hooks
onActivate(window, card) {
console.log('Bundle activated');
// Access manifest
const manifest = this.manifest;
console.log('Version:', manifest.version);
// Access user options
const options = card.bundleOptions || {};
const strict = options.strict !== undefined ? options.strict : false;
if (strict) {
// Apply strict behavior
}
},
onDeactivate(window, card) {
console.log('Bundle deactivated');
},
};
β οΈ IMPORTANT: Do NOT add these to main.js (they belong in manifest.json):
name - Use manifest.jsondisplayName - Use manifest.jsondescription - Use manifest.jsonoptions - Use manifest.jsonviewportLock - Use manifest.jsonnavigationMode - Use manifest.jsonfeatures - Use manifest.jsonrequires - Use manifest.jsonprovides - Use manifest.jsonAll bundle configuration must be in manifest.json. The only properties in main.js should be:
style - CSS importonBeforeLoad, onAfterLoad, onActivate, onDeactivate, onNavigate, onKeyDown)userscripts - Pre-configured userscripts (optional, userscript-sandbox bundle only)CSS that gets injected into the target site:
/* Viewport lock */
html, body {
width: 1920px !important;
min-width: 1920px !important;
}
/* Focus indicators */
a:focus, button:focus, [tabindex]:focus {
outline: 3px solid #00a8ff !important;
outline-offset: 2px !important;
}
Bundles can declare options in manifest.json that appear in the Site Editor. These are saved per card and passed to the bundle at runtime.
Supported option types:
toggle β boolean on/offtext β text input fieldurl β URL input with validation; contents can be fetchednumber β numeric inputselect β dropdown with fixed list of optionscolor β color pickertextarea β multi-line text inputExample:
export default {
name: 'my-bundle',
options: [
{ key: 'strict', label: 'Strict Mode', type: 'toggle', default: false },
{ key: 'mode', label: 'Mode', type: 'select', options: [
{ value: 'basic', label: 'Basic' },
{ value: 'advanced', label: 'Advanced' },
]},
{ key: 'customCss', label: 'Custom CSS', type: 'text' },
{ key: 'allowlistUrl', label: 'Allowlist URL', type: 'url' },
]
};
At runtime the selected values are available on the card:
- `card.bundleOptions` β key/value map of option values
- `card.bundleOptionData` β fetched data for `url` options
Use these inside your bundle lifecycle hooks to drive behavior.
---
## 3. Creating a Bundle
### Step 1: Create Bundle Folder
bundles/ βββ my-site/ βββ main.js βββ style.css βββ manifest.json
### Step 2: Write manifest.json
Define your bundle metadata and configuration:
```json
{
"name": "my-site",
"displayName": "My Site",
"version": "1.0.0",
"description": "TV support for My Site",
"author": "Your Name",
"navigationMode": "geometric",
"viewportLock": true,
"provides": ["focus-styling", "navigation"],
"options": [
{
"key": "enableFeatureX",
"label": "Enable Feature X",
"type": "toggle",
"default": true,
"description": "Enable experimental feature X"
}
]
}
See Manifest Schema for all available fields.
Implement lifecycle hooks using the declarative-first approach:
import myStyles from './style.css';
export default {
// CSS to inject
style: myStyles,
onActivate(window, card) {
console.log('[my-site] Bundle activated');
// Access manifest
console.log('Version:', this.manifest.version);
console.log('Display Name:', this.manifest.displayName);
// Access options
const options = card.bundleOptions || {};
const featureX = options.enableFeatureX !== undefined
? options.enableFeatureX
: true; // Use manifest default
// 1. Register element manipulations (declarative)
window.TizenPortal.elements.register({
selector: 'nav a, #menu a',
operation: 'focusable',
nav: 'vertical'
});
window.TizenPortal.elements.register({
selector: 'button',
operation: 'focusable'
});
// 2. Register cards if needed
window.TizenPortal.cards.register({
selector: '.media-item',
type: 'single'
});
// 3. Custom bundle-specific logic (if needed)
if (featureX) {
// Initialize feature X with custom logic
this.initializeFeatureX();
}
// Core automatically:
// - Observes DOM for dynamic content
// - Processes registrations
// - Cleans up on deactivation
},
onDeactivate(window, card) {
console.log('[my-site] Bundle deactivated');
// Registrations automatically cleared
// Only cleanup custom bundle-specific code here
},
initializeFeatureX: function() {
// Custom bundle logic
}
};
Bundles are automatically registered during build. Just run:
npm run build
The build system:
bundles/ for folders with main.jsmanifest.jsonbundles/registry.generated.jsdist/tizenportal.jsNo manual registration needed!
Bundles can implement lifecycle hooks that are called at different points:
export default {
style: myStyles,
/**
* Called before page content loads
* @param {Window} window - Target site's window object
* @param {Object} card - Card configuration
*/
onBeforeLoad(window, card) {
console.log('Before load:', card.url);
// Access manifest
console.log('Bundle:', this.manifest.displayName);
},
/**
* Called after DOM content loaded
*/
onAfterLoad(window, card) {
console.log('After load');
},
/**
* Called when bundle is activated (main setup point)
*/
onActivate(window, card) {
console.log('Activated');
// Main initialization here
},
/**
* Called when leaving the site
*/
onDeactivate(window, card) {
console.log('Deactivated');
// Cleanup here
},
/**
* Called on keydown events
* @returns {boolean} true to consume event, false to pass through
*/
onKeyDown(event) {
if (event.keyCode === 13) { // Enter
// Custom enter handling
return true; // Consumed
}
return false; // Let default handle
},
/**
* Called by core when the URL changes during SPA navigation.
* Use this to reset per-page state, set initial focus, clear caches, etc.
* Core polls window.location.href every 500 ms and also listens for
* popstate, so you don't need your own URL-change watcher.
*
* @param {string} url - The new URL after navigation
*/
onNavigate(url) {
console.log('Navigated to:', url);
// Re-focus the first interactive element on the new page
// setInitialFocus(['button', 'a[href]'], 200);
},
};
Note: For complete lifecycle hook documentation with detailed examples and use cases, see API Reference - Bundle Interface.
ALWAYS clean up global modifications when a bundle deactivates to prevent memory leaks and state pollution.
When bundles are deactivated (switching to another bundle or returning to portal), all global modifications must be reversed. Failure to clean up causes:
Use the built-in helpers instead of manually tracking references wherever possible:
TizenPortal.onCleanup(fn) β register a cleanup callback that is called automatically after onDeactivate when the bundle deactivates. No need to store references in module state.
onActivate: function(window, card) {
var observer = new MutationObserver(handleMutation);
observer.observe(document.body, { childList: true, subtree: true });
TizenPortal.onCleanup(function() { observer.disconnect(); });
var pollId = setInterval(recheckCards, 2000);
TizenPortal.onCleanup(function() { clearInterval(pollId); });
},
// onDeactivate not needed for the above - core calls the callbacks automatically
TizenPortal.once(element, eventType, handler) β one-time event listener that removes itself after the first call. Returns a cancel function:
onActivate: function(window, card) {
// If DOM is still loading, wait for it; otherwise run immediately
if (document.readyState === 'loading') {
var cancel = TizenPortal.once(document, 'DOMContentLoaded', this.onDOMReady.bind(this));
TizenPortal.onCleanup(cancel); // Cancels the pending listener if bundle deactivates before DOM fires
} else {
this.onDOMReady();
}
},
| Modification Type | Preferred Method | Manual Fallback |
|---|---|---|
| Event Listeners | TizenPortal.once() + onCleanup() |
Store reference, removeEventListener in onDeactivate |
| Timers | TizenPortal.onCleanup() |
Store IDs, clearInterval/clearTimeout in onDeactivate |
| Observers | TizenPortal.onCleanup() |
Store reference, disconnect() in onDeactivate |
| DOM Modifications | TizenPortal.onCleanup() |
Store originals, restore in onDeactivate |
| Request Interception | onDeactivate (needs window ref) |
Store originals, restore originals |
| Injected Elements | TizenPortal.onCleanup() |
Store reference, remove() in onDeactivate |
| Global Flags | TizenPortal.onCleanup() |
Reset to defaults in onDeactivate |
// Module-level state tracking
var eventHandlers = {
click: null,
focus: null,
};
var observerInstance = null;
var pollInterval = null;
var originalXHROpen = null;
var targetWindow = null;
export default {
onActivate(window, card) {
// Store window reference for cleanup
targetWindow = window;
// Event listener with stored reference
eventHandlers.click = function(e) {
console.log('Clicked');
};
document.addEventListener('click', eventHandlers.click);
// Observer with stored reference
observerInstance = new MutationObserver(function() {
console.log('DOM changed');
});
observerInstance.observe(document.body, { childList: true });
// Timer with stored ID
pollInterval = setInterval(function() {
console.log('Polling');
}, 1000);
// Request interception with stored original
originalXHROpen = window.XMLHttpRequest.prototype.open;
window.XMLHttpRequest.prototype.open = function(method, url) {
console.log('XHR:', method, url);
return originalXHROpen.apply(this, arguments);
};
},
onDeactivate(window, card) {
console.log('Cleaning up...');
// Remove event listeners
if (eventHandlers.click) {
document.removeEventListener('click', eventHandlers.click);
eventHandlers.click = null;
}
// Disconnect observers
if (observerInstance) {
observerInstance.disconnect();
observerInstance = null;
}
// Clear timers
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
// Restore intercepted methods
if (originalXHROpen && targetWindow) {
targetWindow.XMLHttpRequest.prototype.open = originalXHROpen;
originalXHROpen = null;
}
targetWindow = null;
},
};
// Module state
var monitoredAudioElement = null;
var audioHandlers = {
play: null,
pause: null,
error: null,
};
export default {
onActivate(window, card) {
var audio = document.getElementById('audio-player');
if (!audio) return;
// Store element reference
monitoredAudioElement = audio;
// Add listeners with stored references
audioHandlers.play = function() { console.log('Playing'); };
audioHandlers.pause = function() { console.log('Paused'); };
audioHandlers.error = function() { console.error('Error'); };
audio.addEventListener('play', audioHandlers.play);
audio.addEventListener('pause', audioHandlers.pause);
audio.addEventListener('error', audioHandlers.error);
},
onDeactivate(window, card) {
if (monitoredAudioElement) {
// Remove all listeners
if (audioHandlers.play) {
monitoredAudioElement.removeEventListener('play', audioHandlers.play);
}
if (audioHandlers.pause) {
monitoredAudioElement.removeEventListener('pause', audioHandlers.pause);
}
if (audioHandlers.error) {
monitoredAudioElement.removeEventListener('error', audioHandlers.error);
}
monitoredAudioElement = null;
}
// Reset handler references
audioHandlers = {
play: null,
pause: null,
error: null,
};
},
};
// Module state
var requestState = {
intercepted: false,
originalXHROpen: null,
originalXHRSend: null,
originalFetch: null,
targetWindow: null,
};
export default {
onActivate(window, card) {
// Guard against duplicate interception
if (requestState.intercepted) return;
// Store window and originals
requestState.targetWindow = window;
requestState.originalXHROpen = window.XMLHttpRequest.prototype.open;
requestState.originalXHRSend = window.XMLHttpRequest.prototype.send;
// Intercept
window.XMLHttpRequest.prototype.open = function(method, url) {
if (shouldBlock(url)) return;
return requestState.originalXHROpen.apply(this, arguments);
};
window.XMLHttpRequest.prototype.send = function() {
return requestState.originalXHRSend.apply(this, arguments);
};
// Intercept fetch if available
if (window.fetch && typeof window.fetch === 'function') {
requestState.originalFetch = window.fetch;
window.fetch = function(url, options) {
if (shouldBlock(url)) {
return Promise.reject(new Error('Blocked'));
}
return requestState.originalFetch.apply(this, arguments);
};
}
requestState.intercepted = true;
},
onDeactivate(window, card) {
if (!requestState.intercepted) return;
var win = requestState.targetWindow;
if (!win) {
console.error('Cannot cleanup: targetWindow not available');
return;
}
// Restore XHR methods
if (requestState.originalXHROpen && win.XMLHttpRequest && win.XMLHttpRequest.prototype) {
win.XMLHttpRequest.prototype.open = requestState.originalXHROpen;
}
if (requestState.originalXHRSend && win.XMLHttpRequest && win.XMLHttpRequest.prototype) {
win.XMLHttpRequest.prototype.send = requestState.originalXHRSend;
}
// Restore fetch
if (requestState.originalFetch && win.fetch && typeof win.fetch === 'function') {
win.fetch = requestState.originalFetch;
}
// Reset state
requestState = {
intercepted: false,
originalXHROpen: null,
originalXHRSend: null,
originalFetch: null,
targetWindow: null,
};
},
};
Before marking your bundle complete, verify:
TizenPortal.once() or have removeEventListener registered via TizenPortal.onCleanup()setInterval/setTimeout IDs are cleared via TizenPortal.onCleanup() or stored and cleared in onDeactivateMutationObserver/IntersectionObserver instances are disconnected via TizenPortal.onCleanup() or onDeactivateonDeactivateTesting: Activate your bundle, then switch to another bundle and back. Verify:
Best Practice: Use declarative registration APIs instead of imperative DOM manipulation.
The primary way to manipulate elements in modern bundles:
export default {
name: 'my-bundle',
onActivate(window, card) {
// Make navigation elements focusable
window.TizenPortal.elements.register({
selector: 'nav a',
operation: 'focusable',
nav: 'vertical'
});
// Hide mobile-only elements
window.TizenPortal.elements.register({
selector: '.mobile-only',
operation: 'hide'
});
// Style toolbar for TV
window.TizenPortal.elements.register({
selector: '#toolbar',
operation: 'style',
styles: {
position: 'fixed',
top: '0',
right: '320px'
},
important: true
});
// Core automatically observes DOM and processes elements
// No manual observation needed!
},
onDeactivate(window, card) {
// Element registrations automatically cleared by core
}
};
See Section 8.5: Element Registration for complete documentation.
For sites with multi-element cards (media items, books, etc.):
onActivate(window, card) {
// Register cards for multi-element interaction
window.TizenPortal.cards.register({
selector: '.media-card',
type: 'multi' // or 'single', or omit for auto-detect
});
// Core automatically processes cards and observes DOM
}
onDeactivate(window, card) {
// Card registrations automatically cleared by core
}
Card Types:
single: Cards with one focusable element (Enter activates immediately)multi: Cards with multiple elements (Enter enters card, Back exits)type to detect based on focusable children countSee API Reference - Cards API for complete documentation.
Use these for cases that canβt be handled declaratively:
import {
enableScrollIntoView,
disableScrollIntoView,
setInitialFocus,
lockViewport,
} from '../../focus/manager.js';
export default {
name: 'my-bundle',
onActivate(window, card) {
// Enable smooth scroll-into-view for focused elements
enableScrollIntoView({
marginTop: 100,
marginBottom: 100,
});
// Lock viewport to 1920px (or use manifest.json "viewportLock": true)
lockViewport();
// Set initial focus on first card
setInitialFocus(['.card', '.item', 'a']);
},
onDeactivate(window, card) {
disableScrollIntoView();
},
};
Note: Element and card registrations automatically observe DOM. Only use manual observation for custom bundle-specific logic:
import { observeDOM, stopObservingDOM } from '../../focus/manager.js';
onActivate(window, card) {
// Only for bundle-specific logic that can't use declarative APIs
var stopObserver = observeDOM(function() {
// Custom bundle logic here
updateCustomState();
});
}
onDeactivate(window, card) {
stopObservingDOM();
}
import { wrapTextInputs } from '../../input/text-input.js';
onActivate(window, card) {
// Make text inputs TV-friendly (or enable globally via features)
wrapTextInputs();
}
// Access via window.TizenPortal to avoid circular imports
onActivate(window, card) {
// Register custom key handler (runs BEFORE core handlers)
window.TizenPortal.input.registerKeyHandler(function(event) {
if (event.keyCode === 415) { // Play key
playCurrentItem();
return true; // Consumed
}
return false; // Let core handle
});
}
import {
injectSpacingCSS,
SPACING_CLASS,
} from '../../navigation/geometry.js';
onActivate(window, card) {
// Add spacing between navigable elements
injectSpacingCSS();
}
/* Force desktop width, disable responsive breakpoints */
html {
width: 1920px !important;
min-width: 1920px !important;
overflow-x: hidden !important;
}
body {
width: 1920px !important;
min-width: 1920px !important;
}
/* Ensure focusable elements have visible focus */
a:focus,
button:focus,
[role="button"]:focus,
[tabindex]:focus {
outline: 3px solid #00a8ff !important;
outline-offset: 2px !important;
}
.mobile-nav,
.hamburger-menu,
[class*="mobile-only"] {
display: none !important;
}
/* β NOT SUPPORTED in Chrome 47 */
display: grid; /* Use flexbox */
gap: 10px; /* Use margin */
:focus-visible /* Use :focus */
var(--custom-property) /* Use literal values */
/* β
SAFE */
display: flex;
margin: 10px;
outline: 2px solid blue;
Babel transpiles your code, but avoid patterns that donβt transpile well:
// β AVOID
const { a, b } = obj; // Destructuring can be verbose
async/await // Not supported in Chrome 47
// β
PREFER
var a = obj.a;
var b = obj.b;
// Promises work, but not async/await syntax
doSomething().then(function(result) {
// handle result
});
CRITICAL: Uncaught errors crash Tizen. Always wrap code in try-catch:
onActivate(window, card) {
try {
this.init();
} catch (err) {
console.error('[my-bundle] Init failed:', err.message);
}
}
onActivate(window, card) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', this.init.bind(this));
} else {
this.init();
}
}
Mark interactive elements for special handling using data-tp-card attribute:
// Mark a simple clickable element
element.setAttribute('data-tp-card', 'single');
// Mark a container with multiple actions
element.setAttribute('data-tp-card', 'multi');
Single Cards: Enter activates the element directly.
Multi Cards: Enter enters the card, allowing navigation within. Escape exits.
import { registerCards, processCards } from '../../core/cards.js';
onActivate(window, card) {
// Register card selectors
registerCards('.book-card', { type: 'single' });
registerCards('.player-controls', { type: 'multi' });
// Process existing cards
processCards();
// Watch for new cards
observeDOM(processCards);
}
New in v1050+: The element registration system provides a declarative way to manipulate DOM elements, significantly reducing bundle code complexity.
Instead of imperative DOM manipulation:
// β Imperative (verbose, error-prone)
var links = document.querySelectorAll('#sidebar a');
for (var i = 0; i < links.length; i++) {
if (!links[i].hasAttribute('tabindex')) {
links[i].setAttribute('tabindex', '0');
}
}
// Need to watch for dynamic content, handle timing issues, etc.
Use declarative registration:
// β
Declarative (concise, automatic)
TizenPortal.elements.register({
selector: '#sidebar a',
operation: 'focusable'
});
// Core handles observation, timing, duplicates automatically
| Operation | Purpose | Config Options |
|---|---|---|
focusable |
Make elements keyboard/remote navigable | nav, classes |
class |
Add/remove CSS classes | classes, remove |
attribute |
Set HTML attributes | attributes |
style |
Apply inline CSS styles | styles, important |
hide |
Hide elements | - |
show |
Show elements | - |
remove |
Remove elements from DOM | - |
onActivate(window, card) {
// Make toolbar buttons focusable with horizontal navigation
TizenPortal.elements.register({
selector: '#toolbar button',
operation: 'focusable',
nav: 'horizontal'
});
// Make siderail links focusable with vertical navigation
TizenPortal.elements.register({
selector: '#sidebar a',
operation: 'focusable',
nav: 'vertical',
classes: ['tp-spacing'] // Add spacing class
});
}
// Add utility classes
TizenPortal.elements.register({
selector: '.card',
operation: 'class',
classes: ['tp-card', 'tp-focusable']
});
// Remove classes
TizenPortal.elements.register({
selector: '.mobile-only',
operation: 'class',
classes: ['visible'],
remove: true
});
// Set ARIA labels for accessibility
TizenPortal.elements.register({
selector: 'button.icon-only',
operation: 'attribute',
attributes: {
'aria-label': 'Close dialog',
'role': 'button'
}
});
// Dynamic attribute values
TizenPortal.elements.register({
selector: '[data-id]',
operation: 'attribute',
attributes: {
'aria-label': function(element) {
return 'Item ' + element.getAttribute('data-id');
}
}
});
// Position toolbar for TV layout
TizenPortal.elements.register({
selector: '#toolbar',
operation: 'style',
styles: {
position: 'fixed',
top: '0',
right: '320px',
zIndex: '100',
display: 'flex'
},
important: true // Apply with !important
});
// Note: camelCase is automatically converted to kebab-case
// Hide mobile keyboard hints
TizenPortal.elements.register({
selector: '.mobile-keyboard-hint',
operation: 'hide'
});
// Show desktop-only menus
TizenPortal.elements.register({
selector: '.desktop-only-menu',
operation: 'show'
});
// Remove ads (use carefully - cannot be undone)
TizenPortal.elements.register({
selector: '.ad-container',
operation: 'remove'
});
// Protected elements (html, head, body) cannot be removed
// Only apply if element meets criteria
TizenPortal.elements.register({
selector: '.dynamic-content',
operation: 'focusable',
condition: function(element) {
// Only if not already focusable
return !element.hasAttribute('tabindex');
}
});
// Limit to specific container
TizenPortal.elements.register({
selector: 'button',
operation: 'focusable',
container: '#main-content' // Only buttons inside #main-content
});
// Process immediately without debounce
TizenPortal.elements.register({
selector: '.critical',
operation: 'focusable',
immediate: true
});
// Custom debounce delay for performance tuning
TizenPortal.elements.register({
selector: '.frequent-updates',
operation: 'class',
classes: ['styled'],
debounceMs: 500 // Wait 500ms after last DOM change
});
The element registration system automatically:
Before (Imperative - 35 lines):
function setupSiderail() {
var siderail = document.querySelector('[role="toolbar"]');
if (!siderail) return;
siderail.setAttribute('data-tp-nav', 'vertical');
var links = siderail.querySelectorAll('a');
for (var i = 0; i < links.length; i++) {
if (!links[i].hasAttribute('tabindex')) {
links[i].setAttribute('tabindex', '0');
}
}
var buttons = siderail.querySelectorAll('button');
for (var i = 0; i < buttons.length; i++) {
if (!buttons[i].hasAttribute('tabindex')) {
buttons[i].setAttribute('tabindex', '0');
}
}
}
onActivate(window, card) {
setupSiderail();
observeDOM(setupSiderail);
}
After (Declarative - 13 lines, 63% reduction):
onActivate(window, card) {
// Siderail container
TizenPortal.elements.register({
selector: '[role="toolbar"]',
operation: 'focusable',
nav: 'vertical'
});
// Siderail links
TizenPortal.elements.register({
selector: '[role="toolbar"] a',
operation: 'focusable'
});
// Siderail buttons
TizenPortal.elements.register({
selector: '[role="toolbar"] button',
operation: 'focusable'
});
// Core handles observation automatically - no manual setup needed
}
Element registrations are automatically cleared when the bundle is unloaded. No manual cleanup needed in onDeactivate.
β Good Practices:
*, div, etc.)β Avoid:
remove operation on critical page elementsUse Element Registration When:
Use Imperative Code When:
export default {
name: 'my-bundle',
onActivate(window, card) {
// Make navigation elements focusable
TizenPortal.elements.register({
selector: '#sidebar a',
operation: 'focusable',
nav: 'vertical'
});
TizenPortal.elements.register({
selector: '#toolbar button',
operation: 'focusable',
nav: 'horizontal'
});
// Style toolbar for TV
TizenPortal.elements.register({
selector: '#toolbar',
operation: 'style',
styles: {
position: 'fixed',
top: '0',
right: '320px'
},
important: true
});
// Hide mobile elements
TizenPortal.elements.register({
selector: '.mobile-hint',
operation: 'hide'
});
// Custom logic still uses imperative code
var player = document.querySelector('audio');
if (player) {
player.addEventListener('play', this.handlePlay.bind(this));
}
},
onDeactivate(window, card) {
// Element registrations cleared automatically
// Only clean up custom imperative code
var player = document.querySelector('audio');
if (player) {
player.removeEventListener('play', this.handlePlay);
}
},
handlePlay: function() {
console.log('Audio playing');
}
};
Modern Approach: Use element registration (declarative) instead of imperative DOM manipulation.
// β
Declarative - Recommended
onActivate(window, card) {
window.TizenPortal.elements.register({
selector: '.card',
operation: 'focusable',
nav: 'vertical' // Optional navigation direction
});
// Automatically processes existing and future elements
// No manual loops or observation needed
}
Only use imperative code for complex bundle-specific logic that canβt be expressed declaratively:
// β Imperative - Only for special cases
document.querySelectorAll('.card').forEach(function(el) {
if (!el.hasAttribute('tabindex')) {
el.setAttribute('tabindex', '0');
}
});
// Vertical navigation (up/down)
window.TizenPortal.elements.register({
selector: 'nav a',
operation: 'focusable',
nav: 'vertical'
});
// Horizontal navigation (left/right)
window.TizenPortal.elements.register({
selector: '#toolbar button',
operation: 'focusable',
nav: 'horizontal'
});
// Add utility classes for styling
window.TizenPortal.elements.register({
selector: '.card',
operation: 'class',
classes: ['tp-card', 'tp-focusable']
});
// Only apply to visible elements
window.TizenPortal.elements.register({
selector: '.dynamic-content',
operation: 'focusable',
condition: function(element) {
return element.offsetParent !== null; // Is visible
}
});
import { setInitialFocus } from '../../focus/manager.js';
// Try selectors in order until one matches
setInitialFocus([
'.currently-playing',
'.book-card:first-child',
'a:first-of-type'
]);
import { enableScrollIntoView } from '../../focus/manager.js';
// Enable with margins to keep focused element visible
enableScrollIntoView({
marginTop: 120, // Space from top
marginBottom: 100, // Space from bottom
marginLeft: 50,
marginRight: 50,
behavior: 'smooth' // or 'auto'
});
See Section 8.5: Element Registration for complete API documentation including all operations (focusable, class, attribute, style, hide, show, remove) and advanced patterns.
| Key | Code | Usage |
|---|---|---|
| Arrow keys | 37-40 | Navigation |
| Enter | 13 | Confirmation |
| Red | 403 | Address bar |
| Green | 404 | Pointer toggle |
| Yellow | 405 | Preferences (portal) / Return to portal (sites) |
| Blue | 406 | Diagnostics |
| Play | 415 | Media play |
| Pause | 19 | Media pause |
| Play/Pause | 10252 | Toggle |
onKeyDown(event) {
switch (event.keyCode) {
case 415: // Play
this.play();
return true;
case 413: // Stop
this.stop();
return true;
default:
return false; // Let core handle
}
}
The core runtime handles BACK for history navigation and diagnostics close. Bundles should avoid intercepting BACK unless they have a specific, well-tested reason to do so.
console.log('[my-bundle] Initialized');
console.warn('[my-bundle] Warning:', message);
console.error('[my-bundle] Error:', error);
Logs appear in the diagnostics panel (Blue button).
if (window.TizenPortal) {
window.TizenPortal.log('Bundle initialized');
window.TizenPortal.warn('Something unusual');
window.TizenPortal.error('Something failed');
}
The Audiobookshelf bundle demonstrates best practices:
// bundles/audiobookshelf/main.js
import absStyles from './style.css';
import {
enableScrollIntoView,
setInitialFocus,
lockViewport,
observeDOM,
} from '../../focus/manager.js';
import { wrapTextInputs } from '../../input/text-input.js';
import { KEYS } from '../../input/keys.js';
var SELECTORS = {
bookCard: '.book-card, .card',
playButton: '[data-cy="play-button"]',
navItem: '.nav-item a',
};
export default {
name: 'audiobookshelf',
displayName: 'Audiobookshelf',
description: 'Enhanced TV support for Audiobookshelf',
style: absStyles,
onActivate(window, card) {
try {
lockViewport();
enableScrollIntoView({ marginTop: 100, marginBottom: 100 });
wrapTextInputs();
this.makeFocusable();
setInitialFocus([SELECTORS.bookCard, SELECTORS.navItem]);
observeDOM(this.makeFocusable.bind(this));
} catch (err) {
console.error('[audiobookshelf] Activation error:', err);
}
},
makeFocusable() {
document.querySelectorAll(SELECTORS.bookCard).forEach(function(el) {
el.setAttribute('tabindex', '0');
el.setAttribute('data-tp-card', 'single');
});
},
onKeyDown(event) {
if (event.keyCode === KEYS.PLAY) {
var playBtn = document.querySelector(SELECTORS.playButton);
if (playBtn) {
playBtn.click();
return true;
}
}
return false;
},
};
registry.jsstyle property exports CSS string!important conflicts with site CSSnpm run buildTizenPortal.features.addNavigableSelector('.my-selector') in onDOMReady β this extends the global list and is picked up by the live observer for any new DOM nodes, with no restart required.outline: none !important is countered by the focus-styling observer which keeps its stylesheet last in <head>; if a site still overrides rings, add a bundle CSS rule with !important for the :focus state.MutationObserver watches document.body with childList + subtree automatically β if elements are still missed, verify tabindexInjection is enabled in the feature toggles.display: none prevents focus.MutationObserver handles dynamically inserted elements automatically β no manual observeDOM() needed for making new elements focusable.watchUrlChanges() (see the Audiobookshelf bundle as a reference) to re-process card registrations after navigation.| Version | Date | Changes |
|---|---|---|
| 1.0 | 2026-01-12 | Initial version (APP mode) |
| 2.0 | 2026-01-20 | MOD mode architecture |
| 3.0 | 2026-01-31 | Universal runtime, core utilities |
| 4.1 | 2026-02-22 | addNavigableSelector() API; focus outline expanded to 5 modes; dynamic-content observer always uses live selector list |
End of Bundle Authoring Guide