diff --git a/config/locales/js-en.yml b/config/locales/js-en.yml
index 3a02759c3c8..5d9143b68f6 100644
--- a/config/locales/js-en.yml
+++ b/config/locales/js-en.yml
@@ -401,6 +401,7 @@ en:
label_in_more_than: "in more than"
label_incoming_emails: "Incoming emails"
label_information_plural: "Information"
+ label_infinity: "Infinity"
label_invalid: "Invalid"
label_import: "Import"
label_latest_activity: "Latest activity"
@@ -425,6 +426,7 @@ en:
label_no_value: "No value"
label_none: "none"
label_not_contains: "doesn't contain"
+ label_not_available: "Not available"
label_not_equals: "is not"
label_on: "on"
label_open_menu: "Open menu"
diff --git a/frontend/src/app/shared/components/primer/counter.component.html b/frontend/src/app/shared/components/primer/counter.component.html
new file mode 100644
index 00000000000..81291f808b2
--- /dev/null
+++ b/frontend/src/app/shared/components/primer/counter.component.html
@@ -0,0 +1,8 @@
+
diff --git a/frontend/src/app/shared/components/primer/counter.component.spec.ts b/frontend/src/app/shared/components/primer/counter.component.spec.ts
new file mode 100644
index 00000000000..6c9ee1ba009
--- /dev/null
+++ b/frontend/src/app/shared/components/primer/counter.component.spec.ts
@@ -0,0 +1,131 @@
+//-- copyright
+// OpenProject is an open source project management software.
+// Copyright (C) the OpenProject GmbH
+//
+// This program is free software; you can redistribute it and/or
+// modify it under the terms of the GNU General Public License version 3.
+//
+// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+// Copyright (C) 2006-2013 Jean-Philippe Lang
+// Copyright (C) 2010-2013 the ChiliProject Team
+//
+// This program is free software; you can redistribute it and/or
+// modify it under the terms of the GNU General Public License
+// as published by the Free Software Foundation; either version 2
+// of the License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program; if not, write to the Free Software
+// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+//
+// See COPYRIGHT and LICENSE files for more details.
+//++
+
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { I18nService } from 'core-app/core/i18n/i18n.service';
+import { PrimerCounterComponent } from './counter.component';
+
+describe('PrimerCounterComponent', () => {
+ const I18nStub = {
+ locale: 'en',
+ t(key:string) {
+ return {
+ 'js.label_infinity': 'Infinity',
+ 'js.label_not_available': 'Not available',
+ }[key] ?? key;
+ },
+ };
+
+ let fixture:ComponentFixture;
+ let span:HTMLSpanElement;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [PrimerCounterComponent],
+ providers: [
+ { provide: I18nService, useValue: I18nStub },
+ ],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(PrimerCounterComponent);
+ span = fixture.elementRef.nativeElement.querySelector('span.Counter') as HTMLSpanElement;
+ });
+
+ function render(inputs:Record):void {
+ Object.entries(inputs).forEach(([key, value]) => fixture.componentRef.setInput(key, value));
+ fixture.detectChanges();
+ }
+
+ it('renders a plain count', () => {
+ render({ count: 12 });
+
+ expect(span.textContent).toEqual('12');
+ expect(span.getAttribute('title')).toEqual('12');
+ });
+
+ it('delimits large numbers', () => {
+ render({ count: 1234, limit: null });
+
+ expect(span.textContent).toEqual('1,234');
+ });
+
+ it('applies the limit with a trailing plus', () => {
+ render({ count: 6000, limit: 5000 });
+
+ expect(span.textContent).toEqual('5,000+');
+ expect(span.getAttribute('title')).toEqual('5,000+');
+ });
+
+ it('renders nothing and a fallback title for a null count', () => {
+ render({ count: null });
+
+ expect(span.textContent).toEqual('');
+ expect(span.getAttribute('title')).toEqual('Not available');
+ });
+
+ it('renders nothing and a fallback title for an undefined count', () => {
+ render({ count: undefined });
+
+ expect(span.textContent).toEqual('');
+ expect(span.getAttribute('title')).toEqual('Not available');
+ });
+
+ it('renders nothing and a fallback title for a NaN count', () => {
+ render({ count: NaN });
+
+ expect(span.textContent).toEqual('');
+ expect(span.getAttribute('title')).toEqual('Not available');
+ });
+
+ it('renders the infinity symbol', () => {
+ render({ count: Infinity });
+
+ expect(span.textContent).toEqual('∞');
+ expect(span.getAttribute('title')).toEqual('Infinity');
+ });
+
+ it('applies the scheme modifier class', () => {
+ render({ count: 1, scheme: 'secondary' });
+
+ expect(span.classList).toContain('Counter--secondary');
+ expect(span.classList).not.toContain('Counter--primary');
+ });
+
+ it('hides a zero count when hideIfZero is set', () => {
+ render({ count: 0, 'hide-if-zero': true });
+
+ expect(span.hidden).toBe(true);
+ });
+
+ it('does not hide a zero count by default', () => {
+ render({ count: 0 });
+
+ expect(span.hidden).toBe(false);
+ expect(span.textContent).toEqual('0');
+ });
+});
diff --git a/frontend/src/app/shared/components/primer/counter.component.ts b/frontend/src/app/shared/components/primer/counter.component.ts
new file mode 100644
index 00000000000..74f374cf3d9
--- /dev/null
+++ b/frontend/src/app/shared/components/primer/counter.component.ts
@@ -0,0 +1,106 @@
+//-- copyright
+// OpenProject is an open source project management software.
+// Copyright (C) the OpenProject GmbH
+//
+// This program is free software; you can redistribute it and/or
+// modify it under the terms of the GNU General Public License version 3.
+//
+// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+// Copyright (C) 2006-2013 Jean-Philippe Lang
+// Copyright (C) 2010-2013 the ChiliProject Team
+//
+// This program is free software; you can redistribute it and/or
+// modify it under the terms of the GNU General Public License
+// as published by the Free Software Foundation; either version 2
+// of the License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program; if not, write to the Free Software
+// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+//
+// See COPYRIGHT and LICENSE files for more details.
+//++
+
+/* eslint-disable @angular-eslint/component-selector, @angular-eslint/no-input-rename */
+
+import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';
+import { I18nService } from 'core-app/core/i18n/i18n.service';
+
+type Scheme = 'default' | 'primary' | 'secondary';
+
+// Angular port of Primer::Beta::Counter, used to add a count to navigational
+// elements. The `text` and `round` options of the Ruby component are not
+// supported.
+@Component({
+ selector: 'primer-counter',
+ templateUrl: './counter.component.html',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ standalone: true,
+})
+export class PrimerCounterComponent {
+ private readonly I18n = inject(I18nService);
+
+ // The number to be displayed (e.g. # of issues, pull requests).
+ readonly count = input(0);
+
+ // Color scheme. One of default | primary | secondary.
+ readonly scheme = input('default');
+
+ // Maximum value to display. Pass `null` for no limit. When `count` exceeds
+ // `limit`, the value is rendered as e.g. "5,000+".
+ readonly limit = input(5_000);
+
+ // When true, a `hidden` attribute is added to the counter if `count` is zero.
+ readonly hideIfZero = input(false, { alias: 'hide-if-zero' });
+
+ // Displayed text: "" when no value (CSS hides it), "∞" for infinity,
+ // otherwise the delimited count.
+ readonly value = computed(() => {
+ const count = this.count();
+ if (count === null || count === undefined) {
+ return ''; // CSS will hide it
+ }
+ if (count === Infinity || count === -Infinity) {
+ return '∞';
+ }
+ if (Number.isNaN(count)) {
+ return '';
+ }
+ return this.displayNumber(count);
+ });
+
+ // Title attribute, mirroring the displayed count (including limit capping) as a tooltip.
+ readonly titleText = computed(() => {
+ const count = this.count();
+ if (count === null || count === undefined) {
+ return this.I18n.t('js.label_not_available');
+ }
+ if (count === Infinity || count === -Infinity) {
+ return this.I18n.t('js.label_infinity');
+ }
+ if (Number.isNaN(count)) {
+ return this.I18n.t('js.label_not_available');
+ }
+ return this.displayNumber(count);
+ });
+
+ readonly hidden = computed(() => this.count() === 0 && this.hideIfZero());
+
+ private displayNumber(count:number):string {
+ const value = Math.trunc(count);
+ const limit = this.limit();
+ const capped = limit === null ? value : Math.min(value, limit);
+ const formatter = new Intl.NumberFormat(this.I18n.locale, {
+ maximumFractionDigits: 0,
+ useGrouping: true,
+ });
+ const str = formatter.format(capped);
+
+ return limit !== null && value > limit ? `${str}+` : str;
+ }
+}
diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts
index 0775f06eb3e..473dbd8896f 100644
--- a/frontend/src/app/shared/shared.module.ts
+++ b/frontend/src/app/shared/shared.module.ts
@@ -84,6 +84,7 @@ import { OpenprojectModalModule } from 'core-app/shared/components/modal/modal.m
import { FullCalendarModule } from '@fullcalendar/angular';
import { OpDatePickerModule } from 'core-app/shared/components/datepicker/datepicker.module';
import { OpBreadcrumbsComponent } from './components/breadcrumbs/op-breadcrumbs.component';
+import { PrimerCounterComponent } from './components/primer/counter.component';
import { PrimerIconButtonComponent } from './components/primer/icon-button.component';
export function bootstrapModule(injector:Injector):void {
@@ -127,6 +128,7 @@ export function bootstrapModule(injector:Injector):void {
FullCalendarModule,
OpDatePickerModule,
+ PrimerCounterComponent,
PrimerIconButtonComponent
],
exports: [
@@ -181,6 +183,7 @@ export function bootstrapModule(injector:Injector):void {
OpNonWorkingDaysListComponent,
+ PrimerCounterComponent,
PrimerIconButtonComponent
],
providers: [