feat: added links-list class + various UI improvements

scarlett
NGPixel 4 weeks ago
parent c182d2c974
commit aa279332c5
No known key found for this signature in database

@ -93,6 +93,14 @@ export class BlockIndexElement extends LitElement {
hint: 'How many folders below the path to include. 0 is the folder itself.',
default: 0
},
{
name: 'columns',
type: 'select',
label: 'Columns',
options: ['1', '2', '3'],
hint: 'Most columns to lay the pages out in. Narrower screens use fewer.',
default: '2'
},
{
name: 'showIcons',
type: 'boolean',
@ -134,11 +142,33 @@ export class BlockIndexElement extends LitElement {
grid-template-columns: repeat(1, minmax(0, 1fr));
gap: 0.5rem;
}
/*
The columns prop is a ceiling, not a count: the listing starts at one column and widens with
the window, stopping at whatever the author asked for. A phone gets one column whichever value
it carries, which is the whole reason the choice cannot simply be the number of columns -- a
three-column listing on a 400px screen is three unreadable slivers.
The second column arrives at the app's md breakpoint (--breakpoint-md in
frontend/src/css/tailwind.css), which is the width the listing has always widened at. The
third waits for 1600px, which is a width of this block's own rather than one of the shared
ones: at lg (1440) a third of the article column, minus the sidebar beside it, leaves a title
and its description with nowhere to go but two lines each.
Matched off the host's attribute rather than read from a custom property, because the ceiling
has to be applied per breakpoint -- and clamping one is math inside repeat(), which is not
something an engine can be relied on to take.
*/
@media (min-width: 1024px) {
ul {
:host(:not([columns='1'])) ul {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (min-width: 1600px) {
:host([columns='3']) ul {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
li {
background-color: #fafafa;
@ -296,6 +326,17 @@ export class BlockIndexElement extends LitElement {
*/
noResultMsg: { type: String },
/**
* Most columns to lay the pages out in (1, 2, 3)
*
* Declared for the sake of the pair -- an authored prop belongs in both lists -- and because
* Lit would otherwise not know the attribute at all. Nothing in `render()` reads it: the layout
* is the styles' business, and they match `:host([columns])` on the page's own attribute.
*
* @type {string}
*/
columns: { type: String },
/**
* Whether each page's icon is drawn beside its title
* @type {boolean}
@ -319,6 +360,7 @@ export class BlockIndexElement extends LitElement {
this.orderByDirection = 'asc'
this.depth = 0
this.noResultMsg = 'No pages matching your query.'
this.columns = '2'
this.showIcons = false
// -> Puts `dark` on this element for the styles above to key off
this._darkMode = new DarkMode(this)

@ -166,11 +166,11 @@ watch(
<style lang="scss">
/*
Just under the width `MainLayout` gives this sidebar's drawer as `overlayBelow` (1100), which is where it
Just under the width `MainLayout` gives this sidebar's drawer as `overlayBelow` (1200), which is where it
stops being a column beside the content and starts overlaying it. Not one of the app's shared breakpoints
-- it belongs to this sidebar -- so it is stated here and cross-referenced there.
*/
$sidebar-overlay-max: 1099.98px;
$sidebar-overlay-max: 1199.98px;
.sidebar-nav {
border-top: 1px solid rgba(255, 255, 255, 0.15);

@ -403,7 +403,7 @@ function removePendingAsset(item) {
`MainLayout` hands its drawer as `overlayBelow`, and the same one `NavSidebar` states for its own use.
Below it the corner button lands in this rail; see the padding rule.
*/
$sidebar-overlay-max: 1099.98px;
$sidebar-overlay-max: 1199.98px;
/** One row of this rail, which is what the bottom group has to clear. Matches the buttons' `h-12`. */
$action-btn-height: 3rem;

@ -81,7 +81,7 @@ const props = defineProps({
* Width in px below which the drawer overlays the page instead of taking a column of its own.
*
* 1024 is the `md` breakpoint and what every drawer used before this was a prop; the site sidebar asks
* for 1100, because it is 255px wide beside an article that also gives up a contents column see
* for 1200, because it is 255px wide beside an article that also gives up a contents column see
* `MainLayout`. Read once, at setup: a caller states this as a constant, not something that changes
* under a mounted drawer.
*/

@ -1,9 +1,10 @@
<template>
<transition name="w-page-scroller">
<transition :name="anchorX ? `w-page-scroller-slide` : `w-page-scroller-fade`">
<div
v-if="visible"
class="w-page-scroller fixed z-40"
:style="anchorStyle"
class="w-page-scroller fixed bottom-0 z-40"
:class="anchorX ? `w-page-scroller--anchored` : `right-0`"
:style="anchorX ? { left: anchorX } : null"
@click="scrollToTop">
<slot />
</div>
@ -11,15 +12,16 @@
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { onBeforeUnmount, onMounted, ref } from 'vue'
/**
* Floating "back to top" affordance that appears once the page has been scrolled past
* `scrollOffset`, and smooth-scrolls to the top when clicked.
* "Back to top" affordance that appears once the page has been scrolled past `scrollOffset`, and
* smooth-scrolls to the top when clicked.
*
* Sits in the bottom-right corner unless `anchorX` puts it somewhere else horizontally. Scrolling
* uses the platform's own smooth behaviour rather than the hand-rolled easing the previous component
* shipped, and honours `prefers-reduced-motion` for free.
* Always flush against the bottom of the viewport, in one of two places: the bottom-right corner, or
* with its right edge at `anchorX` which is how it tucks into the bottom of the nav sidebar's
* column. Scrolling uses the platform's own smooth behaviour rather than the hand-rolled easing the
* previous component shipped, and honours `prefers-reduced-motion` for free.
*/
const props = defineProps({
/** Show once the window has scrolled this many pixels. */
@ -27,14 +29,9 @@ const props = defineProps({
type: Number,
default: 1000
},
/** `[x, y]` distance from the viewport corner, in px. `x` is unused when `anchorX` is set. */
offset: {
type: Array,
default: () => [18, 18]
},
/**
* Any CSS length, which becomes the x of the button's CENTRE measured from the left of the
* viewport so it can straddle an edge rather than clear it. Null keeps it in the corner.
* Any CSS length, which becomes the x of the button's RIGHT EDGE measured from the left of the
* viewport so it ends where a column beside it does. Null keeps it in the corner.
*/
anchorX: {
type: String,
@ -56,14 +53,6 @@ const props = defineProps({
const visible = ref(false)
const anchorStyle = computed(() => {
const bottom = `${props.offset[1]}px`
// -> `translateX(-50%)` is what makes `anchorX` a centre rather than a left edge
return props.anchorX
? { bottom, left: props.anchorX, transform: 'translateX(-50%)' }
: { bottom, right: `${props.offset[0]}px` }
})
/** The scrolling element, or null when it is the window. */
function scroller() {
return props.target ? document.querySelector(props.target) : null
@ -89,18 +78,45 @@ onBeforeUnmount(() => window.removeEventListener('scroll', onScroll, { capture:
</script>
<style scoped>
.w-page-scroller-enter-active,
.w-page-scroller-leave-active {
/*
`anchorX` is where the button ENDS, so it is shifted left by its own width -- which is the one
measurement only the button itself knows, and the reason this is a transform rather than arithmetic
on the anchor. Restated in the slide below, since a transform is one property and the two movements
share it.
*/
.w-page-scroller--anchored {
transform: translateX(-100%);
}
/*
Anchored, it comes and goes by the edge it sits on: out of the bottom of the viewport and back down
into it, which is a movement the corner it is tucked into can explain. The button is flush against
that edge, so there is nothing for it to slide behind and no gap to cross.
*/
.w-page-scroller-slide-enter-active,
.w-page-scroller-slide-leave-active {
transition: transform 0.2s var(--ease-standard);
}
.w-page-scroller-slide-enter-from,
.w-page-scroller-slide-leave-to {
transform: translateX(-100%) translateY(100%);
}
/* In the corner it still fades, which is how every other corner button gives way -- see `.corner-btn` */
.w-page-scroller-fade-enter-active,
.w-page-scroller-fade-leave-active {
transition: opacity 0.2s var(--ease-standard);
}
.w-page-scroller-enter-from,
.w-page-scroller-leave-to {
.w-page-scroller-fade-enter-from,
.w-page-scroller-fade-leave-to {
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.w-page-scroller-enter-active,
.w-page-scroller-leave-active {
.w-page-scroller-slide-enter-active,
.w-page-scroller-slide-leave-active,
.w-page-scroller-fade-enter-active,
.w-page-scroller-fade-leave-active {
transition-duration: 0.01ms;
}
}

@ -122,6 +122,30 @@
--content-mark: #fdf1a0;
/*
The rows of a `{.links-list}`: the wash each one sits on, and the tint it takes under the pointer.
Washes rather than fixed greys, for the same reason the table's rows are ones -- the editor's
preview pane sits on grey, where a fixed near-white row would read as a lit strip.
The hover tint is mixed from the site's colour, so it answers the bar that appears beside it rather
than being a blue that happens to sit near one.
*/
--content-links-row: rgba(0, 0, 0, 0.045);
--content-links-row-hover: color-mix(in srgb, var(--color-primary) 8%, transparent);
/*
And the light down the top of a row, over whichever of the two it is carrying. A separate layer
rather than a two-stop wash, because the wash is the half that changes: a gradient cannot be
transitioned to another gradient, so a row built out of one would jump to its hover colour while
everything else on the page fades.
*/
--content-links-sheen: rgba(255, 255, 255, 0.8);
/*
And the drop under it, so a row sits ON the page rather than being a tint drawn into it. One tight
layer where the table has two: these come five and ten at a time down a page, and the wide soft
half of a table's shadow repeated that often reads as a smudge behind the whole list.
*/
--content-links-shadow: 0 1px 3px rgba(0, 0, 0, 0.14);
/* The box behind the tick of a done task-list item; the tick itself is white in both themes */
--content-tick: #5b616b;
/* -> And the empty box of an item still to do, white whatever surface the content sits on */
@ -220,6 +244,14 @@
--content-mark: #6b5d13;
/* -> Lifted off the dark page, and mixed from the lighter blue for the same reason links are */
--content-links-row: rgba(255, 255, 255, 0.05);
--content-links-row-hover: color-mix(in srgb, var(--color-primary-light) 14%, transparent);
/* -> Barely there: on a dark page the same light that reads as a rounded top edge reads as a bar */
--content-links-sheen: rgba(255, 255, 255, 0.045);
/* -> Deepened, and no wider, for the reason the table's is: a 7% black drop is invisible here */
--content-links-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
/*
Lighter than the light theme's, because the box has to be seen against a dark page -- but held
well below the white of the tick on it, which is what makes the tick readable.
@ -745,6 +777,117 @@
display: none;
}
/*
A LIST OF LINKS, from `{.links-list}` on a bulleted list -- carried over from 2.x, where it is what
the guide pages are built out of:
- [The Basics *New to Wiki.js? Learn how to use it and create your first page.*](/guide/intro)
- [Using Editors *Learn how to use the various editors.*](/editors)
{.links-list}
Each item becomes a row rather than a bullet: the title in the link's colour, the emphasis after it
read as a description and set upright in the body colour, with a rule between the two. The class
reaches the list through `markdown-it-attrs`, which is why nothing in the renderer knows about this.
Bulleted lists only. `{.links-list}` sticks to an ordered list just as readily, and there it would
be a numbered list of links with its numbers taken away -- which is a list the author asked for and
would no longer be looking at.
*/
ul.links-list {
margin: 1.5em 0;
padding-left: 0;
list-style: none;
> li {
margin-top: 4px;
border-radius: 4px;
/*
The bar down the left, grey at rest and the link's colour under the pointer -- so what hovering
does is colour something already there rather than add a bar and shunt the words sideways.
*/
border-left: 3px solid var(--content-rule);
background-color: var(--content-links-row);
/* -> The light down the top of the row; see `--content-links-sheen` */
background-image: linear-gradient(to bottom, var(--content-links-sheen), transparent 65%);
box-shadow: var(--content-links-shadow);
transition:
background-color 0.15s var(--ease-standard),
border-color 0.15s var(--ease-standard);
&:hover,
&:focus-within {
border-left-color: var(--content-link);
background-color: var(--content-links-row-hover);
}
}
/*
The padding is the link's, so the whole row is the target rather than just the words in it. A
loose list -- one written with blank lines between its items -- wraps each item in a paragraph
first, which is why the link is reached through both shapes.
*/
> li > a,
> li > p > a {
display: block;
padding: 0.6em 0.9em;
font-weight: 600;
&:hover,
&:focus-visible {
text-decoration: none;
}
}
/* -> An item still being typed, with no link in it yet, is a row all the same */
> li:not(:has(> a, > p > a)) {
padding: 0.6em 0.9em;
color: var(--content-ink-muted);
}
/*
The description. Upright and in the body colour: it is a second voice in the row, not emphasis
within the title -- and italic at this length is what makes the difference between a caption and
a line of small print.
*/
em {
margin-left: 0.5em;
padding-left: 0.75em;
border-left: 1px solid var(--content-rule);
color: var(--content-ink-muted);
font-style: normal;
font-weight: 400;
}
/*
On a phone the two halves stack. The rule between them goes with the arrangement it was drawn
for: down the left of a wrapped description it would read as a quote inside the row.
*/
@media (max-width: $breakpoint-xs-max) {
em {
margin-left: 0;
padding-left: 0;
border-left: 0;
font-size: 0.9375em;
}
/*
-> A line break rather than a block, so that what follows the description on the row -- the
marker on an external link is the one thing that does -- stays on its last line instead of
dropping to one of its own.
*/
em::before {
content: '\a';
white-space: pre;
}
}
}
@media (prefers-reduced-motion: reduce) {
ul.links-list > li {
transition-duration: 0.01ms;
}
}
// ---------------------------------------------------------------------------
// QUOTES AND ADMONITIONS
// ---------------------------------------------------------------------------

@ -105,6 +105,14 @@
*/
--color-primary-light: color-mix(in srgb, var(--color-primary) 60%, white);
/*
And the sidebar's colour a shade lighter, for the one control that sits INSIDE that column and has
to read as a button against it rather than as a second accent beside it: scroll-to-top, tucked into
the bottom of the sidebar in `MainLayout`. Mixed, for the reason above -- a themed sidebar takes its
lighter shade with it.
*/
--color-sidebar-light: color-mix(in srgb, var(--color-sidebar) 88%, white);
/*
Restated here rather than inherited from Tailwind's default theme: the defaults live in a
prunable block, and components resolve `var(--color-white)` at runtime where the scanner cannot

@ -541,7 +541,7 @@ const narrowSidebarOpen = ref(false)
/**
* Where the drawer stops overlaying the page and takes its own column `WDrawer`'s own default, which
* this layout leaves alone (unlike the site sidebar, which asks for 1100).
* this layout leaves alone (unlike the site sidebar, which asks for 1200).
*/
const isWideViewport = useMinWidth(1024)
@ -745,8 +745,15 @@ onMounted(async () => {
backdrop-filter: blur(5px) saturate(180%);
}
> .w-dialog-viewport {
padding: 24px 64px;
// -> Equal margins all round until 1600px, where the sides can afford to be wider. Same rule and
// same reasoning as `.main-overlay` in `MainLayout`, which the admin overlays match.
padding: 24px;
@media (min-width: 1600px) {
padding: 24px 64px;
}
// -> Last of the three, so it still wins on a phone: all three have the same specificity
@media (max-width: 1023.98px) {
padding: 0;
}

@ -134,8 +134,13 @@
The mirror of the sidebar button in the opposite corner while the layout is in its narrow mode:
flush to the edge, and rounded on the top LEFT, since this is the corner it is tucked into from
the other side. It keeps the floating disc on a wide screen, where it has a seam to straddle
rather than a corner to sit in.
the other side.
On a wide screen it is the same button in a corner of its own -- the bottom of the sidebar's
column, ending where that column ends (`scrollerAnchorX`). Flush there too, so it is unelevated:
a shadow is what a disc floating over the page needs, and this one is not floating over anything.
And it is filled in the sidebar's own colour a shade lighter (`--color-sidebar-light`), since
there it is part of that column rather than an accent laid over the page.
And it stands down below 750px, where the page view's contents panel takes this corner for its own
opener -- one button per corner, and there the contents are the more useful of the two. See
@ -144,15 +149,15 @@
<w-page-scroller
v-if="isAtLeastTocPanelWidth"
:scroll-offset="150"
:offset="scrollerOffset"
:anchor-x="scrollerAnchorX"
target=".page-container-scrl">
<w-btn
:class="isWideViewport ? `` : `corner-btn corner-btn--right`"
class="corner-btn corner-btn--right"
icon="la:arrow-up"
color="primary"
:color="scrollerAnchorX ? `sidebar-light` : `primary`"
round
size="md" />
size="md"
:unelevated="Boolean(scrollerAnchorX)" />
</w-page-scroller>
</w-page-container>
<main-overlay-dialog />
@ -236,15 +241,15 @@ const isNarrowSidebarOpen = ref(false)
/**
* Where this sidebar stops overlaying the page and takes its own column of its own.
*
* 1100 rather than `WDrawer`'s default of 1024: this sidebar is 255px, and the page beside it gives up a
* contents column of its own before this point so by ~1050px the article is the narrowest of the three
* 1200 rather than `WDrawer`'s default of 1024: this sidebar is 255px, and the page beside it gives up a
* contents column of its own before this point so by ~1150px the article is the narrowest of the three
* things sharing the window. Passed INTO the drawer rather than changed there, so the admin area's drawer
* keeps the 1024 it was written against.
*
* `NavSidebar` has to agree with it too: the dent marking the current page is only meaningful while the
* sidebar is beside the content. See `$sidebar-overlay-max` there.
*/
const SIDEBAR_OVERLAY_BELOW = 1100
const SIDEBAR_OVERLAY_BELOW = 1200
/**
* The same boundary as a reactive flag, for everything in this layout that has to know which mode the
@ -312,13 +317,13 @@ const SIDEBAR_WIDTH_MINI = 56
const sidebarWidth = computed(() => (isSidebarMini.value ? SIDEBAR_WIDTH_MINI : SIDEBAR_WIDTH))
/*
The scroll-to-top button straddles the seam between the sidebar and the content, half over each, so
its centre is the sidebar's inner edge — which is the sidebar's width on the left, or the same
distance in from the right when the site puts its sidebar there.
The scroll-to-top button ENDS where the sidebar's column does, tucked into the bottom of it: the
sidebar's own width on the left, or the window's right edge when the site puts its sidebar there,
since that is the side that column ends on.
Null puts it back in the corner, for every case where there is no seam: a narrow viewport (the
drawer overlays the page), a site with no sidebar, and the editor, which closes the sidebar to take
the full width.
Null puts it back in the corner, for every case where there is no sidebar beside it: a narrow
viewport (the drawer overlays the page), a site with no sidebar, and the editor, which closes the
sidebar to take the full width. That is the corner button, and it is left exactly as it was.
*/
const scrollerAnchorX = computed(() => {
// -> No separate test for `sidebarPosition === 'off'`: that IS `sideNavIsDisabled`, which
@ -326,18 +331,9 @@ const scrollerAnchorX = computed(() => {
if (!isWideViewport.value || !isSidebarAvailable.value) {
return null
}
return siteStore.theme.sidebarPosition === 'right'
? `calc(100% - ${sidebarWidth.value}px)`
: `${sidebarWidth.value}px`
return siteStore.theme.sidebarPosition === 'right' ? '100%' : `${sidebarWidth.value}px`
})
/*
And no gap at all from the corner once it is in one: `[15, 15]` is the clearance a disc needs to read as
floating over the page, which is what it does on a wide screen. Squared into the corner there is nothing
to clear -- the button IS the corner.
*/
const scrollerOffset = computed(() => (isWideViewport.value ? [15, 15] : [0, 0]))
// -> The "Allow Browsing" site feature (admin/general): with it off the tree browser is not something
// a reader can reach, so the button that opens it does not render
const canBrowse = computed(() => siteStore.features.browse)
@ -421,8 +417,24 @@ body.body--dark {
backdrop-filter: blur(5px) saturate(180%);
}
> .w-dialog-viewport {
padding: 24px 64px;
/*
Equal margins all round, until there is width to spare for more.
64px down each side is pitched for a wide desktop; on a 1280 or 1440 window it is an eighth of
the screen taken off a panel that is a file listing or a table, and the overlay ends up narrower
than the page it was opened from. Below 1600 the sides come in to match the 24px above and below,
which is the clearance that says "over the page" -- more than that is decoration.
1600 is this rule's own number, not one of the app's `--breakpoint-*`: it is where an overlay is
wide enough that 128px of it can go to margins without the content noticing.
*/
padding: 24px;
@media (min-width: 1600px) {
padding: 24px 64px;
}
// -> Last of the three, so it still wins on a phone: all three have the same specificity
@media (max-width: $breakpoint-sm-max) {
padding: 0;
}

Loading…
Cancel
Save