mirror of https://github.com/requarks/wiki
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
463 lines
17 KiB
463 lines
17 KiB
import { LitElement, html, css } from 'lit'
|
|
import { unsafeSVG } from 'lit/directives/unsafe-svg.js'
|
|
import { fetchIcon } from '../shared/icons.js'
|
|
import { DarkMode } from '../shared/theme.js'
|
|
|
|
/**
|
|
* Asked of a block that might be hiding the element the event was dispatched on.
|
|
*
|
|
* The app sends it at a heading before scrolling to it — see `helpers/anchors.js` — so that a heading
|
|
* inside a panel that is not showing is opened rather than scrolled at. Matched by name only: a block
|
|
* answers it or ignores it, and neither side has to know about the other.
|
|
*/
|
|
const REVEAL_EVENT = 'block-reveal'
|
|
|
|
/**
|
|
* The room a heading in an article is given above it when it is scrolled to, in pixels.
|
|
*
|
|
* The article scrolls in its own column inside a fixed shell, so a heading brought into view stops
|
|
* clear of the column's top edge rather than flush against it. Written out because a block cannot read
|
|
* the app's stylesheet: this is the `scroll-margin-top: 1.25rem` that `_page-contents.scss` puts on
|
|
* every heading, and the two have to be kept in step.
|
|
*/
|
|
const HEADING_CLEARANCE = 20
|
|
|
|
/**
|
|
* Block Tabs
|
|
*/
|
|
export class BlockTabsElement extends LitElement {
|
|
/**
|
|
* Metadata for the admin area and the editor's block picker. Collected at build time into
|
|
* `compiled/blocks.manifest.json`, which the server reads to register the block. Values must be
|
|
* plain literals. See `props` in `block-index` for what the picker does with that list.
|
|
*
|
|
* `template` is the body the picker writes into the page along with the opening line. A block that
|
|
* has one is fenced with `:::`, so that the `::block-tab` children inside it are read as blocks of
|
|
* their own rather than as the end of this one.
|
|
*/
|
|
static definition = {
|
|
block: 'tabs',
|
|
name: 'Tabs',
|
|
description: 'Groups content into tabbed panels.',
|
|
icon: 'resume-template',
|
|
template: `::block-tab{label="First tab"}
|
|
Content of the first tab.
|
|
::
|
|
|
|
::block-tab{label="Second tab"}
|
|
Content of the second tab.
|
|
::`
|
|
}
|
|
|
|
static get styles() {
|
|
return css`
|
|
:host {
|
|
display: block;
|
|
}
|
|
|
|
/*
|
|
One raised card: the border and the rounded corners belong to the outer box, and clipping to
|
|
it is what rounds the strip's top corners and the panel's bottom ones without either of them
|
|
having to know where it sits.
|
|
|
|
-> It also carries the gap below the block. On this element rather than :host: see block-index.
|
|
*/
|
|
.tabs {
|
|
margin-bottom: 16px;
|
|
border: 1px solid var(--tabs-border);
|
|
border-radius: 6px;
|
|
overflow: hidden;
|
|
box-shadow:
|
|
0 1px 3px rgb(0 0 0 / 0.1),
|
|
0 1px 2px rgb(0 0 0 / 0.06);
|
|
}
|
|
:host([dark]) .tabs {
|
|
box-shadow:
|
|
0 1px 3px rgb(0 0 0 / 0.5),
|
|
0 1px 2px rgb(0 0 0 / 0.35);
|
|
}
|
|
|
|
/*
|
|
The whole row is the unselected surface, tabs and the space past the last one alike, so the
|
|
gradient is drawn once here and the tabs sit on it rather than repeating it. The line along
|
|
the bottom is the panel's top edge; the tabs are pulled down onto it so the active one can
|
|
paint over its own stretch and open the seam into the panel.
|
|
*/
|
|
.strip {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
margin: 0;
|
|
padding: 0;
|
|
border-bottom: 1px solid var(--tabs-border);
|
|
background-image: var(--tabs-strip-bg);
|
|
}
|
|
|
|
.tab {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
margin-bottom: -1px;
|
|
padding: 10px 18px;
|
|
border: 0;
|
|
border-right: 1px solid var(--tabs-border);
|
|
border-bottom: 1px solid transparent;
|
|
border-top: 3px solid transparent;
|
|
background-color: transparent;
|
|
color: var(--tabs-inactive-fg);
|
|
font: inherit;
|
|
font-weight: 500;
|
|
line-height: 1.4;
|
|
cursor: pointer;
|
|
transition:
|
|
background-color 0.15s ease,
|
|
color 0.15s ease;
|
|
}
|
|
.tab:hover:not(.is-active) {
|
|
background-color: rgb(255 255 255 / 0.5);
|
|
color: var(--tabs-active-label);
|
|
}
|
|
:host([dark]) .tab:hover:not(.is-active) {
|
|
background-color: rgb(255 255 255 / 0.05);
|
|
}
|
|
.tab:focus-visible {
|
|
outline: 2px solid var(--tabs-active-fg);
|
|
outline-offset: -3px;
|
|
}
|
|
|
|
/* -> Flat panel colour, which is what lifts it out of the row's gradient */
|
|
.tab.is-active {
|
|
border-top-color: var(--tabs-active-fg);
|
|
border-bottom-color: var(--tabs-panel-bg);
|
|
background-color: var(--tabs-panel-bg);
|
|
background-image: none;
|
|
color: var(--tabs-active-label);
|
|
}
|
|
|
|
.tab svg {
|
|
width: 1.15em;
|
|
height: 1.15em;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.panel {
|
|
padding: 16px 20px;
|
|
background-color: var(--tabs-panel-bg);
|
|
}
|
|
|
|
/*
|
|
Tighter on a phone. The panel sits inside an article that pads by 8px there, so 20px of its own
|
|
put the text 28px in from the edge of the screen -- a third of the indent a 390px column can
|
|
afford, spent twice over on the same margin.
|
|
|
|
599.98px is the app's own phone breakpoint -- --breakpoint-sm is 600px in css/tailwind.css. A
|
|
block cannot read the app's Sass variables, so the value is written out, as block-infobox does
|
|
with its own. (No backticks in here: this whole stylesheet is a template literal, and one ends
|
|
it mid-rule.)
|
|
*/
|
|
@media (max-width: 599.98px) {
|
|
.panel {
|
|
padding: 12px;
|
|
}
|
|
}
|
|
|
|
/* -> The panel owns the spacing, so the content inside it does not add its own at the edges */
|
|
::slotted(block-tab) {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
/*
|
|
ON PAPER
|
|
--------
|
|
|
|
The strip goes. A tabset shows one panel at a time because a screen has one place to put it,
|
|
and on paper every panel prints, one after another, each under a bar carrying its own label --
|
|
so a row of buttons naming panels that are all right there is a control with nothing left to
|
|
control.
|
|
|
|
Only this half is here. Showing the panels and drawing those bars belongs to the app's print
|
|
stylesheet, because the panels are slotted light DOM and which one is showing is an inline
|
|
display this block writes onto them: nothing in here reaches either. See the block-tab rules in
|
|
the print section of the app's css/_page-contents.scss.
|
|
|
|
The frame stays, and it is the reason for the one thing that does change here: with the strip
|
|
gone it is all that says these sections were one set, so it keeps its border and its radius and
|
|
gives up only the shadow, which prints as a grey smudge along two edges and lifts nothing.
|
|
|
|
What the panel gives up is its padding, down from 16px/20px to 8px all round. On screen that
|
|
inset is what holds a panel's content off the frame around it; on paper it is 40px of the
|
|
measure spent on white space that the label bar and the spine inside it already mark out --
|
|
and it is spent twice, since each tab pads itself off its own spine as well. Eight is what
|
|
keeps the content from touching the frame, and no more than that.
|
|
*/
|
|
@media print {
|
|
.strip {
|
|
display: none;
|
|
}
|
|
.tabs,
|
|
:host([dark]) .tabs {
|
|
box-shadow: none;
|
|
}
|
|
.panel {
|
|
padding: 8px;
|
|
}
|
|
}
|
|
|
|
:host {
|
|
--tabs-border: #e0e0e0;
|
|
--tabs-strip-bg: linear-gradient(to bottom, #fdfdfd, #eeeeee);
|
|
--tabs-inactive-fg: #424242;
|
|
--tabs-active-fg: var(--q-primary, #1976d2);
|
|
/*
|
|
The label of the tab being pointed at or opened. The active foreground, except on dark,
|
|
where writing in a mid-tone picked to read on white is too dim -- the strip and the panel
|
|
both sit near #1b212a. The edge that marks the open tab and the focus ring keep the plain
|
|
brand colour: they are shapes rather than writing, and read at that weight.
|
|
*/
|
|
--tabs-active-label: var(--tabs-active-fg);
|
|
--tabs-panel-bg: #fff;
|
|
}
|
|
/*
|
|
The dark palette, and only where there is a lit screen to read it on.
|
|
|
|
The dark attribute comes off the body class, which says nothing about the medium -- so without
|
|
this a page printed from the dark theme printed a near-black panel, and the app prints the text
|
|
inside it in black ink (the article's palette is light on paper, by the same reasoning as here:
|
|
see the note on the dark block in css/_page-contents.scss). Black on #1e232a is a panel of
|
|
content that cannot be read at all.
|
|
|
|
Stated as a media query rather than unpicked token by token in the print block above, so that a
|
|
token added here later cannot quietly miss it.
|
|
*/
|
|
@media not print {
|
|
:host([dark]) {
|
|
--tabs-border: rgba(255, 255, 255, 0.15);
|
|
--tabs-strip-bg: linear-gradient(to bottom, #1b212a, #12161d);
|
|
--tabs-inactive-fg: rgba(255, 255, 255, 0.7);
|
|
/* -> A mix of --q-primary, so a re-themed site's own hue comes with it. See block-index. */
|
|
--tabs-active-label: var(--color-primary-light);
|
|
--tabs-panel-bg: #1e232a;
|
|
}
|
|
}
|
|
`
|
|
}
|
|
|
|
static get properties() {
|
|
return {
|
|
_tabs: { state: true },
|
|
/*
|
|
Which panel is open, zero-based. A property rather than internal state because two things
|
|
outside this block have a use for it: an author can open a block on something other than its
|
|
first panel (`<block-tabs active="1">`), and the markdown editor reads it off one element and
|
|
writes it onto the next, because every keystroke rebuilds the preview and with it this block --
|
|
which otherwise snapped back to the first tab while an author was typing in the second.
|
|
*/
|
|
active: { type: Number }
|
|
}
|
|
}
|
|
|
|
constructor() {
|
|
super()
|
|
this._tabs = []
|
|
this.active = 0
|
|
// -> Bound once, so that removing the listener later takes the same function that was added
|
|
this._onReveal = this._onReveal.bind(this)
|
|
// -> Puts `dark` on this element for the styles above to key off
|
|
this._darkMode = new DarkMode(this)
|
|
}
|
|
|
|
/**
|
|
* Read the panels the page gave this block, and start showing the first.
|
|
*
|
|
* The panels stay in the light DOM, slotted in below the strip: their content is page content and
|
|
* is styled by the article's own stylesheet, the way an included page is.
|
|
*/
|
|
_collectTabs() {
|
|
const panels = [...this.querySelectorAll(':scope > block-tab')]
|
|
this._tabs = panels.map((panel, index) => {
|
|
this._trimEdgeMargins(panel)
|
|
return {
|
|
panel,
|
|
label: panel.getAttribute('label') || `Tab ${index + 1}`,
|
|
icon: panel.getAttribute('icon') || '',
|
|
svg: ''
|
|
}
|
|
})
|
|
this._showActive()
|
|
this._loadIcons()
|
|
}
|
|
|
|
/**
|
|
* Drop the outermost margins of a panel's content.
|
|
*
|
|
* The panel supplies the padding; the content adding its own on top of it leaves a gap under the
|
|
* strip that reads as a mistake — a heading, whose margin is the largest of any element, most of
|
|
* all. Set on the element rather than in the stylesheet because the content is slotted: it lives in
|
|
* the page, styled by the page, and `::slotted()` reaches only the panel itself, never inside it.
|
|
*/
|
|
_trimEdgeMargins(panel) {
|
|
panel.firstElementChild?.style.setProperty('margin-top', '0')
|
|
panel.lastElementChild?.style.setProperty('margin-bottom', '0')
|
|
}
|
|
|
|
/**
|
|
* Keep the whole block on screen when something inside a panel is scrolled to.
|
|
*
|
|
* A heading carries a `scroll-margin-top` so it does not land flush against the top edge, but that
|
|
* margin knows nothing about the strip standing above it — following a link to a heading in a tab
|
|
* would scroll the tabs themselves out of view, leaving the reader in a panel with no way to see
|
|
* which one they were in. Set on the elements because the content is slotted, and measured because
|
|
* a block cannot be told in CSS how tall its own strip is.
|
|
*
|
|
* Measured from the panel back up to the block, so that what a scroll clears is everything above
|
|
* the panel rather than the strip alone: the strip may have wrapped onto two rows, the frame draws
|
|
* a border, and the panel pads itself. Clearing only the strip's height put the tabs *just* on the
|
|
* edge of the column with nothing above them, and a tab acting as a page heading — `header` on
|
|
* `block-tab`, anchored on the panel because the label is an attribute and no heading in the page
|
|
* carries it — is aimed at from the contents list, so it landed with its own label against the
|
|
* edge. `HEADING_CLEARANCE` on top is what every heading in an article gets, so a tab arrived at
|
|
* from the contents list sits where a section heading would.
|
|
*/
|
|
_applyScrollMargin() {
|
|
const strip = this.renderRoot.querySelector('.strip')
|
|
if (!strip) {
|
|
return
|
|
}
|
|
/*
|
|
From the open panel, which is the only one with a box to measure. They all sit in the same
|
|
place, so its offset is every panel's — and a block inside a panel that is not showing measures
|
|
nothing at all, which is why the strip's height stands in until there is something to read.
|
|
*/
|
|
const open = this._tabs[this.active]?.panel
|
|
const above = open
|
|
? Math.round(open.getBoundingClientRect().top - this.getBoundingClientRect().top)
|
|
: strip.offsetHeight
|
|
const margin = `${above + HEADING_CLEARANCE}px`
|
|
for (const { panel } of this._tabs) {
|
|
// -> The panel as well as what is in it: either can be what a link or the contents list aims at
|
|
panel.style.setProperty('scroll-margin-top', margin)
|
|
for (const child of panel.children) {
|
|
child.style.setProperty('scroll-margin-top', margin)
|
|
}
|
|
}
|
|
}
|
|
|
|
_showActive() {
|
|
this._tabs.forEach(({ panel }, index) => {
|
|
panel.style.display = index === this.active ? 'block' : 'none'
|
|
})
|
|
}
|
|
|
|
async _loadIcons() {
|
|
for (const tab of this._tabs.filter((t) => t.icon)) {
|
|
tab.svg = await fetchIcon(tab.icon)
|
|
this.requestUpdate()
|
|
}
|
|
}
|
|
|
|
/*
|
|
-> Setting the property is the whole of it: `updated` is what shows the panel, so a tab opened from
|
|
the strip and one opened by whoever set `active` from outside travel the same path
|
|
*/
|
|
_select(index) {
|
|
this.active = index
|
|
}
|
|
|
|
/**
|
|
* Open the panel holding a given node, if it is one of these.
|
|
*
|
|
* Both ways in end up here: the app asking for a heading it is about to scroll to, and the reader
|
|
* arriving on a URL whose fragment names a heading in a panel that is not the first.
|
|
*/
|
|
_reveal(node) {
|
|
const index = this._tabs.findIndex(({ panel }) => panel.contains(node))
|
|
if (index >= 0 && index !== this.active) {
|
|
this._select(index)
|
|
}
|
|
return index >= 0
|
|
}
|
|
|
|
_onReveal(event) {
|
|
this._reveal(event.target)
|
|
}
|
|
|
|
/** The panel holding the heading the URL points at, if the URL points at one. */
|
|
_revealFromHash() {
|
|
const id = decodeURIComponent(window.location.hash.replace(/^#/, ''))
|
|
const target = id ? document.getElementById(id) : null
|
|
if (target) {
|
|
this._reveal(target)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Left and right walk the strip, as they do in every other set of tabs — the panels are a single
|
|
* stop in the tab order, so the arrow keys are how a keyboard reaches the other ones.
|
|
*/
|
|
_onKeydown(event) {
|
|
const step = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0
|
|
if (!step) {
|
|
return
|
|
}
|
|
event.preventDefault()
|
|
const next = (this.active + step + this._tabs.length) % this._tabs.length
|
|
this._select(next)
|
|
this.renderRoot.querySelectorAll('.tab')[next]?.focus()
|
|
}
|
|
|
|
/*
|
|
-> The panels live in the light DOM, so `render()` never touches them: which one is showing has to
|
|
be applied by hand, here, where it covers a click on the strip, an arrow key, a `block-reveal`
|
|
and an `active` set from outside alike
|
|
*/
|
|
updated(changed) {
|
|
if (changed.has('active')) {
|
|
this._showActive()
|
|
}
|
|
this._applyScrollMargin()
|
|
}
|
|
|
|
connectedCallback() {
|
|
super.connectedCallback()
|
|
this._collectTabs()
|
|
// -> On arrival, and again whenever the fragment changes under a reader using back and forward
|
|
this._revealFromHash()
|
|
this._onHashChange = () => this._revealFromHash()
|
|
window.addEventListener('hashchange', this._onHashChange)
|
|
this.addEventListener(REVEAL_EVENT, this._onReveal)
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
super.disconnectedCallback()
|
|
window.removeEventListener('hashchange', this._onHashChange)
|
|
this.removeEventListener(REVEAL_EVENT, this._onReveal)
|
|
}
|
|
|
|
render() {
|
|
if (this._tabs.length < 1) {
|
|
return html`<slot></slot>`
|
|
}
|
|
return html`
|
|
<div class="tabs">
|
|
<div class="strip" role="tablist" @keydown="${this._onKeydown}">
|
|
${this._tabs.map(
|
|
(tab, index) => html`
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
class="tab ${index === this.active ? 'is-active' : ''}"
|
|
aria-selected="${index === this.active}"
|
|
tabindex="${index === this.active ? 0 : -1}"
|
|
@click="${() => this._select(index)}">
|
|
${tab.svg ? unsafeSVG(tab.svg) : null}${tab.label}
|
|
</button>
|
|
`
|
|
)}
|
|
</div>
|
|
<div class="panel" role="tabpanel"><slot></slot></div>
|
|
</div>
|
|
`
|
|
}
|
|
}
|
|
|
|
window.customElements.define('block-tabs', BlockTabsElement)
|