mirror of
https://github.com/ranaldsgift/KefinTweaks.git
synced 2026-11-05 15:52:47 +01:00
Add support for rendering sections for TV display mode and improve TV display mode handling in general
This commit is contained in:
@@ -868,6 +868,14 @@
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* TV: no hover tooltips; keep them out of the focus tree */
|
||||
.layout-tv .spotlight-truncated-list-tooltip,
|
||||
.layout-tv .spotlight-overview-tooltip {
|
||||
display: none !important;
|
||||
pointer-events: none !important;
|
||||
visibility: hidden !important;
|
||||
}
|
||||
|
||||
.spotlight-section:not(.hide)[data-section-id] > .spotlight-banner-container > .spotlight-nav-container {
|
||||
position: absolute !important;
|
||||
display: flex;
|
||||
@@ -2114,6 +2122,7 @@
|
||||
flex-direction: column;
|
||||
justify-content: end;
|
||||
align-items: center;
|
||||
grid-gap: 3em;
|
||||
}
|
||||
|
||||
.layout-mobile .spotlight-overview-section,
|
||||
@@ -4199,4 +4208,60 @@ html.sleekfin-main-ui .emby-scroller-container[data-section-id="liveTv-channels"
|
||||
}
|
||||
[data-section-id="liveTv-on-now"] .card[data-type="Program"] .cardTextStack .cardText-secondary:nth-child(3) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* TV layout: native emby-scroller + button cards (match Jellyfin layout-tv rows) */
|
||||
.layout-tv .emby-scroller-container > .emby-scroller.no-padding {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.layout-tv .itemsContainer-tv > button.card {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
display: inline-block;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-align: inherit;
|
||||
vertical-align: top;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Section controls are hover-only on desktop; TV has no hover */
|
||||
.layout-tv .emby-scroller-container .section-refresh-button,
|
||||
.layout-tv .emby-scroller-container .section-configure-button,
|
||||
.layout-tv .emby-scroller-container .section-controls-more,
|
||||
.layout-tv .spotlight-section .section-refresh-button,
|
||||
.layout-tv .spotlight-section .section-configure-button {
|
||||
opacity: 0.65;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.verticalSection[data-card-title-position^="overlay-"] .cardScalable::after,
|
||||
.emby-scroller-container[data-card-title-position^="overlay-"] .cardScalable::after {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.verticalSection[data-card-title-position^="overlay-"] .cardTextStack a,
|
||||
.verticalSection[data-card-title-position^="overlay-"] .cardTextStack button,
|
||||
.emby-scroller-container[data-card-title-position^="overlay-"] .cardTextStack a,
|
||||
.emby-scroller-container[data-card-title-position^="overlay-"] .cardTextStack button {
|
||||
pointer-events: auto !important;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.spotlight-section-title-container {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
isolation: isolate;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.emby-scroller-container[data-section-id] {
|
||||
z-index: 1;
|
||||
}
|
||||
+403
-160
@@ -1648,6 +1648,7 @@
|
||||
const selectButton = document.createElement('button');
|
||||
selectButton.type = 'button';
|
||||
selectButton.className = 'emby-button raised multi-query-picker-button';
|
||||
if (isTvLayout()) selectButton.classList.add('show-focus');
|
||||
selectButton.textContent = getMultiQueryPickerButtonLabel(sectionConfig);
|
||||
selectButton.style.cssText = 'margin-left: 1em; padding: 0.5em 1em; font-size: 0.9em;';
|
||||
selectButton.setAttribute('aria-label', 'Select query');
|
||||
@@ -1666,27 +1667,22 @@
|
||||
titleContainer.appendChild(selectButton);
|
||||
}
|
||||
|
||||
let activePopover = null;
|
||||
let activePopoverCloseHandler = null;
|
||||
const closePopover = () => {
|
||||
if (activePopoverCloseHandler) {
|
||||
document.removeEventListener('click', activePopoverCloseHandler);
|
||||
activePopoverCloseHandler = null;
|
||||
}
|
||||
if (activePopover) {
|
||||
activePopover.remove();
|
||||
activePopover = null;
|
||||
}
|
||||
};
|
||||
const MULTI_QUERY_MODAL_ID = 'kefin-multi-query-picker';
|
||||
|
||||
const multiQueryPopoverCss = `
|
||||
.multiQueryPopover {
|
||||
background-color: rgba(0, 0, 0, 0.95);
|
||||
position: absolute !important;
|
||||
.kefin-multi-query-modal {
|
||||
background-color: rgba(0, 0, 0, 0.95) !important;
|
||||
z-index: 1001;
|
||||
display: block;
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
min-width: 12rem;
|
||||
max-width: min(90vw, 320px);
|
||||
max-height: min(60vh, 420px);
|
||||
overflow: hidden;
|
||||
padding: 0 !important;
|
||||
}
|
||||
.kefin-multi-query-modal [data-name="kefin-modal-content"] {
|
||||
padding: 0.25em 0 !important;
|
||||
overflow-y: auto !important;
|
||||
max-height: min(60vh, 420px);
|
||||
}
|
||||
`;
|
||||
let multiQueryStyleEl = document.getElementById('kefinTweaks-multiQueryPopover-style');
|
||||
@@ -1701,130 +1697,115 @@
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
if (activePopover) {
|
||||
closePopover();
|
||||
if (!window.ModalSystem?.create) return;
|
||||
if (window.ModalSystem.isOpen?.(MULTI_QUERY_MODAL_ID)) {
|
||||
window.ModalSystem.close(MULTI_QUERY_MODAL_ID);
|
||||
return;
|
||||
}
|
||||
|
||||
const popover = document.createElement('div');
|
||||
popover.className = 'kefinTweaks-popover multiQueryPopover itemDetailsGroup';
|
||||
const list = document.createElement('div');
|
||||
list.className = 'kefinTweaks-popover multiQueryPopover itemDetailsGroup';
|
||||
const selectedIndex = resolveActiveQueryIndex(sectionConfig, { initialize: false });
|
||||
let selectedItemElement = null;
|
||||
|
||||
const selectQuery = async (index) => {
|
||||
window.ModalSystem.close(MULTI_QUERY_MODAL_ID);
|
||||
if (index === resolveActiveQueryIndex(sectionConfig, { initialize: false })) {
|
||||
return;
|
||||
}
|
||||
|
||||
sectionConfig._selectedQueryIndex = index;
|
||||
|
||||
const sectionIdAttr =
|
||||
typeof CSS !== 'undefined' && typeof CSS.escape === 'function'
|
||||
? CSS.escape(String(sectionConfig.id))
|
||||
: String(sectionConfig.id).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
|
||||
let activeSectionEl =
|
||||
document.querySelector(
|
||||
`.verticalSection[data-section-id="${sectionIdAttr}"], .spotlight-section[data-section-id="${sectionIdAttr}"]`,
|
||||
) || sectionElement;
|
||||
|
||||
const livePicker = activeSectionEl.querySelector('.multi-query-picker-button');
|
||||
if (livePicker) {
|
||||
livePicker.textContent = getMultiQueryPickerButtonLabel(sectionConfig);
|
||||
}
|
||||
updateSectionTitleText(activeSectionEl, getSectionTitleForMultiQuery(sectionConfig), getSectionCaption(sectionConfig));
|
||||
updateSectionViewMoreLink(activeSectionEl, getActiveViewMoreUrl(sectionConfig));
|
||||
activeSectionEl.dataset.refreshing = 'true';
|
||||
|
||||
try {
|
||||
const loaded = await loadActiveSectionQueryItems(sectionConfig);
|
||||
let newContent = null;
|
||||
|
||||
if (loaded.canPaintCached) {
|
||||
newContent = await replaceSectionContent(sectionConfig, activeSectionEl, loaded.items);
|
||||
} else {
|
||||
activeSectionEl = showSectionQuerySkeletons(sectionConfig, activeSectionEl);
|
||||
const freshItems = await loaded.itemsPromise;
|
||||
newContent = await replaceSectionContent(sectionConfig, activeSectionEl, freshItems);
|
||||
}
|
||||
|
||||
if (typeof sectionConfig._onMultiQueryChange === 'function') {
|
||||
sectionConfig._onMultiQueryChange(index, sectionConfig, newContent);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[KefinTweaks CardBuilder] Error switching query:', error);
|
||||
const live =
|
||||
document.querySelector(
|
||||
`.verticalSection[data-section-id="${sectionIdAttr}"], .spotlight-section[data-section-id="${sectionIdAttr}"]`,
|
||||
) || activeSectionEl;
|
||||
if (live) live.dataset.refreshing = 'false';
|
||||
}
|
||||
};
|
||||
|
||||
queries.forEach((query, index) => {
|
||||
const itemElement = document.createElement('div');
|
||||
itemElement.className = 'kefinTweaks-popover-item detailsGroupItem';
|
||||
const itemElement = document.createElement('button');
|
||||
itemElement.type = 'button';
|
||||
itemElement.className = 'kefinTweaks-popover-item detailsGroupItem emby-button button-flat';
|
||||
if (index === selectedIndex) {
|
||||
itemElement.classList.add('selected');
|
||||
itemElement.classList.add('selected', 'is-active');
|
||||
selectedItemElement = itemElement;
|
||||
}
|
||||
itemElement.textContent = getQueryDisplayName(query, index);
|
||||
itemElement.addEventListener('click', async () => {
|
||||
closePopover();
|
||||
if (index === resolveActiveQueryIndex(sectionConfig, { initialize: false })) {
|
||||
return;
|
||||
}
|
||||
|
||||
sectionConfig._selectedQueryIndex = index;
|
||||
|
||||
const sectionIdAttr =
|
||||
typeof CSS !== 'undefined' && typeof CSS.escape === 'function'
|
||||
? CSS.escape(String(sectionConfig.id))
|
||||
: String(sectionConfig.id).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
|
||||
// Prefer the live section node (may have been replaced by a prior switch).
|
||||
let activeSectionEl =
|
||||
document.querySelector(
|
||||
`.verticalSection[data-section-id="${sectionIdAttr}"], .spotlight-section[data-section-id="${sectionIdAttr}"]`,
|
||||
) || sectionElement;
|
||||
|
||||
const livePicker = activeSectionEl.querySelector('.multi-query-picker-button');
|
||||
if (livePicker) {
|
||||
livePicker.textContent = getMultiQueryPickerButtonLabel(sectionConfig);
|
||||
}
|
||||
updateSectionTitleText(activeSectionEl, getSectionTitleForMultiQuery(sectionConfig), getSectionCaption(sectionConfig));
|
||||
updateSectionViewMoreLink(activeSectionEl, getActiveViewMoreUrl(sectionConfig));
|
||||
activeSectionEl.dataset.refreshing = 'true';
|
||||
|
||||
try {
|
||||
const loaded = await loadActiveSectionQueryItems(sectionConfig);
|
||||
let newContent = null;
|
||||
|
||||
if (loaded.canPaintCached) {
|
||||
newContent = await replaceSectionContent(sectionConfig, activeSectionEl, loaded.items);
|
||||
} else {
|
||||
// Skeletons before awaiting network — cache check already finished above.
|
||||
activeSectionEl = showSectionQuerySkeletons(sectionConfig, activeSectionEl);
|
||||
const freshItems = await loaded.itemsPromise;
|
||||
newContent = await replaceSectionContent(sectionConfig, activeSectionEl, freshItems);
|
||||
}
|
||||
|
||||
if (typeof sectionConfig._onMultiQueryChange === 'function') {
|
||||
sectionConfig._onMultiQueryChange(index, sectionConfig, newContent);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[KefinTweaks CardBuilder] Error switching query:', error);
|
||||
const live =
|
||||
document.querySelector(
|
||||
`.verticalSection[data-section-id="${sectionIdAttr}"], .spotlight-section[data-section-id="${sectionIdAttr}"]`,
|
||||
) || activeSectionEl;
|
||||
if (live) live.dataset.refreshing = 'false';
|
||||
}
|
||||
itemElement.addEventListener('click', (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
selectQuery(index);
|
||||
});
|
||||
popover.appendChild(itemElement);
|
||||
list.appendChild(itemElement);
|
||||
});
|
||||
|
||||
// Mount on body with absolute + document coords so the menu scrolls with the button
|
||||
document.body.appendChild(popover);
|
||||
const margin = 8;
|
||||
const edgeMargin = 16;
|
||||
const gap = 8;
|
||||
const scrollX = window.scrollX || window.pageXOffset || 0;
|
||||
const scrollY = window.scrollY || window.pageYOffset || 0;
|
||||
const buttonRect = selectButton.getBoundingClientRect();
|
||||
const popoverWidth = popover.offsetWidth || popover.getBoundingClientRect().width || 200;
|
||||
const spaceBelow = window.innerHeight - buttonRect.bottom - margin;
|
||||
const spaceAbove = buttonRect.top - margin;
|
||||
const placeBelow = spaceBelow >= Math.min(spaceAbove, 160) || spaceBelow >= spaceAbove;
|
||||
const available = placeBelow ? spaceBelow : spaceAbove;
|
||||
const maxHeight = Math.max(120, Math.min(window.innerHeight * 0.6, available - gap));
|
||||
|
||||
let leftViewport = buttonRect.left;
|
||||
leftViewport = Math.min(
|
||||
Math.max(edgeMargin, leftViewport),
|
||||
Math.max(edgeMargin, window.innerWidth - popoverWidth - edgeMargin),
|
||||
);
|
||||
|
||||
popover.style.position = 'absolute';
|
||||
popover.style.left = `${leftViewport + scrollX}px`;
|
||||
popover.style.maxHeight = `${maxHeight}px`;
|
||||
popover.style.bottom = 'auto';
|
||||
if (placeBelow) {
|
||||
popover.style.top = `${buttonRect.bottom + gap + scrollY}px`;
|
||||
} else {
|
||||
const height = Math.min(popover.offsetHeight || maxHeight, maxHeight);
|
||||
popover.style.top = `${Math.max(scrollY + margin, buttonRect.top - gap - height + scrollY)}px`;
|
||||
}
|
||||
activePopover = popover;
|
||||
|
||||
if (selectedItemElement) {
|
||||
setTimeout(() => {
|
||||
selectedItemElement.scrollIntoView({
|
||||
behavior: 'instant',
|
||||
block: 'nearest',
|
||||
inline: 'nearest',
|
||||
});
|
||||
}, 10);
|
||||
}
|
||||
|
||||
activePopoverCloseHandler = (ev) => {
|
||||
if (!popover.contains(ev.target) && !selectButton.contains(ev.target)) {
|
||||
closePopover();
|
||||
}
|
||||
};
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', activePopoverCloseHandler);
|
||||
}, 100);
|
||||
window.ModalSystem.create({
|
||||
id: MULTI_QUERY_MODAL_ID,
|
||||
title: null,
|
||||
content: list,
|
||||
footer: null,
|
||||
showCloseButton: false,
|
||||
closeOnBackdrop: true,
|
||||
closeOnEscape: true,
|
||||
fixedSize: false,
|
||||
returnFocus: selectButton,
|
||||
initialFocus: selectedItemElement,
|
||||
anchor: selectButton,
|
||||
dialogClassName: 'kefin-multi-query-modal',
|
||||
onOpen: (modal) => {
|
||||
if (modal.backdrop) modal.backdrop.style.background = 'transparent';
|
||||
if (modal.dialogContainer) modal.dialogContainer.style.pointerEvents = 'none';
|
||||
if (modal.dialog) modal.dialog.style.pointerEvents = 'auto';
|
||||
if (modal.dialogContent) {
|
||||
modal.dialogContent.style.padding = '0.25em 0';
|
||||
modal.dialogContent.style.overflowY = 'auto';
|
||||
modal.dialogContent.style.minHeight = '0';
|
||||
modal.dialogContent.style.flex = '0 1 auto';
|
||||
}
|
||||
if (selectedItemElement) {
|
||||
requestAnimationFrame(() => {
|
||||
selectedItemElement.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1846,7 +1827,8 @@
|
||||
|
||||
const moreButton = document.createElement('button');
|
||||
moreButton.type = 'button';
|
||||
moreButton.className = 'section-controls-more material-icons more_vert';
|
||||
moreButton.className = 'section-controls-more material-icons more_vert paper-icon-button-light';
|
||||
if (isTvLayout()) moreButton.classList.add('show-focus');
|
||||
moreButton.title = 'More';
|
||||
moreButton.setAttribute('aria-label', 'More');
|
||||
|
||||
@@ -2189,7 +2171,7 @@
|
||||
useParentCard = false,
|
||||
borderStyle = null,
|
||||
) {
|
||||
return createJellyfinCardElement(item, overflowCard, cardFormat, customFooterText, useParentCard, borderStyle);
|
||||
return createCardElement(item, overflowCard, cardFormat, customFooterText, useParentCard, borderStyle);
|
||||
},
|
||||
|
||||
/** Push-register lazy images under an attached root (parallel native path). */
|
||||
@@ -4216,18 +4198,146 @@
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a spotlight section (Netflix-style slim banner carousel)
|
||||
* @param {Array} items - Array of Jellyfin item objects
|
||||
* @param {string} title - Title for the spotlight section
|
||||
* @param {Object} options - Options for the spotlight carousel
|
||||
* @param {boolean} options.autoPlay - Auto-cycle through items (default: true)
|
||||
* @param {number} options.interval - Auto-play interval in ms (default: 10000)
|
||||
* @param {boolean} options.showSlideState - Show slide state container (dots or numeric) (default: true)
|
||||
* @param {boolean} options.showDots - When showSlideState is true: true = dots (max 5), false = numeric "N / X" (default: true)
|
||||
* @param {boolean} options.showNavButtons - Show prev/next buttons (default: true)
|
||||
* @param {boolean} options.pauseOnHover - Pause auto-cycle when cursor is over spotlight (default: true, false when fullScreen)
|
||||
* @returns {HTMLElement} - The constructed spotlight container
|
||||
* TV layout detection (Jellyfin sets layout-tv on <html>).
|
||||
* Evaluated at call time so Display Mode changes are respected after re-render.
|
||||
*/
|
||||
function isTvLayout() {
|
||||
return document.documentElement.classList.contains('layout-tv');
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer TV button cards when layout-tv is active; otherwise desktop cards.
|
||||
*/
|
||||
function createCardElement(
|
||||
item,
|
||||
overflowCard = false,
|
||||
cardFormat = null,
|
||||
customFooterText = null,
|
||||
useParentCard = false,
|
||||
borderStyle = null,
|
||||
) {
|
||||
if (isTvLayout()) {
|
||||
return buildTvCardElement(item, overflowCard, cardFormat, customFooterText, useParentCard, borderStyle);
|
||||
}
|
||||
return createJellyfinCardElement(item, overflowCard, cardFormat, customFooterText, useParentCard, borderStyle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a desktop card DOM tree into a Jellyfin TV focusable button card.
|
||||
* Reuses desktop image/footer construction, then strips hover overlays and linked titles.
|
||||
*/
|
||||
function convertDesktopCardToTvButton(sourceCard, item) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
|
||||
Array.from(sourceCard.attributes).forEach((attr) => {
|
||||
if (attr.name === 'class') return;
|
||||
button.setAttribute(attr.name, attr.value);
|
||||
});
|
||||
|
||||
const classes = String(sourceCard.className || '')
|
||||
.split(/\s+/)
|
||||
.filter((c) => c && c !== 'card-hoverable');
|
||||
if (!classes.includes('show-focus')) classes.push('show-focus');
|
||||
if (!classes.includes('show-animation')) classes.push('show-animation');
|
||||
if (!classes.includes('itemAction')) classes.push('itemAction');
|
||||
button.className = classes.join(' ');
|
||||
button.setAttribute('data-action', 'link');
|
||||
button.setAttribute('aria-label', item?.Name || sourceCard.getAttribute('aria-label') || 'Unknown');
|
||||
|
||||
while (sourceCard.firstChild) {
|
||||
button.appendChild(sourceCard.firstChild);
|
||||
}
|
||||
|
||||
const imageLink = button.querySelector('a.cardImageContainer');
|
||||
if (imageLink) {
|
||||
const imageDiv = document.createElement('div');
|
||||
Array.from(imageLink.attributes).forEach((attr) => {
|
||||
if (attr.name === 'href') return;
|
||||
imageDiv.setAttribute(attr.name, attr.value);
|
||||
});
|
||||
while (imageLink.firstChild) {
|
||||
imageDiv.appendChild(imageLink.firstChild);
|
||||
}
|
||||
imageLink.replaceWith(imageDiv);
|
||||
}
|
||||
|
||||
button.querySelectorAll('.cardOverlayContainer').forEach((el) => el.remove());
|
||||
|
||||
button.querySelectorAll('a.textActionButton').forEach((link) => {
|
||||
const text = link.textContent || '';
|
||||
const bdi = link.closest('bdi');
|
||||
if (bdi) {
|
||||
bdi.textContent = text;
|
||||
} else {
|
||||
link.replaceWith(document.createTextNode(text));
|
||||
}
|
||||
});
|
||||
|
||||
// Match Jellyfin TV backdrop image request size when easy
|
||||
const imageEl = button.querySelector('.cardImageContainer[data-src]');
|
||||
if (imageEl) {
|
||||
const src = imageEl.getAttribute('data-src') || '';
|
||||
if (src.includes('fillHeight=267') && src.includes('fillWidth=474')) {
|
||||
imageEl.setAttribute(
|
||||
'data-src',
|
||||
src
|
||||
.replace('fillHeight=267', 'fillHeight=335')
|
||||
.replace('fillWidth=474', 'fillWidth=596'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Custom / external cards: overlay link was removed — navigate on activate
|
||||
const customUrl = item?.cardUrl;
|
||||
if (customUrl) {
|
||||
button.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (customUrl.startsWith('#') || customUrl.startsWith('/')) {
|
||||
window.location.href = customUrl;
|
||||
} else {
|
||||
window.open(customUrl, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return button;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedicated TV card builder (button root, no overlays, plain titles).
|
||||
* Same argument shape as createJellyfinCardElement.
|
||||
*/
|
||||
function buildTvCardElement(
|
||||
item,
|
||||
overflowCard = false,
|
||||
cardFormat = null,
|
||||
customFooterText = null,
|
||||
useParentCard = false,
|
||||
borderStyle = null,
|
||||
) {
|
||||
const normalizedFormat = (cardFormat || '').toLowerCase() || null;
|
||||
if (normalizedFormat === 'button') {
|
||||
const libraryButton = createLibraryButtonElement(item);
|
||||
libraryButton.classList.add('show-focus', 'show-animation', 'itemAction');
|
||||
if (!libraryButton.getAttribute('data-action')) {
|
||||
libraryButton.setAttribute('data-action', 'link');
|
||||
}
|
||||
return libraryButton;
|
||||
}
|
||||
|
||||
const desktopCard = createJellyfinCardElement(
|
||||
item,
|
||||
overflowCard,
|
||||
cardFormat,
|
||||
customFooterText,
|
||||
useParentCard,
|
||||
borderStyle,
|
||||
);
|
||||
return convertDesktopCardToTvButton(desktopCard, item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve spotlight layout/size/tileCount from options (incl. legacy fullScreen).
|
||||
* Shared by live create, skeleton create, and configure/defaults callers.
|
||||
@@ -4332,6 +4442,7 @@
|
||||
{
|
||||
const sectionTitleContainer = document.createElement('div');
|
||||
sectionTitleContainer.className = 'spotlight-section-title-container';
|
||||
if (isTvLayout()) sectionTitleContainer.classList.add('focuscontainer-x');
|
||||
|
||||
if (title) {
|
||||
let sectionTitleEl;
|
||||
@@ -4342,8 +4453,10 @@
|
||||
titleLink.textContent = title;
|
||||
titleLink.title = 'See All';
|
||||
titleLink.style.textDecoration = 'none';
|
||||
if (isTvLayout()) titleLink.classList.add('show-focus');
|
||||
|
||||
if (typeof viewMoreUrl === 'function') {
|
||||
if (isTvLayout()) titleLink.setAttribute('tabindex', '0');
|
||||
titleLink.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -4673,6 +4786,7 @@
|
||||
nameElement.className = 'spotlight-person-link';
|
||||
nameElement.href = `${serverAddress}/web/#/details?id=${itemData.id}&serverId=${serverId}`;
|
||||
nameElement.textContent = itemData.name;
|
||||
if (isTvLayout()) nameElement.classList.add('show-focus');
|
||||
nameElement.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
@@ -4681,12 +4795,14 @@
|
||||
nameElement.className = 'spotlight-genre-link';
|
||||
nameElement.href = `${serverAddress}/web/#/list.html?genreId=${itemData.id}&serverId=${serverId}`;
|
||||
nameElement.textContent = itemData.name;
|
||||
if (isTvLayout()) nameElement.classList.add('show-focus');
|
||||
nameElement.addEventListener('click', (e) => e.stopPropagation());
|
||||
} else if (useStudioLinks && itemData.id) {
|
||||
nameElement = document.createElement('a');
|
||||
nameElement.className = 'spotlight-studio-link';
|
||||
nameElement.href = `${serverAddress}/web/#/list.html?studioId=${itemData.id}&serverId=${serverId}`;
|
||||
nameElement.textContent = itemData.name;
|
||||
if (isTvLayout()) nameElement.classList.add('show-focus');
|
||||
nameElement.addEventListener('click', (e) => e.stopPropagation());
|
||||
} else {
|
||||
nameElement = document.createElement('span');
|
||||
@@ -4707,8 +4823,8 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Tooltip when more items exist (full list on hover)
|
||||
if (hasMore) {
|
||||
// Tooltip when more items exist (full list on hover) — skip on TV (no hover; links steal focus)
|
||||
if (hasMore && !isTvLayout()) {
|
||||
// Tooltip: full list as vertical stack with clickable links (no layout shift)
|
||||
const tooltip = document.createElement('div');
|
||||
tooltip.className = 'spotlight-truncated-list-tooltip';
|
||||
@@ -4901,11 +5017,13 @@
|
||||
// Buttons container
|
||||
const buttonsContainer = document.createElement('div');
|
||||
buttonsContainer.className = 'spotlight-buttons-container mainDetailButtons';
|
||||
if (isTvLayout()) buttonsContainer.classList.add('focuscontainer-x');
|
||||
|
||||
// Play button (material icon span button)
|
||||
const playButton = document.createElement('button');
|
||||
playButton.className = 'emby-button raised button-flat btnPlay detailButton';
|
||||
playButton.title = 'Play';
|
||||
if (isTvLayout()) playButton.classList.add('show-focus');
|
||||
const playIcon = document.createElement('span');
|
||||
playIcon.className = 'material-icons';
|
||||
playIcon.textContent = 'play_arrow';
|
||||
@@ -4937,6 +5055,7 @@
|
||||
watchlistButton = document.createElement('button');
|
||||
watchlistButton.type = 'button';
|
||||
watchlistButton.className = 'watchlist-button spotlight-watchlist-button emby-button button-flat paper-icon-button-light';
|
||||
if (isTvLayout()) watchlistButton.classList.add('show-focus');
|
||||
watchlistButton.setAttribute('data-action', 'none');
|
||||
watchlistButton.setAttribute('data-id', item.Id);
|
||||
watchlistButton.setAttribute('data-active', isLiked ? 'true' : 'false');
|
||||
@@ -4955,14 +5074,15 @@
|
||||
// Info button (shows overview on hover)
|
||||
const infoButton = document.createElement('button');
|
||||
infoButton.className = 'emby-button button-flat spotlight-info-button paper-icon-button-light';
|
||||
if (isTvLayout()) infoButton.classList.add('show-focus');
|
||||
const infoIcon = document.createElement('span');
|
||||
infoIcon.className = 'material-icons';
|
||||
infoIcon.textContent = 'info';
|
||||
infoIcon.title = 'Go To Item';
|
||||
infoButton.appendChild(infoIcon);
|
||||
|
||||
// Overview tooltip (hidden by default, shown on hover)
|
||||
if (item.Overview) {
|
||||
// Overview tooltip (hover only) — skip on TV
|
||||
if (item.Overview && !isTvLayout()) {
|
||||
const overviewTooltip = document.createElement('div');
|
||||
overviewTooltip.className = 'spotlight-overview-tooltip';
|
||||
overviewTooltip.textContent = item.Overview;
|
||||
@@ -4997,6 +5117,7 @@
|
||||
|
||||
const metadataContainer = document.createElement('div');
|
||||
metadataContainer.className = 'metadata-container';
|
||||
if (isTvLayout()) metadataContainer.classList.add('focuscontainer-x');
|
||||
|
||||
if (metadataRow.children.length > 0) {
|
||||
metadataContainer.appendChild(metadataRow);
|
||||
@@ -5091,6 +5212,29 @@
|
||||
firstItemActive.setAttribute('data-active', 'true');
|
||||
}
|
||||
|
||||
function syncSpotlightTvFocus() {
|
||||
if (!isTvLayout()) return;
|
||||
const isTooltipFocusable = (el) =>
|
||||
!!el.closest?.('.spotlight-truncated-list-tooltip, .spotlight-overview-tooltip');
|
||||
bannerContainer.querySelectorAll('.spotlight-item').forEach((slide) => {
|
||||
const active = slide.getAttribute('data-active') === 'true';
|
||||
slide.querySelectorAll('a[href], button').forEach((el) => {
|
||||
if (el.classList.contains('spotlight-dot') || isTooltipFocusable(el)) {
|
||||
el.setAttribute('tabindex', '-1');
|
||||
return;
|
||||
}
|
||||
if (active) {
|
||||
if (el.getAttribute('tabindex') === '-1') el.removeAttribute('tabindex');
|
||||
} else {
|
||||
el.setAttribute('tabindex', '-1');
|
||||
}
|
||||
});
|
||||
});
|
||||
bannerContainer.querySelectorAll('.spotlight-dot').forEach((dot) => {
|
||||
dot.setAttribute('tabindex', '-1');
|
||||
});
|
||||
}
|
||||
|
||||
if (!inert) {
|
||||
// Delegated handler: info button click -> navigate to item details for the current slide
|
||||
bannerContainer.addEventListener('click', (e) => {
|
||||
@@ -5131,6 +5275,7 @@
|
||||
|
||||
const prevButton = document.createElement('button');
|
||||
prevButton.className = 'spotlight-nav-button spotlight-nav-prev emby-button';
|
||||
if (isTvLayout()) prevButton.classList.add('show-focus');
|
||||
const prevIcon = document.createElement('span');
|
||||
prevIcon.className = 'material-icons';
|
||||
prevIcon.textContent = 'chevron_left';
|
||||
@@ -5143,6 +5288,7 @@
|
||||
|
||||
const nextButton = document.createElement('button');
|
||||
nextButton.className = 'spotlight-nav-button spotlight-nav-next emby-button';
|
||||
if (isTvLayout()) nextButton.classList.add('show-focus');
|
||||
const nextIcon = document.createElement('span');
|
||||
nextIcon.className = 'material-icons';
|
||||
nextIcon.textContent = 'chevron_right';
|
||||
@@ -5169,6 +5315,7 @@
|
||||
if (navButtonsContainer) {
|
||||
const pauseButton = document.createElement('button');
|
||||
pauseButton.className = 'spotlight-pause-button spotlight-nav-button emby-button';
|
||||
if (isTvLayout()) pauseButton.classList.add('show-focus');
|
||||
const pauseIcon = document.createElement('span');
|
||||
pauseIcon.className = 'material-icons';
|
||||
pauseIcon.textContent = 'pause';
|
||||
@@ -5494,6 +5641,8 @@
|
||||
advanceDue = false;
|
||||
startAutoPlay();
|
||||
}
|
||||
|
||||
syncSpotlightTvFocus();
|
||||
}
|
||||
|
||||
// Auto-play: single-timeout per slide, controlled only by pause/visibility (hover is ignored)
|
||||
@@ -5681,8 +5830,11 @@
|
||||
if (!items[idx]) return;
|
||||
createAndAppendSlide(items[idx], idx, idx === currentIndex);
|
||||
});
|
||||
syncSpotlightTvFocus();
|
||||
};
|
||||
|
||||
syncSpotlightTvFocus();
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
@@ -5697,6 +5849,8 @@
|
||||
};
|
||||
|
||||
function resolveItemsLayout(sectionConfig) {
|
||||
// Jellyfin TV rows are horizontal-only; never expand to grid/tiles on layout-tv
|
||||
if (isTvLayout()) return 'row';
|
||||
if (!sectionConfig) return 'row';
|
||||
if (sectionConfig.itemsLayout === 'grid') return 'grid';
|
||||
if (sectionConfig.itemsLayout === 'row') return 'row';
|
||||
@@ -6080,7 +6234,7 @@
|
||||
getLabel: () => getShowAllControlLabel(sectionElement),
|
||||
label: 'Grid layout',
|
||||
className: 'show-all-button',
|
||||
isVisible: () => true,
|
||||
isVisible: () => !isTvLayout(),
|
||||
onClick: (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -6112,7 +6266,8 @@
|
||||
const label = typeof def.getLabel === 'function' ? def.getLabel() : def.label;
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = `${def.className} material-icons ${def.icon}`;
|
||||
button.className = `${def.className} material-icons ${def.icon} paper-icon-button-light`;
|
||||
if (isTvLayout()) button.classList.add('show-focus');
|
||||
button.title = label;
|
||||
button.setAttribute('aria-label', label);
|
||||
button.dataset.sectionControlId = def.id;
|
||||
@@ -6126,6 +6281,9 @@
|
||||
});
|
||||
|
||||
const newMoreButton = moreButton.cloneNode(true);
|
||||
if (isTvLayout()) {
|
||||
newMoreButton.classList.add('paper-icon-button-light', 'show-focus');
|
||||
}
|
||||
moreButton.replaceWith(newMoreButton);
|
||||
|
||||
const MORE_CONTROLS_MODAL_ID = 'kefin-section-controls-more';
|
||||
@@ -6288,6 +6446,76 @@
|
||||
return document.documentElement.classList.contains('layout-mobile') || window.innerWidth < 900;
|
||||
}
|
||||
|
||||
function createTvScrollerElement() {
|
||||
const scroller = document.createElement('div');
|
||||
scroller.setAttribute('is', 'emby-scroller');
|
||||
scroller.setAttribute('data-centerfocus', 'true');
|
||||
scroller.className = 'padded-top-focusscale padded-bottom-focusscale emby-scroller';
|
||||
scroller.style.overflow = 'hidden';
|
||||
return scroller;
|
||||
}
|
||||
|
||||
function createTvItemsSliderElement() {
|
||||
const itemsContainer = document.createElement('div');
|
||||
itemsContainer.setAttribute('is', 'emby-itemscontainer');
|
||||
itemsContainer.className = 'scrollSlider focuscontainer-x itemsContainer animatedScrollX itemsContainer-tv';
|
||||
itemsContainer.style.whiteSpace = 'nowrap';
|
||||
return itemsContainer;
|
||||
}
|
||||
|
||||
/**
|
||||
* TV-mode scrollable section: native emby-scroller + itemsContainer-tv.
|
||||
* No Kefin custom-scroller drag/wheel, no scroll buttons — Jellyfin owns row motion on focus.
|
||||
*/
|
||||
function createTvScrollableContainer(
|
||||
items,
|
||||
title,
|
||||
viewMoreUrl = null,
|
||||
overflowCard = false,
|
||||
cardFormat = null,
|
||||
sectionConfig = null,
|
||||
) {
|
||||
const verticalSection = document.createElement('div');
|
||||
verticalSection.className = 'verticalSection emby-scroller-container';
|
||||
const facetRowType = getFacetRowType(items);
|
||||
if (facetRowType) {
|
||||
verticalSection.style.setProperty('--row-hue', `${Math.floor(Math.random() * 360)}deg`);
|
||||
verticalSection.dataset.facetRowType = facetRowType;
|
||||
}
|
||||
|
||||
if (cardFormat) {
|
||||
verticalSection.setAttribute('data-card-format', cardFormat);
|
||||
}
|
||||
|
||||
const sectionTitleContainer = document.createElement('div');
|
||||
sectionTitleContainer.className = 'sectionTitleContainer sectionTitleContainer-cards padded-left';
|
||||
|
||||
appendSectionTitleContent(sectionTitleContainer, {
|
||||
title,
|
||||
caption: getSectionCaption(sectionConfig),
|
||||
captionUrl: getSectionCaptionUrl(sectionConfig),
|
||||
viewMoreUrl,
|
||||
});
|
||||
|
||||
ensureSectionControlsMount(sectionTitleContainer);
|
||||
|
||||
const scroller = createTvScrollerElement();
|
||||
const itemsContainer = createTvItemsSliderElement();
|
||||
|
||||
const useParentCard = !!sectionConfig?.useParentCard;
|
||||
const borderStyle = sectionConfig?.borderStyle || null;
|
||||
items.forEach((item, index) => {
|
||||
const card = buildTvCardElement(item, overflowCard, cardFormat, item.cardFooter, useParentCard, borderStyle);
|
||||
card.setAttribute('data-index', index);
|
||||
itemsContainer.appendChild(card);
|
||||
});
|
||||
|
||||
scroller.appendChild(itemsContainer);
|
||||
verticalSection.appendChild(sectionTitleContainer);
|
||||
verticalSection.appendChild(scroller);
|
||||
return verticalSection;
|
||||
}
|
||||
|
||||
function getSectionSkeletonCount(sectionConfig, cardFormat = null, itemsLayout = null) {
|
||||
const raw = sectionConfig?.itemLimit || sectionConfig?.queries?.[0]?.queryOptions?.Limit || 0;
|
||||
const fullLimit = raw > 0 ? raw : 16;
|
||||
@@ -6974,6 +7202,7 @@
|
||||
} else if (viewMoreUrl) {
|
||||
const titleLink = document.createElement('a');
|
||||
titleLink.className = 'sectionTitle-link button-flat button-flat-mini sectionTitleTextButton emby-button';
|
||||
if (isTvLayout()) titleLink.classList.add('show-focus');
|
||||
titleLink.style.cssText = 'text-decoration: none; cursor: pointer; display: flex; align-items: center;';
|
||||
|
||||
if (typeof viewMoreUrl === 'function') {
|
||||
@@ -7015,6 +7244,7 @@
|
||||
} else if (captionUrl) {
|
||||
captionEl = document.createElement('a');
|
||||
captionEl.className = 'sectionTitle sectionCaption button-flat button-flat-mini sectionTitleTextButton emby-button';
|
||||
if (isTvLayout()) captionEl.classList.add('show-focus');
|
||||
if (typeof captionUrl === 'function') {
|
||||
captionEl.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
@@ -7042,6 +7272,10 @@
|
||||
sectionConfig = null,
|
||||
registerScrollButtons = true,
|
||||
) {
|
||||
if (isTvLayout()) {
|
||||
return createTvScrollableContainer(items, title, viewMoreUrl, overflowCard, cardFormat, sectionConfig);
|
||||
}
|
||||
|
||||
const normalizedFormat = (cardFormat || '').toLowerCase();
|
||||
const isButtonLayout = normalizedFormat === 'button';
|
||||
|
||||
@@ -7322,9 +7556,13 @@
|
||||
overflowCard = false,
|
||||
sectionConfig = null,
|
||||
) {
|
||||
// Create the main vertical section container (same structure as createScrollableContainer)
|
||||
const tvLayout = isTvLayout();
|
||||
|
||||
// Create the main vertical section container (same structure as createScrollableContainer / TV path)
|
||||
const verticalSection = document.createElement('div');
|
||||
verticalSection.className = 'verticalSection emby-scroller-container custom-scroller-container';
|
||||
verticalSection.className = tvLayout
|
||||
? 'verticalSection emby-scroller-container'
|
||||
: 'verticalSection emby-scroller-container custom-scroller-container';
|
||||
|
||||
// Persist the card format if provided (ensures consistency for random/updates)
|
||||
if (cardFormat) {
|
||||
@@ -7355,11 +7593,11 @@
|
||||
|
||||
ensureSectionControlsMount(sectionTitleContainer);
|
||||
|
||||
const scroller = createScrollerElement();
|
||||
const scroller = tvLayout ? createTvScrollerElement() : createScrollerElement();
|
||||
// Sticky is=emby-itemscontainer on skeleton shell — never recreate on enhance
|
||||
const itemsContainer = createItemsSliderElement(true);
|
||||
const itemsContainer = tvLayout ? createTvItemsSliderElement() : createItemsSliderElement(true);
|
||||
|
||||
const itemsLayout = resolveItemsLayout(sectionConfig);
|
||||
const itemsLayout = tvLayout ? 'row' : resolveItemsLayout(sectionConfig);
|
||||
const skeletonCount = getSectionSkeletonCount(sectionConfig, cardFormat, itemsLayout);
|
||||
for (let i = 0; i < skeletonCount; i++) {
|
||||
const skeletonCard = createSkeletonCard(cardFormat, overflowCard, sectionConfig);
|
||||
@@ -7369,11 +7607,12 @@
|
||||
|
||||
scroller.appendChild(itemsContainer);
|
||||
|
||||
const scrollButtons = attachScrollableSectionChrome(verticalSection, scroller);
|
||||
|
||||
verticalSection.appendChild(sectionTitleContainer);
|
||||
if (scrollButtons) {
|
||||
verticalSection.appendChild(scrollButtons);
|
||||
if (!tvLayout) {
|
||||
const scrollButtons = attachScrollableSectionChrome(verticalSection, scroller);
|
||||
if (scrollButtons) {
|
||||
verticalSection.appendChild(scrollButtons);
|
||||
}
|
||||
}
|
||||
verticalSection.appendChild(scroller);
|
||||
|
||||
@@ -8362,7 +8601,7 @@
|
||||
) {
|
||||
const cards = [];
|
||||
items.forEach((item, offset) => {
|
||||
const card = createJellyfinCardElement(item, overflowCard, cardFormat, item.cardFooter, useParentCard, borderStyle);
|
||||
const card = createCardElement(item, overflowCard, cardFormat, item.cardFooter, useParentCard, borderStyle);
|
||||
card.setAttribute('data-index', String(startIndex + offset));
|
||||
itemsContainer.appendChild(card);
|
||||
cards.push(card);
|
||||
@@ -8454,7 +8693,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const card = createJellyfinCardElement(
|
||||
const card = createCardElement(
|
||||
item,
|
||||
overflowCard,
|
||||
cardFormat,
|
||||
@@ -8553,7 +8792,11 @@
|
||||
const cardFormat = sectionElement.getAttribute('data-card-format') || sectionConfig.cardFormat;
|
||||
const normalizedFormat = (cardFormat || '').toLowerCase();
|
||||
const layoutFromDom = itemsContainer.getAttribute('data-layout');
|
||||
const layout = layoutFromDom === 'grid' || layoutFromDom === 'row' ? layoutFromDom : resolveItemsLayout(sectionConfig);
|
||||
const layout = isTvLayout()
|
||||
? 'row'
|
||||
: layoutFromDom === 'grid' || layoutFromDom === 'row'
|
||||
? layoutFromDom
|
||||
: resolveItemsLayout(sectionConfig);
|
||||
const gapless = itemsContainer.getAttribute('data-gapless') === 'true' || resolveUseGaplessCards(sectionConfig);
|
||||
|
||||
invalidateLastRowPadding(itemsContainer);
|
||||
@@ -8569,7 +8812,7 @@
|
||||
const useParentCard = !!sectionConfig?.useParentCard;
|
||||
const borderStyle = sectionConfig?.borderStyle || null;
|
||||
items.forEach((item, index) => {
|
||||
const card = createJellyfinCardElement(item, overflowCard, cardFormat, item.cardFooter, useParentCard, borderStyle);
|
||||
const card = createCardElement(item, overflowCard, cardFormat, item.cardFooter, useParentCard, borderStyle);
|
||||
card.setAttribute('data-index', String(index));
|
||||
fragment.appendChild(card);
|
||||
});
|
||||
|
||||
@@ -16,6 +16,11 @@
|
||||
const CARD_TITLE_MENU_MODAL_ID = 'kefin-section-card-title-menu';
|
||||
const RESTORE_SECTION_MODAL_ID = 'kefin-section-restore-defaults';
|
||||
const PUBLISH_SECTION_MODAL_ID = 'kefin-section-publish-defaults';
|
||||
const CONFIGURE_MODAL_ID = 'kefin-section-configure';
|
||||
|
||||
function isTvLayout() {
|
||||
return document.documentElement.classList.contains('layout-tv');
|
||||
}
|
||||
|
||||
/** True when the current pointer gesture started inside a Pickr app (drag may end outside). */
|
||||
let pickrGestureFromApp = false;
|
||||
@@ -1003,11 +1008,8 @@
|
||||
activePopover.entry = entry;
|
||||
activePopover.sectionElement = entry.element;
|
||||
activePopover.anchorButton = getConfigureAnchorButton(entry);
|
||||
const mountElement = preparePopoverMount(activePopover.anchorButton, entry.element);
|
||||
activePopover.mountElement = mountElement;
|
||||
if (activePopover.popover.parentElement !== mountElement) {
|
||||
mountElement.appendChild(activePopover.popover);
|
||||
}
|
||||
activePopover.mountElement = resolvePopoverMount(activePopover.anchorButton, entry.element);
|
||||
preparePopoverMount(activePopover.anchorButton, entry.element);
|
||||
requestAnimationFrame(() => repositionActivePopover({ force: true }));
|
||||
}
|
||||
}
|
||||
@@ -1083,7 +1085,7 @@
|
||||
}
|
||||
if (modal.dialogContent) {
|
||||
modal.dialogContent.style.padding = '0.25em 0';
|
||||
modal.dialogContent.style.overflow = 'hidden';
|
||||
modal.dialogContent.style.overflow = 'visible';
|
||||
modal.dialogContent.style.minHeight = '0';
|
||||
}
|
||||
if (modal.backdrop) {
|
||||
@@ -1093,6 +1095,7 @@
|
||||
modal.dialogContainer.style.pointerEvents = 'none';
|
||||
}
|
||||
modal.dialog.style.pointerEvents = 'auto';
|
||||
modal.dialog.style.overflow = 'visible';
|
||||
requestAnimationFrame(() => positionFormatMenu(modal.dialog, anchorBtn));
|
||||
}
|
||||
|
||||
@@ -1259,6 +1262,9 @@
|
||||
closeOnBackdrop: true,
|
||||
closeOnEscape: true,
|
||||
fixedSize: false,
|
||||
returnFocus: anchorBtn,
|
||||
initialFocus: '.kefin-section-format-option.is-active',
|
||||
anchor: anchorBtn,
|
||||
onOpen: (modalInstance) => {
|
||||
styleAnchoredPopoverDialog(modalInstance, anchorBtn);
|
||||
const root = modalInstance.dialogContent;
|
||||
@@ -1403,7 +1409,7 @@
|
||||
|
||||
const content = `
|
||||
<div class="kefin-section-card-title-menu-body">
|
||||
<div class="kefin-section-card-title-menu-icons">
|
||||
<div class="kefin-section-card-title-menu-icons" data-kefin-focus-row>
|
||||
<button type="button" is="paper-icon-button-light" class="paper-icon-button-light emby-button kefin-section-card-title-visibility-btn${titlesVisible ? '' : ' is-hidden-visibility'}" title="${titlesVisible ? 'Hide card titles' : 'Show card titles'}" aria-label="${titlesVisible ? 'Hide card titles' : 'Show card titles'}" aria-pressed="${titlesVisible}">
|
||||
<span class="material-icons" aria-hidden="true">${titlesVisible ? 'visibility' : 'visibility_off'}</span>
|
||||
</button>
|
||||
@@ -1445,6 +1451,9 @@
|
||||
closeOnBackdrop: true,
|
||||
closeOnEscape: true,
|
||||
fixedSize: false,
|
||||
returnFocus: anchorBtn,
|
||||
initialFocus: '.kefin-section-card-title-visibility-btn',
|
||||
anchor: anchorBtn,
|
||||
onOpen: (modalInstance) => {
|
||||
styleAnchoredPopoverDialog(modalInstance, anchorBtn);
|
||||
const root = modalInstance.dialogContent;
|
||||
@@ -2135,7 +2144,7 @@
|
||||
}
|
||||
|
||||
function closePopover() {
|
||||
if (!activePopover) return;
|
||||
if (!activePopover && !window.ModalSystem?.isOpen?.(CONFIGURE_MODAL_ID)) return;
|
||||
closeCardTitleMenu();
|
||||
if (window.ModalSystem?.isOpen?.(RESTORE_SECTION_MODAL_ID)) {
|
||||
window.ModalSystem.close(RESTORE_SECTION_MODAL_ID);
|
||||
@@ -2143,11 +2152,28 @@
|
||||
if (window.ModalSystem?.isOpen?.(PUBLISH_SECTION_MODAL_ID)) {
|
||||
window.ModalSystem.close(PUBLISH_SECTION_MODAL_ID);
|
||||
}
|
||||
const { popover, entry, stopTracking, mountElement, sectionElement } = activePopover;
|
||||
if (window.ModalSystem?.isOpen?.(OPTION_MENU_MODAL_ID)) {
|
||||
window.ModalSystem.close(OPTION_MENU_MODAL_ID);
|
||||
}
|
||||
if (window.ModalSystem?.isOpen?.(CARD_TITLE_MENU_MODAL_ID)) {
|
||||
window.ModalSystem.close(CARD_TITLE_MENU_MODAL_ID);
|
||||
}
|
||||
// Closing the ModalSystem dialog runs onClose → finalizeConfigurePopoverClose
|
||||
if (window.ModalSystem?.isOpen?.(CONFIGURE_MODAL_ID)) {
|
||||
window.ModalSystem.close(CONFIGURE_MODAL_ID);
|
||||
return;
|
||||
}
|
||||
finalizeConfigurePopoverClose();
|
||||
}
|
||||
|
||||
function finalizeConfigurePopoverClose() {
|
||||
if (!activePopover) return;
|
||||
const { entry, stopTracking, mountElement, sectionElement, outsideHandler } = activePopover;
|
||||
stopTracking?.();
|
||||
popover.remove();
|
||||
if (outsideHandler) {
|
||||
document.removeEventListener('click', outsideHandler, true);
|
||||
}
|
||||
cleanupPopoverMount(mountElement, sectionElement || entry?.element);
|
||||
document.removeEventListener('click', activePopover.outsideHandler, true);
|
||||
pickrGestureFromApp = false;
|
||||
activePopover = null;
|
||||
}
|
||||
@@ -2306,18 +2332,17 @@
|
||||
if (activePopover) activePopover.anchorMode = placeBelow ? 'below' : 'above';
|
||||
}
|
||||
|
||||
const scrollX = window.scrollX || window.pageXOffset || 0;
|
||||
const scrollY = window.scrollY || window.pageYOffset || 0;
|
||||
|
||||
popover.classList.toggle('is-below', placeBelow);
|
||||
popover.style.position = 'absolute';
|
||||
popover.style.left = `${leftViewport + scrollX}px`;
|
||||
popover.style.position = 'fixed';
|
||||
popover.style.left = `${leftViewport}px`;
|
||||
popover.style.bottom = 'auto';
|
||||
popover.style.right = 'auto';
|
||||
popover.style.margin = '0';
|
||||
if (placeBelow) {
|
||||
popover.style.top = `${buttonRect.bottom + gap + scrollY}px`;
|
||||
popover.style.top = `${buttonRect.bottom + gap}px`;
|
||||
} else {
|
||||
const height = getPopoverPlacementHeight(popover, popover.classList.contains('is-expanded'));
|
||||
popover.style.top = `${Math.max(scrollY + margin, buttonRect.top - gap - height + scrollY)}px`;
|
||||
popover.style.top = `${Math.max(margin, buttonRect.top - gap - height)}px`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2390,11 +2415,16 @@
|
||||
const spotlightTilesLabel = spotlightNextTileCount === 1
|
||||
? '1 tile'
|
||||
: `${spotlightNextTileCount} tiles`;
|
||||
const popover = document.createElement('div');
|
||||
popover.className = 'kefin-section-configure-popover dialog';
|
||||
popover.innerHTML = `
|
||||
if (!window.ModalSystem?.create) {
|
||||
WARN('ModalSystem unavailable for configure popover');
|
||||
return;
|
||||
}
|
||||
|
||||
const contentRoot = document.createElement('div');
|
||||
contentRoot.className = 'kefin-section-configure-popover-body';
|
||||
contentRoot.innerHTML = `
|
||||
<div class="kefin-section-configure-title-container">
|
||||
<div class="kefin-section-configure-title-row">
|
||||
<div class="kefin-section-configure-title-row" data-kefin-focus-row>
|
||||
<h3>${escapeHtml(sectionConfig.name)}</h3>
|
||||
<button type="button" is="paper-icon-button-light" class="paper-icon-button-light emby-button kefin-section-restore-defaults-btn${sectionDirty ? '' : ' is-disabled'}" title="Restore defaults" aria-label="Restore defaults" ${sectionDirty ? '' : 'hidden disabled'} aria-disabled="${sectionDirty ? 'false' : 'true'}">
|
||||
<span class="material-icons" aria-hidden="true">settings_backup_restore</span>
|
||||
@@ -2412,14 +2442,14 @@
|
||||
</div>
|
||||
<hr class="kefin-section-configure-separator" aria-hidden="true">
|
||||
</div>
|
||||
<div class="kefin-section-configure-more-panel">
|
||||
<div class="kefin-section-configure-more-panel-inner">
|
||||
<div class="kefin-section-configure-more-panel" aria-hidden="true">
|
||||
<div class="kefin-section-configure-more-panel-inner" data-kefin-focus-row>
|
||||
${moreModeRows}
|
||||
</div>
|
||||
</div>
|
||||
<hr class="kefin-section-configure-separator" aria-hidden="true">
|
||||
<div class="kefin-section-configure-footer">
|
||||
<div class="kefin-section-configure-primary-row">
|
||||
<div class="kefin-section-configure-primary-row" data-kefin-focus-row>
|
||||
<div class="kefin-section-configure-order">
|
||||
<button type="button" is="paper-icon-button-light" class="paper-icon-button-light emby-button kefin-section-order-up" title="Move section up. Long press to move to top." aria-label="Move section up"><span class="material-icons" aria-hidden="true">arrow_upward</span></button>
|
||||
<button type="button" is="paper-icon-button-light" class="paper-icon-button-light emby-button kefin-section-order-down" title="Move section down. Long press to move to bottom." aria-label="Move section down"><span class="material-icons" aria-hidden="true">arrow_downward</span></button>
|
||||
@@ -2458,9 +2488,46 @@
|
||||
`;
|
||||
|
||||
const mountElement = preparePopoverMount(anchorButton, sectionElement);
|
||||
mountElement.appendChild(popover);
|
||||
applyPresentationToElement(entry);
|
||||
|
||||
const modal = window.ModalSystem.create({
|
||||
id: CONFIGURE_MODAL_ID,
|
||||
title: null,
|
||||
content: contentRoot,
|
||||
footer: null,
|
||||
showCloseButton: false,
|
||||
closeOnBackdrop: true,
|
||||
closeOnEscape: true,
|
||||
fixedSize: false,
|
||||
returnFocus: anchorButton,
|
||||
initialFocus: '.kefin-section-order-up',
|
||||
dialogClassName: 'kefin-section-configure-popover',
|
||||
onClose: () => {
|
||||
finalizeConfigurePopoverClose();
|
||||
},
|
||||
onOpen: (modalInstance) => {
|
||||
if (modalInstance.backdrop) modalInstance.backdrop.style.background = 'transparent';
|
||||
if (modalInstance.dialogContainer) modalInstance.dialogContainer.style.pointerEvents = 'none';
|
||||
if (modalInstance.dialog) {
|
||||
modalInstance.dialog.classList.remove('centeredDialog', 'formDialog', 'smoothScrollY', 'dialog-fixedSize');
|
||||
modalInstance.dialog.style.pointerEvents = 'auto';
|
||||
modalInstance.dialog.style.maxHeight = 'none';
|
||||
modalInstance.dialog.style.minHeight = '0';
|
||||
modalInstance.dialog.style.height = 'auto';
|
||||
modalInstance.dialog.style.width = 'auto';
|
||||
}
|
||||
if (modalInstance.dialogContent) {
|
||||
modalInstance.dialogContent.style.padding = '0';
|
||||
modalInstance.dialogContent.style.overflow = 'visible';
|
||||
modalInstance.dialogContent.style.minHeight = '0';
|
||||
modalInstance.dialogContent.style.flex = '0 0 auto';
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const popover = modal.dialog;
|
||||
// Queries / expand state use the dialog root (has kefin-section-configure-popover)
|
||||
|
||||
const openEditorBtn = popover.querySelector('.kefin-section-open-editor-btn');
|
||||
const publishBtn = popover.querySelector('.kefin-section-publish-btn');
|
||||
if (openEditorBtn) {
|
||||
@@ -2748,21 +2815,19 @@
|
||||
popover.classList.toggle('is-expanded', willExpand);
|
||||
const moreBtn = popover.querySelector('.kefin-section-more-toggle');
|
||||
if (moreBtn) moreBtn.setAttribute('aria-expanded', String(willExpand));
|
||||
const morePanel = popover.querySelector('.kefin-section-configure-more-panel');
|
||||
if (morePanel) {
|
||||
if (willExpand) morePanel.removeAttribute('aria-hidden');
|
||||
else morePanel.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
});
|
||||
|
||||
// Backdrop close is handled by ModalSystem; ignore Pickr / nested modal hosts via capture filter
|
||||
const outsideHandler = (e) => {
|
||||
// Drag started in Pickr but released outside — not a real dismiss click
|
||||
if (pickrGestureFromApp) return;
|
||||
if (popover.contains(e.target) || anchorButton.contains(e.target)) return;
|
||||
// Pickr mounts its app on document.body (outside our modals)
|
||||
if (e.target.closest?.('.pcr-app, .pickr, .kefin-pickr-app')) return;
|
||||
const modalHost = e.target.closest?.('[data-modal-id]');
|
||||
const modalId = modalHost?.getAttribute('data-modal-id');
|
||||
if (modalId === OPTION_MENU_MODAL_ID || modalId === CARD_TITLE_MENU_MODAL_ID || modalId === RESTORE_SECTION_MODAL_ID || modalId === PUBLISH_SECTION_MODAL_ID) return;
|
||||
if (e.target.closest(
|
||||
'.kefin-section-format-btn, .kefin-section-border-style-btn, .kefin-section-card-title-btn, .kefin-section-spotlight-layout-btn, .kefin-section-spotlight-size-btn, .kefin-section-spotlight-tiles-btn'
|
||||
)) return;
|
||||
closePopover();
|
||||
if (e.target.closest?.('.pcr-app, .pickr, .kefin-pickr-app')) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', outsideHandler, true), 0);
|
||||
|
||||
@@ -2770,6 +2835,7 @@
|
||||
|
||||
activePopover = {
|
||||
popover,
|
||||
modal,
|
||||
entry,
|
||||
anchorButton,
|
||||
mountElement,
|
||||
@@ -2797,7 +2863,8 @@
|
||||
function createConfigureButton(sectionConfig, sectionElement) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'section-configure-button material-icons settings';
|
||||
button.className = 'section-configure-button material-icons settings paper-icon-button-light';
|
||||
if (isTvLayout()) button.classList.add('show-focus');
|
||||
button.title = 'Configure';
|
||||
button.setAttribute('aria-label', 'Configure');
|
||||
button.addEventListener('click', (e) => {
|
||||
@@ -2877,16 +2944,25 @@
|
||||
overflow: visible;
|
||||
}
|
||||
.kefin-section-configure-popover {
|
||||
position: absolute;
|
||||
position: fixed;
|
||||
z-index: 12000;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: auto;
|
||||
min-width: 14rem;
|
||||
max-width: min(100vw - 16px, 380px);
|
||||
padding: 0.5em;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
}
|
||||
.kefin-section-configure-popover.dialog.formDialog,
|
||||
.kefin-section-configure-popover.dialog {
|
||||
background: var(--dialog-bg, #101010);
|
||||
}
|
||||
.kefin-section-configure-popover [data-name="kefin-modal-content"] {
|
||||
padding: 0 !important;
|
||||
overflow: visible !important;
|
||||
flex: 0 0 auto !important;
|
||||
min-height: 0 !important;
|
||||
}
|
||||
.kefin-section-configure-popover.is-below {
|
||||
flex-direction: column-reverse;
|
||||
@@ -2939,6 +3015,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.2em;
|
||||
padding: 0.15em;
|
||||
}
|
||||
.kefin-section-configure-order {
|
||||
display: flex;
|
||||
|
||||
+344
-49
@@ -14,6 +14,20 @@ window.ModalSystem = (function() {
|
||||
let savedBodyOverflow = null;
|
||||
let savedHtmlOverflow = null;
|
||||
|
||||
const FOCUSABLE_SELECTOR = [
|
||||
'button:not([disabled]):not([tabindex="-1"])',
|
||||
'[href]:not([tabindex="-1"])',
|
||||
'input:not([disabled]):not([type="hidden"]):not([tabindex="-1"])',
|
||||
'select:not([disabled]):not([tabindex="-1"])',
|
||||
'textarea:not([disabled]):not([tabindex="-1"])',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
'[role="button"]:not([tabindex="-1"])',
|
||||
].join(',');
|
||||
|
||||
function isTvLayout() {
|
||||
return document.documentElement.classList.contains('layout-tv');
|
||||
}
|
||||
|
||||
function lockBodyScroll() {
|
||||
if (bodyScrollLockCount === 0) {
|
||||
savedBodyOverflow = document.body.style.overflow;
|
||||
@@ -52,20 +66,243 @@ window.ModalSystem = (function() {
|
||||
dialog.addEventListener('animationend', onEnd);
|
||||
}
|
||||
|
||||
function getFocusableElements(root) {
|
||||
if (!root) return [];
|
||||
return Array.from(root.querySelectorAll(FOCUSABLE_SELECTOR)).filter((el) => {
|
||||
if (el.closest('[hidden], [aria-hidden="true"]')) return false;
|
||||
const style = window.getComputedStyle(el);
|
||||
if (style.display === 'none' || style.visibility === 'hidden') return false;
|
||||
if (el.disabled) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function stampShowFocus(root) {
|
||||
if (!root || !isTvLayout()) return;
|
||||
root.querySelectorAll('button, a.emby-button, .emby-button, .paper-icon-button-light, [role="button"]').forEach((el) => {
|
||||
if (el.getAttribute('tabindex') === '-1') return;
|
||||
el.classList.add('show-focus');
|
||||
});
|
||||
}
|
||||
|
||||
function resolveInitialFocus(root, initialFocus) {
|
||||
if (!root) return null;
|
||||
if (initialFocus instanceof HTMLElement && root.contains(initialFocus)) {
|
||||
return initialFocus;
|
||||
}
|
||||
if (typeof initialFocus === 'string') {
|
||||
const bySelector = root.querySelector(initialFocus);
|
||||
if (bySelector) return bySelector;
|
||||
}
|
||||
return (
|
||||
root.querySelector('[data-autofocus]') ||
|
||||
root.querySelector('.is-active') ||
|
||||
root.querySelector('.selected') ||
|
||||
getFocusableElements(root)[0] ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Position a dialog near an anchor (fixed coords, viewport-clamped).
|
||||
* @param {HTMLElement} dialog
|
||||
* @param {HTMLElement} anchor
|
||||
*/
|
||||
function positionNearAnchor(dialog, anchor) {
|
||||
if (!dialog || !anchor) return;
|
||||
const margin = 8;
|
||||
const gap = 8;
|
||||
const edgeMargin = 16;
|
||||
const btnRect = anchor.getBoundingClientRect();
|
||||
const dialogWidth = dialog.offsetWidth || dialog.getBoundingClientRect().width || 200;
|
||||
const dialogHeight = dialog.offsetHeight || dialog.getBoundingClientRect().height || 120;
|
||||
const spaceBelow = window.innerHeight - btnRect.bottom - margin;
|
||||
const spaceAbove = btnRect.top - margin;
|
||||
const placeBelow = spaceBelow >= Math.min(spaceAbove, 160) || spaceBelow >= spaceAbove;
|
||||
|
||||
let left = btnRect.left;
|
||||
left = Math.min(
|
||||
Math.max(edgeMargin, left),
|
||||
Math.max(edgeMargin, window.innerWidth - dialogWidth - edgeMargin),
|
||||
);
|
||||
|
||||
let top;
|
||||
if (placeBelow) {
|
||||
top = btnRect.bottom + gap;
|
||||
} else {
|
||||
top = Math.max(margin, btnRect.top - gap - dialogHeight);
|
||||
}
|
||||
top = Math.min(Math.max(top, margin), window.innerHeight - Math.min(dialogHeight, window.innerHeight - margin * 2) - margin);
|
||||
|
||||
dialog.style.position = 'fixed';
|
||||
dialog.style.left = `${left}px`;
|
||||
dialog.style.top = `${top}px`;
|
||||
dialog.style.margin = '0';
|
||||
dialog.style.right = 'auto';
|
||||
dialog.style.bottom = 'auto';
|
||||
}
|
||||
|
||||
function focusElement(el) {
|
||||
if (!el || typeof el.focus !== 'function') return;
|
||||
try {
|
||||
el.focus({ preventScroll: true });
|
||||
} catch (_) {
|
||||
el.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function owningDialog(el) {
|
||||
return el && typeof el.closest === 'function' ? el.closest('.dialog') : null;
|
||||
}
|
||||
|
||||
function getFocusRows(dialogEl) {
|
||||
const rows = Array.from(dialogEl.querySelectorAll('[data-kefin-focus-row]'));
|
||||
const withItems = rows.map((row) => ({
|
||||
row,
|
||||
items: getFocusableElements(row).filter((el) => el.closest('[data-kefin-focus-row]') === row),
|
||||
})).filter((entry) => entry.items.length);
|
||||
withItems.sort((a, b) => {
|
||||
const at = a.row.getBoundingClientRect();
|
||||
const bt = b.row.getBoundingClientRect();
|
||||
if (Math.abs(at.top - bt.top) > 4) return at.top - bt.top;
|
||||
return at.left - bt.left;
|
||||
});
|
||||
return withItems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach focus trap to a dialog element. Returns a dispose function.
|
||||
* @param {HTMLElement} dialogEl
|
||||
* @param {{ returnFocusEl?: HTMLElement|null, initialFocusEl?: HTMLElement|string|null, onEscape?: Function|null }} options
|
||||
*/
|
||||
function attachFocusTrap(dialogEl, options = {}) {
|
||||
if (!dialogEl) return () => {};
|
||||
const returnFocusEl = options.returnFocusEl || null;
|
||||
const onEscape = typeof options.onEscape === 'function' ? options.onEscape : null;
|
||||
|
||||
dialogEl.classList.add('focuscontainer');
|
||||
stampShowFocus(dialogEl);
|
||||
|
||||
const focusInitial = () => {
|
||||
const target = resolveInitialFocus(dialogEl, options.initialFocusEl);
|
||||
if (target) {
|
||||
focusElement(target);
|
||||
} else if (typeof dialogEl.focus === 'function') {
|
||||
if (!dialogEl.hasAttribute('tabindex')) dialogEl.setAttribute('tabindex', '-1');
|
||||
focusElement(dialogEl);
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(focusInitial);
|
||||
});
|
||||
|
||||
const keydownHandler = (e) => {
|
||||
if (!dialogEl.isConnected) return;
|
||||
|
||||
const active = document.activeElement;
|
||||
const activeDialog = owningDialog(active);
|
||||
// Nested/child modal owns keys until it closes
|
||||
if (activeDialog && activeDialog !== dialogEl) return;
|
||||
|
||||
const isNavKey = e.key === 'ArrowDown' || e.key === 'ArrowUp'
|
||||
|| e.key === 'ArrowLeft' || e.key === 'ArrowRight'
|
||||
|| e.key === 'Tab';
|
||||
|
||||
if (!dialogEl.contains(active) && e.key !== 'Escape') {
|
||||
if (isNavKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (typeof e.stopImmediatePropagation === 'function') e.stopImmediatePropagation();
|
||||
focusInitial();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (typeof e.stopImmediatePropagation === 'function') e.stopImmediatePropagation();
|
||||
if (onEscape) onEscape(e);
|
||||
return;
|
||||
}
|
||||
|
||||
const focusables = getFocusableElements(dialogEl);
|
||||
if (!focusables.length) return;
|
||||
|
||||
const currentIndex = focusables.indexOf(active);
|
||||
const at = currentIndex >= 0 ? currentIndex : 0;
|
||||
const rows = getFocusRows(dialogEl);
|
||||
const useRows = rows.length >= 2;
|
||||
|
||||
const consume = () => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (typeof e.stopImmediatePropagation === 'function') e.stopImmediatePropagation();
|
||||
};
|
||||
|
||||
if (e.key === 'Tab') {
|
||||
consume();
|
||||
const next = e.shiftKey
|
||||
? focusables[(at - 1 + focusables.length) % focusables.length]
|
||||
: focusables[(at + 1) % focusables.length];
|
||||
focusElement(next);
|
||||
return;
|
||||
}
|
||||
|
||||
if (useRows && (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
|
||||
consume();
|
||||
let rowIndex = rows.findIndex((entry) => entry.items.includes(active));
|
||||
if (rowIndex < 0) {
|
||||
rowIndex = rows.findIndex((entry) => entry.row.contains(active));
|
||||
}
|
||||
if (rowIndex < 0) rowIndex = 0;
|
||||
const row = rows[rowIndex];
|
||||
let col = row.items.indexOf(active);
|
||||
if (col < 0) col = 0;
|
||||
|
||||
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
||||
const nextCol = e.key === 'ArrowRight'
|
||||
? (col + 1) % row.items.length
|
||||
: (col - 1 + row.items.length) % row.items.length;
|
||||
focusElement(row.items[nextCol]);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextRowIndex = e.key === 'ArrowDown'
|
||||
? (rowIndex + 1) % rows.length
|
||||
: (rowIndex - 1 + rows.length) % rows.length;
|
||||
const nextRow = rows[nextRowIndex];
|
||||
const nextCol = Math.min(col, nextRow.items.length - 1);
|
||||
focusElement(nextRow.items[nextCol]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
|
||||
consume();
|
||||
focusElement(focusables[(at + 1) % focusables.length]);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
|
||||
consume();
|
||||
focusElement(focusables[(at - 1 + focusables.length) % focusables.length]);
|
||||
}
|
||||
};
|
||||
|
||||
// Document capture so Left/Right are not taken by Jellyfin spatial navigation
|
||||
document.addEventListener('keydown', keydownHandler, true);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', keydownHandler, true);
|
||||
if (returnFocusEl && typeof returnFocusEl.focus === 'function' && document.contains(returnFocusEl)) {
|
||||
focusElement(returnFocusEl);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Jellyfin-style modal dialog
|
||||
* @param {Object} options - Modal configuration
|
||||
* @param {string} options.id - Unique modal ID
|
||||
* @param {string} options.title - Modal title
|
||||
* @param {string|HTMLElement} options.content - HTML content for the modal body
|
||||
* @param {string|HTMLElement} options.footer - Optional footer HTML content
|
||||
* @param {Function} options.onClose - Callback when modal closes
|
||||
* @param {Function} options.onOpen - Callback when modal opens
|
||||
* @param {boolean} options.closeOnBackdrop - Whether to close when clicking backdrop (default: true)
|
||||
* @param {boolean} options.closeOnEscape - Whether to close on Escape key (default: true)
|
||||
* @param {boolean} options.showCloseButton - Whether to show close button in header (default: true if title exists)
|
||||
* @param {boolean} [options.fixedSize] - Use dialog-fixedSize. If undefined, enable when window width < 900.
|
||||
* @param {Object} [options.dialogStyle] - CSS styles applied to the dialog before open (e.g. width/height).
|
||||
* @returns {Object} Modal instance
|
||||
*/
|
||||
function createModal(options = {}) {
|
||||
@@ -80,7 +317,12 @@ window.ModalSystem = (function() {
|
||||
closeOnEscape = true,
|
||||
showCloseButton = true,
|
||||
fixedSize,
|
||||
dialogStyle
|
||||
dialogStyle,
|
||||
returnFocus,
|
||||
initialFocus,
|
||||
trapFocus,
|
||||
anchor = null,
|
||||
dialogClassName = '',
|
||||
} = options;
|
||||
|
||||
if (!id) {
|
||||
@@ -94,10 +336,20 @@ window.ModalSystem = (function() {
|
||||
|| (fixedSize !== false && typeof window !== 'undefined' && window.innerWidth < 900);
|
||||
|
||||
let _showCloseButton = showCloseButton;
|
||||
if (window.innerWidth < 900 && useFixedSize) {
|
||||
if (title == null && showCloseButton === false) {
|
||||
_showCloseButton = false;
|
||||
} else if (window.innerWidth < 900 && useFixedSize && showCloseButton !== false && title) {
|
||||
_showCloseButton = true;
|
||||
}
|
||||
|
||||
const shouldTrap = trapFocus === true || (trapFocus !== false && isTvLayout());
|
||||
const returnFocusEl =
|
||||
returnFocus instanceof HTMLElement
|
||||
? returnFocus
|
||||
: document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null;
|
||||
|
||||
// Create modal elements
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'dialogBackdrop dialogBackdropOpened';
|
||||
@@ -108,7 +360,8 @@ window.ModalSystem = (function() {
|
||||
dialogContainer.setAttribute('data-modal-id', id);
|
||||
|
||||
const dialog = document.createElement('div');
|
||||
dialog.className = `focuscontainer dialog smoothScrollY ui-body-a background-theme-a formDialog ${useFixedSize ? 'dialog-fixedSize' : 'centeredDialog'} opened`;
|
||||
const extraClasses = dialogClassName ? ` ${dialogClassName}` : '';
|
||||
dialog.className = `focuscontainer dialog smoothScrollY ui-body-a background-theme-a formDialog ${useFixedSize ? 'dialog-fixedSize' : 'centeredDialog'} opened${extraClasses}`;
|
||||
dialog.setAttribute('data-history', 'true');
|
||||
dialog.setAttribute('data-autofocus', 'true');
|
||||
dialog.setAttribute('data-removeonclose', 'true');
|
||||
@@ -126,42 +379,43 @@ window.ModalSystem = (function() {
|
||||
|
||||
// Create header if title is provided
|
||||
let dialogHeader = null;
|
||||
dialogHeader = document.createElement('div');
|
||||
dialogHeader.className = 'formDialogHeader';
|
||||
dialogHeader.style.display = 'flex';
|
||||
dialogHeader.style.justifyContent = 'space-between';
|
||||
dialogHeader.style.alignItems = 'center';
|
||||
dialogHeader.style.padding = '1.25em 1.5em';
|
||||
dialogHeader.style.borderBottom = '1px solid rgba(255,255,255,0.1)';
|
||||
dialogHeader.style.flexShrink = '0';
|
||||
|
||||
if (title) {
|
||||
const titleElement = document.createElement('h2');
|
||||
titleElement.style.margin = '0';
|
||||
titleElement.style.textAlign = 'left';
|
||||
titleElement.textContent = title;
|
||||
dialogHeader.appendChild(titleElement);
|
||||
}
|
||||
if (title || _showCloseButton) {
|
||||
dialogHeader = document.createElement('div');
|
||||
dialogHeader.className = 'formDialogHeader';
|
||||
dialogHeader.style.display = 'flex';
|
||||
dialogHeader.style.justifyContent = 'space-between';
|
||||
dialogHeader.style.alignItems = 'center';
|
||||
dialogHeader.style.padding = '1.25em 1.5em';
|
||||
dialogHeader.style.borderBottom = '1px solid rgba(255,255,255,0.1)';
|
||||
dialogHeader.style.flexShrink = '0';
|
||||
|
||||
// Add close button if enabled
|
||||
if (_showCloseButton) {
|
||||
const closeButton = document.createElement('button');
|
||||
closeButton.setAttribute('is', 'paper-icon-button-light');
|
||||
closeButton.className = 'btnCancel btnClose autoSize paper-icon-button-light';
|
||||
closeButton.setAttribute('tabindex', '-1');
|
||||
closeButton.title = 'Close';
|
||||
closeButton.onclick = () => closeModal(id);
|
||||
if (title) {
|
||||
const titleElement = document.createElement('h2');
|
||||
titleElement.style.margin = '0';
|
||||
titleElement.style.textAlign = 'left';
|
||||
titleElement.textContent = title;
|
||||
dialogHeader.appendChild(titleElement);
|
||||
}
|
||||
|
||||
const closeIcon = document.createElement('span');
|
||||
closeIcon.className = 'material-icons close';
|
||||
closeIcon.setAttribute('aria-hidden', 'true');
|
||||
closeButton.appendChild(closeIcon);
|
||||
if (_showCloseButton) {
|
||||
const closeButton = document.createElement('button');
|
||||
closeButton.setAttribute('is', 'paper-icon-button-light');
|
||||
closeButton.className = 'btnCancel btnClose autoSize paper-icon-button-light';
|
||||
closeButton.setAttribute('tabindex', '-1');
|
||||
closeButton.title = 'Close';
|
||||
closeButton.onclick = () => closeModal(id);
|
||||
|
||||
dialogHeader.appendChild(closeButton);
|
||||
}
|
||||
const closeIcon = document.createElement('span');
|
||||
closeIcon.className = 'material-icons close';
|
||||
closeIcon.setAttribute('aria-hidden', 'true');
|
||||
closeButton.appendChild(closeIcon);
|
||||
|
||||
if (dialogHeader.childNodes.length > 0) {
|
||||
dialog.appendChild(dialogHeader);
|
||||
dialogHeader.appendChild(closeButton);
|
||||
}
|
||||
|
||||
if (dialogHeader.childNodes.length > 0) {
|
||||
dialog.appendChild(dialogHeader);
|
||||
}
|
||||
}
|
||||
|
||||
// Create scrollable content area
|
||||
@@ -227,11 +481,15 @@ window.ModalSystem = (function() {
|
||||
dialogHeader,
|
||||
dialogFooter,
|
||||
isOpen: true,
|
||||
onClose: typeof onClose === 'function' ? onClose : null,
|
||||
returnFocusEl,
|
||||
close: () => closeModal(id),
|
||||
updateContent: (newContent) => updateModalContent(id, newContent),
|
||||
addEventListener: (event, handler) => {
|
||||
dialog.addEventListener(event, handler);
|
||||
}
|
||||
},
|
||||
_disposeFocusTrap: null,
|
||||
_escapeHandler: null,
|
||||
};
|
||||
|
||||
// Store modal instance
|
||||
@@ -248,7 +506,7 @@ window.ModalSystem = (function() {
|
||||
});
|
||||
}
|
||||
|
||||
if (closeOnEscape) {
|
||||
if (closeOnEscape && !shouldTrap) {
|
||||
const escapeHandler = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
closeModal(id);
|
||||
@@ -258,6 +516,27 @@ window.ModalSystem = (function() {
|
||||
modalInstance._escapeHandler = escapeHandler;
|
||||
}
|
||||
|
||||
if (anchor instanceof HTMLElement) {
|
||||
dialog.classList.remove('centeredDialog', 'formDialog', 'smoothScrollY', 'dialog-fixedSize');
|
||||
dialog.style.maxHeight = 'none';
|
||||
if (dialogContent) {
|
||||
dialogContent.style.padding = dialogContent.style.padding || '0.25em 0';
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
positionNearAnchor(dialog, anchor);
|
||||
});
|
||||
}
|
||||
|
||||
if (shouldTrap) {
|
||||
modalInstance._disposeFocusTrap = attachFocusTrap(dialog, {
|
||||
returnFocusEl,
|
||||
initialFocusEl: initialFocus,
|
||||
onEscape: closeOnEscape ? () => closeModal(id) : null,
|
||||
});
|
||||
} else if (isTvLayout()) {
|
||||
stampShowFocus(dialog);
|
||||
}
|
||||
|
||||
// Call onOpen callback
|
||||
if (onOpen && typeof onOpen === 'function') {
|
||||
onOpen(modalInstance);
|
||||
@@ -277,6 +556,18 @@ window.ModalSystem = (function() {
|
||||
// Remove escape handler if it exists
|
||||
if (modal._escapeHandler) {
|
||||
document.removeEventListener('keydown', modal._escapeHandler);
|
||||
modal._escapeHandler = null;
|
||||
}
|
||||
|
||||
if (typeof modal._disposeFocusTrap === 'function') {
|
||||
modal._disposeFocusTrap();
|
||||
modal._disposeFocusTrap = null;
|
||||
} else if (modal.returnFocusEl && typeof modal.returnFocusEl.focus === 'function' && document.contains(modal.returnFocusEl)) {
|
||||
try {
|
||||
modal.returnFocusEl.focus({ preventScroll: true });
|
||||
} catch (_) {
|
||||
modal.returnFocusEl.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from DOM
|
||||
@@ -323,6 +614,7 @@ window.ModalSystem = (function() {
|
||||
modal.dialogContent.innerHTML = '';
|
||||
modal.dialogContent.appendChild(content);
|
||||
}
|
||||
stampShowFocus(modal.dialog);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -359,6 +651,9 @@ window.ModalSystem = (function() {
|
||||
isOpen: isModalOpen,
|
||||
getActiveIds: getActiveModalIds,
|
||||
closeAll: closeAllModals,
|
||||
applyOpenAnimation
|
||||
applyOpenAnimation,
|
||||
attachFocusTrap,
|
||||
positionNearAnchor,
|
||||
getFocusableElements,
|
||||
};
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user