Fixed observable completion semantics

This commit is contained in:
squidfunk
2021-11-28 14:54:14 +01:00
parent abe475e151
commit c30c3d196e
55 changed files with 493 additions and 1347 deletions

View File

@@ -36,6 +36,7 @@ import {
import {
ElementOffset,
getElement,
watchElementContentOffset,
watchElementFocus,
watchElementOffset
@@ -122,7 +123,7 @@ export function mountAnnotation(
})
/* Blur open annotation on click (= close) */
const index = el.lastElementChild!
const index = getElement(":scope > :last-child")
const blur$ = fromEvent(index, "mousedown", { once: true })
push$
.pipe(

View File

@@ -120,36 +120,36 @@ export function mountAnnotationList(
/* Create and return component */
return defer(() => {
const push$ = new Subject<Annotation>()
const done$ = new Subject<void>()
/* Handle print mode - see https://bit.ly/3rgPdpt */
print$
.pipe(
startWith(false),
takeUntil(push$.pipe(takeLast(1)))
takeUntil(done$.pipe(takeLast(1)))
)
.subscribe(active => {
el.hidden = !active
/* Move annotation contents back into list */
for (const [id, annotation] of annotations) {
const tooltip = getElement(".md-typeset", annotation)
const inner = getElement(".md-typeset", annotation)
const child = getElement(`li:nth-child(${id})`, el)
if (!active)
swap(child, tooltip)
swap(child, inner)
else
swap(tooltip, child)
swap(inner, child)
}
})
/* Create and return component */
return merge(
...[...annotations].map(([, annotation]) => (
return merge(...[...annotations]
.map(([, annotation]) => (
mountAnnotation(annotation, container)
))
)
.pipe(
finalize(() => push$.complete()),
finalize(() => done$.complete()),
share()
)
})

View File

@@ -23,6 +23,7 @@
import {
Observable,
Subject,
defer,
filter,
finalize,
map,
@@ -41,7 +42,7 @@ import { Component } from "../../_"
* Details
*/
export interface Details {
action: "open" | "close" /* Action */
action: "open" | "close" /* Details state */
scroll?: boolean /* Scroll into view */
}
@@ -117,21 +118,23 @@ export function watchDetails(
export function mountDetails(
el: HTMLDetailsElement, options: MountOptions
): Observable<Component<Details>> {
const internal$ = new Subject<Details>()
internal$.subscribe(({ action, scroll }) => {
if (action === "open")
el.setAttribute("open", "")
else
el.removeAttribute("open")
if (scroll)
el.scrollIntoView()
})
return defer(() => {
const push$ = new Subject<Details>()
push$.subscribe(({ action, scroll }) => {
if (action === "open")
el.setAttribute("open", "")
else
el.removeAttribute("open")
if (scroll)
el.scrollIntoView()
})
/* Create and return component */
return watchDetails(el, options)
.pipe(
tap(state => internal$.next(state)),
finalize(() => internal$.complete()),
map(state => ({ ref: el, ...state }))
)
/* Create and return component */
return watchDetails(el, options)
.pipe(
tap(state => push$.next(state)),
finalize(() => push$.complete()),
map(state => ({ ref: el, ...state }))
)
})
}

View File

@@ -23,6 +23,7 @@
import {
Observable,
Subject,
defer,
finalize,
fromEvent,
map,
@@ -33,6 +34,7 @@ import {
import {
getElement,
getElementOffset,
getElements
} from "~/browser"
@@ -64,7 +66,11 @@ export function watchContentTabs(
el: HTMLElement
): Observable<ContentTabs> {
return merge(...getElements(":scope > input", el)
.map(input => fromEvent(input, "change").pipe(mapTo(input.id)))
.map(input => fromEvent(input, "change")
.pipe(
mapTo(input.id)
)
)
)
.pipe(
map(id => ({
@@ -76,6 +82,11 @@ export function watchContentTabs(
/**
* Mount content tabs
*
* This function scrolls the active tab into view. While this functionality is
* provided by browsers as part of `scrollInfoView`, browsers will always also
* scroll the vertical axis, which we do not want. Thus, we decided to provide
* this functionality ourselves.
*
* @param el - Content tabs element
*
* @returns Content tabs component observable
@@ -83,25 +94,23 @@ export function watchContentTabs(
export function mountContentTabs(
el: HTMLElement
): Observable<Component<ContentTabs>> {
const internal$ = new Subject<ContentTabs>()
internal$.subscribe(({ active }) => {
// TODO: Hack, scrollIntoView is too buggy
const container = active.parentElement!
if (
active.offsetLeft + active.offsetWidth > container.scrollLeft + container.offsetWidth ||
active.offsetLeft < container.scrollLeft
)
const container = getElement(".tabbed-labels", el)
return defer(() => {
const push$ = new Subject<ContentTabs>()
push$.subscribe(({ active }) => {
const { x } = getElementOffset(active)
container.scrollTo({
behavior: "smooth",
left: active.offsetLeft
left: x
})
})
})
/* Create and return component */
return watchContentTabs(el)
.pipe(
tap(state => internal$.next(state)),
finalize(() => internal$.complete()),
map(state => ({ ref: el, ...state }))
)
/* Create and return component */
return watchContentTabs(el)
.pipe(
tap(state => push$.next(state)),
finalize(() => push$.complete()),
map(state => ({ ref: el, ...state }))
)
})
}

View File

@@ -23,22 +23,17 @@
import {
Observable,
Subject,
animationFrameScheduler,
defer,
delay,
finalize,
map,
merge,
observeOn,
of,
switchMap,
tap
} from "rxjs"
import {
resetDialogState,
setDialogMessage,
setDialogState
} from "~/actions"
import { getElement } from "~/browser"
import { Component } from "../_"
@@ -103,7 +98,7 @@ export function watchDialog(
/**
* Mount dialog
*
* This function reveals the dialog in the right cornerwhen a new alert is
* This function reveals the dialog in the right corner when a new alert is
* emitted through the subject that is passed as part of the options.
*
* @param el - Dialog element
@@ -114,24 +109,23 @@ export function watchDialog(
export function mountDialog(
el: HTMLElement, options: MountOptions
): Observable<Component<Dialog>> {
const internal$ = new Subject<Dialog>()
internal$
.pipe(
observeOn(animationFrameScheduler)
)
.subscribe(({ message, open }) => {
setDialogMessage(el, message)
if (open)
setDialogState(el, "open")
else
resetDialogState(el)
})
const inner = getElement(".md-typeset", el)
return defer(() => {
const push$ = new Subject<Dialog>()
push$.subscribe(({ message, open }) => {
inner.textContent = message
if (open)
el.setAttribute("data-md-state", "open")
else
el.removeAttribute("data-md-state")
})
/* Create and return component */
return watchDialog(el, options)
.pipe(
tap(state => internal$.next(state)),
finalize(() => internal$.complete()),
map(state => ({ ref: el, ...state }))
)
/* Create and return component */
return watchDialog(el, options)
.pipe(
tap(state => push$.next(state)),
finalize(() => push$.complete()),
map(state => ({ ref: el, ...state }))
)
})
}

View File

@@ -23,7 +23,6 @@
import {
Observable,
Subject,
animationFrameScheduler,
bufferCount,
combineLatest,
combineLatestWith,
@@ -32,18 +31,15 @@ import {
distinctUntilKeyChanged,
filter,
map,
observeOn,
of,
shareReplay,
startWith,
switchMap
switchMap,
takeLast,
takeUntil
} from "rxjs"
import { feature } from "~/_"
import {
resetHeaderState,
setHeaderState
} from "~/actions"
import {
Viewport,
watchElementSize,
@@ -184,24 +180,28 @@ export function watchHeader(
export function mountHeader(
el: HTMLElement, { header$, main$ }: MountOptions
): Observable<Component<Header>> {
const internal$ = new Subject<Main>()
internal$
.pipe(
distinctUntilKeyChanged("active"),
combineLatestWith(header$),
observeOn(animationFrameScheduler)
)
.subscribe(([{ active }, { hidden }]) => {
if (active)
setHeaderState(el, hidden ? "hidden" : "shadow")
else
resetHeaderState(el)
})
return defer(() => {
const push$ = new Subject<Main>()
push$
.pipe(
distinctUntilKeyChanged("active"),
combineLatestWith(header$)
)
.subscribe(([{ active }, { hidden }]) => {
if (active)
el.setAttribute("data-md-state", hidden ? "hidden" : "shadow")
else
el.removeAttribute("data-md-state")
})
/* Connect to long-living subject and return component */
main$.subscribe(main => internal$.next(main))
return header$
.pipe(
map(state => ({ ref: el, ...state }))
)
/* Link to main area */
main$.subscribe(push$)
/* Create and return component */
return header$
.pipe(
takeUntil(push$.pipe(takeLast(1))),
map(state => ({ ref: el, ...state }))
)
})
}

View File

@@ -21,21 +21,16 @@
*/
import {
NEVER,
EMPTY,
Observable,
Subject,
animationFrameScheduler,
defer,
distinctUntilKeyChanged,
finalize,
map,
observeOn,
tap
} from "rxjs"
import {
resetHeaderTitleState,
setHeaderTitleState
} from "~/actions"
import {
Viewport,
getElementSize,
@@ -118,28 +113,26 @@ export function watchHeaderTitle(
export function mountHeaderTitle(
el: HTMLElement, options: MountOptions
): Observable<Component<HeaderTitle>> {
const internal$ = new Subject<HeaderTitle>()
internal$
.pipe(
observeOn(animationFrameScheduler)
)
.subscribe(({ active }) => {
if (active)
setHeaderTitleState(el, "active")
else
resetHeaderTitleState(el)
})
return defer(() => {
const push$ = new Subject<HeaderTitle>()
push$.subscribe(({ active }) => {
if (active)
el.setAttribute("data-md-state", "active")
else
el.removeAttribute("data-md-state")
})
/* Obtain headline, if any */
const headline = getOptionalElement<HTMLHeadingElement>("article h1")
if (typeof headline === "undefined")
return NEVER
/* Obtain headline, if any */
const heading = getOptionalElement<HTMLHeadingElement>("article h1")
if (typeof heading === "undefined")
return EMPTY
/* Create and return component */
return watchHeaderTitle(headline, options)
.pipe(
tap(state => internal$.next(state)),
finalize(() => internal$.complete()),
map(state => ({ ref: el, ...state }))
)
/* Create and return component */
return watchHeaderTitle(heading, options)
.pipe(
tap(state => push$.next(state)),
finalize(() => push$.complete()),
map(state => ({ ref: el, ...state }))
)
})
}

View File

@@ -23,6 +23,7 @@
import {
Observable,
Subject,
defer,
finalize,
fromEvent,
map,
@@ -74,9 +75,9 @@ export function watchPalette(
inputs: HTMLInputElement[]
): Observable<Palette> {
const current = __md_get<Palette>("__palette") || {
index: inputs.findIndex(input => (
matchMedia(input.getAttribute("data-md-color-media")!).matches
))
index: inputs.findIndex(input => matchMedia(
input.getAttribute("data-md-color-media")!
).matches)
}
/* Emit changes in color palette */
@@ -99,11 +100,6 @@ export function watchPalette(
shareReplay(1)
)
/* Persist preference in local storage */
palette$.subscribe(palette => {
__md_set("__palette", palette)
})
/* Return palette */
return palette$
}
@@ -118,28 +114,33 @@ export function watchPalette(
export function mountPalette(
el: HTMLElement
): Observable<Component<Palette>> {
const internal$ = new Subject<Palette>()
return defer(() => {
const push$ = new Subject<Palette>()
push$.subscribe(palette => {
/* Set color palette */
internal$.subscribe(palette => {
for (const [key, value] of Object.entries(palette.color))
if (typeof value === "string")
document.body.setAttribute(`data-md-color-${key}`, value)
/* Set color palette */
for (const [key, value] of Object.entries(palette.color))
if (typeof value === "string")
document.body.setAttribute(`data-md-color-${key}`, value)
/* Toggle visibility */
for (let index = 0; index < inputs.length; index++) {
const label = inputs[index].nextElementSibling
if (label instanceof HTMLElement)
label.hidden = palette.index !== index
}
/* Toggle visibility */
for (let index = 0; index < inputs.length; index++) {
const label = inputs[index].nextElementSibling
if (label instanceof HTMLElement)
label.hidden = palette.index !== index
}
/* Persist preference in local storage */
__md_set("__palette", palette)
})
/* Create and return component */
const inputs = getElements<HTMLInputElement>("input", el)
return watchPalette(inputs)
.pipe(
tap(state => push$.next(state)),
finalize(() => push$.complete()),
map(state => ({ ref: el, ...state }))
)
})
/* Create and return component */
const inputs = getElements<HTMLInputElement>("input", el)
return watchPalette(inputs)
.pipe(
tap(state => internal$.next(state)),
finalize(() => internal$.complete()),
map(state => ({ ref: el, ...state }))
)
}

View File

@@ -36,8 +36,6 @@ import {
Keyboard,
getActiveElement,
getElements,
setElementFocus,
setElementSelection,
setToggle
} from "~/browser"
import {
@@ -166,14 +164,14 @@ export function mountSearch(
case "Escape":
case "Tab":
setToggle("search", false)
setElementFocus(query, false)
query.blur()
break
/* Vertical arrows: select previous or next search result */
case "ArrowUp":
case "ArrowDown":
if (typeof active === "undefined") {
setElementFocus(query)
query.focus()
} else {
const els = [query, ...getElements(
":not(details) > [href], summary, details[open] [href]",
@@ -184,7 +182,7 @@ export function mountSearch(
key.type === "ArrowUp" ? -1 : +1
)
) % els.length)
setElementFocus(els[i])
els[i].focus()
}
/* Prevent scrolling of page */
@@ -194,7 +192,7 @@ export function mountSearch(
/* All other keys: hand to search query */
default:
if (query !== getActiveElement())
setElementFocus(query)
query.focus()
}
})
@@ -210,8 +208,10 @@ export function mountSearch(
case "f":
case "s":
case "/":
setElementFocus(query)
setElementSelection(query)
query.focus()
query.select()
/* Prevent scrolling of page */
key.claim()
break
}

View File

@@ -40,13 +40,9 @@ import {
tap
} from "rxjs"
import {
resetSearchQueryPlaceholder,
setSearchQueryPlaceholder
} from "~/actions"
import { translation } from "~/_"
import {
getLocation,
setElementFocus,
setToggle,
watchElementFocus
} from "~/browser"
@@ -143,10 +139,10 @@ export function watchSearchQuery(
export function mountSearchQuery(
el: HTMLInputElement, { tx$, rx$ }: SearchWorker
): Observable<Component<SearchQuery, HTMLInputElement>> {
const internal$ = new Subject<SearchQuery>()
const push$ = new Subject<SearchQuery>()
/* Handle value changes */
internal$
push$
.pipe(
distinctUntilKeyChanged("value"),
map(({ value }): SearchQueryMessage => ({
@@ -157,31 +153,31 @@ export function mountSearchQuery(
.subscribe(tx$.next.bind(tx$))
/* Handle focus changes */
internal$
push$
.pipe(
distinctUntilKeyChanged("focus")
)
.subscribe(({ focus }) => {
if (focus) {
setToggle("search", focus)
setSearchQueryPlaceholder(el, "")
el.placeholder = ""
} else {
resetSearchQueryPlaceholder(el)
el.placeholder = translation("search.placeholder")
}
})
/* Handle reset */
fromEvent(el.form!, "reset")
.pipe(
takeUntil(internal$.pipe(takeLast(1)))
takeUntil(push$.pipe(takeLast(1)))
)
.subscribe(() => setElementFocus(el))
.subscribe(() => el.focus())
/* Create and return component */
return watchSearchQuery(el, { tx$, rx$ })
.pipe(
tap(state => internal$.next(state)),
finalize(() => internal$.complete()),
tap(state => push$.next(state)),
finalize(() => push$.complete()),
map(state => ({ ref: el, ...state }))
)
}

View File

@@ -39,12 +39,7 @@ import {
zipWith
} from "rxjs"
import {
addToSearchResultList,
resetSearchResultList,
resetSearchResultMeta,
setSearchResultMeta
} from "~/actions"
import { translation } from "~/_"
import {
getElement,
watchElementBoundary
@@ -56,6 +51,7 @@ import {
isSearchResultMessage
} from "~/integrations"
import { renderSearchResultItem } from "~/templates"
import { round } from "~/utilities"
import { Component } from "../../_"
import { SearchQuery } from "../query"
@@ -90,7 +86,7 @@ interface MountOptions {
export function mountSearchResult(
el: HTMLElement, { rx$ }: SearchWorker, { query$ }: MountOptions
): Observable<Component<SearchResult>> {
const internal$ = new Subject<SearchResult>()
const push$ = new Subject<SearchResult>()
const boundary$ = watchElementBoundary(el.parentElement!)
.pipe(
filter(Boolean)
@@ -108,24 +104,43 @@ export function mountSearchResult(
)
/* Update search result metadata */
internal$
push$
.pipe(
observeOn(animationFrameScheduler),
withLatestFrom(query$),
skipUntil(ready$)
)
.subscribe(([{ items }, { value }]) => {
if (value)
setSearchResultMeta(meta, items.length)
else
resetSearchResultMeta(meta)
if (value) {
switch (items.length) {
/* No results */
case 0:
meta.textContent = translation("search.result.none")
break
/* One result */
case 1:
meta.textContent = translation("search.result.one")
break
/* Multiple result */
default:
meta.textContent = translation(
"search.result.other",
round(items.length)
)
}
} else {
meta.textContent = translation("search.result.placeholder")
}
})
/* Update search result list */
internal$
push$
.pipe(
observeOn(animationFrameScheduler),
tap(() => resetSearchResultList(list)),
tap(() => list.innerHTML = ""),
switchMap(({ items }) => merge(
of(...items.slice(0, 10)),
of(...items.slice(10))
@@ -136,9 +151,9 @@ export function mountSearchResult(
)
))
)
.subscribe(result => {
addToSearchResultList(list, renderSearchResultItem(result))
})
.subscribe(result => list.appendChild(
renderSearchResultItem(result)
))
/* Filter search result message */
const result$ = rx$
@@ -150,8 +165,8 @@ export function mountSearchResult(
/* Create and return component */
return result$
.pipe(
tap(state => internal$.next(state)),
finalize(() => internal$.complete()),
tap(state => push$.next(state)),
finalize(() => push$.complete()),
map(state => ({ ref: el, ...state }))
)
}

View File

@@ -101,8 +101,8 @@ export function watchSearchShare(
export function mountSearchShare(
el: HTMLAnchorElement, options: MountOptions
): Observable<Component<SearchShare>> {
const internal$ = new Subject<SearchShare>()
internal$.subscribe(({ url }) => {
const push$ = new Subject<SearchShare>()
push$.subscribe(({ url }) => {
el.setAttribute("data-clipboard-text", el.href)
el.href = `${url}`
})
@@ -114,8 +114,8 @@ export function mountSearchShare(
/* Create and return component */
return watchSearchShare(el, options)
.pipe(
tap(state => internal$.next(state)),
finalize(() => internal$.complete()),
tap(state => push$.next(state)),
finalize(() => push$.complete()),
map(state => ({ ref: el, ...state }))
)
}

View File

@@ -83,7 +83,7 @@ interface MountOptions {
export function mountSearchSuggest(
el: HTMLElement, { rx$ }: SearchWorker, { keyboard$ }: MountOptions
): Observable<Component<SearchSuggest>> {
const internal$ = new Subject<SearchResult>()
const push$ = new Subject<SearchResult>()
/* Retrieve query component and track all changes */
const query = getComponentElement("search-query")
@@ -98,7 +98,7 @@ export function mountSearchSuggest(
)
/* Update search suggestions */
internal$
push$
.pipe(
combineLatestWith(query$),
map(([{ suggestions }, value]) => {
@@ -147,8 +147,8 @@ export function mountSearchSuggest(
/* Create and return component */
return result$
.pipe(
tap(state => internal$.next(state)),
finalize(() => internal$.complete()),
tap(state => push$.next(state)),
finalize(() => push$.complete()),
map(() => ({ ref: el }))
)
}

View File

@@ -24,22 +24,21 @@ import {
Observable,
Subject,
animationFrameScheduler,
auditTime,
combineLatest,
defer,
distinctUntilChanged,
finalize,
map,
observeOn,
tap,
withLatestFrom
} from "rxjs"
import {
resetSidebarHeight,
resetSidebarOffset,
setSidebarHeight,
setSidebarOffset
} from "~/actions"
import { Viewport } from "~/browser"
Viewport,
getElement,
getElementOffset
} from "~/browser"
import { Component } from "../_"
import { Header } from "../header"
@@ -125,6 +124,19 @@ export function watchSidebar(
/**
* Mount sidebar
*
* This function doesn't set the height of the actual sidebar, but of its first
* child the `.md-sidebar__scrollwrap` element in order to mitigiate jittery
* sidebars when the footer is scrolled into view. At some point we switched
* from `absolute` / `fixed` positioning to `sticky` positioning, significantly
* reducing jitter in some browsers (respectively Firefox and Safari) when
* scrolling from the top. However, top-aligned sticky positioning means that
* the sidebar snaps to the bottom when the end of the container is reached.
* This is what leads to the mentioned jitter, as the sidebar's height may be
* updated too slowly.
*
* This behaviour can be mitigiated by setting the height of the sidebar to `0`
* while preserving the padding, and the height on its first element.
*
* @param el - Sidebar element
* @param options - Options
*
@@ -133,32 +145,36 @@ export function watchSidebar(
export function mountSidebar(
el: HTMLElement, { header$, ...options }: MountOptions
): Observable<Component<Sidebar>> {
const internal$ = new Subject<Sidebar>()
internal$
.pipe(
observeOn(animationFrameScheduler),
withLatestFrom(header$)
)
.subscribe({
const inner = getElement(".md-sidebar__scrollwrap", el)
const { y } = getElementOffset(inner)
return defer(() => {
const push$ = new Subject<Sidebar>()
push$
.pipe(
auditTime(0, animationFrameScheduler),
withLatestFrom(header$)
)
.subscribe({
/* Update height and offset */
next([{ height }, { height: offset }]) {
setSidebarHeight(el, height)
setSidebarOffset(el, offset)
},
/* Handle emission */
next([{ height }, { height: offset }]) {
inner.style.height = `${height - 2 * y}px`
el.style.top = `${offset}px`
},
/* Reset on complete */
complete() {
resetSidebarOffset(el)
resetSidebarHeight(el)
}
})
/* Handle complete */
complete() {
inner.style.height = ""
el.style.top = ""
}
})
/* Create and return component */
return watchSidebar(el, options)
.pipe(
tap(state => internal$.next(state)),
finalize(() => internal$.complete()),
map(state => ({ ref: el, ...state }))
)
/* Create and return component */
return watchSidebar(el, options)
.pipe(
tap(state => push$.next(state)),
finalize(() => push$.complete()),
map(state => ({ ref: el, ...state }))
)
})
}

View File

@@ -21,7 +21,7 @@
*/
import {
NEVER,
EMPTY,
Observable,
Subject,
catchError,
@@ -34,10 +34,7 @@ import {
tap
} from "rxjs"
import {
setSourceFacts,
setSourceState
} from "~/actions"
import { getElement } from "~/browser"
import { renderSourceFacts } from "~/templates"
import { Component } from "../../_"
@@ -94,7 +91,7 @@ export function watchSource(
)
})
.pipe(
catchError(() => NEVER),
catchError(() => EMPTY),
filter(facts => Object.keys(facts).length > 0),
map(facts => ({ facts })),
shareReplay(1)
@@ -111,17 +108,20 @@ export function watchSource(
export function mountSource(
el: HTMLAnchorElement
): Observable<Component<Source>> {
const internal$ = new Subject<Source>()
internal$.subscribe(({ facts }) => {
setSourceFacts(el, renderSourceFacts(facts))
setSourceState(el, "done")
})
const inner = getElement(":scope > :last-child", el)
return defer(() => {
const push$ = new Subject<Source>()
push$.subscribe(({ facts }) => {
inner.appendChild(renderSourceFacts(facts))
inner.setAttribute("data-md-state", "done")
})
/* Create and return component */
return watchSource(el)
.pipe(
tap(state => internal$.next(state)),
finalize(() => internal$.complete()),
map(state => ({ ref: el, ...state }))
)
/* Create and return component */
return watchSource(el)
.pipe(
tap(state => push$.next(state)),
finalize(() => push$.complete()),
map(state => ({ ref: el, ...state }))
)
})
}

View File

@@ -20,7 +20,7 @@
* IN THE SOFTWARE.
*/
import { NEVER, Observable } from "rxjs"
import { EMPTY, Observable } from "rxjs"
import { fetchSourceFactsFromGitHub } from "../github"
import { fetchSourceFactsFromGitLab } from "../gitlab"
@@ -83,6 +83,6 @@ export function fetchSourceFacts(
/* Everything else */
default:
return NEVER
return EMPTY
}
}

View File

@@ -50,7 +50,7 @@ interface Release {
/**
* Fetch GitHub repository facts
*
* @param user - GitHub user
* @param user - GitHub user or organization
* @param repo - GitHub repository
*
* @returns Repository facts observable

View File

@@ -23,21 +23,16 @@
import {
Observable,
Subject,
animationFrameScheduler,
defer,
distinctUntilKeyChanged,
finalize,
map,
observeOn,
of,
switchMap,
tap
} from "rxjs"
import { feature } from "~/_"
import {
resetTabsState,
setTabsState
} from "~/actions"
import {
Viewport,
watchElementSize,
@@ -119,36 +114,34 @@ export function watchTabs(
export function mountTabs(
el: HTMLElement, options: MountOptions
): Observable<Component<Tabs>> {
const internal$ = new Subject<Tabs>()
internal$
.pipe(
observeOn(animationFrameScheduler)
)
.subscribe({
/* Update state */
next({ hidden }) {
if (hidden)
setTabsState(el, "hidden")
else
resetTabsState(el)
},
/* Reset on complete */
complete() {
resetTabsState(el)
}
})
/* Create and return component */
return (
feature("navigation.tabs.sticky")
? of({ hidden: false })
: watchTabs(el, options)
)
.pipe(
tap(state => internal$.next(state)),
finalize(() => internal$.complete()),
map(state => ({ ref: el, ...state }))
return defer(() => {
const push$ = new Subject<Tabs>()
push$.subscribe({
/* Handle emission */
next({ hidden }) {
if (hidden)
el.setAttribute("data-md-state", "hidden")
else
el.removeAttribute("data-md-state")
},
/* Handle complete */
complete() {
el.removeAttribute("data-md-state")
}
})
/* Create and return component */
return (
feature("navigation.tabs.sticky")
? of({ hidden: false })
: watchTabs(el, options)
)
.pipe(
tap(state => push$.next(state)),
finalize(() => push$.complete()),
map(state => ({ ref: el, ...state }))
)
})
}

View File

@@ -23,7 +23,6 @@
import {
Observable,
Subject,
animationFrameScheduler,
bufferCount,
combineLatest,
defer,
@@ -31,7 +30,6 @@ import {
distinctUntilKeyChanged,
finalize,
map,
observeOn,
of,
scan,
startWith,
@@ -40,12 +38,6 @@ import {
} from "rxjs"
import { feature } from "~/_"
import {
resetAnchorActive,
resetAnchorState,
setAnchorActive,
setAnchorState
} from "~/actions"
import {
Viewport,
getElements,
@@ -253,52 +245,55 @@ export function watchTableOfContents(
export function mountTableOfContents(
el: HTMLElement, options: MountOptions
): Observable<Component<TableOfContents>> {
const internal$ = new Subject<TableOfContents>()
internal$
.pipe(
observeOn(animationFrameScheduler),
)
.subscribe(({ prev, next }) => {
return defer(() => {
const push$ = new Subject<TableOfContents>()
push$.subscribe(({ prev, next }) => {
/* Look forward */
for (const [anchor] of next) {
resetAnchorActive(anchor)
resetAnchorState(anchor)
}
/* Look forward */
for (const [anchor] of next) {
anchor.removeAttribute("data-md-state")
anchor.classList.remove(
"md-nav__link--active"
)
}
/* Look backward */
for (const [index, [anchor]] of prev.entries()) {
setAnchorActive(anchor, index === prev.length - 1)
setAnchorState(anchor, "blur")
}
/* Look backward */
for (const [index, [anchor]] of prev.entries()) {
anchor.setAttribute("data-md-state", "blur")
anchor.classList.toggle(
"md-nav__link--active",
index === prev.length - 1
)
}
/* Set up anchor tracking, if enabled */
if (feature("navigation.tracking")) {
const url = getLocation()
/* Set up anchor tracking, if enabled */
if (feature("navigation.tracking")) {
const url = getLocation()
/* Set hash fragment to active anchor */
const anchor = prev[prev.length - 1]
if (anchor && anchor.length) {
const [active] = anchor
const { hash } = new URL(active.href)
if (url.hash !== hash) {
url.hash = hash
history.replaceState({}, "", `${url}`)
}
/* Reset anchor when at the top */
} else {
url.hash = ""
/* Set hash fragment to active anchor */
const anchor = prev[prev.length - 1]
if (anchor && anchor.length) {
const [active] = anchor
const { hash } = new URL(active.href)
if (url.hash !== hash) {
url.hash = hash
history.replaceState({}, "", `${url}`)
}
}
})
/* Create and return component */
return watchTableOfContents(el, options)
.pipe(
tap(state => internal$.next(state)),
finalize(() => internal$.complete()),
map(state => ({ ref: el, ...state }))
)
/* Reset anchor when at the top */
} else {
url.hash = ""
history.replaceState({}, "", `${url}`)
}
}
})
/* Create and return component */
return watchTableOfContents(el, options)
.pipe(
tap(state => push$.next(state)),
finalize(() => push$.complete()),
map(state => ({ ref: el, ...state }))
)
})
}

View File

@@ -23,30 +23,17 @@
import {
Observable,
Subject,
animationFrameScheduler,
bufferCount,
combineLatest,
distinctUntilChanged,
distinctUntilKeyChanged,
finalize,
map,
observeOn,
tap,
withLatestFrom
} from "rxjs"
import {
resetBackToTopOffset,
resetBackToTopState,
resetFocusable,
setBackToTopOffset,
setBackToTopState,
setFocusable
} from "~/actions"
import {
Viewport,
setElementFocus
} from "~/browser"
import { Viewport } from "~/browser"
import { Component } from "../_"
import { Header } from "../header"
@@ -141,10 +128,9 @@ export function watchBackToTop(
export function mountBackToTop(
el: HTMLElement, { viewport$, header$, main$ }: MountOptions
): Observable<Component<BackToTop>> {
const internal$ = new Subject<BackToTop>()
internal$
const push$ = new Subject<BackToTop>()
push$
.pipe(
observeOn(animationFrameScheduler),
withLatestFrom(header$
.pipe(
distinctUntilKeyChanged("height")
@@ -153,32 +139,33 @@ export function mountBackToTop(
)
.subscribe({
/* Update state */
/* Handle emission */
next([{ hidden }, { height }]) {
setBackToTopOffset(el, height + 16)
el.style.top = `${height + 16}px`
if (hidden) {
setBackToTopState(el, "hidden")
setElementFocus(el, false)
setFocusable(el, -1)
el.setAttribute("data-md-state", "hidden")
el.setAttribute("tabindex", "-1")
el.blur()
} else {
resetBackToTopState(el)
resetFocusable(el)
el.style.top = ""
el.removeAttribute("data-md-state")
el.removeAttribute("tabindex")
}
},
/* Reset on complete */
/* Handle complete */
complete() {
resetBackToTopOffset(el)
resetBackToTopState(el)
resetFocusable(el)
el.style.top = ""
el.removeAttribute("data-md-state")
el.removeAttribute("tabindex")
}
})
/* Create and return component */
return watchBackToTop(el, { viewport$, header$, main$ })
.pipe(
tap(state => internal$.next(state)),
finalize(() => internal$.complete()),
tap(state => push$.next(state)),
finalize(() => push$.complete()),
map(state => ({ ref: el, ...state }))
)
}