Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<article class="vehicle-card">
<div class="vehicle-card__image-wrapper">
<img
class="vehicle-card__image"
[src]="imageUrl"
[alt]="vehicle.name"
loading="lazy"
/>
</div>

<div class="vehicle-card__body">
<h2 class="vehicle-card__name">{{ vehicle.name }}</h2>
<p class="vehicle-card__price">From {{ vehicle.price }}</p>
<p class="vehicle-card__description">{{ vehicle.description }}</p>
</div>
</article>
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// Animation runs on the host element so that the parent can control
// animation-delay via [style.animation-delay] without ViewEncapsulation issues.
:host {
display: block;
opacity: 0;
animation: vehicle-card-fade-in 0.5s ease forwards;
}

@keyframes vehicle-card-fade-in {
to {
opacity: 1;
}
}

.vehicle-card {
display: flex;
flex-direction: row;
align-items: center;
padding: 16px;
border-bottom: 1px solid #e0e0e0;
background-color: #fff;

// ── Tablet and above: flip to vertical stacked layout ──
@media (min-width: 768px) {
flex-direction: column;
align-items: stretch;
padding: 0;
border-bottom: none;
}

// ─────────────────────────────────────────────
// Image wrapper
// The same <img> src is used at every breakpoint.
// Changing aspect-ratio + object-fit: cover gives the
// illusion of a different crop without swapping src.
// ─────────────────────────────────────────────
&__image-wrapper {
flex-shrink: 0;
width: 38%;
aspect-ratio: 1 / 1; // mobile: square crop
overflow: hidden;

@media (min-width: 768px) {
width: 100%;
aspect-ratio: 16 / 9; // tablet+: widescreen crop
}
}

&__image {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}

// ─────────────────────────────────────────────
// Text body
// ─────────────────────────────────────────────
&__body {
flex: 1;
padding: 0 16px;

@media (min-width: 768px) {
padding: 20px 16px 28px;
text-align: center;
}
}

&__name {
font-family: Georgia, 'Times New Roman', serif;
font-size: 1rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
margin: 0 0 6px;

@media (min-width: 768px) {
font-size: 1.0625rem;
text-decoration: underline;
text-underline-offset: 5px;
margin-bottom: 14px;
}
}

&__price {
font-size: 0.875rem;
color: #333;
margin: 0 0 6px;

@media (min-width: 768px) {
margin-bottom: 10px;
}
}

&__description {
font-size: 0.875rem;
color: #555;
line-height: 1.45;
margin: 0;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { AnyVehicle } from '../../../../core/interfaces/vehicle.interface';

@Component({
selector: 'app-vehicle-card',
imports: [],
templateUrl: './vehicle-card.component.html',
styleUrl: './vehicle-card.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class VehicleCardComponent {
@Input({ required: true }) vehicle!: AnyVehicle;

/**
* Used by the parent list to stagger the fade-in animation via
* [style.animation-delay] bound on the host element.
*/
@Input() index = 0;

/**
* Prefer the 16x9 image; CSS aspect-ratio + object-fit handles the
* visual crop at all breakpoints so we never need to swap src.
*/
get imageUrl(): string {
return (
this.vehicle.media.find((m) => m.url.includes('16x9'))?.url ??
this.vehicle.media[0]?.url ??
''
);
}

get emissionsLabel(): string {
const { template, value } = this.vehicle.meta.emissions;
return template.replace('$value', String(value));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<section class="vehicle-list" aria-label="Vehicles">

@if (vehicleService.loading()) {

<!-- Skeleton grid mirrors the real grid layout so there's no layout shift on load -->
<ul class="vehicle-list__grid" aria-label="Loading vehicles" aria-busy="true">
@for (_ of skeletonCount; track $index) {
<li class="vehicle-list__item">
<div class="vehicle-list__skeleton" aria-hidden="true">
<div class="vehicle-list__skeleton-image"></div>
<div class="vehicle-list__skeleton-body">
<div class="vehicle-list__skeleton-line vehicle-list__skeleton-line--title"></div>
<div class="vehicle-list__skeleton-line"></div>
<div class="vehicle-list__skeleton-line vehicle-list__skeleton-line--short"></div>
</div>
</div>
</li>
}
</ul>

} @else if (vehicleService.error()) {

<div class="vehicle-list__error" role="alert">
<p class="vehicle-list__error-message">{{ vehicleService.error() }}</p>
</div>

} @else {

<ul class="vehicle-list__grid" aria-label="Vehicle list">
@for (vehicle of vehicleService.vehicles(); track vehicle.id; let i = $index) {
<li class="vehicle-list__item">
<app-vehicle-card
[vehicle]="vehicle"
[index]="i"
[style.animation-delay]="i * 0.1 + 's'"
/>
</li>
}
</ul>

}

</section>
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// ─────────────────────────────────────────────────────────────────────────────
// Shared shimmer animation used by skeleton elements
// ─────────────────────────────────────────────────────────────────────────────
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}

// ─────────────────────────────────────────────────────────────────────────────
// Section wrapper
// ─────────────────────────────────────────────────────────────────────────────
.vehicle-list {
max-width: 1440px;
margin: 0 auto;
}

// ─────────────────────────────────────────────────────────────────────────────
// Responsive grid
//
// Mobile (default): single-column stacked list — no grid needed.
// Tablet (≥768px): 2-column grid. The 1px gap + background-color on the
// container creates the dividing lines between cards without
// needing borders on every card.
// Desktop (≥1280px): 4-column grid.
// ─────────────────────────────────────────────────────────────────────────────
.vehicle-list__grid {
list-style: none;
margin: 0;
padding: 0;

@media (min-width: 768px) {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1px;
background-color: #d0d0d0; // shows through the 1px gap as grid lines
}

@media (min-width: 1280px) {
grid-template-columns: repeat(4, 1fr);
}
}

// Each <li> needs its own background to mask the grid container's background.
.vehicle-list__item {
@media (min-width: 768px) {
background-color: #fff;
}
}

// ─────────────────────────────────────────────────────────────────────────────
// Loading skeleton
// Mirrors the card layout so there's no layout shift when data arrives.
// ─────────────────────────────────────────────────────────────────────────────
%shimmer {
background: linear-gradient(90deg, #efefef 25%, #e0e0e0 50%, #efefef 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}

.vehicle-list__skeleton {
display: flex;
flex-direction: row;
align-items: center;
padding: 16px;
border-bottom: 1px solid #e0e0e0;
background-color: #fff;

@media (min-width: 768px) {
flex-direction: column;
padding: 0;
border-bottom: none;
}
}

.vehicle-list__skeleton-image {
@extend %shimmer;

flex-shrink: 0;
width: 38%;
aspect-ratio: 1 / 1;

@media (min-width: 768px) {
width: 100%;
aspect-ratio: 16 / 9;
}
}

.vehicle-list__skeleton-body {
flex: 1;
padding: 0 16px;
display: flex;
flex-direction: column;
gap: 10px;

@media (min-width: 768px) {
padding: 20px 16px 28px;
align-items: center;
}
}

.vehicle-list__skeleton-line {
@extend %shimmer;

height: 14px;
border-radius: 2px;
width: 90%;

&--title {
height: 18px;
width: 70%;
}

&--short {
width: 55%;
}
}

// ─────────────────────────────────────────────────────────────────────────────
// Error state
// ─────────────────────────────────────────────────────────────────────────────
.vehicle-list__error {
padding: 48px 24px;
text-align: center;
}

.vehicle-list__error-message {
color: #c0392b;
font-size: 1rem;
margin: 0;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { VehicleService } from '../../../../core/services/vehicle.service';
import { VehicleCardComponent } from '../vehicle-card/vehicle-card.component';

@Component({
selector: 'app-vehicle-list',
imports: [VehicleCardComponent],
templateUrl: './vehicle-list.component.html',
styleUrl: './vehicle-list.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class VehicleListComponent {
protected readonly vehicleService = inject(VehicleService);

/** Drives the skeleton loading grid — one placeholder per expected vehicle. */
protected readonly skeletonCount = Array.from({ length: 6 });
}