Skip to content

Commit 855c7d5

Browse files
zJaaalclaude
andauthored
feat(content-drive): chip-style filter component for toolbar (#35465)
## Summary - Adds `DotChipFilterComponent` — a pill-shaped, single-line filter trigger that displays a `title` and a derived selection summary (none / one / two-comma-joined / "first and N more") - Replaces the fixed-column toolbar grid with a wrapping flex row, restoring the PrimeNG multiselect `min-w-56` floor that the new layout would otherwise collapse - Active state uses the primary palette tints (`primary-100/900`) so multiple active chips read as a group rather than competing fills Closes #33524 ## Test plan - [ ] `nx test content-drive-ui --testPathPattern=dot-chip-filter` - [ ] Open content drive, narrow the viewport — filter row wraps cleanly, multiselects keep their 14rem min width - [ ] Toggle selections via store/state — chip switches between inactive (white + slate border) and active (primary-100 tint) and the icon flips chevron-down ↔ close - [ ] Click the close icon on an active chip — `removed` fires, `clicked` does NOT (propagation stopped) - [ ] Inspect labels: empty selections → just title; 1 → `"Title: A"`; 2 → `"Title: A, B"`; 3+ → `"Title: A and N more"` (with i18n) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 59ba69f commit 855c7d5

6 files changed

Lines changed: 267 additions & 7 deletions

File tree

core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.html

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<p-toolbar class="gap-1 h-full px-3 justify-start border-none!">
22
<div
3-
class="grid w-full grid-cols-[min-content_1fr] grid-rows-2 items-center transition-all duration-300 ease-in-out gap-y-1 py-3"
3+
class="grid w-full grid-cols-[min-content_1fr] grid-rows-[auto_auto] items-center transition-all duration-300 ease-in-out gap-y-4 py-3"
44
[class.gap-x-1]="!$treeExpanded()"
55
[class.gap-x-0]="$treeExpanded()">
66
<dot-content-drive-tree-toggler data-testid="tree-toggler" [style]="$togglerStyles()" />
@@ -32,12 +32,14 @@
3232
}
3333
</div>
3434

35-
<div
36-
class="grid w-full gap-1 row-start-2 col-start-2"
37-
[class]="'grid-cols-[repeat(3,minmax(4.5rem,15rem))_1fr]'">
38-
<dot-content-drive-base-type-selector data-testid="base-type-selector" />
39-
<dot-content-drive-content-type-field data-testid="content-type-field" />
40-
<dot-content-drive-language-field data-testid="language-field" />
35+
<div class="flex flex-wrap w-full gap-2 row-start-2 col-start-2">
36+
<dot-content-drive-base-type-selector
37+
class="min-w-56"
38+
data-testid="base-type-selector" />
39+
<dot-content-drive-content-type-field
40+
class="min-w-56"
41+
data-testid="content-type-field" />
42+
<dot-content-drive-language-field class="min-w-56" data-testid="language-field" />
4143
</div>
4244
</div>
4345
</p-toolbar>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from './lib/dot-folder-list-view/dot-folder-list-view.component';
22
export * from './lib/dot-tree-folder/dot-tree-folder.component';
3+
export * from './lib/dot-chip-filter/dot-chip-filter.component';
34
export * from './lib/shared/models';
45
export * from './lib/shared/constants';
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<span class="truncate">
2+
<span class="font-semibold" data-testid="chip-title">{{ title() }}</span>
3+
@if (active()) {
4+
<span data-testid="chip-values">: {{ valuesLabel() }}</span>
5+
}
6+
</span>
7+
@if (active()) {
8+
<button
9+
type="button"
10+
class="flex items-center justify-center bg-transparent border-0 p-0 cursor-pointer text-current"
11+
[attr.aria-label]="'dot.common.remove' | dm"
12+
(click)="onRemove($event)"
13+
data-testid="chip-remove">
14+
<i class="pi pi-times text-sm leading-none"></i>
15+
</button>
16+
} @else {
17+
<i class="pi pi-chevron-down text-sm leading-none"></i>
18+
}
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { byTestId, createComponentFactory, Spectator } from '@ngneat/spectator/jest';
2+
3+
import { DotMessageService } from '@dotcms/data-access';
4+
import { MockDotMessageService } from '@dotcms/utils-testing';
5+
6+
import { DotChipFilterComponent } from './dot-chip-filter.component';
7+
8+
describe('DotChipFilterComponent', () => {
9+
let spectator: Spectator<DotChipFilterComponent>;
10+
11+
const createComponent = createComponentFactory({
12+
component: DotChipFilterComponent,
13+
providers: [
14+
{
15+
provide: DotMessageService,
16+
useValue: new MockDotMessageService({
17+
'content-drive.chip-filter.overflow-label': '{0} and {1} more',
18+
'dot.common.remove': 'Remove'
19+
})
20+
}
21+
]
22+
});
23+
24+
const getTitle = () => spectator.query(byTestId('chip-title'))?.textContent?.trim();
25+
const getValues = () => spectator.query(byTestId('chip-values'))?.textContent?.trim();
26+
27+
beforeEach(() => {
28+
spectator = createComponent({ props: { title: 'Type' } });
29+
});
30+
31+
it('should create', () => {
32+
expect(spectator.component).toBeTruthy();
33+
});
34+
35+
describe('label', () => {
36+
it('should always render the title', () => {
37+
expect(getTitle()).toBe('Type');
38+
});
39+
40+
it('should not render the values span when there are no selections', () => {
41+
expect(spectator.query(byTestId('chip-values'))).toBeFalsy();
42+
});
43+
44+
it('should render the values span when there is at least one selection', () => {
45+
spectator.setInput('selections', ['Blog']);
46+
expect(spectator.query(byTestId('chip-values'))).toBeTruthy();
47+
});
48+
49+
it('should render one selection', () => {
50+
spectator.setInput('selections', ['Blog']);
51+
expect(getValues()).toBe(': Blog');
52+
});
53+
54+
it('should render two selections joined by comma', () => {
55+
spectator.setInput('selections', ['Blog', 'Activities']);
56+
expect(getValues()).toBe(': Blog, Activities');
57+
});
58+
59+
it.each([
60+
[['Blog', 'News', 'Events'], ': Blog and 2 more'],
61+
[['Blog', 'News', 'Events', 'Sports'], ': Blog and 3 more']
62+
])(
63+
'should render first selection and remaining count for %s',
64+
(selections: string[], expected: string) => {
65+
spectator.setInput('selections', selections);
66+
expect(getValues()).toBe(expected);
67+
}
68+
);
69+
});
70+
71+
describe('active state', () => {
72+
it('should show chevron-down icon when there are no selections', () => {
73+
expect(spectator.query('.pi-chevron-down')).toBeTruthy();
74+
expect(spectator.query('.pi-times')).toBeFalsy();
75+
});
76+
77+
it('should show close icon when there are selections', () => {
78+
spectator.setInput('selections', ['Blog']);
79+
expect(spectator.query('.pi-times')).toBeTruthy();
80+
expect(spectator.query('.pi-chevron-down')).toBeFalsy();
81+
});
82+
});
83+
84+
describe('outputs', () => {
85+
it('should emit clicked on host click', () => {
86+
const handler = jest.fn();
87+
spectator.output('clicked').subscribe(handler);
88+
spectator.click(spectator.element);
89+
expect(handler).toHaveBeenCalled();
90+
});
91+
92+
it('should emit clicked on Enter keydown', () => {
93+
const handler = jest.fn();
94+
spectator.output('clicked').subscribe(handler);
95+
spectator.dispatchKeyboardEvent(spectator.element, 'keydown', 'Enter');
96+
expect(handler).toHaveBeenCalled();
97+
});
98+
99+
it('should emit clicked on Space keydown', () => {
100+
const handler = jest.fn();
101+
spectator.output('clicked').subscribe(handler);
102+
spectator.dispatchKeyboardEvent(spectator.element, 'keydown', ' ');
103+
expect(handler).toHaveBeenCalled();
104+
});
105+
106+
it('should emit removed when the close button is clicked', () => {
107+
spectator.setInput('selections', ['Blog']);
108+
spectator.detectChanges();
109+
110+
const handler = jest.fn();
111+
spectator.output('removed').subscribe(handler);
112+
spectator.click(byTestId('chip-remove'));
113+
expect(handler).toHaveBeenCalled();
114+
});
115+
116+
it('should not emit clicked when the close button is clicked', () => {
117+
spectator.setInput('selections', ['Blog']);
118+
spectator.detectChanges();
119+
120+
const clickedHandler = jest.fn();
121+
spectator.output('clicked').subscribe(clickedHandler);
122+
spectator.click(byTestId('chip-remove'));
123+
expect(clickedHandler).not.toHaveBeenCalled();
124+
});
125+
126+
it('should not emit clicked when Enter is pressed on the close button', () => {
127+
spectator.setInput('selections', ['Blog']);
128+
spectator.detectChanges();
129+
130+
const clickedHandler = jest.fn();
131+
spectator.output('clicked').subscribe(clickedHandler);
132+
133+
const removeBtn = spectator.query(byTestId('chip-remove')) as HTMLElement;
134+
expect(removeBtn).toBeTruthy();
135+
removeBtn.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
136+
137+
expect(clickedHandler).not.toHaveBeenCalled();
138+
});
139+
140+
it('should not emit clicked when Space is pressed on the close button', () => {
141+
spectator.setInput('selections', ['Blog']);
142+
spectator.detectChanges();
143+
144+
const clickedHandler = jest.fn();
145+
spectator.output('clicked').subscribe(clickedHandler);
146+
147+
const removeBtn = spectator.query(byTestId('chip-remove')) as HTMLElement;
148+
expect(removeBtn).toBeTruthy();
149+
removeBtn.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }));
150+
151+
expect(clickedHandler).not.toHaveBeenCalled();
152+
});
153+
});
154+
155+
describe('accessibility', () => {
156+
it('should expose role=button and tabindex=0 on the host', () => {
157+
expect(spectator.element.getAttribute('role')).toBe('button');
158+
expect(spectator.element.getAttribute('tabindex')).toBe('0');
159+
});
160+
161+
it('should label the close button with the remove translation', () => {
162+
spectator.setInput('selections', ['Blog']);
163+
spectator.detectChanges();
164+
165+
const removeBtn = spectator.query(byTestId('chip-remove'));
166+
expect(removeBtn?.getAttribute('aria-label')).toBe('Remove');
167+
});
168+
});
169+
});
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { ChangeDetectionStrategy, Component, computed, inject, input, output } from '@angular/core';
2+
3+
import { DotMessageService } from '@dotcms/data-access';
4+
import { DotMessagePipe } from '@dotcms/ui';
5+
6+
const BASE_CLASSES =
7+
'flex items-center justify-between gap-2 px-3 py-1.5 rounded-full text-sm font-normal leading-normal cursor-pointer select-none whitespace-nowrap min-w-[140px] transition-[color,background-color,border-color,width] duration-200 ease-out';
8+
9+
const INACTIVE_CLASSES = 'bg-white text-slate-600 border border-slate-200 hover:border-primary-400';
10+
11+
const ACTIVE_CLASSES =
12+
'bg-primary-100 text-primary-900 border border-transparent hover:bg-primary-200';
13+
14+
@Component({
15+
selector: 'dot-chip-filter',
16+
imports: [DotMessagePipe],
17+
templateUrl: './dot-chip-filter.component.html',
18+
changeDetection: ChangeDetectionStrategy.OnPush,
19+
host: {
20+
'[class]': 'stateClasses()',
21+
role: 'button',
22+
'[attr.tabindex]': 'tabIndex()',
23+
'(click)': 'clicked.emit()',
24+
'(keydown.enter)': 'onHostKeydown($event)',
25+
'(keydown.space)': 'onHostKeydown($event)'
26+
}
27+
})
28+
export class DotChipFilterComponent {
29+
readonly #dotMessageService = inject(DotMessageService);
30+
31+
title = input.required<string>();
32+
selections = input<string[]>([]);
33+
tabIndex = input<number>(0);
34+
35+
clicked = output<void>();
36+
removed = output<void>();
37+
38+
protected readonly active = computed(() => this.selections().length > 0);
39+
40+
protected readonly valuesLabel = computed(() => {
41+
const selections = this.selections();
42+
43+
if (!selections.length) return '';
44+
if (selections.length <= 2) return selections.join(', ');
45+
46+
return this.#dotMessageService.get(
47+
'content-drive.chip-filter.overflow-label',
48+
selections[0],
49+
String(selections.length - 1)
50+
);
51+
});
52+
53+
protected readonly stateClasses = computed(
54+
() => `${BASE_CLASSES} ${this.active() ? ACTIVE_CLASSES : INACTIVE_CLASSES}`
55+
);
56+
57+
protected onRemove(event: Event): void {
58+
event.stopPropagation();
59+
this.removed.emit();
60+
}
61+
62+
protected onHostKeydown(event: Event): void {
63+
// Ignore keydowns that bubbled from a descendant (e.g. the close button)
64+
if (event.target && event.target !== event.currentTarget) return;
65+
if ((event as KeyboardEvent).key === ' ') event.preventDefault();
66+
this.clicked.emit();
67+
}
68+
}

dotCMS/src/main/webapp/WEB-INF/messages/Language.properties

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6393,6 +6393,8 @@ content-drive.all-folder.label=All
63936393
content-drive.toast.download-success=Downloading {0}
63946394
content-drive.toast.download-success-detail=The download has started.
63956395

6396+
content-drive.chip-filter.overflow-label={0} and {1} more
6397+
63966398
edit.content.preview-link=Preview
63976399

63986400
relative.date.now=Now

0 commit comments

Comments
 (0)