Skip to content

Commit b060b10

Browse files
authored
feat(Vertical Rich List): Vertical Rich List pattern refactor (#5797)
* feat(image): add p-image-container--auto-height modifier * refactor(rich-vertical-list): items[] + media API, matching basic-section * docs(rich-vertical-list): migrate 9 examples to new config-based macro API * feat: add new examples for media aspect ratios, auto-height, and video integration * fix(parker): update threshold for stylesheet size metric * feat(releases): update to version 4.51.0 * feat: implement item type filtering and add fixture for disallowed items * fix: update image-container classes for 1:1 ratio handling * fix: correct aspect ratio descriptions and update auto-height constraints * fix: update auto-height modifier notes and change image object-fit to contain * fix: correct threshold value for stylesheet size metric * feat: add auto-height variables and remove obsolete fixtures
1 parent 672be97 commit b060b10

19 files changed

Lines changed: 1059 additions & 811 deletions

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "vanilla-framework",
3-
"version": "4.50.1",
3+
"version": "4.51.0",
44
"author": {
55
"email": "webteam@canonical.com",
66
"name": "Canonical Webteam"

releases.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1+
- version: 4.51.0
2+
features:
3+
- component: Rich vertical list
4+
url: /docs/patterns/rich-list-vertical
5+
status: Updated
6+
notes: Added <a href="/docs/patterns/rich-list-vertical#items">items</a> successor to slots, reusing the <a href="/docs/patterns/basic-section#content-blocks">Basic section</a> content blocks model. Introduced a new <a href="/docs/patterns/rich-list-vertical#media">media</a> parameter supporting image or video with per-breakpoint <a href="/docs/patterns/rich-list-vertical#aspect-ratios">aspect ratios</a> and an <a href="/docs/patterns/rich-list-vertical#auto-height">auto-height</a> mode that stretches the media column to match the content column.
7+
- component: Image container
8+
url: /docs/patterns/images
9+
status: Updated
10+
notes: Added a new <code>p-image-container--auto-height</code> modifier that clamps image height between a 16:9 minimum and a 2:3 maximum of the column width using CSS container queries.
111
- version: 4.50.0
212
features:
313
- component: Card pattern

scss/_patterns_image.scss

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@
1818
Wraps contents in a container with an aspect ratio of 2.4:1.
1919
.p-image-container--square:
2020
Wraps contents in a container with an aspect ratio of 1:1.
21+
.p-image-container--auto-height:
22+
Stretches the container to the height of an adjacent grid column, clamped between a 16:9 minimum and a 2:3 maximum of the column width using CSS container queries.
23+
.p-image-container--auto-height-on-(small|medium|large):
24+
Auto-height variant applied only at the specified breakpoint.
2125
.p-image-container--(16-9|3-2|2-3|cinematic|square)-on-(small|medium|large):
2226
Wraps contents in a container with the specified aspect ratio on the specified breakpoint.
2327
Image:
@@ -37,6 +41,11 @@ $aspect-ratios: (
3741
'square': 1,
3842
);
3943

44+
// Auto-height clamp bounds, expressed as a percentage of the container's width.
45+
// Upper bound matches the 2:3 (portrait) ratio; lower bound matches 16:9.
46+
$image-container-auto-height-max: math.div(1, map-get($aspect-ratios, '2-3')) * 100cqw;
47+
$image-container-auto-height-min: math.div(1, map-get($aspect-ratios, '16-9')) * 100cqw;
48+
4049
@mixin apply-aspect-ratio-styles($padding-value) {
4150
&::before {
4251
content: '';
@@ -57,6 +66,19 @@ $aspect-ratios: (
5766
}
5867
}
5968

69+
@mixin apply-auto-height-styles {
70+
container-type: inline-size;
71+
height: 100%;
72+
max-height: $image-container-auto-height-max;
73+
min-height: $image-container-auto-height-min;
74+
75+
.p-image-container__image {
76+
height: 100%;
77+
object-fit: contain;
78+
width: 100%;
79+
}
80+
}
81+
6082
@mixin aspect-ratio-classes {
6183
@each $aspect-ratio, $aspect-ratio-value in $aspect-ratios {
6284
$padding-percentage: math.div(1, $aspect-ratio-value) * 100%;
@@ -65,6 +87,10 @@ $aspect-ratios: (
6587
}
6688
}
6789

90+
.p-image-container--auto-height {
91+
@include apply-auto-height-styles;
92+
}
93+
6894
// Responsive aspect ratios
6995
@each $breakpoint-name, $breakpoint-bounds-pair in $breakpoint-bounds {
7096
$min-width: map-get($breakpoint-bounds-pair, min);
@@ -78,6 +104,9 @@ $aspect-ratios: (
78104
@include apply-aspect-ratio-styles($padding-percentage);
79105
}
80106
}
107+
.p-image-container--auto-height-on-#{$breakpoint-name} {
108+
@include apply-auto-height-styles;
109+
}
81110
}
82111
} @else if $min-width {
83112
@media (width >= $min-width) {
@@ -87,6 +116,9 @@ $aspect-ratios: (
87116
@include apply-aspect-ratio-styles($padding-percentage);
88117
}
89118
}
119+
.p-image-container--auto-height-on-#{$breakpoint-name} {
120+
@include apply-auto-height-styles;
121+
}
90122
}
91123
} @else if $max-width {
92124
@media (width < $max-width) {
@@ -96,6 +128,9 @@ $aspect-ratios: (
96128
@include apply-aspect-ratio-styles($padding-percentage);
97129
}
98130
}
131+
.p-image-container--auto-height-on-#{$breakpoint-name} {
132+
@include apply-auto-height-styles;
133+
}
99134
}
100135
}
101136
}
Lines changed: 106 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -1,127 +1,127 @@
1+
{% from "_macros/vf_basic-section.jinja" import basic_section_title, vf_basic_section_blocks %}
2+
{% from "_macros/shared/vf_section_top_rule.jinja" import vf_section_top_rule %}
3+
14
# Params
2-
# title_text (string) (required): Title of the rich vertical list
3-
# list_item_tick_style (string) (optional): Type of list item tick styling. Options are "bullet", "tick", "number".
4-
# is_flipped (boolean) (optional): Whether the list items are flipped so image is on the left and the text is on the right. Defaults to false.
5-
# Slots
6-
# description: Paragraph-style description content
7-
# logo_section Logo section block
8-
# list_item_[1-7]: List item content, assumed to be li.p-list__item
9-
# image (required)
5+
# title (dict) (required): {text, link_attrs?} — rendered via basic_section_title (always renders <h2>).
6+
# items (array) (optional): Array of {type, item} dicts rendered via vf_basic_section_blocks.
7+
# Supported types: description, list, code-block, logo-block, cta-block.
8+
# Entries with any other type are silently dropped.
9+
# media (dict) (required): Media column config. Keys:
10+
# - type (string): "image" | "video". Defaults to "image".
11+
# - ratio.large (string): "16-9" | "3-2" | "1-1" | "2-3" | "auto-height". Defaults to "3-2".
12+
# - ratio.medium_small (string): "16-9" | "3-2" | "1-1". Defaults to "3-2".
13+
# - fit (string): "cover" | "contain". Defaults to "cover".
14+
# - attrs (dict): Passthrough HTML attributes for the <img> or <iframe>.
15+
# is_flipped (bool) (optional): Swap content and media columns. Defaults to false.
16+
# padding (string) (optional): "deep" | "shallow" | "default". Defaults to "default".
17+
# top_rule_variant (string) (optional): "default" | "muted". Defaults to "default".
18+
# attrs (dict) (optional): HTML attrs for the <section>.
1019
{% macro vf_rich_vertical_list(
11-
title_text,
12-
list_item_tick_style="",
13-
is_flipped=false
20+
title={},
21+
items=[],
22+
media={},
23+
is_flipped=false,
24+
padding="default",
25+
top_rule_variant="default",
26+
attrs={}
1427
) -%}
15-
{% set description_content = caller('description') %}
16-
{% set has_description = description_content|trim|length > 0 %}
17-
{% set logo_section_content = caller('logo_section') %}
18-
{% set has_logo_section = logo_section_content|trim|length > 0 %}
19-
{% set cta_content = caller('cta') %}
20-
{% set has_cta = cta_content|trim|length > 0 %}
21-
{% set has_list = caller('list_item_1')|trim|length > 0 %}
22-
{% set image_content = caller('image') %}
23-
{% set max_list_items = 7 %}
24-
25-
{% set list_item_tick_style=list_item_tick_style|trim|lower %}
26-
{% if list_item_tick_style|length > 0 and list_item_tick_style not in ['bullet', 'tick', 'number'] %}
27-
{% set list_item_tick_style = '' %}
28-
{% endif %}
28+
{#- Normalise & validate padding -#}
29+
{%- set padding = padding | string | trim | lower -%}
30+
{%- if padding not in ['deep', 'shallow', 'default'] -%}{%- set padding = 'default' -%}{%- endif -%}
31+
{%- set padding_classes = 'p-section--' ~ padding -%}
32+
{%- if padding == 'default' -%}{%- set padding_classes = 'p-section' -%}{%- endif -%}
2933

30-
{% if list_item_tick_style == "bullet" %}
31-
{% set list_item_tick_class = "has-bullet" %}
32-
{% elif list_item_tick_style == "tick" %}
33-
{% set list_item_tick_class = "is-ticked" %}
34-
{% endif %}
34+
{#- Normalise & validate top_rule_variant -#}
35+
{%- set top_rule_variant = top_rule_variant | string | trim | lower -%}
36+
{%- if top_rule_variant not in ['default', 'muted'] -%}{%- set top_rule_variant = 'default' -%}{%- endif -%}
3537

36-
{% set list_element_type = "ul" %}
37-
{% if list_item_tick_style == "number" %}
38-
{% set list_element_type = "ol" %}
39-
{% endif %}
38+
{#- Normalise & validate media config -#}
39+
{%- set media_type = (media.get('type', 'image') | string | trim | lower) -%}
40+
{%- if media_type not in ['image', 'video'] -%}{%- set media_type = 'image' -%}{%- endif -%}
4041

41-
{#-
42-
Construct list of list items using caller in the top-level macro
43-
The _text_column_contents macro will not have access to the caller block, so we need to extract the list items here.
44-
-#}
45-
{% set list_items = [] %}
46-
{% if has_list %}
47-
{% for list_item_index in range(1, max_list_items + 1) %}
48-
{% set list_item_content = caller('list_item_' + list_item_index|string) %}
49-
{% set has_list_item_content = list_item_content|trim|length > 0 %}
50-
{% if has_list_item_content %}
51-
{{ list_items.append(list_item_content) or ""}}
52-
{% endif %}
53-
{% endfor %}
54-
{% endif %}
42+
{%- set media_ratio = media.get('ratio', {}) -%}
43+
{%- set media_ratio_large = (media_ratio.get('large', '3-2') | string | trim | lower) -%}
44+
{%- set valid_large_ratios = ['16-9', '3-2', '1-1', '2-3', 'auto-height'] -%}
45+
{%- if media_ratio_large not in valid_large_ratios -%}{%- set media_ratio_large = '3-2' -%}{%- endif -%}
5546

56-
{%- macro _text_column_contents(list_items) %}
57-
{#- Mandatory title -#}
58-
<div class="p-section--shallow">
59-
<h2>{{ title_text }}</h2>
60-
</div>
61-
62-
{%- if has_logo_section %}
63-
{#- Optional logo section -#}
64-
<div class="p-section--shallow">
65-
<div class="u-fixed-width">
66-
{{- logo_section_content -}}
67-
</div>
68-
</div>
69-
{%- endif -%}
47+
{%- set media_ratio_medium_small = (media_ratio.get('medium_small', '3-2') | string | trim | lower) -%}
48+
{#- 'auto-height' is intentionally excluded — it requires side-by-side columns, but medium/small layouts stack -#}
49+
{%- set valid_medium_small_ratios = ['16-9', '3-2', '1-1'] -%}
50+
{%- if media_ratio_medium_small not in valid_medium_small_ratios -%}{%- set media_ratio_medium_small = '3-2' -%}{%- endif -%}
7051

71-
{%- if has_description %}
72-
{#- Optional description -#}
73-
<div class="p-section--shallow">
74-
{{- description_content -}}
75-
</div>
76-
{%- endif -%}
52+
{%- set media_fit = (media.get('fit', 'cover') | string | trim | lower) -%}
53+
{%- if media_fit not in ['cover', 'contain'] -%}{%- set media_fit = 'cover' -%}{%- endif -%}
7754

78-
{%- if list_items|length > 0 %}
79-
{#- Optional list -#}
80-
<{{ list_element_type }} class="p-list--divided">
81-
{% for list_item in list_items %}
82-
<li class="p-list__item {{ list_item_tick_class }}">
83-
{{- list_item -}}
84-
</li>
85-
{% endfor %}
86-
</{{ list_element_type }}>
87-
{%- endif -%}
55+
{%- set media_attrs = media.get('attrs', {}) -%}
56+
{%- set is_auto_height = (media_ratio_large == 'auto-height') -%}
8857

89-
{%- if has_cta %}
90-
{#- Optional CTA block -#}
91-
<div class="p-cta-block">
92-
{{- cta_content -}}
93-
</div>
94-
{%- endif -%}
58+
{#- Constrain items to the curated allow-list. Disallowed types are silently dropped. -#}
59+
{%- set allowed_item_types = ['description', 'list', 'code-block', 'logo-block', 'cta-block'] -%}
60+
{%- set filtered_items = items | selectattr('type', 'in', allowed_item_types) | list -%}
9561

62+
{%- macro _rich_list_image(ratio_large, ratio_medium_small, fit, attrs) %}
63+
{%- set is_cover = (fit == 'cover') -%}
64+
{#- The image-container CSS uses 'square' for the 1:1 ratio class, not '1-1'. -#}
65+
{%- set ratio_large_class = 'square' if ratio_large == '1-1' else ratio_large -%}
66+
{%- set ratio_medium_small_class = 'square' if ratio_medium_small == '1-1' else ratio_medium_small -%}
67+
{%- set classes = 'p-image-container--' ~ ratio_large_class ~ '-on-large' -%}
68+
{%- set classes = classes ~ ' p-image-container--' ~ ratio_medium_small_class ~ '-on-medium' -%}
69+
{%- set classes = classes ~ ' p-image-container--' ~ ratio_medium_small_class ~ '-on-small' -%}
70+
{%- if is_cover -%}{%- set classes = classes ~ ' is-cover' -%}{%- endif -%}
71+
<div class="{{ classes }}">
72+
<img class="p-image-container__image{%- if 'class' in attrs %} {{ attrs['class'] }}{%- endif %}"
73+
{%- for attr, value in attrs.items() -%}
74+
{%- if attr != 'class' %} {{ attr }}="{{ value }}"{%- endif -%}
75+
{%- endfor -%}
76+
/>
77+
</div>
9678
{%- endmacro -%}
9779

98-
{%- macro _image_column_contents() %}
99-
{#- Mandatory image -#}
100-
<div class="p-section--shallow">
101-
{{- image_content -}}
80+
{%- macro _rich_list_video(attrs) %}
81+
<div class="u-embedded-media">
82+
<iframe class="u-embedded-media__element{%- if 'class' in attrs %} {{ attrs['class'] }}{%- endif %}"
83+
{%- for attr, value in attrs.items() -%}
84+
{%- if attr != 'class' %} {{ attr }}="{{ value }}"{%- endif -%}
85+
{%- endfor -%}
86+
></iframe>
10287
</div>
10388
{%- endmacro -%}
10489

105-
<div class="p-section">
106-
<div class="grid-row--50-50-on-large">
107-
<hr>
108-
{% if not is_flipped -%}
109-
<div class="grid-col">
110-
{{- _text_column_contents(list_items) -}}
111-
</div>
112-
<div class="grid-col">
113-
{{- _image_column_contents() -}}
114-
</div>
90+
{%- macro _media_column_contents(
91+
media_type, media_ratio_large, media_ratio_medium_small, media_fit, media_attrs, is_auto_height
92+
) -%}
93+
{%- if is_auto_height -%}
94+
<div>
95+
{%- else -%}
96+
<div class="p-section--shallow">
97+
{%- endif -%}
98+
{%- if media_type == 'video' -%}
99+
{{ _rich_list_video(media_attrs) }}
115100
{%- else -%}
116-
{#- For flipped layout, place the image contents in the first column and the text in the second column -#}
117-
<div class="grid-col">
118-
{{- _image_column_contents() -}}
119-
</div>
120-
<div class="grid-col">
121-
{{- _text_column_contents(list_items) -}}
122-
</div>
101+
{{ _rich_list_image(media_ratio_large, media_ratio_medium_small, media_fit, media_attrs) }}
123102
{%- endif -%}
124103
</div>
125-
</div>
104+
{%- endmacro -%}
126105

106+
{%- macro _content_column_contents(title, items) -%}
107+
{{ basic_section_title(title) }}
108+
{{ vf_basic_section_blocks(items=items) }}
109+
{%- endmacro -%}
110+
111+
<section class="{{ padding_classes }}{%- if 'class' in attrs %} {{ attrs['class'] }}{%- endif -%}"
112+
{%- for attr, value in attrs.items() -%}
113+
{%- if attr != 'class' %} {{ attr }}="{{ value }}"{%- endif -%}
114+
{%- endfor -%}
115+
>
116+
<div class="grid-row--50-50-on-large">
117+
{{ vf_section_top_rule(top_rule_variant) }}
118+
{%- if not is_flipped -%}
119+
<div class="grid-col">{{ _content_column_contents(title, filtered_items) }}</div>
120+
<div class="grid-col">{{ _media_column_contents(media_type, media_ratio_large, media_ratio_medium_small, media_fit, media_attrs, is_auto_height) }}</div>
121+
{%- else -%}
122+
<div class="grid-col">{{ _media_column_contents(media_type, media_ratio_large, media_ratio_medium_small, media_fit, media_attrs, is_auto_height) }}</div>
123+
<div class="grid-col">{{ _content_column_contents(title, filtered_items) }}</div>
124+
{%- endif -%}
125+
</div>
126+
</section>
127127
{%- endmacro %}

0 commit comments

Comments
 (0)