mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
ui: cards get legible titles, contained posters, and meta beside the title
Four defects on /videos, /docs and /archives. Titles were invisible outside light. `.card-link` carried a literal `color: black` from before the themes existed -- about 1.1:1 on the dark, night and amber panels. It applies to every card title and to the channel/author/domain line beside it. The date was fine, since it takes `--muted` from the card body, which is what made this look like a card bug rather than a link bug. Titles now take `--text`: they are links, but they are the card's text, not a wall of blue. Posters were cropped, not mis-proportioned. The image carried a fixed 290x163 box; a card in the default grid is about 233px wide and Mantine's Card sets `overflow: hidden`, so the browser painted the middle 233px of a 290px image. The ratio was right in the layout tree and wrong on screen. Replaced with caps on both axes -- the card's width and `--card-poster-max-height: 200px` -- with `width`/`height` left `auto` so the pair behaves as "contain". Bounding the width alone was tried first and is not enough: a square thumbnail then stands as tall as the card is wide, and a portrait book cover taller still. The meta line was foot-anchored, so on /docs a PDF with no author had its date shoved up to 55px below the title, at the bottom of a card that was tall only because of its neighbour in the row. It now sits directly under the title. Archive cards read buttons-then-domain, because the buttons were children and the meta was what got pushed to the foot. Card gains an `actions` slot, rendered last and anchored to the foot, so a row lines its buttons up however many lines each title took. Gallery gains a poster row covering all five ratios and a row showing meta/actions against unequal titles. 1076 jest tests, 145 in ui-layout.cy.js, clean build. Every new guard was checked against a planted regression: dropping the height cap reddens the square and portrait cases and leaves 16:9 green; `width: 100%` reddens containment; restoring `color: black` reddens dark/night/amber and leaves light green; re-anchoring the meta and swapping the slot order redden both the jest order test and the cypress geometry tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
928bdbdfa1
commit
687b41bf20
8 changed files with 361 additions and 41 deletions
|
|
@ -82,8 +82,14 @@ pre.wrap-text {
|
|||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
/*
|
||||
* A card's title and its author/channel/domain line are links, but they are the card's
|
||||
* text -- they take the body colour rather than the link colour, so a grid of cards reads
|
||||
* as titles and not as a wall of blue. This was a literal `black` until the themes
|
||||
* arrived, which left every title at about 1.1:1 on the dark, night and amber panels.
|
||||
*/
|
||||
.card-link {
|
||||
color: black;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.center-me {
|
||||
|
|
|
|||
|
|
@ -426,22 +426,22 @@ export function ArchiveCard({file}) {
|
|||
</div>
|
||||
</>;
|
||||
|
||||
return <Card media={media} title={titleElm} meta={meta}
|
||||
color={mimetypeColor(file.mimetype, file.primary_path)}>
|
||||
<div style={{display: 'flex', gap: '0.5em', marginTop: '0.5em'}}>
|
||||
<Link to={`/archives/${file.id}`}>
|
||||
<Button icon='file alternate'>Details</Button>
|
||||
</Link>
|
||||
<IconButton
|
||||
icon='external'
|
||||
label='Open original URL'
|
||||
component='a'
|
||||
href={file.url}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
const actions = <div style={{display: 'flex', gap: '0.5em'}}>
|
||||
<Link to={`/archives/${file.id}`}>
|
||||
<Button icon='file alternate'>Details</Button>
|
||||
</Link>
|
||||
<IconButton
|
||||
icon='external'
|
||||
label='Open original URL'
|
||||
component='a'
|
||||
href={file.url}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
/>
|
||||
</div>;
|
||||
|
||||
return <Card media={media} title={titleElm} meta={meta} actions={actions}
|
||||
color={mimetypeColor(file.mimetype, file.primary_path)}/>
|
||||
}
|
||||
|
||||
// Domain table column configuration
|
||||
|
|
|
|||
|
|
@ -727,8 +727,6 @@ export function findPosterPath(file) {
|
|||
}
|
||||
|
||||
export function CardPoster({to, file}) {
|
||||
// Used to center posters in CardIcon.
|
||||
const style = {display: 'flex', justifyContent: 'center'};
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Marks a tagged file, pinned to the poster's corner.
|
||||
|
|
@ -741,25 +739,17 @@ export function CardPoster({to, file}) {
|
|||
// FileGroup has a poster (screenshot/thumbnail) file.
|
||||
posterPath = `/media/${encodeMediaPath(posterPath)}`;
|
||||
|
||||
const image = <>
|
||||
{/* Replicate <Image label/> but with maxHeight applied to image */}
|
||||
{imageLabel}
|
||||
<img alt='poster' src={posterPath} style={{maxHeight: '163px', maxWidth: '290px', width: 'auto'}}/>
|
||||
</>;
|
||||
// Sized by `.wrolpi-card-poster`: as wide as the card allows, natural ratio.
|
||||
const image = <img alt='poster' src={posterPath}/>;
|
||||
|
||||
if (to) {
|
||||
// Link within this App.
|
||||
return <Link to={to} style={style}>
|
||||
{image}
|
||||
</Link>
|
||||
} else {
|
||||
// Preview the file.
|
||||
return <div style={style}>
|
||||
<PreviewLink file={file}>
|
||||
{image}
|
||||
</PreviewLink>
|
||||
</div>
|
||||
}
|
||||
return <div className='wrolpi-card-poster'>
|
||||
{imageLabel}
|
||||
{to
|
||||
// Link within this App.
|
||||
? <Link to={to}>{image}</Link>
|
||||
// Preview the file.
|
||||
: <PreviewLink file={file}>{image}</PreviewLink>}
|
||||
</div>
|
||||
} else {
|
||||
// FileGroup has no poster.
|
||||
if (!to || (to.startsWith('/media/') || to.startsWith('/download/'))) {
|
||||
|
|
|
|||
|
|
@ -54,6 +54,15 @@ import {contrastingColor} from './Common';
|
|||
* already happened more than once.
|
||||
*/
|
||||
|
||||
/*
|
||||
* A stand-in poster of exact intrinsic dimensions, so the poster caps can be seen against
|
||||
* the ratios that actually break them -- a real library has all five and the gallery has
|
||||
* no media of its own.
|
||||
*/
|
||||
const samplePoster = (width, height) => `data:image/svg+xml,${encodeURIComponent(
|
||||
`<svg xmlns='http://www.w3.org/2000/svg' width='${width}' height='${height}'>` +
|
||||
`<rect width='100%' height='100%' fill='%23808080'/></svg>`)}`;
|
||||
|
||||
const Section = ({label, children}) => <section style={{marginBottom: 34}}>
|
||||
<p style={{
|
||||
fontSize: 11, fontWeight: 600, letterSpacing: '0.12em', textTransform: 'uppercase',
|
||||
|
|
@ -714,6 +723,57 @@ export function ThemeSamplePage() {
|
|||
}}><Icon name={icon} size={34}/></div>}
|
||||
/>)}
|
||||
</CardGroup>
|
||||
|
||||
<Header as='h4'>Posters</Header>
|
||||
<CardGroup>
|
||||
{[
|
||||
['16:9 video thumbnail', 1280, 720],
|
||||
['4:3 screenshot', 800, 600],
|
||||
['square thumbnail', 600, 600],
|
||||
['portrait book cover', 306, 396],
|
||||
['smaller than the card', 120, 90],
|
||||
].map(([label, w, h]) => <Card
|
||||
key={label}
|
||||
title={label}
|
||||
meta={`${w}×${h}`}
|
||||
color='blue'
|
||||
media={<div className='wrolpi-card-poster'>
|
||||
<img alt='' src={samplePoster(w, h)}/>
|
||||
</div>}
|
||||
/>)}
|
||||
</CardGroup>
|
||||
<p style={{fontSize: 12, color: 'var(--muted)', marginTop: 10}}>
|
||||
A poster is capped on both axes — the card's width, and{' '}
|
||||
<code>--card-poster-max-height</code> — and keeps its own ratio whichever
|
||||
cap bites. Capping the width alone let a square thumbnail stand as tall as
|
||||
the card is wide. A poster smaller than the card is left at its own size
|
||||
rather than upscaled.
|
||||
</p>
|
||||
|
||||
<Header as='h4'>Meta, body and actions</Header>
|
||||
<CardGroup>
|
||||
{[
|
||||
['Rainwater Harvesting And Filtration For A Small Off-Grid Homestead',
|
||||
'engineering775.com · 2025-01-30', true],
|
||||
['Solar Basics', 'wiki.example.org · 2024-08-02', true],
|
||||
['Field Notes.pdf', 'docs/ · 2023-06-14', false],
|
||||
].map(([title, meta, withActions]) => <Card
|
||||
key={title}
|
||||
title={<a href='#cards' className='no-link-underscore card-link'>{title}</a>}
|
||||
meta={meta}
|
||||
color='blue'
|
||||
actions={withActions ? <div style={{display: 'flex', gap: '0.5em'}}>
|
||||
<Button icon='file alternate'>Details</Button>
|
||||
<IconButton icon='external' label='Open original URL'/>
|
||||
</div> : undefined}
|
||||
/>)}
|
||||
</CardGroup>
|
||||
<p style={{fontSize: 12, color: 'var(--muted)', marginTop: 10}}>
|
||||
Titles are links but take the body colour, not the link colour — a grid of
|
||||
results should read as titles. The meta line sits directly under the title;
|
||||
actions are pushed to the foot, so a row lines its buttons up however many
|
||||
lines each title took. A card with no actions simply ends.
|
||||
</p>
|
||||
<p style={{fontSize: 12, color: 'var(--muted)', marginTop: 10}}>
|
||||
The bottom edge carries the file type's colour, so a grid of results is
|
||||
scannable by kind before any title is read. It comes from a token, so night
|
||||
|
|
|
|||
|
|
@ -149,8 +149,19 @@ export interface CardProps {
|
|||
/** Image or poster area above the body. */
|
||||
media?: React.ReactNode;
|
||||
title?: React.ReactNode;
|
||||
/**
|
||||
* The card's secondary line -- author, channel, domain, date. It sits directly under
|
||||
* the title rather than at the foot of the card: a grid stretches every card to the
|
||||
* tallest in its row, so a foot-anchored meta line strands the date a long way from
|
||||
* the title it belongs to.
|
||||
*/
|
||||
meta?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
/**
|
||||
* Controls, rendered last and pushed to the card's foot so that a row of cards lines
|
||||
* its buttons up regardless of how many lines each title took.
|
||||
*/
|
||||
actions?: React.ReactNode;
|
||||
onClick?: React.MouseEventHandler<HTMLDivElement>;
|
||||
/**
|
||||
* A token colour name, drawn as an accent along the card's top edge. File cards use
|
||||
|
|
@ -162,7 +173,7 @@ export interface CardProps {
|
|||
className?: string;
|
||||
}
|
||||
|
||||
export function Card({media, title, meta, children, onClick, color, className}: CardProps) {
|
||||
export function Card({media, title, meta, children, actions, onClick, color, className}: CardProps) {
|
||||
return <MCard
|
||||
withBorder
|
||||
padding={0}
|
||||
|
|
@ -183,8 +194,11 @@ export function Card({media, title, meta, children, onClick, color, className}:
|
|||
{media && <div style={{borderBottom: '1px solid var(--border)'}}>{media}</div>}
|
||||
<div style={{padding: '10px 12px 12px', display: 'flex', flexDirection: 'column', gap: 4, flex: 1}}>
|
||||
{title && <div style={{fontSize: 13, fontWeight: 600, lineHeight: 1.35}}>{title}</div>}
|
||||
{meta && <div className='wrolpi-card-meta' style={{fontSize: 12, color: 'var(--muted)'}}>{meta}</div>}
|
||||
{children}
|
||||
{meta && <div style={{fontSize: 12, color: 'var(--muted)', marginTop: 'auto'}}>{meta}</div>}
|
||||
{actions && <div className='wrolpi-card-actions' style={{marginTop: 'auto', paddingTop: 8}}>
|
||||
{actions}
|
||||
</div>}
|
||||
</div>
|
||||
</MCard>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
ActionInput, Button, Header, Icon, IconStack, Loading, MultiSelect, Panel, PathInput,
|
||||
ActionInput, Button, Card, Header, Icon, IconStack, Loading, MultiSelect, Panel, PathInput,
|
||||
Message, Placeholder, Progress, Statistic, StatisticGroup, Status, TabBar, Table,
|
||||
tabClassName, TextInput,
|
||||
} from './index';
|
||||
import {contrastingColor, HelpHeader, LoadStatistic} from '../Common';
|
||||
import {MemoryRouter} from 'react-router';
|
||||
import {CardPoster, contrastingColor, HelpHeader, LoadStatistic} from '../Common';
|
||||
import {Notifications} from '@mantine/notifications';
|
||||
import {clearToasts, toast} from './toast';
|
||||
import {monochromeThemes, themeNames} from '../../themes/names';
|
||||
|
|
@ -1672,3 +1673,193 @@ describe('media filtering', () => {
|
|||
expect(getComputedStyle($img[0]).filter).to.equal('none'));
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* A poster of exact intrinsic dimensions. It has to come over the wire rather than as a
|
||||
* data: URI, because CardPoster rewrites whatever it is given into `/media/<path>` -- so
|
||||
* the bytes are served by an intercept and the component builds its own URL, which is the
|
||||
* URL the app actually requests.
|
||||
*/
|
||||
const posterFile = (width, height) => ({
|
||||
id: 1,
|
||||
tags: [],
|
||||
poster_path: `videos/poster-${width}x${height}.svg`,
|
||||
primary_path: 'videos/one.mp4',
|
||||
});
|
||||
|
||||
const servePoster = (width, height) => cy.intercept(
|
||||
'GET', `**/media/videos/poster-${width}x${height}.svg`,
|
||||
{
|
||||
headers: {'content-type': 'image/svg+xml'},
|
||||
body: `<svg xmlns='http://www.w3.org/2000/svg' width='${width}' height='${height}'>` +
|
||||
`<rect width='100%' height='100%' fill='#888'/></svg>`,
|
||||
},
|
||||
);
|
||||
|
||||
/* A card as narrow as the default grid makes them, which is where the cropping showed. */
|
||||
const narrowCard = (children) =>
|
||||
<MemoryRouter><div style={{width: 233}}>{children}</div></MemoryRouter>;
|
||||
|
||||
/*
|
||||
* The height cap, read from the stylesheet rather than repeated here, so the two cannot
|
||||
* drift. Deleting the declaration makes this NaN and every comparison against it fails,
|
||||
* which is the point -- and the bound below keeps "read whatever the CSS says" from being
|
||||
* a test that any value at all satisfies.
|
||||
*/
|
||||
const cardPosterCap = () => {
|
||||
const value = parseFloat(getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--card-poster-max-height'));
|
||||
expect(value, 'a poster cap is declared, and is small enough to be a cap')
|
||||
.to.be.within(80, 240);
|
||||
return value;
|
||||
};
|
||||
|
||||
describe('a card poster is bounded by the card, and keeps its ratio', () => {
|
||||
/*
|
||||
* The poster carried a fixed 290x163 box. A card in the default grid is about 233px
|
||||
* wide and Mantine's Card sets `overflow: hidden`, so the browser painted the middle
|
||||
* 233px of a 290px image: the ratio was right in the layout tree and wrong on screen.
|
||||
* None of that is visible to jsdom, which lays nothing out.
|
||||
*
|
||||
* The cap that replaced it is on the WIDTH, so a square or portrait source grows as
|
||||
* tall as it likes -- a 1:1 thumbnail became a 233px-tall band above a two-line title.
|
||||
* Hence a height cap as well. Both caps have to preserve the ratio, which is the part
|
||||
* worth testing: `max-width` and `max-height` together only "contain" an image while
|
||||
* its own width and height stay `auto`.
|
||||
*/
|
||||
const cases = [
|
||||
{name: 'a 16:9 video thumbnail', w: 1280, h: 720},
|
||||
{name: 'a square thumbnail', w: 600, h: 600},
|
||||
{name: 'a portrait book cover', w: 306, h: 396},
|
||||
{name: 'a poster smaller than the card', w: 120, h: 90},
|
||||
];
|
||||
|
||||
cases.forEach(({name, w, h}) => {
|
||||
it(`fits ${name} inside the card without distorting it`, () => {
|
||||
servePoster(w, h);
|
||||
cy.mountUI(narrowCard(<CardPoster file={posterFile(w, h)}/>));
|
||||
|
||||
cy.get('.wrolpi-card-poster img')
|
||||
// The intrinsic size has to have arrived, or every ratio below is 0/0.
|
||||
.should(($img) => expect($img[0].naturalWidth, 'poster loaded').to.equal(w))
|
||||
.should(($img) => {
|
||||
const img = $img[0];
|
||||
const box = img.getBoundingClientRect();
|
||||
const card = img.closest('.wrolpi-card-poster').getBoundingClientRect();
|
||||
|
||||
expect(box.width, 'poster stays within the card').to.be.at.most(card.width + 0.5);
|
||||
expect(box.height, 'poster is capped in height').to.be.at.most(cardPosterCap() + 0.5);
|
||||
// Never upscaled: a small poster is left at its own size.
|
||||
expect(box.width, 'poster is not stretched past its own size').to.be.at.most(w + 0.5);
|
||||
expect(box.width / box.height, `${name} keeps its ratio`)
|
||||
.to.be.closeTo(w / h, 0.01);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('shrinks a tall poster by its width rather than squashing it', () => {
|
||||
/*
|
||||
* The failure mode a ratio check alone would miss: a height cap applied while the
|
||||
* width is pinned crops or squashes instead of containing. A 600x600 source in a
|
||||
* 233px card is width-bound at 233 if the cap is loose and height-bound if it is
|
||||
* tight -- either way the OTHER axis must have moved with it.
|
||||
*/
|
||||
servePoster(600, 600);
|
||||
cy.mountUI(narrowCard(<CardPoster file={posterFile(600, 600)}/>));
|
||||
|
||||
cy.get('.wrolpi-card-poster img')
|
||||
.should(($img) => expect($img[0].naturalWidth).to.equal(600))
|
||||
.should(($img) => {
|
||||
const box = $img[0].getBoundingClientRect();
|
||||
expect(box.height, 'the cap actually bit').to.be.at.most(cardPosterCap() + 0.5);
|
||||
expect(box.width, 'and the width came down with it')
|
||||
.to.be.closeTo(box.height, 0.5);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('a card reads as titles, not as a wall of links', () => {
|
||||
/*
|
||||
* A card's title and its author/channel/domain line are links, and they carried a
|
||||
* literal `color: black` from before the themes existed. On the dark, night and amber
|
||||
* panels that is about 1.1:1 -- the titles were there, and invisible. The date beside
|
||||
* them was fine, because it takes `--muted` from the card body, which is what made the
|
||||
* bug look like a card-title bug rather than a link bug.
|
||||
*
|
||||
* jsdom cannot see this: `.card-link` now resolves a custom property, and jsdom drops
|
||||
* `color: var(--text)` as an invalid declaration.
|
||||
*/
|
||||
themeNames.forEach((theme) => {
|
||||
it(`keeps a card title legible in ${theme}`, () => {
|
||||
cy.mountUI(<MemoryRouter>
|
||||
<Card title={<a href='/videos/1' className='no-link-underscore card-link'>
|
||||
How To Sharpen An Axe
|
||||
</a>} meta='Wranglerstar'/>
|
||||
</MemoryRouter>, {theme});
|
||||
|
||||
cy.get('.card-link').should(($link) => {
|
||||
const panel = toRgb(getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--panel'));
|
||||
const title = toRgb(getComputedStyle($link[0]).color);
|
||||
expect(contrast(title, panel), `card title on the panel in ${theme}`)
|
||||
.to.be.greaterThan(4.5);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('a card keeps its meta with the title and its actions at the foot', () => {
|
||||
/*
|
||||
* A grid stretches every card to the tallest in its row. The meta line used to carry
|
||||
* `margin-top: auto`, so on /docs a PDF with no author had its date shoved 55px below
|
||||
* the title it belongs to, at the bottom of a card that was tall only because of its
|
||||
* neighbour. Actions are the thing that wants the foot -- a row of cards should line
|
||||
* its buttons up however many lines each title took.
|
||||
*
|
||||
* Real layout, because `margin-top: auto` in a flex column is resolved by the layout
|
||||
* engine and jsdom has none. The DOM ORDER half of this is in ui.test.js.
|
||||
*/
|
||||
const row = <div style={{display: 'flex', alignItems: 'stretch', width: 520}}>
|
||||
{[
|
||||
'A title long enough to wrap onto three separate lines inside a narrow card',
|
||||
'Short',
|
||||
].map((title, index) => <div key={index} style={{width: 240, display: 'flex'}}>
|
||||
<Card title={title} meta='example.com · 2025-01-30'
|
||||
actions={<Button>Details</Button>}/>
|
||||
</div>)}
|
||||
</div>;
|
||||
|
||||
it('leaves no gap between the title and the meta line', () => {
|
||||
cy.mountUI(row);
|
||||
|
||||
cy.get('.wrolpi-card-meta').should(($metas) => {
|
||||
expect($metas.length).to.equal(2);
|
||||
const gaps = [...$metas].map((meta) => {
|
||||
const title = meta.previousElementSibling;
|
||||
return meta.getBoundingClientRect().top - title.getBoundingClientRect().bottom;
|
||||
});
|
||||
// The card body's own 4px gap, and nothing else.
|
||||
gaps.forEach((gap, index) => expect(gap, `card ${index} title-to-meta`).to.be.at.most(6));
|
||||
// Both cards really were stretched to the same height, or nothing was pushed.
|
||||
const heights = [...Cypress.$('.mantine-Card-root')]
|
||||
.map(card => Math.round(card.getBoundingClientRect().height));
|
||||
expect(new Set(heights).size, 'the row stretched both cards equally').to.equal(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('lines the actions up at the foot however tall the title got', () => {
|
||||
cy.mountUI(row);
|
||||
|
||||
cy.get('.wrolpi-card-actions').should(($actions) => {
|
||||
expect($actions.length).to.equal(2);
|
||||
const [tall, short] = [...$actions];
|
||||
// The premise: the two titles really are different heights.
|
||||
const titleHeights = [...Cypress.$('.mantine-Card-root')].map(card =>
|
||||
Math.round(card.lastElementChild.firstElementChild.getBoundingClientRect().height));
|
||||
expect(new Set(titleHeights).size, 'the two titles differ in height').to.equal(2);
|
||||
|
||||
expect(tall.getBoundingClientRect().bottom, 'both action rows sit on the same line')
|
||||
.to.be.closeTo(short.getBoundingClientRect().bottom, 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1117,6 +1117,34 @@ html[data-theme="amber"] .wrolpi-progress-fill {
|
|||
* poster area agree in every theme.
|
||||
*/
|
||||
|
||||
/*
|
||||
* A poster is bounded by the card and keeps whatever ratio the source image has. It used
|
||||
* to carry a fixed 290x163 box, which is wider than a card in a default grid (~233px at
|
||||
* desktop widths), so `overflow: hidden` on the card cropped the sides off and the poster
|
||||
* read as the wrong aspect ratio.
|
||||
*
|
||||
* Both caps are needed. Bounding the width alone lets a square thumbnail stand as tall as
|
||||
* the card is wide, and a portrait book cover taller still, so the media band dwarfs the
|
||||
* title it belongs to. `width` and `height` stay `auto` so the pair behaves as "contain":
|
||||
* whichever cap bites first, the other axis comes down with it and the ratio survives.
|
||||
*/
|
||||
:root {
|
||||
--card-poster-max-height: 200px;
|
||||
}
|
||||
|
||||
.wrolpi-card-poster {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.wrolpi-card-poster img {
|
||||
display: block;
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
max-height: var(--card-poster-max-height);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.wrolpi-card-icon {
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -894,6 +894,37 @@ describe('Card', () => {
|
|||
|
||||
expect(container.firstChild.style.borderBottom).toBe('');
|
||||
});
|
||||
|
||||
it('puts the meta line under the title and the actions last', () => {
|
||||
/*
|
||||
* Order is the half of the fix jsdom can judge. An Archive card renders its domain
|
||||
* and date as `meta` and its Details/Open buttons as `actions`; before `actions`
|
||||
* existed the buttons were children and the meta was foot-anchored, so every archive
|
||||
* card read buttons-then-domain. Which slot lands where is what this asserts; that
|
||||
* the foot anchoring actually resolves is in ui-layout.cy.js, since `margin-top:
|
||||
* auto` needs a layout engine.
|
||||
*/
|
||||
renderUI(<Card title='Rainwater Harvesting'
|
||||
meta='engineering775.com'
|
||||
actions={<button type='button'>Details</button>}>
|
||||
<span>body</span>
|
||||
</Card>);
|
||||
|
||||
const body = screen.getByText('Rainwater Harvesting').parentElement;
|
||||
expect([...body.children].map(child => child.textContent)).toEqual([
|
||||
'Rainwater Harvesting', 'engineering775.com', 'body', 'Details',
|
||||
]);
|
||||
expect(body.querySelector('.wrolpi-card-actions')).toHaveStyle({marginTop: 'auto'});
|
||||
expect(body.querySelector('.wrolpi-card-meta').style.marginTop).toBe('');
|
||||
});
|
||||
|
||||
it('renders no actions row when a card has no actions', () => {
|
||||
// Otherwise every card in a grid pays for an empty foot, and the spacing below the
|
||||
// meta line differs between a card with buttons and one without.
|
||||
const {container} = renderUI(<Card title='Plain' meta='docs/'/>);
|
||||
|
||||
expect(container.querySelector('.wrolpi-card-actions')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CardGroup', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue