diff --git a/kolibri/plugins/bloompub_viewer/frontend/views/BloomPubRendererIndex.vue b/kolibri/plugins/bloompub_viewer/frontend/views/BloomPubRendererIndex.vue index 4d75bdaafd2..1e83607bdfd 100644 --- a/kolibri/plugins/bloompub_viewer/frontend/views/BloomPubRendererIndex.vue +++ b/kolibri/plugins/bloompub_viewer/frontend/views/BloomPubRendererIndex.vue @@ -6,28 +6,10 @@ :style="{ width: iframeWidth }" @changeFullscreen="isInFullscreen = $event" > -
- - - - {{ fullscreenText }} - -
+
@import '~kolibri-design-system/lib/styles/definitions'; - $frame-topbar-height: 37px; - - .fullscreen-header { - text-align: right; - } - - .fs-icon { - position: relative; - top: 8px; - width: 24px; - height: 24px; - } + $frame-topbar-height: 48px; .bloompub-viewer { position: relative; diff --git a/kolibri/plugins/epub_viewer/frontend/views/BottomBar.vue b/kolibri/plugins/epub_viewer/frontend/views/BottomBar.vue index dee4a2f4c2a..5f0a5381c1e 100644 --- a/kolibri/plugins/epub_viewer/frontend/views/BottomBar.vue +++ b/kolibri/plugins/epub_viewer/frontend/views/BottomBar.vue @@ -100,7 +100,7 @@ .bottom-bar { height: 54px; - padding: 8px 8px 0; + padding: 8px 40px 0; overflow: hidden; text-align: center; } diff --git a/kolibri/plugins/epub_viewer/frontend/views/EpubRendererIndex.vue b/kolibri/plugins/epub_viewer/frontend/views/EpubRendererIndex.vue index 61b87557d9c..c3742753054 100644 --- a/kolibri/plugins/epub_viewer/frontend/views/EpubRendererIndex.vue +++ b/kolibri/plugins/epub_viewer/frontend/views/EpubRendererIndex.vue @@ -1,163 +1,157 @@ @@ -172,6 +166,7 @@ import Lockr from 'lockr'; import FocusLock from 'vue-focus-lock'; import CoreFullscreen from 'kolibri-common/components/CoreFullscreen'; + import EmbeddedReadCard from 'kolibri-common/components/EmbeddedReadCard'; import useKResponsiveWindow from 'kolibri-design-system/lib/composables/useKResponsiveWindow'; import useContentViewer from 'kolibri/composables/useContentViewer'; import { ref, computed } from 'vue'; @@ -185,9 +180,6 @@ import BottomBar from './BottomBar'; import PreviousButton from './PreviousButton'; import NextButton from './NextButton'; - import TocButton from './TocButton'; - import SettingsButton from './SettingsButton'; - import SearchButton from './SearchButton'; import { THEMES, isDarkColor, DEFAULT_LINK_COLOR, resolveTheme } from './EpubConstants'; @@ -209,6 +201,7 @@ name: 'EpubRendererIndex', components: { CoreFullscreen, + EmbeddedReadCard, TopBar, TableOfContentsSideBar, SettingsSideBar, @@ -218,9 +211,6 @@ PreviousButton, NextButton, FocusLock, - TocButton, - SettingsButton, - SearchButton, LoadingError, }, setup(props, context) { @@ -245,6 +235,7 @@ contentDirection, contentIsRtl, reportLoadingError, + embedded, } = useContentViewer(context, { defaultDuration }); return { windowIsSmall, @@ -256,6 +247,7 @@ contentDirection, contentIsRtl, reportLoadingError, + embedded, }; }, data() { @@ -365,10 +357,25 @@ searchSideBarIsOpen() { return this.sideBarOpen === SIDE_BARS.SEARCH; }, + mobileEmbedded() { + return this.embedded && this.windowIsSmall && !this.isInFullscreen; + }, epubViewerStyle() { - return { + const embeddedInline = this.embedded && !this.isInFullscreen; + const style = { backgroundColor: this.$themeTokens.surface, }; + if (embeddedInline) { + style.height = '25vh'; + if (!this.mobileEmbedded) { + // The mobile-embedded preview gets its card border from EmbeddedReadCard. + style.border = `1px solid ${this.$themeTokens.fineLine}`; + } + if (!this.windowIsSmall) { + style.minHeight = '400px'; + } + } + return style; }, navigationButtonColor() { // Choose arrow color from the actual background luminance so custom themes @@ -403,7 +410,9 @@ if (oldSideBar === SIDE_BARS.SEARCH) { this.clearMarks(); } - if (!newSideBar) { + // Esc can close a side bar and exit fullscreen at once, which unmounts + // the top bar in the mobile embedded case before we return focus to it. + if (!newSideBar && this.$refs.topBar) { switch (oldSideBar) { case SIDE_BARS.TOC: this.$refs.topBar.focusOnTocButton(); @@ -433,8 +442,7 @@ this.visitedPages = this.savedVisitedPages || {}; }, beforeMount() { - global.ePub = Epub; - this.book = new Epub(this.epubURL); + this.book = new Epub(this.epubURL, { openAs: 'epub' }); this.book.on(EVENTS.BOOK.OPEN_FAILED, err => { this.errorLoading = true; this.reportLoadingError(err); @@ -525,9 +533,6 @@ window.removeEventListener('mousedown', this.handleMouseDown, { passive: true }); clearInterval(this.updateContentStateInterval); }, - destroyed() { - delete global.ePub; - }, methods: { updateProgress() { if (this.locations.length > 0) { @@ -606,6 +611,11 @@ } }, handleMouseDown(event) { + // The top bar sits outside the content div that stops this event, so its + // toggles would close the side bar here and immediately reopen it on click. + if (this.$refs.topBar && this.$refs.topBar.$el.contains(event.target)) { + return; + } // This check is necessary because event listeners don't seem to be removed on beforeDestroy if (this.$refs.epubViewer) { let closeSideBar = false; @@ -818,47 +828,39 @@ @import '~kolibri-design-system/lib/styles/definitions'; @import './EpubStyles'; - $top-bar-height: 36px; + $top-bar-height: 48px; $bottom-bar-height: 54px; $navigation-button-small: 36px; $navigation-button-normal: 52px; .epub-viewer { position: relative; - // Counter-balance the padding to avoid unnecessary scroll + display: flex; + flex-direction: column; height: calc(100vh - 64px); - padding: 32px 24px; overflow: hidden; font-size: smaller; border-radius: $radius; } - .epub-viewer:fullscreen, - .epub-viewer.small:fullscreen { - padding: 0; + .top-bar-component { + flex-shrink: 0; } .epub-viewer-content { position: relative; - height: 100%; + flex: 1; + min-height: 0; overflow: hidden; border: solid 1px; border-radius: $radius; } - .top-bar-component { - position: absolute; - top: 0; - right: 0; - left: 0; - height: $top-bar-height; - } - .side-bar { @extend %momentum-scroll; position: absolute; - top: $top-bar-height; + top: 0; bottom: $bottom-bar-height; } @@ -870,34 +872,11 @@ right: 0; } - .toc-button { - left: 3px; - } - - .toc-button, - .settings-button, - .search-button { - position: absolute; - top: 2px; - z-index: 2; - } - - .settings-button { - right: 67px; - } - - .search-button { - // Positioned to be in the exact same spot as the TopBar's SearchButton, - // which is given opacity: 0 when this button is shown - right: 35px; - } - .bottom-bar { position: absolute; right: 0; bottom: 0; left: 0; - padding: 0 40px; } .d-t { @@ -916,7 +895,7 @@ @extend %momentum-scroll; position: absolute; - top: $top-bar-height; + top: 0; right: 0; bottom: $bottom-bar-height; left: 0; @@ -941,6 +920,17 @@ left: 0; } + .epub-viewer.no-bottom-bar { + .side-bar, + .navigation-and-epubjs { + bottom: 0; + } + + .navigation-and-epubjs { + max-height: none; + } + } + .epub-viewer.small .epubjs-navigation { width: $navigation-button-small; } diff --git a/kolibri/plugins/epub_viewer/frontend/views/SearchButton.vue b/kolibri/plugins/epub_viewer/frontend/views/SearchButton.vue index db651ef23ad..2834a926951 100644 --- a/kolibri/plugins/epub_viewer/frontend/views/SearchButton.vue +++ b/kolibri/plugins/epub_viewer/frontend/views/SearchButton.vue @@ -4,7 +4,6 @@ icon="search" :ariaLabel="$tr('toggleSearchSideBar')" data-testid="search button" - size="small" @click="$emit('click')" /> diff --git a/kolibri/plugins/epub_viewer/frontend/views/SearchSideBar.vue b/kolibri/plugins/epub_viewer/frontend/views/SearchSideBar.vue index 703e3def63a..85262102327 100644 --- a/kolibri/plugins/epub_viewer/frontend/views/SearchSideBar.vue +++ b/kolibri/plugins/epub_viewer/frontend/views/SearchSideBar.vue @@ -6,13 +6,14 @@ @submit.prevent="submitSearch" >
+ diff --git a/kolibri/plugins/epub_viewer/frontend/views/TocButton.vue b/kolibri/plugins/epub_viewer/frontend/views/TocButton.vue index dccca98cf57..2f34cf8748b 100644 --- a/kolibri/plugins/epub_viewer/frontend/views/TocButton.vue +++ b/kolibri/plugins/epub_viewer/frontend/views/TocButton.vue @@ -4,7 +4,6 @@ icon="list" :ariaLabel="$tr('toggleTocSideBar')" data-testid="toc button" - size="small" @click="$emit('click')" /> diff --git a/kolibri/plugins/epub_viewer/frontend/views/TopBar.vue b/kolibri/plugins/epub_viewer/frontend/views/TopBar.vue index e066a27518c..96eda9dbc84 100644 --- a/kolibri/plugins/epub_viewer/frontend/views/TopBar.vue +++ b/kolibri/plugins/epub_viewer/frontend/views/TopBar.vue @@ -1,66 +1,42 @@ + + + @@ -121,22 +91,6 @@ @import './EpubStyles'; - .invisible { - // When the SearchSideBar is shown, hide this SearchButton so it does not appear - // under the second SearchButton rendered inside EpubRendererIndex - opacity: 0; - } - - .top-bar { - z-index: 1; - } - - .top-bar-grid { - margin-top: 2px; - margin-right: 3px; - margin-left: 3px; - } - .top-bar-title { @include truncate-text; diff --git a/kolibri/plugins/epub_viewer/frontend/views/__tests__/SearchSideBar.spec.js b/kolibri/plugins/epub_viewer/frontend/views/__tests__/SearchSideBar.spec.js index 6aa998426c0..afb073fd5c1 100644 --- a/kolibri/plugins/epub_viewer/frontend/views/__tests__/SearchSideBar.spec.js +++ b/kolibri/plugins/epub_viewer/frontend/views/__tests__/SearchSideBar.spec.js @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/vue'; +import { render, screen, fireEvent } from '@testing-library/vue'; import { defineComponent, ref } from 'vue'; import SearchSideBar from '../SearchSideBar'; @@ -50,4 +50,37 @@ describe('Search side bar', () => { const input = screen.getByRole('searchbox'); expect(input).toHaveFocus(); }); + + it('should let escape from the input reach the ancestor that closes the side bar', async () => { + const closeSideBar = jest.fn(); + const Parent = defineComponent({ + components: { SearchSideBar }, + setup() { + // eslint-disable-next-line vue/no-unused-properties + return { closeSideBar }; + }, + template: `
+ +
`, + }); + + render(Parent, { attachTo: document.body }); + + await fireEvent.keyUp(screen.getByRole('searchbox'), { key: 'Escape' }); + + expect(closeSideBar).toHaveBeenCalled(); + }); + + it('should suppress the native search field clear on escape', () => { + renderComponent(); + + const event = new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + cancelable: true, + }); + screen.getByRole('searchbox').dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + }); }); diff --git a/kolibri/plugins/epub_viewer/frontend/views/__tests__/TopBar.spec.js b/kolibri/plugins/epub_viewer/frontend/views/__tests__/TopBar.spec.js index 9a1aa26dd4f..8d612d3219f 100644 --- a/kolibri/plugins/epub_viewer/frontend/views/__tests__/TopBar.spec.js +++ b/kolibri/plugins/epub_viewer/frontend/views/__tests__/TopBar.spec.js @@ -1,14 +1,15 @@ import { render, screen, fireEvent } from '@testing-library/vue'; import { createTranslator } from 'kolibri/utils/i18n'; +import { viewerToolbarStrings } from 'kolibri-common/components/ViewerToolbar'; import TopBar from '../TopBar'; import TocButton from '../TocButton'; import SettingsButton from '../SettingsButton'; import SearchButton from '../SearchButton'; +const { enterFullscreen$ } = viewerToolbarStrings; const { toggleTocSideBar$ } = createTranslator(TocButton.name, TocButton.$trs); const { toggleSettingsSideBar$ } = createTranslator(SettingsButton.name, SettingsButton.$trs); const { toggleSearchSideBar$ } = createTranslator(SearchButton.name, SearchButton.$trs); -const { toggleFullscreen$ } = createTranslator(TopBar.name, TopBar.$trs); function renderTopBar(props = {}) { return render(TopBar, { @@ -85,7 +86,7 @@ describe('Top bar', () => { it('emits event when fullscreen button is clicked', async () => { const { emitted } = renderTopBar(); - await fireEvent.click(screen.getByRole('button', { name: toggleFullscreen$() })); + await fireEvent.click(screen.getByRole('button', { name: enterFullscreen$() })); expect(emitted().fullscreenButtonClicked).toBeTruthy(); }); diff --git a/kolibri/plugins/html5_viewer/frontend/views/Html5AppRendererIndex.vue b/kolibri/plugins/html5_viewer/frontend/views/Html5AppRendererIndex.vue index b3de5732743..bcae0ed4fa7 100644 --- a/kolibri/plugins/html5_viewer/frontend/views/Html5AppRendererIndex.vue +++ b/kolibri/plugins/html5_viewer/frontend/views/Html5AppRendererIndex.vue @@ -6,28 +6,10 @@ :style="{ width: iframeWidth }" @changeFullscreen="isInFullscreen = $event" > -
- - - - {{ fullscreenText }} - -
+
@import '~kolibri-design-system/lib/styles/definitions'; - $frame-topbar-height: 37px; - - .fullscreen-header { - text-align: right; - } - - .fs-icon { - position: relative; - top: 8px; - width: 24px; - height: 24px; - } + $frame-topbar-height: 48px; .html5-viewer { position: relative; diff --git a/kolibri/plugins/pdf_viewer/frontend/views/PdfRendererIndex.vue b/kolibri/plugins/pdf_viewer/frontend/views/PdfRendererIndex.vue index d91bba360b1..7a150de89d1 100644 --- a/kolibri/plugins/pdf_viewer/frontend/views/PdfRendererIndex.vue +++ b/kolibri/plugins/pdf_viewer/frontend/views/PdfRendererIndex.vue @@ -1,122 +1,121 @@ @@ -133,6 +132,8 @@ import commonCoreStrings from 'kolibri/uiText/commonCoreStrings'; import useKResponsiveWindow from 'kolibri-design-system/lib/composables/useKResponsiveWindow'; import CoreFullscreen from 'kolibri-common/components/CoreFullscreen'; + import EmbeddedReadCard from 'kolibri-common/components/EmbeddedReadCard'; + import ViewerToolbar from 'kolibri-common/components/ViewerToolbar'; import useContentViewer from 'kolibri/composables/useContentViewer'; import { ref, computed } from 'vue'; import '../utils/domPolyfills'; @@ -153,7 +154,9 @@ SideBar, PdfPage, CoreFullscreen, + EmbeddedReadCard, RecyclableScroller, + ViewerToolbar, }, mixins: [commonCoreStrings], setup(props, context) { @@ -202,6 +205,26 @@ outline: null, }), computed: { + // Fullscreen reached from an embedded PDF should look like standalone fullscreen. + embeddedInline() { + return this.embedded && !this.isInFullscreen; + }, + mobileEmbedded() { + return this.embeddedInline && this.windowIsSmall; + }, + pdfViewerStyle() { + const style = { + backgroundColor: this.embeddedInline ? this.$themeTokens.surface : this.$themeTokens.text, + }; + if (this.embeddedInline && !this.mobileEmbedded) { + // The mobile-embedded preview gets its card border from EmbeddedReadCard. + style.border = `1px solid ${this.$themeTokens.fineLine}`; + if (!this.windowIsSmall) { + style.minHeight = '400px'; + } + } + return style; + }, // Returns whether or not the current device is iOS. // Probably not perfect, but worked in testing. iOS() { @@ -235,9 +258,6 @@ this.visitedPages = value; }, }, - fullscreenText() { - return this.isInFullscreen ? this.$tr('exitFullscreen') : this.$tr('enterFullscreen'); - }, debouncedShowVisiblePages() { // So as not to share debounced functions between instances of the same component // and also to allow access to the cancel method of the debounced function @@ -246,6 +266,10 @@ return debounce(this.showVisiblePages, renderDebounceTime); }, screenSizeMultiplier() { + if (this.mobileEmbedded) { + // Shrink the rendered pages so the preview fits within the card. + return 1.2; + } if (this.windowIsLarge) { return 1.25; } @@ -256,7 +280,23 @@ }, }, watch: { + mobileEmbedded() { + // screenSizeMultiplier depends on mobileEmbedded; re-derive scale so + // pages re-render at the appropriate size when entering/leaving the + // preview state. + this.$nextTick(() => { + if (this.firstPageWidth) { + this.scale = this.viewerWidth() / (this.firstPageWidth * this.screenSizeMultiplier); + } + }); + }, recycleListIsMounted(newVal) { + // Re-fit now the scroller exists; deferred until it applies its classes. + if (newVal === true && this.firstPageWidth) { + this.$nextTick(() => { + this.scale = this.viewerWidth() / (this.firstPageWidth * this.screenSizeMultiplier); + }); + } // On iOS pinch zooming always targets the document no matter what. // meta viewport attrs for `user-scalable` are ignored in iOS because // Apple considered it an a11y issue not to. So pinch-zooming on iOS @@ -281,11 +321,9 @@ }, showSideBar() { this.$nextTick(() => { - if (!this.$refs.pdfContainer || !this.$refs.pdfContainer.$el) { - return; + if (this.firstPageWidth) { + this.scale = this.viewerWidth() / (this.firstPageWidth * this.screenSizeMultiplier); } - const containerWidth = this.$refs.pdfContainer.$el.clientWidth; - this.scale = containerWidth / (this.firstPageWidth * this.screenSizeMultiplier); }); }, }, @@ -323,7 +361,7 @@ const viewPort = firstPage.getViewport({ scale: 1 }); this.firstPageHeight = viewPort.height; this.firstPageWidth = viewPort.width; - this.scale = this.$el.clientWidth / (this.firstPageWidth * this.screenSizeMultiplier); + this.scale = this.viewerWidth() / (this.firstPageWidth * this.screenSizeMultiplier); // init pdfPages array // ensuring that firstPageToRender is resolved so that we do not refetch the page @@ -340,7 +378,7 @@ const outline = await pdfDocument.getOutline(); this.outline = outline; - this.showSideBar = outline && outline.length > 0 && this.windowIsLarge; // Remove if other tabs are already implemented + this.showSideBar = outline && outline.length > 0 && this.windowIsLarge && !this.embedded; // Remove if other tabs are already implemented // Reduce the scale slightly if we are showing the sidebar // at first load. this.scale = this.showSideBar ? 0.75 * this.scale : this.scale; @@ -462,6 +500,14 @@ this.showPage(i); } }, + // The scroller is the box pages lay out in; before it exists, fall back to + // the viewer (never `this.$el`, which is the card wrapper, not the viewer). + viewerWidth() { + if (this.$refs.recycleList && this.$refs.recycleList.$el) { + return this.$refs.recycleList.$el.clientWidth; + } + return this.$refs.pdfViewer ? this.$refs.pdfViewer.$el.clientWidth : 0; + }, zoomIn() { this.setScale(Math.min(scaleIncrement * 20, this.scale + scaleIncrement)); }, @@ -673,18 +719,6 @@ }); }, }, - $trs: { - exitFullscreen: { - message: 'Exit fullscreen', - context: - "Learners can use the Esc key or the 'exit fullscreen' button to close the fullscreen view on the PDF Viewer.", - }, - enterFullscreen: { - message: 'Enter fullscreen', - context: - 'Learners can use the full screen button in the upper right corner to open a PDF in fullscreen view.', - }, - }, }; @@ -693,38 +727,30 @@ diff --git a/packages/kolibri-common/components/SafeHTML/SafeHtmlTable.vue b/packages/kolibri-common/components/SafeHTML/SafeHtmlTable.vue index 136eaf3e716..b2647cbfaeb 100644 --- a/packages/kolibri-common/components/SafeHTML/SafeHtmlTable.vue +++ b/packages/kolibri-common/components/SafeHTML/SafeHtmlTable.vue @@ -3,6 +3,7 @@
+ /* Negative margins cancel the extra width, so wide tables scroll full-bleed. */ .table-container { - margin: 1em 0; + width: calc(100% + 32px); + padding: 0 16px; + margin: 1em -16px; overflow-x: auto; } + .table-container:focus-visible { + outline: 3px solid var(--table-focus-outline) !important; + outline-offset: -3px !important; + } + + table.safe-html { + min-width: 640px; + margin: 16px auto; + font-size: 16px; + table-layout: fixed; + border-collapse: collapse; + } + + /* Slot content carries the parent's scope id, not this component's. */ + /deep/ caption.safe-html { + margin: 0 auto 12px; + font-weight: 600; + } + + /deep/ caption.safe-html.small-window { + text-align: start; + } + + /deep/ thead.safe-html { + font-weight: 600; + } + + /deep/ th.safe-html { + font-weight: 600; + text-align: left; + } + + /deep/ th.safe-html, + /deep/ td.safe-html { + min-width: 200px; + padding: 16px; + } + diff --git a/packages/kolibri-common/components/SafeHTML/__tests__/SafeHTML.spec.js b/packages/kolibri-common/components/SafeHTML/__tests__/SafeHTML.spec.js index 550d8d72025..27ad58e512e 100644 --- a/packages/kolibri-common/components/SafeHTML/__tests__/SafeHTML.spec.js +++ b/packages/kolibri-common/components/SafeHTML/__tests__/SafeHTML.spec.js @@ -377,6 +377,20 @@ describe('SafeHTML', () => { expect(container.querySelector('video')).not.toBeInTheDocument(); }); + it('wraps the ContentViewer in an embedded layout container', () => { + kolibri.canHandleElement = jest.fn().mockReturnValue(true); + + const { container } = render(SafeHTML, { + props: { + html: '', + }, + }); + + expect( + container.querySelector('.embedded-content-viewer > [data-testid="content-viewer"]'), + ).toBeInTheDocument(); + }); + it('renders original element when canHandleElement returns false', () => { kolibri.canHandleElement = jest.fn().mockReturnValue(false); diff --git a/packages/kolibri-common/components/SafeHTML/index.js b/packages/kolibri-common/components/SafeHTML/index.js index 22a6aac080c..7c90352f47d 100644 --- a/packages/kolibri-common/components/SafeHTML/index.js +++ b/packages/kolibri-common/components/SafeHTML/index.js @@ -162,6 +162,10 @@ export function createSafeHTML(customComponents = {}, { allowedOrigins } = {}) { }, mapChildren(node.childNodes), ); + // Wrap embedded ContentViewers in a layout container + if (component === 'ContentViewer') { + return h('div', { class: 'embedded-content-viewer' }, [childVNode]); + } return childVNode; } diff --git a/packages/kolibri-common/components/SafeHTML/style.scss b/packages/kolibri-common/components/SafeHTML/style.scss index 5c0b04ba676..e8bd4f42a06 100644 --- a/packages/kolibri-common/components/SafeHTML/style.scss +++ b/packages/kolibri-common/components/SafeHTML/style.scss @@ -87,49 +87,7 @@ b.safe-html { @include text-style(bold); } -.table-container { - width: calc(100% + 32px); - padding: 0 16px; - margin-left: -16px; - overflow-x: auto; -} - -.table-container:focus-visible { - outline: 3px solid rgb(51, 172, 245) !important; - outline-offset: -3px !important; -} - -table.safe-html { - min-width: 640px; - margin: 16px auto; - font-size: 16px; - table-layout: fixed; - border-collapse: collapse; -} - -caption.safe-html { - margin: 0 auto 12px; - font-weight: 600; -} - -caption.safe-html.small-window { - text-align: start; -} - -thead.safe-html { - font-weight: 600; -} - -th.safe-html { - font-weight: 600; - text-align: left; -} - -th.safe-html, -td.safe-html { - min-width: 200px; - padding: 16px; -} +// Table styles live in SafeHtmlTable, which renders them. .image-container { display: flex; @@ -172,6 +130,12 @@ img.safe-html { opacity: 1; } +.embedded-content-viewer { + max-width: 960px; + margin: 40px auto; + overflow: hidden; +} + math.safe-html { @include text-style(normal, 32px, 130%); diff --git a/packages/kolibri-common/components/ViewerToolbar.vue b/packages/kolibri-common/components/ViewerToolbar.vue new file mode 100644 index 00000000000..203b78a63fe --- /dev/null +++ b/packages/kolibri-common/components/ViewerToolbar.vue @@ -0,0 +1,109 @@ + + + + + + + diff --git a/packages/kolibri-common/components/__tests__/EmbeddedReadCard.spec.js b/packages/kolibri-common/components/__tests__/EmbeddedReadCard.spec.js new file mode 100644 index 00000000000..8e399d6d4c2 --- /dev/null +++ b/packages/kolibri-common/components/__tests__/EmbeddedReadCard.spec.js @@ -0,0 +1,76 @@ +import { fireEvent, render, screen } from '@testing-library/vue'; +import userEvent from '@testing-library/user-event'; +import { coreString } from 'kolibri/uiText/commonCoreStrings'; +import EmbeddedReadCard from '../EmbeddedReadCard'; + +function renderCard(props = {}) { + return render(EmbeddedReadCard, { + props: { + active: false, + ...props, + }, + slots: { + default: 'Viewer', + }, + }); +} + +describe('EmbeddedReadCard', () => { + it('renders its slot content when inactive', () => { + renderCard(); + screen.getByTestId('viewer'); + }); + + it('renders its slot content when active', () => { + renderCard({ active: true }); + screen.getByTestId('viewer'); + }); + + it('exposes no button affordance when inactive', () => { + renderCard(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('exposes the card as a labelled button when active', () => { + renderCard({ active: true }); + screen.getByRole('button', { name: coreString('read') }); + }); + + it('emits read when the active card is clicked', async () => { + const { emitted } = renderCard({ active: true }); + await userEvent.click(screen.getByRole('button', { name: coreString('read') })); + expect(emitted().read).toBeTruthy(); + }); + + it('does not emit read when the inactive card is clicked', async () => { + const { container, emitted } = renderCard(); + await fireEvent.click(container.firstChild); + expect(emitted().read).toBeUndefined(); + }); + + it('emits read on enter and space when active', async () => { + const { emitted } = renderCard({ active: true }); + const card = screen.getByRole('button', { name: coreString('read') }); + card.focus(); + await userEvent.keyboard('{enter}'); + await userEvent.keyboard(' '); + expect(emitted().read).toHaveLength(2); + }); + + it('takes the wrapped content out of the tab order and a11y tree when active', () => { + renderCard({ active: true }); + expect(screen.getByTestId('viewer').parentElement).toHaveAttribute('inert'); + }); + + it('leaves the wrapped content interactive when inactive', () => { + renderCard(); + expect(screen.getByTestId('viewer').parentElement).not.toHaveAttribute('inert'); + }); + + it('does not emit read on enter or space when inactive', async () => { + const { container, emitted } = renderCard(); + await fireEvent.keyDown(container.firstChild, { key: 'Enter' }); + await fireEvent.keyDown(container.firstChild, { key: ' ' }); + expect(emitted().read).toBeUndefined(); + }); +}); diff --git a/packages/kolibri-common/components/__tests__/ViewerToolbar.spec.js b/packages/kolibri-common/components/__tests__/ViewerToolbar.spec.js new file mode 100644 index 00000000000..24734a47b7c --- /dev/null +++ b/packages/kolibri-common/components/__tests__/ViewerToolbar.spec.js @@ -0,0 +1,62 @@ +import { render, screen, within } from '@testing-library/vue'; +import userEvent from '@testing-library/user-event'; +import ViewerToolbar, { viewerToolbarStrings } from '../ViewerToolbar'; + +const { enterFullscreen$, exitFullscreen$ } = viewerToolbarStrings; + +function renderToolbar(props = {}, slots = {}) { + return render(ViewerToolbar, { + props: { + isInFullscreen: false, + ...props, + }, + slots, + }); +} + +describe('ViewerToolbar', () => { + it('should mount', () => { + const { container } = renderToolbar(); + expect(container.firstChild).toBeTruthy(); + }); + + it('shows enter fullscreen button when not in fullscreen', () => { + renderToolbar({ isInFullscreen: false }); + screen.getByRole('button', { name: enterFullscreen$() }); + }); + + it('shows exit fullscreen button when in fullscreen', () => { + renderToolbar({ isInFullscreen: true }); + screen.getByRole('button', { name: exitFullscreen$() }); + }); + + it('emits toggleFullscreen when fullscreen button is clicked', async () => { + const { emitted } = renderToolbar(); + await userEvent.click(screen.getByRole('button', { name: enterFullscreen$() })); + expect(emitted().toggleFullscreen).toBeTruthy(); + }); + + it('renders left slot content', () => { + const { container } = renderToolbar( + {}, + { left: 'Left' }, + ); + within(container.querySelector('.toolbar-left')).getByTestId('left-content'); + }); + + it('renders center slot content', () => { + const { container } = renderToolbar( + {}, + { center: 'Center' }, + ); + within(container.querySelector('.toolbar-center')).getByTestId('center-content'); + }); + + it('renders right slot content', () => { + const { container } = renderToolbar( + {}, + { right: 'Right' }, + ); + within(container.querySelector('.toolbar-right')).getByTestId('right-content'); + }); +}); diff --git a/packages/kolibri/components/internal/filePresetStrings.js b/packages/kolibri/components/internal/filePresetStrings.js index f3daeedfc6a..5b83464c37e 100644 --- a/packages/kolibri/components/internal/filePresetStrings.js +++ b/packages/kolibri/components/internal/filePresetStrings.js @@ -24,6 +24,7 @@ const filePresetStrings = { slideshow_manifest: 'Slideshow ({fileSize})', slideshow_image: 'Slideshow image ({fileSize})', bloompub: 'Bloom Pub Document ({fileSize})', + kpub: 'Kolibri Article ({fileSize})', }; const filePresetTranslator = createTranslator('FilePresetStrings', filePresetStrings);