Modul:CardLua
Innen: SCWIKI
More actions
A modult a Modul:CardLua/doc lapon tudod dokumentálni
require('strict')
--- @module CardLua
--- A reusable card primitive: a bordered, surface-coloured, rounded container
--- with a shared header row (title + description on the left, an optional
--- trailing element on the right) and an optional always-visible footer.
---
--- Specialized cards compose this:
--- * Module:CollapsibleCard wraps a <details> body as the card `content` and
--- supplies a chevron as the header trailing element.
--- * `renderLinkCard` is a built-in specialization: a static card whose
--- trailing element is a Module:ButtonLua button (e.g. "view on external
--- source") — for summaries that link out rather than expand in place.
--- * `renderMediaCard` is a built-in specialization for cards that lead with a
--- picture. It owns only the art — that it bleeds to the card's inner edge
--- without any negative margins, because `.t-card` carries no padding of its
--- own and the shell's `overflow: clip` trims the picture to the inside of
--- the border — and hands everything else to a content slot, with
--- `renderMediaBody` as the helper for the usual text block.
local button = require('Module:ButtonLua')
local p = {}
--- Builds the inner title/description block (no wrapper header row). Exposed so
--- interactive consumers (CollapsibleCard's <summary>) can place it inside their
--- own header element alongside a trailing control.
---
--- @param title string
--- @param description string|nil
--- @return string
function p.renderHeaderContent(title, description)
local root = mw.html.create('div'):addClass('t-card__header-content')
root:tag('div'):addClass('t-card__title'):wikitext(title)
if description and description ~= '' then
root:tag('div'):addClass('t-card__description'):wikitext(description)
end
return tostring(root)
end
--- @class CardHeaderProps
--- @field title string
--- @field description? string
--- @field trailing? string HTML rendered on the right of the header (button, icon, …)
--- Builds a full static header row: title + description on the left, optional
--- `trailing` element on the right.
---
--- @param props CardHeaderProps
--- @return string
function p.renderHeader(props)
local root = mw.html.create('div'):addClass('t-card__header')
root:wikitext(p.renderHeaderContent(props.title, props.description))
if props.trailing and props.trailing ~= '' then
root:tag('div'):addClass('t-card__trailing'):wikitext(tostring(props.trailing))
end
return tostring(root)
end
--- @class CardProps
--- @field content string|mw.html Card body.
--- @field footer? string Always-visible footer (attribution, etc.).
--- @field class? string Extra class(es) appended to the card root.
--- Wraps `content` (and optional `footer`) in the card shell.
---
--- @param props CardProps
--- @return string
function p.render(props)
local styles = mw.getCurrentFrame():extensionTag({
name = 'templatestyles',
args = { src = 'Module:CardLua/styles.css' },
})
local root = mw.html.create('div'):addClass('t-card')
if props.class and props.class ~= '' then
root:addClass(props.class)
end
root:wikitext(tostring(props.content))
if props.footer and props.footer ~= '' then
root:tag('div'):addClass('t-card__footer'):wikitext(tostring(props.footer))
end
return styles .. tostring(root)
end
--- @class LinkCardProps
--- @field title string
--- @field description? string
--- @field buttons ButtonProps[] One or more Module:ButtonLua props; rendered as trailing link-out actions.
--- @field class? string
--- A static card whose body is a header row with one or more link-out buttons
--- on the right — for summaries backed by external sources rather than on-page
--- data (e.g. "Browse 830 recipes" → Wiki API; "Browse trade data" → SC Trade
--- Tools + UEX).
---
--- @param props LinkCardProps
--- @return string
function p.renderLinkCard(props)
local rendered = {}
for _, b in ipairs(props.buttons or {}) do
rendered[#rendered + 1] = button.render(b)
end
return p.render({
class = props.class,
content = p.renderHeader({
title = props.title,
description = props.description,
trailing = table.concat(rendered),
}),
})
end
--- @class MediaCardReadout
--- @field label? string Left-hand label, e.g. "Ends in".
--- @field value? string Right-hand value: a count, a countdown, a price…
--- @field attrs? table<string, string> Attributes set on the value element, for
--- gadgets that animate or refresh it (e.g. a countdown target date).
--- @class MediaBodyProps
--- @field title string Required.
--- @field link? string Page the title links to.
--- @field kicker? string Overline above the title.
--- @field body? string Prose, or a wikitext list. A longer register than
--- the header row's one-line `description`. A `*` list renders as a
--- compact bulleted list — the shape Update: pages already use for
--- "what's new" — and each item should stay to one line, because a
--- wrapped item costs a slot in a card whose height is fixed by whatever
--- sits beside it.
--- @field readout? MediaCardReadout
--- @field more? string Read-more affordance at the foot of the body.
--- Rendered as styled text, never a second anchor: the title already
--- links to the same page, and on a stretch-linked card the whole
--- surface is that link, so an anchor here would only hand keyboard and
--- screen-reader users a duplicate stop to a place they have already
--- been offered. Hidden from assistive tech for the same reason — "read
--- more" is precisely the link text a screen reader user is taught to
--- distrust, and it carries nothing the title has not already said.
--- @class MediaCardProps
--- @field image? string File name for the leading art; a "File:" prefix is optional.
--- @field imageAlt? string Alt text. Defaults to empty, which is correct for
--- decorative art sitting next to a title that already names the subject.
--- @field imageWidth? number Width the file is RENDERED at, in px, not the size
--- of the slot — the art is cropped to fill, so this only needs to be
--- large enough to stay sharp. Defaults to 480.
--- @field layout? string 'split' (art beside the text, the default) or
--- 'banner' (art across the top). Split stays short; banner is taller for
--- the same content, which matters when the card sits above the fold.
--- @field content string|mw.html Everything that is not the art. Usually
--- `renderMediaBody(…)`, optionally followed by more elements — they
--- become further columns of the card's flex row, which is how a
--- countdown or any other aside gets in without this module growing a
--- prop for it.
--- @field stretchLink? boolean Make the whole card clickable. Requires a link
--- in `content` — the CSS stretches the title's anchor — so it pairs with
--- `link` on the body rather than on the card. Off by default, because a
--- card that swallows every click also swallows text selection.
--- @field footer? string Card footer, below a divider.
--- @field class? string
--- Wikitext for the leading art. `link=` is deliberately empty: the title
--- carries the link, and a second target over the picture gives keyboard and
--- screen-reader users a duplicate stop for the same destination.
---
--- @param props MediaCardProps
--- @return string
local function mediaWikitext(props)
local name = mw.text.trim(props.image)
name = name:gsub('^[Ff][Ii][Ll][Ee]:', ''):gsub('^[Ii][Mm][Aa][Gg][Ee]:', '')
return string.format('[[File:%s|%dpx|link=|alt=%s]]', name, tonumber(props.imageWidth) or 480, props.imageAlt or '')
end
--- The visible cue that a card is clickable. A stretched anchor is invisible,
--- so without this the card gives no sign of being a link until a cursor is
--- already over it, and no sign at all to someone reading rather than pointing.
---
--- @param text string
--- @return string
local function moreHtml(text)
return tostring(mw.html.create('span'):addClass('t-card__more'):attr('aria-hidden', 'true'):wikitext(text))
end
--- @param readout MediaCardReadout
--- @return string
local function readoutHtml(readout)
local row = mw.html.create('div'):addClass('t-card__readout')
row:tag('span'):addClass('t-card__readout-label'):wikitext(readout.label or '')
local value = row:tag('span'):addClass('t-card__readout-value')
for name, content in pairs(readout.attrs or {}) do
value:attr(name, content)
end
value:wikitext(readout.value or '')
return tostring(row)
end
--- Builds the standard text block: overline, title, prose, and an optional
--- readout row pinned to the foot.
---
--- Exposed rather than inlined because the card takes a content slot, not a
--- fixed set of fields. A consumer that wants the usual text plus something
--- beside it composes this with whatever else, exactly as `renderLinkCard`
--- composes `renderHeader` with buttons.
---
--- The foot is bottom-anchored on purpose. Cards laid out in a row are
--- stretched to the tallest of them, and this decides where that spare height
--- lands: as a gap above the foot, which reads as deliberate, rather than as a
--- hole below everything, which reads as a mistake.
---
--- @param props MediaBodyProps
--- @return string
function p.renderMediaBody(props)
if not props.title or props.title == '' then
error('renderMediaBody: title is required')
end
local root = mw.html.create('div'):addClass('t-card__media-body')
if props.kicker and props.kicker ~= '' then
root:tag('div'):addClass('t-card__kicker'):wikitext(props.kicker)
end
local linked = props.link and props.link ~= ''
local title = linked and string.format('[[%s|%s]]', props.link, props.title) or props.title
root:tag('div'):addClass('t-card__title'):wikitext(title)
if props.body and props.body ~= '' then
-- The leading newline is load-bearing. mw.html emits this div inline, so a
-- body that opens with `*` would sit immediately after `>` rather than at
-- the start of a line, and the parser renders it as literal text instead
-- of opening a list. Only the FIRST item is affected, so the bug looks
-- like a typo in the wikitext rather than a rendering problem. Harmless
-- for prose: a leading newline inside a block element changes nothing.
root:tag('div'):addClass('t-card__body'):wikitext('\n' .. props.body)
end
-- One wrapper rather than an auto margin on each: flex splits free space
-- evenly between auto margins, so two of them would push the pair apart
-- instead of holding it together at the bottom.
local foot = {}
if props.readout and (props.readout.label or props.readout.value) then
foot[#foot + 1] = readoutHtml(props.readout)
end
if props.more and props.more ~= '' then
foot[#foot + 1] = moreHtml(props.more)
end
if #foot > 0 then
root:tag('div'):addClass('t-card__foot'):wikitext(table.concat(foot))
end
return tostring(root)
end
--- A card that leads with a picture, followed by whatever `content` supplies.
---
--- The card owns only the media: where it sits, that it bleeds to the inner
--- edge, and whether the row runs across or down. Everything else is the
--- caller's, laid out as flex children of the same row — so a second element
--- after the body becomes a second column without this module needing to know
--- what it is.
---
--- @param props MediaCardProps
--- @return string
function p.renderMediaCard(props)
local layout = props.layout == 'banner' and 'banner' or 'split'
local root = mw.html.create('div'):addClass('t-card__media-layout'):addClass('t-card__media-layout--' .. layout)
if props.image and props.image ~= '' then
root:tag('div'):addClass('t-card__media'):wikitext(mediaWikitext(props))
end
root:wikitext(tostring(props.content or ''))
-- The stretch is a class on the card, not an extra element: the title's own
-- anchor is grown to cover the card by CSS. A separate overlay anchor would
-- either have no accessible name, or repeat the title and hand keyboard and
-- screen-reader users a second stop for the same destination.
local class = props.class
if props.stretchLink == true then
class = class and (class .. ' t-card--link') or 't-card--link'
end
return p.render({
content = tostring(root),
footer = props.footer,
class = class,
})
end
--- Wikitext entry point for the media card: the common case, where the content
--- slot is a standard body, optionally followed by something else.
---
--- `after` is deliberately untyped — wikitext cannot compose Lua, so it is how a
--- template hands a second column in (`|after={{#invoke:Countdown|main|…}}`)
--- without this module learning what a countdown is.
---
--- Readout attributes are Lua-only: they exist for gadget hooks, which belong to
--- a module that knows what it is hooking, not to a template parameter.
---
--- @param frame mw.frame
--- @return string
function p.mediaCard(frame)
local args = require('Module:Arguments').getArgs(frame)
local yesno = require('Module:Yesno')
return p.renderMediaCard({
image = args.image,
imageAlt = args.imagealt,
imageWidth = args.imagewidth,
layout = args.layout,
stretchLink = yesno(args.stretchlink, false) == true,
content = p.renderMediaBody({
title = args.title,
link = args.link,
kicker = args.kicker,
body = args.body,
readout = { label = args.readoutlabel, value = args.readout },
more = args.more,
}) .. (args.after or ''),
footer = args.footer,
class = args.class,
})
end
return p