feat(m3u): add inline search functionality for group filtering and enhance group management UI

Entire-Checkpoint: 21b9b9f90f23
This commit is contained in:
4gray committed 2026-04-11 23:30:38 +02:00
1 parent 02f22dbfaf
commit e2d1bb5400
7 files changed
+253 -17

No files matched your search

+1
View File
@@ -9,6 +9,7 @@
"MANAGE_GROUPS_HINT": "Verwenden Sie \"Gruppen verwalten\", um Gruppen wieder einzublenden.",
"NO_FAVORITES": "Keine Favoriten",
"NO_VISIBLE_GROUPS": "Keine sichtbaren Gruppen",
"SEARCH_GROUPS": "Gruppen suchen",
"REMOVE_FAVORITE": "Aus Favoritenliste entfernen",
"SEARCH_CHANNEL": "Nach Sender suchen",
"UNGROUPED": "Ohne Gruppe",
+1
View File
@@ -275,6 +275,7 @@
"MANAGE_GROUPS": "Manage groups",
"MANAGE_GROUPS_HINT": "Use manage groups to show groups again.",
"NO_VISIBLE_GROUPS": "No visible groups",
"SEARCH_GROUPS": "Search groups",
"UNGROUPED": "Ungrouped",
"UNNAMED_CHANNEL": "Unnamed Channel",
"FAVORITES": "Favorites",
+3
View File
@@ -190,6 +190,9 @@ channel-list-container/
- `GroupsViewComponent` owns the M3U-only "Manage groups" action and dialog in
`libs/ui/components/src/lib/channel-list-container/groups-view/`.
- The groups rail header also owns an inline search toggle that filters the
currently visible groups without mutating the workspace-level route search
term used by the broader channel views.
- The dialog operates on the full grouped dataset, while the left rail and
channel pane render only groups whose titles are not listed in
`hiddenGroupTitles`.
@@ -39,17 +39,68 @@
<h2 class="groups-nav-title">
{{ 'CHANNELS.GROUPS' | translate }}
</h2>
<button
mat-icon-button
[matTooltip]="'CHANNELS.MANAGE_GROUPS' | translate"
[attr.aria-label]="
'CHANNELS.MANAGE_GROUPS' | translate
"
(click)="openGroupManagement()"
>
<mat-icon>tune</mat-icon>
</button>
<div class="groups-nav-actions">
<button
type="button"
mat-icon-button
class="groups-nav-action groups-nav-action--search"
[matTooltip]="'CHANNELS.SEARCH_GROUPS' | translate"
[attr.aria-pressed]="
isGroupSearchOpen() ? 'true' : 'false'
"
[attr.aria-label]="
'CHANNELS.SEARCH_GROUPS' | translate
"
(click)="toggleGroupSearch()"
>
<mat-icon>search</mat-icon>
</button>
<button
type="button"
mat-icon-button
class="groups-nav-action groups-nav-action--manage"
[matTooltip]="'CHANNELS.MANAGE_GROUPS' | translate"
[attr.aria-label]="
'CHANNELS.MANAGE_GROUPS' | translate
"
(click)="openGroupManagement()"
>
<mat-icon>tune</mat-icon>
</button>
</div>
</div>
@if (isGroupSearchOpen()) {
<label class="groups-nav-search">
<mat-icon class="groups-nav-search__icon">
search
</mat-icon>
<input
#groupSearchInput
type="search"
spellcheck="false"
[value]="localGroupSearchTerm()"
[placeholder]="
'CHANNELS.SEARCH_GROUPS' | translate
"
(input)="
updateGroupSearchTerm(
$any($event.target).value
)
"
(keydown.escape)="closeGroupSearch()"
/>
<button
type="button"
mat-icon-button
class="groups-nav-search__close"
[attr.aria-label]="'CLOSE' | translate"
(click)="closeGroupSearch()"
>
<mat-icon>close</mat-icon>
</button>
</label>
}
</header>
<div class="groups-nav-list nav-list">
@@ -87,7 +138,7 @@
} @else {
<div class="groups-nav-empty">
{{
(searchTerm()
(hasSearchQuery()
? 'CHANNELS.NO_SEARCH_RESULTS'
: 'CHANNELS.NO_VISIBLE_GROUPS'
) | translate
@@ -201,11 +252,13 @@
<div class="groups-content-empty-state">
<mat-icon class="empty-state-icon">
{{ searchTerm() ? 'search_off' : 'visibility_off' }}
{{
hasSearchQuery() ? 'search_off' : 'visibility_off'
}}
</mat-icon>
<div class="empty-state-title">
{{
(searchTerm()
(hasSearchQuery()
? 'CHANNELS.NO_SEARCH_RESULTS'
: 'CHANNELS.NO_VISIBLE_GROUPS'
) | translate
@@ -213,7 +266,7 @@
</div>
<div class="empty-state-hint">
{{
(searchTerm()
(hasSearchQuery()
? 'CHANNELS.TRY_DIFFERENT_SEARCH'
: 'CHANNELS.MANAGE_GROUPS_HINT'
) | translate
@@ -44,6 +44,9 @@
.groups-nav-header {
padding: 4px 6px 12px;
flex-shrink: 0;
display: flex;
gap: 10px;
flex-direction: column;
}
.groups-nav-header__content {
@@ -61,6 +64,96 @@
color: var(--mat-sys-on-surface);
}
.groups-nav-actions {
display: flex;
align-items: center;
gap: 4px;
}
.groups-nav-action,
.groups-nav-search {
@include mat.form-field-density(-5);
}
.groups-nav-action {
width: 28px;
height: 28px;
min-width: 28px;
padding: 0;
line-height: 28px;
mat-icon {
width: 18px;
height: 18px;
font-size: 18px;
}
}
.groups-nav-search {
display: flex;
align-items: center;
gap: 8px;
padding: 0 10px;
min-height: 36px;
border-radius: 10px;
border: 1px solid var(--app-search-border, var(--mat-sys-outline-variant));
background: var(--app-search-bg, var(--mat-sys-surface-container));
cursor: text;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
&:focus-within {
border-color: var(--mat-sys-primary);
box-shadow: 0 0 0 3px
color-mix(in srgb, var(--mat-sys-primary) 12%, transparent);
}
}
.groups-nav-search__icon {
width: 18px;
height: 18px;
font-size: 18px;
color: var(--mat-sys-on-surface-variant);
flex-shrink: 0;
}
.groups-nav-search input {
flex: 1;
min-width: 0;
width: 100%;
border: none;
outline: none;
background: transparent;
color: var(--mat-sys-on-surface);
font: inherit;
font-size: 0.84rem;
&::placeholder {
color: var(--mat-sys-on-surface-variant);
opacity: 0.6;
}
&::-webkit-search-cancel-button {
display: none;
}
}
.groups-nav-search__close {
width: 24px;
height: 24px;
min-width: 24px;
line-height: 24px;
flex-shrink: 0;
padding: 0;
mat-icon {
width: 16px;
height: 16px;
font-size: 16px;
}
}
.groups-content-header {
@include panel.standard-panel-header();
border-top-left-radius: inherit;
@@ -402,6 +402,43 @@ describe('GroupsViewComponent', () => {
]);
});
it('toggles the inline group search from the header action and filters the visible groups', () => {
const searchButton = fixture.nativeElement.querySelector(
'.groups-nav-action--search'
) as HTMLButtonElement;
searchButton.click();
fixture.detectChanges();
const searchInput = fixture.nativeElement.querySelector(
'.groups-nav-search input'
) as HTMLInputElement | null;
expect(searchInput).not.toBeNull();
searchInput!.value = 'spo';
searchInput!.dispatchEvent(new Event('input'));
fixture.detectChanges();
expect(component.filteredGroups().map((group) => group.key)).toEqual([
'Sports',
]);
expect(component.selectedGroupKey()).toBe('Sports');
searchButton.click();
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('.groups-nav-search input')
).toBeNull();
expect(component.filteredGroups().map((group) => group.key)).toEqual([
'Movies',
'News',
'Series',
'Sports',
]);
});
it('emits channel and favorite events from the selected group pane', () => {
const channelSelected = jest.fn();
const favoriteToggled = jest.fn();
@@ -497,7 +534,7 @@ describe('GroupsViewComponent', () => {
'.groups-content-empty-state'
) as HTMLElement | null;
const manageButton = fixture.nativeElement.querySelector(
'.groups-nav-header button'
'.groups-nav-action--manage'
) as HTMLButtonElement | null;
expect(layout).not.toBeNull();
@@ -531,7 +568,7 @@ describe('GroupsViewComponent', () => {
'.groups-content-empty-state'
) as HTMLElement | null;
const manageButton = fixture.nativeElement.querySelector(
'.groups-nav-header button'
'.groups-nav-action--manage'
) as HTMLButtonElement | null;
expect(layout).not.toBeNull();
@@ -74,6 +74,8 @@ export class GroupsViewComponent {
readonly contextMenuTrigger =
viewChild.required<MatMenuTrigger>('contextMenuTrigger');
readonly groupSearchInput =
viewChild<ElementRef<HTMLInputElement>>('groupSearchInput');
/** Grouped channels object */
readonly groupedChannels = input.required<{ [key: string]: Channel[] }>();
@@ -114,6 +116,8 @@ export class GroupsViewComponent {
readonly sidebarWidthRequestEnded = output<number>();
readonly hiddenGroupTitlesChanged = output<string[]>();
readonly isGroupSearchOpen = signal(false);
readonly localGroupSearchTerm = signal('');
readonly selectedGroupKey = signal<string | null>(null);
readonly groupChannelSortMode = signal<PortalChannelSortMode>(
restorePortalChannelSortMode(GROUP_CHANNEL_SORT_STORAGE_KEY)
@@ -121,6 +125,11 @@ export class GroupsViewComponent {
readonly groupChannelSortLabel = computed(() =>
getPortalChannelSortModeLabel(this.groupChannelSortMode())
);
readonly hasSearchQuery = computed(
() =>
this.searchTerm().trim().length > 0 ||
this.localGroupSearchTerm().trim().length > 0
);
readonly itemSize = computed(() => (this.shouldShowEpg() ? 68 : 48));
readonly contextMenuChannel = signal<Channel | null>(null);
readonly contextMenuPosition = signal({
@@ -212,6 +221,16 @@ export class GroupsViewComponent {
});
});
});
effect(() => {
if (!this.isGroupSearchOpen()) {
return;
}
queueMicrotask(() => {
this.groupSearchInput()?.nativeElement.focus();
});
});
}
readonly allGroups = computed<GroupView[]>(() => {
@@ -234,7 +253,7 @@ export class GroupsViewComponent {
);
});
readonly filteredGroups = computed<FilteredGroupView[]>(() => {
readonly workspaceFilteredGroups = computed<FilteredGroupView[]>(() => {
const term = this.searchTerm().trim().toLowerCase();
const groups = this.visibleGroups();
@@ -270,6 +289,17 @@ export class GroupsViewComponent {
}, []);
});
readonly filteredGroups = computed<FilteredGroupView[]>(() => {
const term = this.localGroupSearchTerm().trim().toLowerCase();
const groups = this.workspaceFilteredGroups();
if (!term) {
return groups;
}
return groups.filter((group) => group.key.toLowerCase().includes(term));
});
readonly hasAnyGroups = computed(() => this.allGroups().length > 0);
readonly selectedGroup = computed(() => {
@@ -326,6 +356,24 @@ export class GroupsViewComponent {
this.selectedGroupKey.set(groupKey);
}
closeGroupSearch(): void {
this.localGroupSearchTerm.set('');
this.isGroupSearchOpen.set(false);
}
toggleGroupSearch(): void {
if (this.isGroupSearchOpen()) {
this.closeGroupSearch();
return;
}
this.isGroupSearchOpen.set(true);
}
updateGroupSearchTerm(value: string): void {
this.localGroupSearchTerm.set(value);
}
openGroupManagement(): void {
const groups = this.allGroups().map<GroupManagementDialogGroup>(
({ key, count }) => ({