{# macros/display.html - Media and content component macros for APEP. Import with: {% from "apep/macros/display.html" import image, logo_scroller, text_block, faq, faq_list, image_gallery, sidescroller, review_card, review_gallery, progress_bar, stepper, timeline %} Macros: image(src, alt, url, on_click, protected, width, height, downloadable) logo_scroller(logos, speed, logo_height, width, height) text_block(text, min_width, max_width, min_height, max_height, scroll, variant) faq(question, answer, open) faq_list(items, open_all) image_gallery(images, downloadable) sidescroller(items, overlay, protected) review_card(text, author, role, rating, avatar, logo, date, expandable, expand_text, collapse_text) review_gallery(reviews, expand_text, collapse_text) progress_bar(value, max, label, show_value, variant) stepper(steps, current) timeline(items, direction, same_side) Author: sora7672 #} {% from "apep/macros/helper.html" import alert %} {# Renders an image or inline SVG with optional link, click handler, drag protection overlay, and a download button shown on hover. src accepts a file path/URL, a raw string, or a raw tag string. url and on_click are mutually exclusive - on_click takes priority when both are set. The protected flag adds an overlay div that blocks drag on the img or svg element but does NOT prevent right-click context menus on the page level. When url is set without protected and without on_click, and src is not an SVG, the image renders as an tag. In all other cases it renders as a
. For SVG input: loading and draggable are ignored. width/height are applied directly to the svg element via inline style. downloadable saves the SVG as a .svg file. If alt is provided with an SVG that already contains aria-label, a warning is shown and the existing aria-label is overwritten. @param src {string} - image path/URL, raw string, or raw tag string @param alt {string} - alt text for img, or aria-label for svg. Default "". @param url {string} - opens in a new tab on click. Without protected: renders the image as an tag (img only). With protected: overlay handles the click and shows a link hint. Optional. @param on_click {string} - name of a global JS function called on click. Works with or without protected. Takes priority over url. Optional. @param protected {bool} - adds an overlay div to block drag. Default false. @param width {string} - CSS width value applied to the wrapper and img/svg. Optional. @param height {string} - CSS height value applied to the img/svg. Optional. @param downloadable {bool} - shows a download icon button on hover. Default false. SVG input saves as .svg file. External URLs trigger a confirm dialog. #} {% macro image(src, alt="", url=None, on_click=None, protected=True, width=None, height=None, downloadable=False, same_origin=False) %} {% if not src %} {{ alert("image: src is required", "Pass a valid image path, svg string, or img tag as the first argument.") }} {% else %} {# Detect input type #} {% set _is_svg = src.strip().lower().startswith(" {% else %}
{% endif %} {% if _is_svg %} {# Inject width/height directly into svg via style attribute #} {% if width or height %} {% set _svg_style = "" %} {% if width %}{% set _svg_style = _svg_style ~ "width:" ~ width ~ ";" %}{% endif %} {% if height %}{% set _svg_style = _svg_style ~ "height:" ~ height ~ ";" %}{% endif %} {{ src | replace("
{% endif %} {% if downloadable %} {% if _is_svg %} {% else %} {% endif %} {% endif %} {% if _linked %}
{% else %}
{% endif %} {% endif %} {% endif %} {% endmacro %} {# Renders an infinitely scrolling horizontal strip of logos with prev/next arrow buttons. The scroll speed and logo height are controlled via CSS custom properties set inline. Each logo's src accepts a file path/URL, a raw string, or a raw tag string. Logos with a url render as anchor tags. Invalid or unresolvable src values are skipped. Maximum 50 logos per instance. @param logos {list} - list of {src, alt, url} dicts. src accepts a path/URL, string, or tag string. alt is used when src is a path/URL. Ignored for svg/img tags. url makes the logo a link opening in a new tab. Optional per logo. @param speed {int} - scroll animation duration in seconds. Lower = faster. Default 30. @param logo_height {int} - logo height in px applied via CSS custom property. Default 40. @param width {string} - CSS width of the scroller strip. Default "100%". Optional. @param height {string} - CSS height of the outer container. Optional. #} {% macro logo_scroller(logos, speed=30, logo_height=40, width=None, height=None) %} {% if not logos %} {{ alert("logo_scroller: logos is required", "Pass a non-empty list of {src, alt, url} dicts.") }} {% elif logos | length > 50 %} {{ alert("logo_scroller: too many logos", "Maximum is 50 logos per scroller instance.") }} {% else %}
{% for logo in logos %} {% set _resolved = logo.src | helper_to_image %} {% if _resolved %}
{% if logo.url is defined and logo.url %} {{ _resolved | safe }} {% else %} {{ _resolved | safe }} {% endif %}
{% endif %} {% endfor %}
{% endif %} {% endmacro %} {# Renders a styled content block around arbitrary HTML text. When scroll is set, the block gets a wrapper div with scroll hint overlays that fade in/out based on scroll position, managed by text-block.js. Width constraints behave differently per scroll direction: for vertical scroll they apply to the outer wrapper; for horizontal scroll they apply to the inner block. @param text {string} - HTML content to render inside the block @param min_width {string} - CSS min-width, e.g. "200px". Optional. @param max_width {string} - CSS max-width, e.g. "600px". Optional. @param min_height {string} - CSS min-height, e.g. "100px". Optional. @param max_height {string} - CSS max-height, e.g. "300px". Optional. @param scroll {string} - "vertical" | "horizontal" | None, default None. vertical - overflow-y auto, content wraps at block width. horizontal - overflow-x auto, content does not wrap. None - no scroll, fluid sizing. @param variant {string} - "default" | "plain", default "default" #} {% macro text_block(text, min_width=None, max_width=None, min_height=None, max_height=None, scroll=None, variant="default") %} {% if not text %} {{ alert("text_block: text is required", "Pass a non-empty string.") }} {% elif variant not in ["default", "plain"] %} {{ alert("text_block: invalid variant \"" ~ variant ~ "\"", "Allowed: \"default\", \"plain\".") }} {% elif scroll is not none and scroll not in ["vertical", "horizontal"] %} {{ alert("text_block: invalid scroll \"" ~ scroll ~ "\"", "Allowed: \"vertical\", \"horizontal\", or omit for no scroll.") }} {% else %} {% set _scroll_class = "" %} {% if scroll == "vertical" %}{% set _scroll_class = "text-block-scroll-v" %}{% endif %} {% if scroll == "horizontal" %}{% set _scroll_class = "text-block-scroll-h" %}{% endif %} {% set _base_class = "text-block" %} {% if variant == "plain" %}{% set _base_class = _base_class ~ " text-block-plain" %}{% endif %} {% if _scroll_class %}{% set _base_class = _base_class ~ " " ~ _scroll_class %}{% endif %} {% if scroll %} {% set _wrapper_parts = [] %} {% if scroll == "vertical" %} {% if min_width %}{% set _ = _wrapper_parts.append("min-width: " ~ min_width) %}{% endif %} {% if max_width %}{% set _ = _wrapper_parts.append("max-width: " ~ max_width) %}{% endif %} {% endif %} {% set _wrapper_style = _wrapper_parts | join(";") %} {% if scroll == "horizontal" %} {% set _block_parts = ["box-sizing: border-box"] %} {% if min_width %}{% set _ = _block_parts.append("min-width: " ~ min_width) %}{% endif %} {% if max_width %}{% set _ = _block_parts.append("max-width: " ~ max_width) %}{% endif %} {% else %} {% set _block_parts = ["width: 100%", "box-sizing: border-box"] %} {% endif %} {% if min_height %}{% set _ = _block_parts.append("min-height: " ~ min_height) %}{% endif %} {% if max_height %}{% set _ = _block_parts.append("max-height: " ~ max_height) %}{% endif %} {% set _block_style = _block_parts | join(";") %}
{{ text }}
{% else %} {% set _style_parts = [] %} {% if min_width %}{% set _ = _style_parts.append("min-width: " ~ min_width) %}{% endif %} {% if max_width %}{% set _ = _style_parts.append("max-width: " ~ max_width) %}{% endif %} {% if min_height %}{% set _ = _style_parts.append("min-height: " ~ min_height) %}{% endif %} {% if max_height %}{% set _ = _style_parts.append("max-height: " ~ max_height) %}{% endif %} {% set _style = _style_parts | join(";") %}
{{ text }}
{% endif %} {% endif %} {% endmacro %} {# Renders a single collapsible FAQ item with an accessible button toggle. Open/close state is toggled by faq.js. aria-expanded is kept in sync. @param question {string} - question text displayed in the toggle button @param answer {string} - answer content, HTML allowed, shown when expanded @param open {bool} - start in the expanded state, default false #} {% macro faq(question, answer, open=False) %} {% if not question %} {{ alert("faq: question is required", "Pass a non-empty string as the first argument.") }} {% elif not answer %} {{ alert("faq: answer is required", "Pass a non-empty string as the second argument.") }} {% else %}
{{ answer }}
{% endif %} {% endmacro %} {# Renders a list of collapsible FAQ items with a global open/close all toggle button. The open_all param overrides individual item open states - when true, all items start expanded regardless of their own open field. @param items {list} - list of {question, answer, open} dicts. open defaults to false when omitted. @param open_all {bool} - start with all items expanded. Default false. #} {% macro faq_list(items, open_all=False) %} {% if not items %} {{ alert("faq_list: items is required", "Pass a non-empty list of {question, answer} dicts.") }} {% else %}
{% for item in items %} {% set is_open = open_all or (item.open is defined and item.open) %}
{{ item.answer }}
{% endfor %}
{% endif %} {% endmacro %} {# Renders a responsive image grid with a fullscreen lightbox overlay. Clicking any image opens it via the global apep-overlay system. src accepts a file path/URL, a raw tag string, or a raw string. For SVG input: rendered inline as thumbnail and shown as SVG in overlay. For img/path input: rendered as a normal tag. @param images {list} - list of {src, alt, caption} dicts. src accepts a path/URL, tag string, or string. alt is used when src is a path/URL. caption is shown below the image in the overlay. Optional. @param downloadable {bool} - shows a download button on hover and in overlay. Default false. SVG downloads save as .svg. External URLs show a confirm dialog. #} {% macro image_gallery(images, downloadable=False) %} {% if not images %} {{ alert("image_gallery: images is required", "Pass a non-empty list of {src, alt, caption} dicts.") }} {% else %} {% endfor %} {% endif %} {% endmacro %} {# Renders a horizontally scrollable item strip with prev/next arrow buttons. Each item is rendered via the image macro, so all image options apply per item. url and on_click are mutually exclusive per item - on_click takes priority. @param items {list} - list of dicts, one per item. Supported keys per item: src {string} - image path/URL, , or tag. Required if no content. content {string} - raw HTML string. Used when src is not set. alt {string} - alt text. Optional. label {string} - text shown below the image. Optional. caption {string} - caption shown in overlay. Optional. url {string} - opens in new tab on click. Optional. on_click {string} - global JS function name called on click. Optional. same_origin {bool} - when true, url opens in same tab. Optional. overlay {bool} - when true, click opens image fullscreen. Optional. protected {bool} - overrides image macro default per item. Optional. downloadable {bool} - shows download button on this item. Optional. #} {% macro sidescroller(items) %} {% if not items %} {{ alert("sidescroller: items is required", "Pass a non-empty list of item dicts.") }} {% else %}
{% for item in items %} {% set _src = item.src if item.src is defined else "" %} {% set _alt = item.alt if item.alt is defined else "" %} {% set _url = item.url if item.url is defined else None %} {% set _onclick = item.on_click if item.on_click is defined else None %} {% set _overlay = item.overlay if item.overlay is defined else False %} {% set _downloadable = item.downloadable if item.downloadable is defined else False %} {% set _same_origin = item.same_origin if item.same_origin is defined else False %} {# on_click takes priority over url - warn if both are set #} {% if _url and _onclick %} {{ alert("sidescroller: url and on_click are mutually exclusive", "on_click takes priority. Remove url or on_click from item: \"" ~ (_alt or _src) ~ "\".") }} {% endif %} {% set _clickable = _url or _onclick or _overlay %}
{% if _src %} {{ image(_src, alt=_alt, url=_url if not _onclick and not _overlay else None, on_click=_onclick, same_origin=_same_origin, downloadable=_downloadable, protected=item.protected if item.protected is defined else True ) }} {% elif item.content is defined %} {{ item.content | safe }} {% endif %}
{% if item.label is defined and item.label %} {{ item.label }} {% endif %}
{% endfor %}
{% endif %} {% endmacro %} {# review_card - Renders a single review card with optional star rating, logo, avatar, author meta, and an automatic expand/collapse toggle for long review texts. logo and avatar accept a file path/URL, a raw string, or a raw tag string. When used inside review_gallery, expand_text and collapse_text are passed down automatically. Only override them here when a standalone card needs different labels. rating must be an integer between 1 and 5 when provided. The expand/collapse button and fade overlay are always rendered but hidden by JS when the text does not actually overflow - no manual flag needed. @param text {string} - review text content, displayed as a blockquote @param author {string} - reviewer name. Optional. @param role {string} - reviewer role or position. Optional. @param rating {int} - star rating, 1-5. Renders filled and empty stars. Optional. @param avatar {string} - image path/URL, , or tag for the reviewer avatar. Optional. @param logo {string} - image path/URL, , or tag for a company or brand logo. Optional. @param date {string} - display date string. Optional. @param expand_text {string} - label for the expand button. Default "Expand". @param collapse_text {string} - label for the collapse button. Default "Collapse". #} {% macro review_card(text, author=None, role=None, rating=None, avatar=None, logo=None, date=None, expand_text="Expand", collapse_text="Collapse") %} {% if not text %} {{ alert("review_card: text is required", "Pass a non-empty string as the first argument.") }} {% elif rating is not none and rating not in [1, 2, 3, 4, 5] %} {{ alert("review_card: invalid rating \"" ~ rating ~ "\"", "Allowed values: 1, 2, 3, 4, 5.") }} {% else %} {# Resolve logo and avatar to proper html #} {% if logo %}{% set _logo_html = logo | helper_to_image %}{% else %}{% set _logo_html = None %}{% endif %} {% if avatar %}{% set _avatar_html = avatar | helper_to_image %}{% else %}{% set _avatar_html = None %}{% endif %}
{% if rating %}
{% for i in range(1, 6) %} {% endfor %}
{% endif %} {% if _logo_html %} {% endif %}
{{ text }}
{# fade and button always rendered - JS hides them when text does not overflow #} {% if author or _avatar_html or role or date %} {% endif %}
{% endif %} {% endmacro %} {# review_gallery - Renders a paginated review carousel with prev/next arrows and dot indicators. Wraps review_card calls and passes expand_text and collapse_text down to every card. Per-card overrides of expand_text and collapse_text are not supported inside this macro. @param reviews {list} - list of review dicts matching the review_card params. Required fields per item: text. Optional fields: author, role, rating, avatar, logo, date. @param expand_text {string} - expand button label applied to all cards. Default "Expand". @param collapse_text {string} - collapse button label applied to all cards. Default "Collapse". #} {% macro review_gallery(reviews, expand_text="Expand", collapse_text="Collapse") %} {% if not reviews %} {{ alert("review_gallery: reviews is required", "Pass a non-empty list of review dicts.") }} {% else %} {% endif %} {% endmacro %} {# Renders a progress bar driven by progress-bar.js. The fill width is NOT set inline - progress-bar.js reads data-progress-value and data-progress-max on load and sets the fill width via CSS custom property. This allows dynamic updates via ApepProgress.set(id, value) at any time. To update a bar dynamically from JS: ApepProgress.set("my-bar", 75) To listen for external value changes on a bar, add data-progress-listen. The bar will then respond to ApepProgress.set() calls targeting its id. @param value {number} - initial fill value, required @param max {number} - maximum value, default 100 @param id {string} - id for the bar element, enables JS targeting. Optional. @param label {string} - visible label above the bar. Optional. @param show_value {bool} - shows "value / max" next to label, updated by JS. Default false. @param variant {string} - "default" | "success" | "warning" | "danger", default "default" #} {% macro progress_bar(value, max=100, id=None, label=None, show_value=False, variant="default") %} {% if value is none %} {{ alert("progress_bar: value is required", "Pass a number as the first argument.") }} {% elif variant not in ["default", "success", "warning", "danger"] %} {{ alert("progress_bar: invalid variant \"" ~ variant ~ "\"", "Allowed values: \"default\", \"success\", \"warning\", \"danger\".") }} {% else %}
{% if label or show_value %}
{% if label %} {{ label }} {% endif %} {% if show_value %} {{ value }} / {{ max }} {% endif %}
{% endif %}
{% endif %} {% endmacro %} {# Renders a horizontal step indicator. Each step shows a number, a label, and a connector line to the next step. The current step is highlighted. Completed steps (index < current) receive a done state. Steps with a detail value render a
custom tag as hover popup. @param steps {list} - list of {label, detail} dicts. label is the step title (required per item). detail is an optional string shown as a hover popup via the
custom tag. @param current {number} - 1-based index of the active step, default 1 #} {% macro stepper(steps, current=1) %} {% if not steps %} {{ alert("stepper: steps is required", "Pass a list of {label} dicts.") }} {% else %}
{% for step in steps %} {% set idx = loop.index %} {% set is_done = idx < current %} {% set is_active = idx == current %}
{% if is_done %} {% else %} {{ idx }} {% endif %}
{% if step.detail is defined and step.detail %}

{{ step.detail }}

{% endif %}
{{ step.label }} {% if not loop.last %} {% endif %}
{% endfor %}
{% endif %} {% endmacro %} {# Renders a timeline of events as a vertical or horizontal sequence. Structure: .timeline-outer - bounds the component, inherits parent size, clips overflow, holds the nav arrows as absolute elements .nav-arrow-* - scroll buttons injected by timeline.js, absolute in outer .timeline-wrapper - scroll container, fills outer .timeline - the JS-calculated layout direction: "down" - vertical, top to bottom (default) "up" - vertical, bottom to top "right" - horizontal, left to right "left" - horizontal, right to left same_side: false - items alternate left/right (vertical) or top/bottom (horizontal) true - all items on one side Per item: text {string} - main content, HTML allowed (required) title {string} - heading above text (optional) pos {string} - "left"/"right"/"top"/"bottom", overrides same_side (optional) @param items {list} - list of {text, title, pos} dicts @param direction {string} - "down" | "up" | "right" | "left", default "down" @param same_side {bool} - all items on one side, default false #} {% macro timeline(items, direction="right", same_side=False) %} {% if not items %} {{ alert("timeline: items is required", "Pass a list of {text, title, pos} dicts.") }} {% elif direction not in ["down", "up", "right", "left"] %} {{ alert("timeline: invalid direction \"" ~ direction ~ "\"", "Allowed values: \"down\", \"up\", \"right\", \"left\".") }} {% else %} {% set _is_vertical = direction in ["down", "up"] %}
{% for item in items %} {% if item.pos is defined and item.pos %} {% set _pos = item.pos %} {% elif same_side %} {% set _pos = "left" if _is_vertical else "top" %} {% else %} {% if _is_vertical %} {% set _pos = "left" if loop.index is odd else "right" %} {% else %} {% set _pos = "top" if loop.index is odd else "bottom" %} {% endif %} {% endif %}
{% if item.title is defined and item.title %} {{ item.title }} {% endif %}
{{ item.text }}
{% endfor %}
{% endif %} {% endmacro %}