Skip to content

Commit 1ba2260

Browse files
asithadeclaude
andauthored
feat(ui): implement scroll shadow directive for dashboards (#220)
* feat(ui): implement scroll shadow directive for dashboards This commit implements a reusable ScrollShadowDirective and applies it across multiple dashboard and module components for consistent scroll fade effects. Key Changes: - Create ScrollShadowDirective with horizontal and vertical scroll detection - Apply directive to dashboard components (recent-progress, foundation-health, organization-involvement) - Add scroll shadow overlays with Tailwind CSS gradients - Enhance committee, mailing-list, and meetings components - Remove custom scroll logic in favor of centralized directive - Fix accessibility modifiers and type safety issues Related tickets: - LFXV2-956: Implement scroll shadow directive - LFXV2-957: Apply scroll shadow directive to carousel components - LFXV2-958: Enhance committee dashboard and table components - LFXV2-959: Enhance mailing-list components - LFXV2-960: Enhance meetings dashboard and navigation 🤖 Generated with Claude Code (https://claude.com/claude-code) Co-Authored-By: Claude Haiku 4.5 <[email protected]> Signed-off-by: Asitha de Silva <[email protected]> * fix(ui): improve scroll shadow directive and remove unused code - Fix memory leak by properly cleaning up scroll event listener - Make directive SSR-safe using afterNextRender - Fix ExpressionChangedAfterItHasBeenCheckedError with setTimeout - Add aria-hidden to decorative shadow overlays for accessibility - Remove unused timeFilterValue input from meetings-top-bar component 🤖 Generated with [Claude Code](https://claude.ai/code) Signed-off-by: Asitha de Silva <[email protected]> --------- Signed-off-by: Asitha de Silva <[email protected]> Co-authored-by: Claude Haiku 4.5 <[email protected]>
1 parent 9b44456 commit 1ba2260

22 files changed

+481
-326
lines changed

apps/lfx-one/src/app/modules/committees/committee-dashboard/committee-dashboard.component.html

Lines changed: 3 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -28,51 +28,6 @@ <h1>{{ committeeLabelPlural }}</h1>
2828
</div>
2929
<p class="mt-2 text-gray-500" data-testid="dashboard-hero-description">Organize people and governance for your project.</p>
3030
</div>
31-
32-
<!-- Sticky Top Bar with Search and Filters -->
33-
<div class="sticky top-0 md:top-6 z-10 bg-white rounded-lg border border-gray-200 p-4 -mx-0 mb-8">
34-
<div class="flex flex-col md:flex-row md:items-center gap-3 md:gap-4">
35-
<!-- Search Input -->
36-
<div class="flex-1">
37-
<lfx-input-text
38-
[form]="searchForm"
39-
control="search"
40-
[placeholder]="'Search ' + committeeLabel.toLowerCase() + '...'"
41-
icon="fa-light fa-search"
42-
styleClass="w-full"
43-
size="small"
44-
data-testid="committee-search-input"></lfx-input-text>
45-
</div>
46-
47-
<!-- Committee Type Filter Dropdown -->
48-
<div class="w-full sm:w-64">
49-
<lfx-select
50-
[form]="searchForm"
51-
control="category"
52-
size="small"
53-
[options]="categories()"
54-
placeholder="Select Type"
55-
[showClear]="true"
56-
styleClass="w-full"
57-
(onChange)="onCategoryChange($event.value)"
58-
data-testid="committee-type-filter"></lfx-select>
59-
</div>
60-
61-
<!-- Voting Status Filter Dropdown -->
62-
<div class="w-full sm:w-64">
63-
<lfx-select
64-
[form]="searchForm"
65-
control="votingStatus"
66-
size="small"
67-
[options]="votingStatusOptions()"
68-
placeholder="Select Voting Status"
69-
[showClear]="true"
70-
styleClass="w-full"
71-
(onChange)="onVotingStatusChange($event.value)"
72-
data-testid="committee-voting-status-filter"></lfx-select>
73-
</div>
74-
</div>
75-
</div>
7631
</div>
7732

7833
<!-- Content Area - Table or Cards -->
@@ -107,6 +62,9 @@ <h3 class="text-xl font-semibold text-gray-900 mt-4">No {{ committeeLabelPlural.
10762
[committees]="filteredCommittees()"
10863
[canManageCommittee]="canCreateGroup()"
10964
[committeeLabel]="committeeLabel"
65+
[searchForm]="searchForm"
66+
[categoryOptions]="categories()"
67+
[votingStatusOptions]="votingStatusOptions()"
11068
(refresh)="refreshCommittees()">
11169
</lfx-committee-table>
11270
}

apps/lfx-one/src/app/modules/committees/committee-dashboard/committee-dashboard.component.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,10 @@
33

44
import { Component, computed, inject, signal, Signal, WritableSignal } from '@angular/core';
55
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
6-
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
6+
import { FormControl, FormGroup } from '@angular/forms';
77
import { Router } from '@angular/router';
88
import { ButtonComponent } from '@components/button/button.component';
99
import { CardComponent } from '@components/card/card.component';
10-
import { InputTextComponent } from '@components/input-text/input-text.component';
11-
import { SelectComponent } from '@components/select/select.component';
1210
import { COMMITTEE_LABEL } from '@lfx-one/shared/constants';
1311
import { Committee, ProjectContext } from '@lfx-one/shared/interfaces';
1412
import { CommitteeService } from '@services/committee.service';
@@ -22,7 +20,7 @@ import { CommitteeTableComponent } from '../components/committee-table/committee
2220

2321
@Component({
2422
selector: 'lfx-committee-dashboard',
25-
imports: [ReactiveFormsModule, InputTextComponent, SelectComponent, ButtonComponent, CardComponent, CommitteeTableComponent],
23+
imports: [ButtonComponent, CardComponent, CommitteeTableComponent],
2624
templateUrl: './committee-dashboard.component.html',
2725
styleUrl: './committee-dashboard.component.scss',
2826
})

apps/lfx-one/src/app/modules/committees/components/committee-table/committee-table.component.html

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,49 @@
22
<!-- SPDX-License-Identifier: MIT -->
33

44
<lfx-card>
5+
<!-- Filter Bar Inside Card -->
6+
<div class="pb-2 mb-2">
7+
<div class="flex flex-col md:flex-row md:items-center gap-3 md:gap-4">
8+
<!-- Search Input -->
9+
<div class="flex-1">
10+
<lfx-input-text
11+
[form]="searchForm()"
12+
control="search"
13+
placeholder="Search committees..."
14+
icon="fa-light fa-search"
15+
styleClass="w-full"
16+
size="small"
17+
data-testid="committee-search-input"></lfx-input-text>
18+
</div>
19+
20+
<!-- Category Filter Dropdown -->
21+
<div class="w-full sm:w-64">
22+
<lfx-select
23+
[form]="searchForm()"
24+
control="category"
25+
size="small"
26+
[options]="categoryOptions()"
27+
placeholder="Select Type"
28+
[showClear]="true"
29+
styleClass="w-full"
30+
data-testid="committee-type-filter"></lfx-select>
31+
</div>
32+
33+
<!-- Voting Status Filter Dropdown -->
34+
<div class="w-full sm:w-64">
35+
<lfx-select
36+
[form]="searchForm()"
37+
control="votingStatus"
38+
size="small"
39+
[options]="votingStatusOptions()"
40+
placeholder="Select Voting Status"
41+
[showClear]="true"
42+
styleClass="w-full"
43+
data-testid="committee-voting-status-filter"></lfx-select>
44+
</div>
45+
</div>
46+
</div>
47+
548
<lfx-table
649
[value]="committees()"
750
[paginator]="committees().length > 10"

apps/lfx-one/src/app/modules/committees/components/committee-table/committee-table.component.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@
33

44
import { DatePipe } from '@angular/common';
55
import { Component, inject, input, output, signal, WritableSignal } from '@angular/core';
6+
import { ReactiveFormsModule, FormGroup } from '@angular/forms';
67
import { RouterLink } from '@angular/router';
78
import { ButtonComponent } from '@components/button/button.component';
89
import { CardComponent } from '@components/card/card.component';
10+
import { InputTextComponent } from '@components/input-text/input-text.component';
11+
import { SelectComponent } from '@components/select/select.component';
912
import { TableComponent } from '@components/table/table.component';
1013
import { TagComponent } from '@components/tag/tag.component';
1114
import { Committee, COMMITTEE_LABEL } from '@lfx-one/shared';
@@ -23,11 +26,14 @@ import { MemberFormComponent } from '../member-form/member-form.component';
2326
selector: 'lfx-committee-table',
2427
imports: [
2528
DatePipe,
29+
ReactiveFormsModule,
2630
RouterLink,
2731
CardComponent,
2832
ButtonComponent,
2933
TableComponent,
3034
TagComponent,
35+
InputTextComponent,
36+
SelectComponent,
3137
TooltipModule,
3238
ConfirmDialogModule,
3339
DynamicDialogModule,
@@ -48,6 +54,9 @@ export class CommitteeTableComponent {
4854
public committees = input.required<Committee[]>();
4955
public canManageCommittee = input<boolean>(false);
5056
public committeeLabel = input<string>(COMMITTEE_LABEL.singular);
57+
public searchForm = input.required<FormGroup>();
58+
public categoryOptions = input.required<{ label: string; value: string | null }[]>();
59+
public votingStatusOptions = input.required<{ label: string; value: string | null }[]>();
5160

5261
// State
5362
public isDeleting: WritableSignal<boolean> = signal<boolean>(false);

apps/lfx-one/src/app/modules/dashboards/components/foundation-health/foundation-health.component.html

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,15 @@ <h2 class="mb-0">{{ title() }}</h2>
2424
<div class="flex items-center gap-2">
2525
<button
2626
type="button"
27-
(click)="scrollLeft()"
27+
(click)="scrollShadowDirective.scrollLeft()"
2828
class="h-8 w-8 p-0 flex items-center justify-center rounded border border-gray-300 bg-white text-gray-600 hover:bg-gray-100 hover:border-gray-400 transition-colors"
2929
data-testid="foundation-health-carousel-prev"
3030
aria-label="Scroll left">
3131
<i class="fa-light fa-chevron-left"></i>
3232
</button>
3333
<button
3434
type="button"
35-
(click)="scrollRight()"
35+
(click)="scrollShadowDirective.scrollRight()"
3636
class="h-8 w-8 p-0 flex items-center justify-center rounded border border-gray-300 bg-white text-gray-600 hover:bg-gray-100 hover:border-gray-400 transition-colors"
3737
data-testid="foundation-health-carousel-next"
3838
aria-label="Scroll right">
@@ -52,8 +52,8 @@ <h2 class="mb-0">{{ title() }}</h2>
5252
</div>
5353
} @else {
5454
<!-- Carousel Container -->
55-
<div class="overflow-hidden">
56-
<div #carouselScroll class="flex gap-4 overflow-x-auto pb-2 hide-scrollbar scroll-smooth" data-testid="foundation-health-carousel">
55+
<div class="relative overflow-hidden">
56+
<div lfxScrollShadow [scrollDistance]="320" class="flex gap-4 overflow-x-auto hide-scrollbar scroll-smooth" data-testid="foundation-health-carousel">
5757
@for (card of metricCards(); track card.testId) {
5858
@switch (card.customContentType) {
5959
<!-- Bar Chart - Use shared metric card -->
@@ -176,6 +176,22 @@ <h2 class="mb-0">{{ title() }}</h2>
176176
}
177177
}
178178
</div>
179+
180+
<!-- Right shadow fade -->
181+
@if (scrollShadowDirective && scrollShadowDirective.showRightShadow()) {
182+
<div
183+
class="absolute top-0 bottom-0 right-0 w-32 z-10 pointer-events-none bg-[linear-gradient(to_right,transparent,#f9fafb)]"
184+
data-testid="foundation-health-shadow-right"
185+
aria-hidden="true"></div>
186+
}
187+
188+
<!-- Left shadow fade -->
189+
@if (scrollShadowDirective && scrollShadowDirective.showLeftShadow()) {
190+
<div
191+
class="absolute top-0 bottom-0 left-0 w-32 z-10 pointer-events-none bg-[linear-gradient(to_left,transparent,#f9fafb)]"
192+
data-testid="foundation-health-shadow-left"
193+
aria-hidden="true"></div>
194+
}
179195
</div>
180196
}
181197
</section>

apps/lfx-one/src/app/modules/dashboards/components/foundation-health/foundation-health.component.ts

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
// Copyright The Linux Foundation and each contributor to LFX.
22
// SPDX-License-Identifier: MIT
33

4-
import { Component, computed, ElementRef, inject, input, signal, ViewChild } from '@angular/core';
4+
import { Component, computed, inject, input, signal, ViewChild } from '@angular/core';
55
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
66
import { DataCopilotComponent } from '@app/shared/components/data-copilot/data-copilot.component';
77
import { FilterOption, FilterPillsComponent } from '@components/filter-pills/filter-pills.component';
88
import { MetricCardComponent } from '@components/metric-card/metric-card.component';
9+
import { ScrollShadowDirective } from '@shared/directives/scroll-shadow.directive';
910
import { BASE_BAR_CHART_OPTIONS, BASE_LINE_CHART_OPTIONS, lfxColors, PRIMARY_FOUNDATION_HEALTH_METRICS } from '@lfx-one/shared/constants';
1011
import { hexToRgba } from '@lfx-one/shared/utils';
1112
import { AnalyticsService } from '@services/analytics.service';
@@ -23,12 +24,12 @@ import type {
2324

2425
@Component({
2526
selector: 'lfx-foundation-health',
26-
imports: [FilterPillsComponent, MetricCardComponent, DataCopilotComponent],
27+
imports: [FilterPillsComponent, MetricCardComponent, DataCopilotComponent, ScrollShadowDirective],
2728
templateUrl: './foundation-health.component.html',
2829
styleUrl: './foundation-health.component.scss',
2930
})
3031
export class FoundationHealthComponent {
31-
@ViewChild('carouselScroll') public carouselScrollContainer!: ElementRef;
32+
@ViewChild(ScrollShadowDirective) public scrollShadowDirective!: ScrollShadowDirective;
3233

3334
private readonly analyticsService = inject(AnalyticsService);
3435
private readonly projectContextService = inject(ProjectContextService);
@@ -88,18 +89,6 @@ export class FoundationHealthComponent {
8889
this.selectedFilter.set(filter);
8990
}
9091

91-
public scrollLeft(): void {
92-
if (!this.carouselScrollContainer?.nativeElement) return;
93-
const container = this.carouselScrollContainer.nativeElement;
94-
container.scrollBy({ left: -320, behavior: 'smooth' });
95-
}
96-
97-
public scrollRight(): void {
98-
if (!this.carouselScrollContainer?.nativeElement) return;
99-
const container = this.carouselScrollContainer.nativeElement;
100-
container.scrollBy({ left: 320, behavior: 'smooth' });
101-
}
102-
10392
private initializeTotalProjectsCard() {
10493
return computed(() => this.transformTotalProjects(this.getMetricConfig('Total Projects')));
10594
}

apps/lfx-one/src/app/modules/dashboards/components/organization-involvement/organization-involvement.component.html

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,15 @@ <h2 class="mb-0">{{ accountName() }}'s Involvement</h2>
2020
<div class="flex items-center gap-2">
2121
<button
2222
type="button"
23-
(click)="scrollLeft()"
23+
(click)="scrollShadowDirective.scrollLeft()"
2424
class="h-8 w-8 p-0 flex items-center justify-center rounded border border-gray-300 bg-white text-gray-600 hover:bg-gray-100 hover:border-gray-400 transition-colors"
2525
data-testid="dashboard-involvement-scroll-left"
2626
aria-label="Scroll left">
2727
<i class="fa-light fa-chevron-left"></i>
2828
</button>
2929
<button
3030
type="button"
31-
(click)="scrollRight()"
31+
(click)="scrollShadowDirective.scrollRight()"
3232
class="h-8 w-8 p-0 flex items-center justify-center rounded border border-gray-300 bg-white text-gray-600 hover:bg-gray-100 hover:border-gray-400 transition-colors"
3333
data-testid="dashboard-involvement-scroll-right"
3434
aria-label="Scroll right">
@@ -47,8 +47,8 @@ <h2 class="mb-0">{{ accountName() }}'s Involvement</h2>
4747
</div>
4848
</div>
4949
} @else {
50-
<div class="overflow-hidden">
51-
<div #carouselScroll class="flex gap-4 overflow-x-auto pb-2 hide-scrollbar scroll-smooth" data-testid="dashboard-involvement-carousel">
50+
<div class="relative overflow-hidden">
51+
<div lfxScrollShadow class="flex gap-4 overflow-x-auto hide-scrollbar scroll-smooth" data-testid="dashboard-involvement-carousel">
5252
@for (metric of primaryMetrics(); track metric.testId) {
5353
<!-- Membership Tier Card (Special) -->
5454
@if (metric.isMembershipTier) {
@@ -93,6 +93,22 @@ <h5 class="text-sm font-medium">{{ metric.title }}</h5>
9393
}
9494
}
9595
</div>
96+
97+
<!-- Right shadow fade -->
98+
@if (scrollShadowDirective && scrollShadowDirective.showRightShadow()) {
99+
<div
100+
class="absolute top-0 bottom-0 right-0 w-32 z-10 pointer-events-none bg-[linear-gradient(to_right,transparent,#f9fafb)]"
101+
data-testid="dashboard-involvement-shadow-right"
102+
aria-hidden="true"></div>
103+
}
104+
105+
<!-- Left shadow fade -->
106+
@if (scrollShadowDirective && scrollShadowDirective.showLeftShadow()) {
107+
<div
108+
class="absolute top-0 bottom-0 left-0 w-32 z-10 pointer-events-none bg-[linear-gradient(to_left,transparent,#f9fafb)]"
109+
data-testid="dashboard-involvement-shadow-left"
110+
aria-hidden="true"></div>
111+
}
96112
</div>
97113
}
98114
</section>

apps/lfx-one/src/app/modules/dashboards/components/organization-involvement/organization-involvement.component.ts

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
// Copyright The Linux Foundation and each contributor to LFX.
22
// SPDX-License-Identifier: MIT
33

4-
import { Component, computed, ElementRef, inject, signal, ViewChild } from '@angular/core';
4+
import { Component, computed, inject, signal, ViewChild } from '@angular/core';
55
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
66
import { DataCopilotComponent } from '@app/shared/components/data-copilot/data-copilot.component';
77
import { FilterOption, FilterPillsComponent } from '@components/filter-pills/filter-pills.component';
88
import { MetricCardComponent } from '@components/metric-card/metric-card.component';
99
import { TagComponent } from '@components/tag/tag.component';
10+
import { ScrollShadowDirective } from '@shared/directives/scroll-shadow.directive';
1011
import { BASE_BAR_CHART_OPTIONS, BASE_LINE_CHART_OPTIONS, lfxColors, PRIMARY_INVOLVEMENT_METRICS } from '@lfx-one/shared/constants';
1112
import { hexToRgba } from '@lfx-one/shared/utils';
1213
import { AccountContextService } from '@services/account-context.service';
@@ -27,12 +28,12 @@ import type { ChartOptions, ChartType } from 'chart.js';
2728

2829
@Component({
2930
selector: 'lfx-organization-involvement',
30-
imports: [FilterPillsComponent, MetricCardComponent, TagComponent, DataCopilotComponent],
31+
imports: [FilterPillsComponent, MetricCardComponent, TagComponent, DataCopilotComponent, ScrollShadowDirective],
3132
templateUrl: './organization-involvement.component.html',
3233
styleUrl: './organization-involvement.component.scss',
3334
})
3435
export class OrganizationInvolvementComponent {
35-
@ViewChild('carouselScroll') public carouselScrollContainer!: ElementRef;
36+
@ViewChild(ScrollShadowDirective) public scrollShadowDirective!: ScrollShadowDirective;
3637

3738
private readonly analyticsService = inject(AnalyticsService);
3839
private readonly accountContextService = inject(AccountContextService);
@@ -87,18 +88,6 @@ export class OrganizationInvolvementComponent {
8788
this.selectedFilter.set(filter);
8889
}
8990

90-
public scrollLeft(): void {
91-
if (!this.carouselScrollContainer?.nativeElement) return;
92-
const container = this.carouselScrollContainer.nativeElement;
93-
container.scrollBy({ left: -300, behavior: 'smooth' });
94-
}
95-
96-
public scrollRight(): void {
97-
if (!this.carouselScrollContainer?.nativeElement) return;
98-
const container = this.carouselScrollContainer.nativeElement;
99-
container.scrollBy({ left: 300, behavior: 'smooth' });
100-
}
101-
10291
private getMetricConfig(title: string): DashboardMetricCard {
10392
return PRIMARY_INVOLVEMENT_METRICS.find((m) => m.title === title || (title === 'Membership Tier' && m.isMembershipTier))!;
10493
}

0 commit comments

Comments
 (0)