@eox/pages-theme-eox 1.1.2 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.2.1](https://gitlab.eox.at/eox/hub/eoxhub-portal/compare/v1.2.0...v1.2.1) (2026-03-23)
4
+
5
+ ### Bug Fixes
6
+
7
+ - add `logo.target` prop to LogoSection ([8b664b4](https://gitlab.eox.at/eox/hub/eoxhub-portal/commit/8b664b4f981a200d6bf28ea53999035850698a49))
8
+
9
+ ## [1.2.0](https://gitlab.eox.at/eox/hub/eoxhub-portal/compare/v1.1.2...v1.2.0) (2026-03-19)
10
+
11
+ ### Features
12
+
13
+ - introduce FeatureCards variants ([#10](https://gitlab.eox.at/eox/hub/eoxhub-portal/pages-theme-eox/-/merge_requests/10))
14
+
15
+ ### Miscellaneous chores
16
+
17
+ - update @eox/ui and add required adjustments ([3dae8d2d](https://gitlab.eox.at/eox/hub/eoxhub-portal/pages-theme-eox/-/commit/3dae8d2d2d2619667e8334c91d492def4981421b))
18
+
3
19
  ## [1.1.2](https://gitlab.eox.at/eox/hub/eoxhub-portal/compare/v1.1.1...v1.1.2) (2026-03-16)
4
20
 
5
21
  ### Bug Fixes
package/README.md CHANGED
@@ -238,7 +238,12 @@ Displays a grid of client or partner logos. It automatically handles logo sizing
238
238
  ```html
239
239
  <LogoSection
240
240
  :logos="[
241
- { image: '/logos/client1.png', alt: 'Client 1', link: 'https://client1.com' },
241
+ {
242
+ image: '/logos/client1.png',
243
+ alt: 'Client 1',
244
+ link: 'https://client1.com',
245
+ target: '_blank'
246
+ },
242
247
  { image: '/logos/client2.png', alt: 'Client 2' }
243
248
  ]"
244
249
  base-height="4"
@@ -270,22 +275,97 @@ An interactive table with expandable rows. The `summary` object keys must match
270
275
 
271
276
  A grid layout for feature cards. Automatically used on pages under `features/`, but can be used manually with custom cards.
272
277
 
278
+ ##### Gallery Props
279
+
280
+ | Prop | Type | Default | Description |
281
+ | :--------------- | :---------------- | :------------------------------ | :----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
282
+ | `sectionTitle` | `String\|Boolean` | `"More {site.title} features:"` | Title displayed above the grid. Set to `false` to hide it. |
283
+ | `variant` | `String` | `"default"` | Card variant to use. Supported values: `"default"`, `"poster"`. |
284
+ | `columns` | `String\|Number` | `"4/2/1"` | Number of columns at large/medium/small breakpoints in `"L/M/S"` format, e.g. `"3/2/1"`. A single number applies to large only (medium defaults to 2, small to 1). |
285
+ | `cards` | `Array` | — | Array of card objects (see **Card Properties** below). Falls back to auto-collected `<FeatureSection>` data if omitted. |
286
+ | `background` | `String` | `"primary primary-gradient-bg"` | CSS class(es) applied to the section background. |
287
+ | `cardBackground` | `String` | `"surface-container-low"` | CSS class(es) applied to each card. |
288
+
289
+ ##### Card Properties
290
+
291
+ Each object in the `cards` array supports the following properties:
292
+
293
+ | Property | Type | Required | Description |
294
+ | :-------------- | :---------------------------------- | :------- | :-------------------------------------------------------------------------------------------------------------------------- |
295
+ | `id` | `String\|Number` | ✅ | Unique identifier for the card. |
296
+ | `title` | `String` | ✅ | Card heading text. |
297
+ | `content` | `String` | ✅ | Card body text (supports HTML). |
298
+ | `icon` | `String\|{ html, width?, height? }` | — | Icon to display. Use an `mdi-*` class string for Material Design icons, or an object with `html` for custom SVG/HTML icons. |
299
+ | `image` | `String` | — | URL of an image. Used as a fallback icon in the `default` variant, and as a background in the `poster` variant. |
300
+ | `chips` | `{ text, class? }[]` | — | Array of chip/badge objects rendered above the title. |
301
+ | `metadata` | `{ text, icon? }[]` | — | Array of metadata items (e.g. date, author) rendered with an optional `mdi-*` icon. |
302
+ | `link` | `{ text, href, target? }` | — | Primary call-to-action link. `target` defaults to `_blank`. |
303
+ | `secondaryLink` | `{ text, href, target? }` | — | Secondary link rendered alongside the primary link. `target` defaults to `_blank`. |
304
+
305
+ ##### Card Variants
306
+
307
+ ###### `default` (default)
308
+
309
+ A standard content card with an icon or image, title, optional chips, metadata, body text and links.
310
+
273
311
  ```html
274
312
  <FeaturesGallery
275
313
  section-title="All Features"
314
+ variant="default"
315
+ columns="3/2/1"
276
316
  background="surface-container-low"
277
317
  :cards="[
278
318
  {
279
319
  id: 1,
280
320
  title: 'Global Coverage',
281
- content: 'Access data from anywhere.',
282
- icon: 'mdi-earth'
321
+ content: 'Access satellite data from anywhere on the globe.',
322
+ icon: 'mdi-earth',
323
+ chips: [{ text: 'New', class: 'primary' }],
324
+ metadata: [{ text: '2024-01-15', icon: 'mdi-calendar' }],
325
+ link: { text: 'Learn more', href: '/coverage' },
326
+ secondaryLink: { text: 'API docs', href: '/api', target: '_self' }
283
327
  },
284
328
  {
285
329
  id: 2,
286
- title: 'Real-time',
287
- content: 'Updates as they happen.',
330
+ title: 'Real-time Updates',
331
+ content: 'Data refreshed as events happen.',
332
+ icon: { html: '<img src=\"/icons/realtime.svg\" alt=\"\" />', width: 48, height: 48 },
288
333
  link: { text: 'Learn more', href: '/real-time' }
334
+ },
335
+ {
336
+ id: 3,
337
+ title: 'Archive Access',
338
+ content: 'Query decades of historical imagery.',
339
+ image: '/images/archive-thumb.png',
340
+ link: { text: 'Explore', href: '/archive' }
341
+ }
342
+ ]"
343
+ />
344
+ ```
345
+
346
+ ###### `poster`
347
+
348
+ An image-overlay card where a background image fills the card and text is rendered on top with a dark gradient. Adds one extra prop:
349
+
350
+ | Prop | Type | Default | Description |
351
+ | :---------- | :------- | :-------- | :------------------------------------- |
352
+ | `minHeight` | `String` | `"20rem"` | Minimum CSS height of the poster card. |
353
+
354
+ ```html
355
+ <FeaturesGallery
356
+ section-title="Case Studies"
357
+ variant="poster"
358
+ columns="3/2/1"
359
+ :cards="[
360
+ {
361
+ id: 1,
362
+ title: 'Arctic Monitoring',
363
+ content: 'Year-round ice extent tracking.',
364
+ image: '/images/arctic.jpg',
365
+ chips: [{ text: 'Featured' }],
366
+ metadata: [{ text: 'Jan 2024', icon: 'mdi-calendar' }],
367
+ link: { text: 'Read case study', href: '/arctic' },
368
+ secondaryLink: { text: 'Dataset', href: '/datasets/arctic' }
289
369
  }
290
370
  ]"
291
371
  />
@@ -1,6 +1,7 @@
1
1
  export const data = [
2
2
  {
3
3
  id: 1,
4
+ url: "/features/mock-feature-1",
4
5
  title: "Mock Feature 1",
5
6
  content: "Content 1",
6
7
  html: "<featuresection><h1>Mock Feature 1</h1><p>Content 1</p></featuresection>",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eox/pages-theme-eox",
3
- "version": "1.1.2",
3
+ "version": "1.2.1",
4
4
  "type": "module",
5
5
  "description": "Vitepress Theme with EOX branding",
6
6
  "main": "src/index.js",
@@ -14,7 +14,7 @@
14
14
  },
15
15
  "dependencies": {
16
16
  "@eox/eslint-config": "^2.0.0",
17
- "@eox/ui": "^0.3.7",
17
+ "@eox/ui": "^1.0.1",
18
18
  "vitepress": "^1.6.3"
19
19
  },
20
20
  "devDependencies": {
@@ -0,0 +1,156 @@
1
+ import FeatureCard from "./Index.vue";
2
+
3
+ describe("<FeatureCard />", () => {
4
+ const baseProps = {
5
+ title: "Test Feature",
6
+ content: "This is a test feature content.",
7
+ };
8
+
9
+ it("renders title and content using props", () => {
10
+ cy.mount(FeatureCard, {
11
+ props: baseProps,
12
+ });
13
+
14
+ cy.get("h5").should("contain", baseProps.title);
15
+ cy.contains(baseProps.content).should("exist");
16
+ cy.get("article").should("have.class", "vertical");
17
+ cy.get("article").should("have.class", "large-padding");
18
+ });
19
+
20
+ it("renders content in a paragraph when using slots", () => {
21
+ const slotContent = "This is content from a slot.";
22
+
23
+ cy.mount(FeatureCard, {
24
+ props: {
25
+ ...baseProps,
26
+ content: "Fallback content",
27
+ },
28
+ slots: {
29
+ default: slotContent,
30
+ },
31
+ });
32
+
33
+ cy.get("h5").should("contain", baseProps.title);
34
+ cy.get("p").should("contain", slotContent);
35
+ cy.contains("Fallback content").should("not.exist");
36
+ });
37
+
38
+ it("renders primary link with explicit target", () => {
39
+ const link = {
40
+ text: "Learn More",
41
+ href: "https://example.com",
42
+ target: "_blank",
43
+ };
44
+
45
+ cy.mount(FeatureCard, {
46
+ props: {
47
+ ...baseProps,
48
+ link,
49
+ },
50
+ });
51
+
52
+ cy.get("nav").should("exist");
53
+ cy.contains("a", "Learn More")
54
+ .should("have.attr", "href", "https://example.com")
55
+ .and("have.attr", "target", "_blank");
56
+ cy.get(".arrow").should("exist");
57
+ });
58
+
59
+ it("defaults primary link target to _blank", () => {
60
+ cy.mount(FeatureCard, {
61
+ props: {
62
+ ...baseProps,
63
+ link: {
64
+ text: "Read",
65
+ href: "/docs",
66
+ },
67
+ },
68
+ });
69
+
70
+ cy.contains("a", "Read")
71
+ .should("have.attr", "target", "_blank")
72
+ .and("have.attr", "href", "/docs");
73
+ });
74
+
75
+ it("renders secondary link and nav layout when provided", () => {
76
+ cy.mount(FeatureCard, {
77
+ props: {
78
+ ...baseProps,
79
+ secondaryLink: {
80
+ text: "Docs",
81
+ href: "/docs",
82
+ target: "_self",
83
+ },
84
+ },
85
+ });
86
+
87
+ cy.get("nav").should("exist");
88
+ cy.get(".secondary-link")
89
+ .should("contain", "Docs")
90
+ .and("have.attr", "href", "/docs")
91
+ .and("have.attr", "target", "_self");
92
+ });
93
+
94
+ it("renders icon from class string", () => {
95
+ cy.mount(FeatureCard, {
96
+ props: {
97
+ ...baseProps,
98
+ icon: "mdi-home",
99
+ },
100
+ });
101
+
102
+ cy.get("i.mdi.mdi-home").should("exist");
103
+ });
104
+
105
+ it("renders html icon", () => {
106
+ const iconHtml = '<svg><circle cx="50" cy="50" r="40" /></svg>';
107
+ cy.mount(FeatureCard, {
108
+ props: {
109
+ ...baseProps,
110
+ icon: { html: iconHtml },
111
+ },
112
+ });
113
+
114
+ cy.get(".icon").should("exist");
115
+ cy.get(".icon").find("svg").should("exist");
116
+ });
117
+
118
+ it("renders image fallback when no icon is provided", () => {
119
+ cy.mount(FeatureCard, {
120
+ props: {
121
+ ...baseProps,
122
+ image: "/feature.png",
123
+ },
124
+ });
125
+
126
+ cy.get("img.icon").should("have.attr", "src", "/feature.png");
127
+ });
128
+
129
+ it("renders chips and metadata", () => {
130
+ cy.mount(FeatureCard, {
131
+ props: {
132
+ ...baseProps,
133
+ chips: [{ text: "New", class: "primary" }, { text: "Popular" }],
134
+ metadata: [
135
+ { text: "5 min read", icon: "mdi-clock-outline" },
136
+ { text: "Updated weekly" },
137
+ ],
138
+ },
139
+ });
140
+
141
+ cy.get(".small-chip").should("have.length", 2);
142
+ cy.contains(".small-chip", "New").should("have.class", "primary");
143
+ cy.contains(".small-chip", "Popular").should("exist");
144
+ cy.get(".metadata-item").should("have.length", 2);
145
+ cy.get(".metadata-item .mdi-clock-outline").should("exist");
146
+ cy.contains("Updated weekly").should("exist");
147
+ });
148
+
149
+ it("does not render nav when no links are provided", () => {
150
+ cy.mount(FeatureCard, {
151
+ props: baseProps,
152
+ });
153
+
154
+ cy.get("nav").should("not.exist");
155
+ });
156
+ });
@@ -0,0 +1,163 @@
1
+ <script setup>
2
+ const { title, content, link, secondaryLink, icon } = defineProps({
3
+ title: String,
4
+ content: String,
5
+ image: {
6
+ type: String,
7
+ required: false,
8
+ },
9
+ chips: {
10
+ /** @type {import('vue').PropType<{ text: string; class?: string }[]>} */
11
+ type: Array,
12
+ required: false,
13
+ default: () => [],
14
+ },
15
+ metadata: {
16
+ /** @type {import('vue').PropType<{ text: string; icon?: string }[]>} */
17
+ type: Array,
18
+ required: false,
19
+ default: () => [],
20
+ },
21
+ link: {
22
+ /** @type {import('vue').PropType<{ text:string ,href: string,target?: string }>} */
23
+ type: Object,
24
+ required: false,
25
+ },
26
+ secondaryLink: {
27
+ /** @type {import('vue').PropType<{ text:string ,href: string,target?: string }>} */
28
+ type: Object,
29
+ required: false,
30
+ },
31
+ icon: {
32
+ /** @type {import('vue').PropType<{ html: string, width?: number, height?: number }>} */
33
+ type: Object,
34
+ required: false,
35
+ },
36
+ });
37
+ const iconStyle = {
38
+ width: (icon?.width ?? 40) + "px",
39
+ height: (icon?.height ?? 40) + "px",
40
+ };
41
+ </script>
42
+ <template>
43
+ <article class="vertical large-padding">
44
+ <div>
45
+ <i
46
+ v-if="typeof icon === 'string' && icon.startsWith('mdi-')"
47
+ :class="`mdi ${icon}`"
48
+ ></i>
49
+ <div
50
+ v-else-if="icon"
51
+ :style="iconStyle"
52
+ v-html="icon.html"
53
+ class="icon"
54
+ ></div>
55
+ <img v-else-if="image" :src="image" alt="" class="icon" />
56
+ <h5 class="small">{{ title }}</h5>
57
+ <div
58
+ v-if="chips && chips.length"
59
+ class="row wrap"
60
+ style="gap: 0.5rem; margin-bottom: 0.5rem"
61
+ >
62
+ <span
63
+ v-for="chip in chips"
64
+ :key="chip.text"
65
+ class="chip small-chip border"
66
+ :class="chip.class"
67
+ >
68
+ {{ chip.text }}
69
+ </span>
70
+ </div>
71
+ <p v-if="metadata && metadata.length" class="small-text">
72
+ <span
73
+ v-for="(item, index) in metadata"
74
+ :key="index"
75
+ class="metadata-item"
76
+ >
77
+ <i
78
+ v-if="item.icon"
79
+ :class="`small mdi ${item.icon}`"
80
+ style="transform: translateY(-1px)"
81
+ ></i>
82
+ {{ item.text }}
83
+ <br v-if="index < metadata.length - 1" />
84
+ </span>
85
+ </p>
86
+
87
+ <p v-if="$slots.default">
88
+ <slot>{{ content }}</slot>
89
+ </p>
90
+ <div v-else v-html="content"></div>
91
+ </div>
92
+ <nav
93
+ v-if="link || secondaryLink"
94
+ class="row wrap align-center"
95
+ style="gap: 1.5rem; margin-right: auto"
96
+ >
97
+ <a
98
+ v-if="link"
99
+ :href="link.href"
100
+ :target="link.target ?? '_blank'"
101
+ class="button transparent bold primary-text no-padding"
102
+ >
103
+ <span>
104
+ {{ link.text }}
105
+ </span>
106
+ <i class="mdi mdi-chevron-right arrow"></i>
107
+ </a>
108
+ <a
109
+ v-if="secondaryLink"
110
+ :href="secondaryLink.href"
111
+ :target="secondaryLink.target ?? '_blank'"
112
+ class="button transparent primary-text no-padding secondary-link"
113
+ >
114
+ <span>
115
+ {{ secondaryLink.text }}
116
+ </span>
117
+ </a>
118
+ </nav>
119
+ </article>
120
+ </template>
121
+ <style scoped>
122
+ /**
123
+ * Undo Vitepress missing with @eox/ui styles
124
+ */
125
+ .VPHome .vp-doc a.button.bold {
126
+ font-weight: bold;
127
+ }
128
+ .secondary-link {
129
+ font-weight: normal;
130
+ opacity: 0.8;
131
+ }
132
+ .secondary-link:hover {
133
+ opacity: 1;
134
+ }
135
+ .VPHome .vp-doc h5.small {
136
+ font-size: 1.25rem;
137
+ }
138
+
139
+ /**
140
+ * Custom styles
141
+ */
142
+ article {
143
+ justify-content: space-between;
144
+ transition: all 0.3s ease-in-out;
145
+ width: 100%;
146
+ }
147
+ .small-chip {
148
+ font-size: 0.75rem;
149
+ padding: 0.125rem 0.375rem;
150
+ height: auto;
151
+ line-height: normal;
152
+ margin: 0;
153
+ }
154
+ .arrow {
155
+ transition: transform 0.2s;
156
+ }
157
+ .button:hover .arrow {
158
+ transform: translateX(4px);
159
+ }
160
+ .button.transparent:hover:after {
161
+ background: none;
162
+ }
163
+ </style>
@@ -0,0 +1,137 @@
1
+ import PosterCard from "./Poster.vue";
2
+
3
+ describe("<FeatureCard Poster />", () => {
4
+ const baseProps = {
5
+ title: "Poster Feature",
6
+ content: "Poster content",
7
+ };
8
+
9
+ it("renders title and content using props", () => {
10
+ cy.mount(PosterCard, {
11
+ props: baseProps,
12
+ });
13
+
14
+ cy.get("article.image-overlay-card").should("exist");
15
+ cy.get("h5").should("contain", baseProps.title);
16
+ cy.contains(baseProps.content).should("exist");
17
+ cy.get(".image-overlay-card-overlay").should("exist");
18
+ cy.get(".image-overlay-card-content").should("exist");
19
+ });
20
+
21
+ it("renders slot content over content prop when slot exists", () => {
22
+ const slotContent = "This is content from a slot.";
23
+
24
+ cy.mount(PosterCard, {
25
+ props: {
26
+ ...baseProps,
27
+ content: "Fallback content",
28
+ },
29
+ slots: {
30
+ default: slotContent,
31
+ },
32
+ });
33
+
34
+ cy.get("p").should("contain", slotContent);
35
+ cy.contains("Fallback content").should("not.exist");
36
+ });
37
+
38
+ it("renders poster image when image prop is set", () => {
39
+ cy.mount(PosterCard, {
40
+ props: {
41
+ ...baseProps,
42
+ image: "/poster.png",
43
+ },
44
+ });
45
+
46
+ cy.get("img.image-overlay-card-image").should(
47
+ "have.attr",
48
+ "src",
49
+ "/poster.png",
50
+ );
51
+ });
52
+
53
+ it("applies custom minHeight", () => {
54
+ cy.mount(PosterCard, {
55
+ props: {
56
+ ...baseProps,
57
+ minHeight: "28rem",
58
+ },
59
+ });
60
+
61
+ cy.get("article.image-overlay-card")
62
+ .should("have.attr", "style")
63
+ .and("include", "min-height: 28rem");
64
+ });
65
+
66
+ it("renders primary and secondary links with targets", () => {
67
+ const link = {
68
+ text: "Learn More",
69
+ href: "https://example.com",
70
+ target: "_blank",
71
+ };
72
+
73
+ const secondaryLink = {
74
+ text: "Docs",
75
+ href: "/docs",
76
+ target: "_self",
77
+ };
78
+
79
+ cy.mount(PosterCard, {
80
+ props: {
81
+ ...baseProps,
82
+ link,
83
+ secondaryLink,
84
+ },
85
+ });
86
+
87
+ cy.get("nav").should("exist");
88
+ cy.contains("a", "Learn More")
89
+ .should("have.attr", "href", "https://example.com")
90
+ .and("have.attr", "target", "_blank");
91
+ cy.contains("a", "Docs")
92
+ .should("have.attr", "href", "/docs")
93
+ .and("have.attr", "target", "_self");
94
+ });
95
+
96
+ it("defaults link target to _blank when omitted", () => {
97
+ cy.mount(PosterCard, {
98
+ props: {
99
+ ...baseProps,
100
+ link: {
101
+ text: "Read",
102
+ href: "/read",
103
+ },
104
+ },
105
+ });
106
+
107
+ cy.contains("a", "Read")
108
+ .should("have.attr", "href", "/read")
109
+ .and("have.attr", "target", "_blank");
110
+ });
111
+
112
+ it("renders chips and metadata", () => {
113
+ cy.mount(PosterCard, {
114
+ props: {
115
+ ...baseProps,
116
+ chips: [{ text: "Launch" }, { text: "EOX", class: "primary" }],
117
+ metadata: [
118
+ { text: "10 min", icon: "mdi-clock-outline" },
119
+ { text: "Advanced" },
120
+ ],
121
+ },
122
+ });
123
+
124
+ cy.get(".small-chip").should("have.length", 2);
125
+ cy.contains(".small-chip", "EOX").should("have.class", "primary");
126
+ cy.get(".metadata-item").should("have.length", 2);
127
+ cy.get(".metadata-item .mdi-clock-outline").should("exist");
128
+ });
129
+
130
+ it("does not render nav when links are not provided", () => {
131
+ cy.mount(PosterCard, {
132
+ props: baseProps,
133
+ });
134
+
135
+ cy.get("nav").should("not.exist");
136
+ });
137
+ });
@@ -0,0 +1,172 @@
1
+ <script setup>
2
+ import { computed } from "vue";
3
+
4
+ const props = defineProps({
5
+ title: String,
6
+ content: String,
7
+ link: {
8
+ /** @type {import('vue').PropType<{ text:string ,href: string,target?: string }>} */
9
+ type: Object,
10
+ required: false,
11
+ },
12
+ icon: {
13
+ /** @type {import('vue').PropType<string | { html: string, width?: number, height?: number }>} */
14
+ type: [String, Object],
15
+ required: false,
16
+ },
17
+ image: {
18
+ type: String,
19
+ required: false,
20
+ },
21
+ chips: {
22
+ /** @type {import('vue').PropType<{ text: string; class?: string }[]>} */
23
+ type: Array,
24
+ required: false,
25
+ default: () => [],
26
+ },
27
+ metadata: {
28
+ /** @type {import('vue').PropType<{ text: string; icon?: string }[]>} */
29
+ type: Array,
30
+ required: false,
31
+ default: () => [],
32
+ },
33
+ secondaryLink: {
34
+ /** @type {import('vue').PropType<{ text: string, href: string, target?: string }>} */
35
+ type: Object,
36
+ required: false,
37
+ },
38
+ minHeight: {
39
+ type: String,
40
+ default: "20rem",
41
+ },
42
+ });
43
+
44
+ const cardStyle = computed(() => ({
45
+ minHeight: props.minHeight,
46
+ }));
47
+ </script>
48
+
49
+ <template>
50
+ <article
51
+ class="no-padding fill small-elevate image-overlay-card"
52
+ :style="cardStyle"
53
+ >
54
+ <img v-if="image" :src="image" alt="" class="image-overlay-card-image" />
55
+ <div class="image-overlay-card-overlay"></div>
56
+ <div class="image-overlay-card-content padding">
57
+ <div style="padding-top: 3rem">
58
+ <div
59
+ v-if="chips && chips.length"
60
+ class="row wrap absolute top"
61
+ style="gap: 0.5rem; margin-top: 1.5rem"
62
+ >
63
+ <span
64
+ v-for="chip in chips"
65
+ :key="chip.text"
66
+ class="chip small-chip border white-text"
67
+ :class="chip.class"
68
+ >
69
+ {{ chip.text }}
70
+ </span>
71
+ </div>
72
+ <h5 class="small white-text">{{ title }}</h5>
73
+ <p v-if="metadata && metadata.length" class="small-text white-text">
74
+ <span
75
+ v-for="(item, index) in metadata"
76
+ :key="index"
77
+ class="metadata-item"
78
+ >
79
+ <i
80
+ v-if="item.icon"
81
+ :class="`small mdi ${item.icon}`"
82
+ style="transform: translateY(-1px)"
83
+ ></i>
84
+ {{ item.text }}
85
+ <br v-if="index < metadata.length - 1" />
86
+ </span>
87
+ </p>
88
+ <p v-if="$slots.default" class="medium-text white-text">
89
+ <slot>{{ content }}</slot>
90
+ </p>
91
+ <p
92
+ v-else-if="content"
93
+ class="medium-text white-text"
94
+ v-html="content"
95
+ ></p>
96
+ </div>
97
+ <nav
98
+ v-if="link || secondaryLink"
99
+ class="row wrap align-center"
100
+ style="gap: 0.5rem"
101
+ >
102
+ <a
103
+ v-if="link"
104
+ class="button small white black-text"
105
+ :href="link.href"
106
+ :target="link.target ?? '_blank'"
107
+ >
108
+ {{ link.text }}
109
+ </a>
110
+ <a
111
+ v-if="secondaryLink"
112
+ class="button small border white-text secondary-link"
113
+ :href="secondaryLink.href"
114
+ :target="secondaryLink.target ?? '_blank'"
115
+ >
116
+ {{ secondaryLink.text }}
117
+ </a>
118
+ </nav>
119
+ </div>
120
+ </article>
121
+ </template>
122
+
123
+ <style scoped>
124
+ .image-overlay-card {
125
+ position: relative;
126
+ display: flex;
127
+ flex-direction: column;
128
+ transition:
129
+ transform 0.3s ease,
130
+ box-shadow 0.3s ease;
131
+ overflow: hidden;
132
+ }
133
+
134
+ .image-overlay-card-image {
135
+ position: absolute;
136
+ inset: 0;
137
+ width: 100%;
138
+ height: 100%;
139
+ object-fit: cover;
140
+ z-index: 0;
141
+ }
142
+
143
+ .image-overlay-card-overlay {
144
+ position: absolute;
145
+ inset: 0;
146
+ background: linear-gradient(
147
+ to bottom,
148
+ rgba(0, 0, 0, 0.1) 0%,
149
+ rgba(0, 0, 0, 0.6) 40%,
150
+ rgba(0, 0, 0, 0.9) 100%
151
+ );
152
+ border-radius: inherit;
153
+ z-index: 1;
154
+ }
155
+
156
+ .image-overlay-card-content {
157
+ position: relative;
158
+ z-index: 2;
159
+ display: flex;
160
+ flex-direction: column;
161
+ justify-content: flex-end;
162
+ flex: 1;
163
+ }
164
+ .small-chip {
165
+ font-size: 0.75rem;
166
+ padding: 0.125rem 0.375rem;
167
+ height: auto;
168
+ line-height: normal;
169
+ margin: 0;
170
+ pointer-events: none;
171
+ }
172
+ </style>
@@ -3,8 +3,23 @@ import { __setMockData } from "../../cypress/support/mocks/vitepress";
3
3
 
4
4
  describe("<FeaturesGallery />", () => {
5
5
  const cards = [
6
- { id: 1, title: "Card 1", content: "Content 1", icon: "mdi-home" },
7
- { id: 2, title: "Card 2", content: "Content 2" },
6
+ {
7
+ id: 1,
8
+ title: "Card 1",
9
+ content: "Content 1",
10
+ icon: "mdi-home",
11
+ chips: [{ text: "New", class: "primary" }],
12
+ metadata: [{ text: "5 min", icon: "mdi-clock-outline" }],
13
+ link: { text: "Read more", href: "/feature-1", target: "_self" },
14
+ secondaryLink: { text: "Docs", href: "/docs", target: "_self" },
15
+ },
16
+ {
17
+ id: 2,
18
+ title: "Card 2",
19
+ content: "Content 2",
20
+ image: "/image.png",
21
+ link: { href: "/card-2", target: "_self" },
22
+ },
8
23
  ];
9
24
 
10
25
  beforeEach(() => {
@@ -22,6 +37,17 @@ describe("<FeaturesGallery />", () => {
22
37
  cy.contains("h5", "My Gallery").should("be.visible");
23
38
  });
24
39
 
40
+ it("hides section title when sectionTitle is false", () => {
41
+ cy.mount(FeaturesGallery, {
42
+ props: {
43
+ cards,
44
+ sectionTitle: false,
45
+ },
46
+ });
47
+
48
+ cy.get(".holder > h5").should("not.exist");
49
+ });
50
+
25
51
  it("renders feature cards", () => {
26
52
  cy.mount(FeaturesGallery, {
27
53
  props: { cards },
@@ -30,16 +56,100 @@ describe("<FeaturesGallery />", () => {
30
56
  cy.contains("Card 1").should("exist");
31
57
  cy.contains("Content 1").should("exist");
32
58
  cy.contains("Card 2").should("exist");
59
+ cy.get(".card-wrapper").should("have.length", 2);
33
60
  });
34
61
 
35
- it("applies background classes", () => {
62
+ it("applies background and card background classes", () => {
36
63
  cy.mount(FeaturesGallery, {
37
64
  props: {
38
65
  cards,
39
66
  background: "custom-bg-class",
67
+ cardBackground: "surface-container",
40
68
  },
41
69
  });
42
70
 
43
- cy.get("section").should("have.class", "custom-bg-class");
71
+ cy.get("section")
72
+ .should("have.class", "custom-bg-class")
73
+ .and("have.class", "full-width");
74
+ cy.get(".card-wrapper article")
75
+ .first()
76
+ .should("have.class", "surface-container");
77
+ });
78
+
79
+ it("uses provided columns to set css variables", () => {
80
+ cy.mount(FeaturesGallery, {
81
+ props: {
82
+ cards: [cards[0]],
83
+ columns: "3/2/1",
84
+ },
85
+ });
86
+
87
+ cy.get(".cards-gallery")
88
+ .should("have.attr", "style")
89
+ .and("include", "--cols-l: 3")
90
+ .and("include", "--cols-m: 2")
91
+ .and("include", "--cols-s: 1");
92
+ });
93
+
94
+ it("wraps cards in anchor when link has no text", () => {
95
+ cy.mount(FeaturesGallery, {
96
+ props: {
97
+ cards: [cards[1]],
98
+ },
99
+ });
100
+
101
+ cy.get("a.link-card")
102
+ .should("have.attr", "href", "/card-2")
103
+ .and("have.attr", "target", "_self");
104
+ cy.get("a.link-card article").should("exist");
105
+ });
106
+
107
+ it("does not wrap card when link has text", () => {
108
+ cy.mount(FeaturesGallery, {
109
+ props: {
110
+ cards: [cards[0]],
111
+ },
112
+ });
113
+
114
+ cy.get("a.link-card").should("not.exist");
115
+ cy.contains("a", "Read more")
116
+ .should("have.attr", "href", "/feature-1")
117
+ .and("have.attr", "target", "_self");
118
+ });
119
+
120
+ it("renders poster variant when selected", () => {
121
+ cy.mount(FeaturesGallery, {
122
+ props: {
123
+ cards: [cards[0]],
124
+ variant: "poster",
125
+ },
126
+ });
127
+
128
+ cy.get("article.image-overlay-card").should("exist");
129
+ });
130
+
131
+ it("falls back to default variant for unknown variant", () => {
132
+ cy.mount(FeaturesGallery, {
133
+ props: {
134
+ cards: [cards[0]],
135
+ variant: "does-not-exist",
136
+ },
137
+ });
138
+
139
+ cy.get("article.image-overlay-card").should("not.exist");
140
+ cy.get("article.vertical.large-padding").should("exist");
141
+ });
142
+
143
+ it("renders cards from mocked features data when cards prop is missing", () => {
144
+ __setMockData({
145
+ page: {
146
+ relativePath: "index.md",
147
+ },
148
+ });
149
+
150
+ cy.mount(FeaturesGallery);
151
+
152
+ cy.get(".card-wrapper").should("have.length.at.least", 1);
153
+ cy.contains("Read more").should("exist");
44
154
  });
45
155
  });
@@ -1,11 +1,22 @@
1
1
  <script setup>
2
2
  import { useData, withBase } from "vitepress";
3
3
  import { data as features } from "../features.data.js";
4
- import FeatureCard from "./FeatureCard.vue";
4
+ import FeatureCardDefault from "./FeatureCard/Index.vue";
5
+ import { defineAsyncComponent, computed } from "vue";
5
6
 
6
- const { cards, sectionTitle, background } = defineProps({
7
+ const { cards, sectionTitle, background, variant, columns } = defineProps({
7
8
  sectionTitle: {
8
- type: String,
9
+ type: [String, Boolean],
10
+ },
11
+ variant: {
12
+ type: /** @type {import('vue').PropType<keyof typeof cardComponents>} */ (
13
+ String
14
+ ),
15
+ default: "default",
16
+ },
17
+ columns: {
18
+ type: [String, Number],
19
+ default: "4/2/1",
9
20
  },
10
21
  cards: {
11
22
  /**
@@ -13,8 +24,12 @@ const { cards, sectionTitle, background } = defineProps({
13
24
  id: number | string,
14
25
  title: string,
15
26
  content: string,
16
- link?: { text: string, href: string,target?: string },
17
- icon?: string | {
27
+ image?: string,
28
+ chips?: { text: string; class?: string }[],
29
+ metadata?: { text: string; icon?: string }[],
30
+ link?: { text: string, href: string,target?: string },
31
+ secondaryLink?: { text: string, href: string,target?: string },
32
+ icon?: string | {
18
33
  html: string,
19
34
  width?: number,
20
35
  height?: number,
@@ -33,6 +48,19 @@ const { cards, sectionTitle, background } = defineProps({
33
48
  },
34
49
  });
35
50
 
51
+ const cardComponents = {
52
+ default: FeatureCardDefault,
53
+ poster: defineAsyncComponent(() => import("./FeatureCard/Poster.vue")),
54
+ };
55
+
56
+ /** @param {keyof typeof cardComponents | undefined} variant */
57
+ const resolveCardComponent = (variant) => {
58
+ if (!variant) {
59
+ return FeatureCardDefault;
60
+ }
61
+ return cardComponents[variant] || FeatureCardDefault;
62
+ };
63
+
36
64
  const { page, site } = useData();
37
65
 
38
66
  const featuresExcerpts =
@@ -73,6 +101,19 @@ const siteTitle =
73
101
  sectionTitle !== false
74
102
  ? sectionTitle || `More ${site.value.title} features:`
75
103
  : false;
104
+
105
+ const gridCols = computed(() => {
106
+ let colsStr = String(columns || "4/2/1");
107
+ if (!colsStr.includes("/")) {
108
+ colsStr = `${colsStr}/2/1`;
109
+ }
110
+ const [l, m, s] = colsStr.split("/");
111
+ return {
112
+ "--cols-l": l || 4,
113
+ "--cols-m": m || 2,
114
+ "--cols-s": s || 1,
115
+ };
116
+ });
76
117
  </script>
77
118
 
78
119
  <template>
@@ -82,31 +123,42 @@ const siteTitle =
82
123
  <h5>{{ siteTitle }}</h5>
83
124
  <div class="medium-space"></div>
84
125
  </template>
85
- <div class="cards-gallery">
126
+ <div class="cards-gallery" :style="gridCols">
86
127
  <div
87
128
  v-for="feature in featuresExcerpts"
88
129
  :key="feature.id"
89
130
  class="card-wrapper"
90
131
  >
91
- <a
92
- v-if="feature.link && !feature.link.text"
93
- :href="feature.link.href"
94
- :target="feature.link.target"
95
- class="link-card"
96
- >
97
- <FeatureCard
132
+ <div v-if="feature.link && !feature.link.text" class="link-card">
133
+ <a
134
+ :href="feature.link.href"
135
+ :target="feature.link.target"
136
+ class="link-overlay"
137
+ ></a>
138
+ <component
139
+ :is="resolveCardComponent(variant)"
98
140
  :title="feature.title"
99
141
  :content="feature.content"
100
142
  :icon="feature.icon"
143
+ :image="feature.image"
144
+ :chips="feature.chips"
145
+ :metadata="feature.metadata"
146
+ :link="feature.link"
147
+ :secondaryLink="feature.secondaryLink"
101
148
  :class="`${cardBackground}`"
102
149
  />
103
- </a>
104
- <FeatureCard
150
+ </div>
151
+ <component
105
152
  v-else
153
+ :is="resolveCardComponent(variant)"
106
154
  :title="feature.title"
107
155
  :content="feature.content"
108
156
  :icon="feature.icon"
109
- :link="feature.link?.text ? feature.link : undefined"
157
+ :image="feature.image"
158
+ :chips="feature.chips"
159
+ :metadata="feature.metadata"
160
+ :link="feature.link"
161
+ :secondaryLink="feature.secondaryLink"
110
162
  :class="`${cardBackground}`"
111
163
  />
112
164
  </div>
@@ -130,6 +182,12 @@ section {
130
182
  display: block;
131
183
  text-decoration: none;
132
184
  border: 0.0625rem solid transparent;
185
+ position: relative;
186
+ }
187
+ .link-overlay {
188
+ position: absolute;
189
+ inset: 0;
190
+ z-index: 1;
133
191
  }
134
192
  .link-card:hover article {
135
193
  box-shadow: none;
@@ -138,24 +196,19 @@ section {
138
196
 
139
197
  .card-wrapper {
140
198
  display: flex;
141
- flex: 0 0 calc(25% - 18px);
142
- }
143
-
144
- @media (max-width: 1280px) {
145
- .card-wrapper {
146
- flex: 0 0 calc(33.333% - 16px);
147
- }
199
+ --cols: var(--cols-l, 4);
200
+ flex: 0 0 calc(100% / var(--cols) - (24px * (var(--cols) - 1) / var(--cols)));
148
201
  }
149
202
 
150
203
  @media (max-width: 960px) {
151
204
  .card-wrapper {
152
- flex: 0 0 calc(50% - 12px);
205
+ --cols: var(--cols-m, 2);
153
206
  }
154
207
  }
155
208
 
156
209
  @media (max-width: 640px) {
157
210
  .card-wrapper {
158
- flex: 0 0 100%;
211
+ --cols: var(--cols-s, 1);
159
212
  }
160
213
  }
161
214
  </style>
@@ -5,6 +5,7 @@
5
5
  v-for="logo in logos"
6
6
  :is="logo.link ? 'a' : 'div'"
7
7
  :href="logo.link || undefined"
8
+ :target="logo.target || undefined"
8
9
  class="logo"
9
10
  >
10
11
  <img :src="logo.image" :alt="logo.alt" />
@@ -136,17 +136,18 @@
136
136
  <nav>
137
137
  <ul class="left-align no-margin">
138
138
  <li v-for="item in theme.nav.filter((i) => !i.action)">
139
- <button
140
- v-if="item.items"
141
- class="button text"
142
- :class="{ active: isActive(item, route.path) }"
143
- >
144
- <span>{{ t(item.text, theme.i18n) }}</span>
145
- <i class="mdi mdi-chevron-down"></i>
139
+ <div v-if="item.items">
140
+ <button
141
+ class="button text"
142
+ :class="{ active: isActive(item, route.path) }"
143
+ >
144
+ <span>{{ t(item.text, theme.i18n) }}</span>
145
+ <i class="mdi mdi-chevron-down"></i>
146
+ </button>
146
147
  <menu class="no-wrap surface-container-lowest">
147
148
  <NavDropdown :items="item.items" />
148
149
  </menu>
149
- </button>
150
+ </div>
150
151
  <a
151
152
  v-else
152
153
  class="button text"
@@ -197,9 +198,11 @@
197
198
  </ul>
198
199
  <ul class="left-align no-margin" v-if="langs && langs.length > 1">
199
200
  <li>
200
- <button class="button text">
201
- <i class="mdi mdi-translate small"></i>
202
- <i class="mdi mdi-chevron-down small"></i>
201
+ <div>
202
+ <button class="button text">
203
+ <i class="mdi mdi-translate small"></i>
204
+ <i class="mdi mdi-chevron-down small"></i>
205
+ </button>
203
206
  <menu class="no-wrap surface-container-lowest">
204
207
  <li
205
208
  v-for="lang in langs"
@@ -211,7 +214,7 @@
211
214
  </a>
212
215
  </li>
213
216
  </menu>
214
- </button>
217
+ </div>
215
218
  </li>
216
219
  </ul>
217
220
  </nav>
@@ -19,9 +19,9 @@
19
19
  @change="updatePrices"
20
20
  />
21
21
  <span class="margin" :for="option.id"
22
- ><h6 :class="`bold ${isMobile ? 'medium-text' : ''}`">
22
+ ><span :class="`bold ${isMobile ? 'medium-text' : ''}`">
23
23
  + {{ option.label }}
24
- </h6></span
24
+ </span></span
25
25
  >
26
26
  </label>
27
27
  </div>
@@ -122,9 +122,9 @@
122
122
  @change="updatePrices"
123
123
  />
124
124
  <span :for="`${plan.name}-${row}`"
125
- ><h6 class="small bold">
125
+ ><span class="small bold">
126
126
  {{ plan.details[row].alternative.name }}
127
- </h6></span
127
+ </span></span
128
128
  >
129
129
  </label>
130
130
  <span
@@ -1,83 +0,0 @@
1
- import FeatureCard from "./FeatureCard.vue";
2
-
3
- describe("<FeatureCard />", () => {
4
- it("renders title and content using props", () => {
5
- const title = "Test Feature";
6
- const content = "This is a test feature content.";
7
-
8
- cy.mount(FeatureCard, {
9
- props: {
10
- title,
11
- content,
12
- },
13
- });
14
-
15
- cy.get("h5").should("contain", title);
16
- // When using content prop without slots, it renders a div with v-html
17
- cy.contains(content).should("exist");
18
- });
19
-
20
- it("renders content in a paragraph when using slots", () => {
21
- const title = "Slot Feature";
22
- const slotContent = "This is content from a slot.";
23
-
24
- cy.mount(FeatureCard, {
25
- props: {
26
- title,
27
- },
28
- slots: {
29
- default: slotContent,
30
- },
31
- });
32
-
33
- cy.get("h5").should("contain", title);
34
- // When using slots, it renders a p tag
35
- cy.get("p").should("contain", slotContent);
36
- });
37
-
38
- it("renders link when provided", () => {
39
- const link = {
40
- text: "Learn More",
41
- href: "https://example.com",
42
- target: "_blank",
43
- };
44
-
45
- cy.mount(FeatureCard, {
46
- props: {
47
- title: "With Link",
48
- content: "Content",
49
- link,
50
- },
51
- });
52
-
53
- cy.get("a").should("have.attr", "href", "https://example.com");
54
- cy.get("a").should("contain", "Learn More");
55
- cy.get("a").should("have.attr", "target", "_blank");
56
- });
57
-
58
- it("renders icon from class string", () => {
59
- cy.mount(FeatureCard, {
60
- props: {
61
- title: "With Icon",
62
- content: "Content",
63
- icon: "mdi-home",
64
- },
65
- });
66
-
67
- cy.get("i.mdi.mdi-home").should("exist");
68
- });
69
-
70
- it("renders html icon", () => {
71
- const iconHtml = '<svg><circle cx="50" cy="50" r="40" /></svg>';
72
- cy.mount(FeatureCard, {
73
- props: {
74
- title: "With SVG Icon",
75
- content: "Content",
76
- icon: { html: iconHtml },
77
- },
78
- });
79
-
80
- cy.get(".icon").should("exist");
81
- cy.get(".icon").find("svg").should("exist");
82
- });
83
- });
@@ -1,82 +0,0 @@
1
- <script setup>
2
- const { title, content, link, icon } = defineProps({
3
- title: String,
4
- content: String,
5
- link: {
6
- /** @type {import('vue').PropType<{ text:string ,href: string,target?: string }>} */
7
- type: Object,
8
- required: false,
9
- },
10
- icon: {
11
- /** @type {import('vue').PropType<{ html: string, width?: number, height?: number }>} */
12
- type: Object,
13
- required: false,
14
- },
15
- });
16
- const iconStyle = {
17
- width: (icon?.width ?? 40) + "px",
18
- height: (icon?.height ?? 40) + "px",
19
- };
20
- </script>
21
- <template>
22
- <article class="vertical large-padding">
23
- <div>
24
- <i
25
- v-if="typeof icon === 'string' && icon.startsWith('mdi-')"
26
- :class="`mdi ${icon}`"
27
- ></i>
28
- <div
29
- v-else-if="icon"
30
- :style="iconStyle"
31
- v-html="icon.html"
32
- class="icon"
33
- ></div>
34
- <h5 class="small">{{ title }}</h5>
35
- <p v-if="$slots.default">
36
- <slot>{{ content }}</slot>
37
- </p>
38
- <div v-else v-html="content"></div>
39
- </div>
40
- <nav v-if="link">
41
- <a
42
- :href="link.href"
43
- :target="link.target ?? '_blank'"
44
- class="button transparent bold primary-text no-padding"
45
- >
46
- <span>
47
- {{ link.text }}
48
- </span>
49
- <i class="mdi mdi-chevron-right arrow"></i>
50
- </a>
51
- </nav>
52
- </article>
53
- </template>
54
- <style scoped>
55
- /**
56
- * Undo Vitepress missing with @eox/ui styles
57
- */
58
- .VPHome .vp-doc a.button.bold {
59
- font-weight: bold;
60
- }
61
- .VPHome .vp-doc h5.small {
62
- font-size: 1.25rem;
63
- }
64
-
65
- /**
66
- * Custom styles
67
- */
68
- article {
69
- justify-content: space-between;
70
- transition: all 0.3s ease-in-out;
71
- width: 100%;
72
- }
73
- .arrow {
74
- transition: transform 0.2s;
75
- }
76
- .button:hover .arrow {
77
- transform: translateX(4px);
78
- }
79
- .button.transparent:hover:after {
80
- background: none;
81
- }
82
- </style>