Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import type {Meta, StoryObj} from '@storybook/web-components-vite';

import {html} from 'lit';

import './badge-indicator.js';

// More on how to set up stories at: https://storybook.js.org/docs/writing-stories
const meta = {
title: 'Components/Badge Indicator',
component: 'craft-badge-indicator',
args: {
variant: 'primary',
altText: 'Has Notifications',
badgeCount: null,
},
argTypes: {
badgeCount: {
control: {
type: 'number'
}
},
variant: {
options: ['primary', 'secondary', 'inverse'],
control: {
type: 'radio'
},
},
altText: {
control: {
type: 'text'
}
},
},
render: (args) => html`
<craft-badge-indicator
.badgeCount="${args.badgeCount}"
.badgeCountSuffix="${args.badgeCountSuffix}"
.altText="${args.altText}"
.variant="${args.variant}"></craft-badge-indicator>
`,
} satisfies Meta<any>;

export default meta;
type Story = StoryObj<any>;

// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args

export const Dot: Story = {
name: 'Dot',
parameters: {
controls: { exclude: ['badgeCount', 'badgeCountSuffix'] },
},
}

export const Numbered: Story = {
name: 'Numbered',
args: {
altText: null,
badgeCount: 5,
badgeCountSuffix: 'updates'
},
parameters: {
controls: { exclude: ['altText'] },
},
}

export const Primary: Story = {
name: 'Primary',
parameters: {
controls: { exclude: ['badgeCount', 'badgeCountSuffix'] },
},
}

export const Secondary: Story = {
name: 'Secondary',
args: {
variant: 'secondary',
},
parameters: {
controls: { exclude: ['badgeCount', 'badgeCountSuffix'] },
},
}

export const Inverse: Story = {
name: 'Inverse',
args: {
variant: 'inverse',
},
parameters: {
controls: { exclude: ['badgeCount', 'badgeCountSuffix'] },
},
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import {css} from 'lit';

export default css`
.badge-indicator {
--badge-color: var(--c-color-accent-bg-emphasis);
--text-color: white;
--badge-size: calc(8rem / 16);
display: inline-flex;
min-width: var(--badge-size);
min-height: var(--badge-size);
justify-content: center;
align-items: center;
background-color: var(--badge-color);
color: var(--text-color);
border-radius: var(--c-radius-full);
border: 2px solid var(--c-fg-white);
}

.badge-indicator--secondary {
--badge-color: var(--c-color-brand-bg-emphasis);
}

.badge-indicator--inverse {
--badge-color: var(--c-color-neutral-bg-normal);
--text-color: var(--c-fg-text);
}

.badge-indicator--with-number {
--badge-size: var(--c-size-icon-md);
padding: calc(2rem / 16);
}

.number {
display: inline-flex;
font-size: var(--c-text-xs);
font-weight: var(--font-weight-semibold);
line-height: 1;
}
`;
107 changes: 107 additions & 0 deletions packages/craftcms-cp/src/components/badge-indicator/badge-indicator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import {html, css, LitElement, nothing} from 'lit';
import {property} from 'lit/decorators.js';
import styles from './badge-indicator.styles.js';
import {classMap} from 'lit/directives/class-map.js';
import '@shoelace-style/shoelace/dist/components/visually-hidden/visually-hidden.js';

/**
* @summary A badge indicator component. Used in various places to indicate that
* something is new or has been updated. The indicator can have an optional
* notification count.
*/

export default class CraftBadgeIndicator extends LitElement {
static override styles = [styles];

/** Alternative text if the badge is not decorative */
@property() altText: string | null = null;

/** Displays a number on the badge. If using `badgeCount`, the `badgeCountSuffix` should also be used to describe what the number represents. */
@property() badgeCount: number | null = null;

/** Visually hidden text that comes after the count to provide additional context. */
@property() badgeCountSuffix: string | null = null;

/** Theme variant of the badge indicator. Defaults to "primary" */
@property() variant: 'primary' | 'secondary' | 'inverse' = 'primary';

@property()
override id: string;

constructor() {
super();
this.id =
this.id || `badge-${Math.floor(Math.random() * 1000000000).toString()}`;
}

private showCount() {
return this.badgeCount !== null && this.badgeCount > 0;
}

private truncatedNumber() {
if (!this.showCount) return;

// @ts-ignore we're already checking that badgeCount is not null in showCount
if (this.badgeCount > 99) {
return '99+';
} else {
// @ts-ignore we're already checking that badgeCount is not null in showCount
return this.badgeCount.toString();
}
}

private getBadgeRole() {
return this.altText ? 'img' : nothing;
}

private getLabelId() {
return `${this.id}-label`;
}

private renderBadgeContents() {
return html`
${this.showCount()
? html`
<span class="number">${this.truncatedNumber()}</span>
<sl-visually-hidden>${this.badgeCountSuffix}</sl-visually-hidden>
`
: nothing}
${this.altText
? html`
<sl-visually-hidden id=${this.getLabelId()}
>${this.altText}</sl-visually-hidden
>
`
: nothing}
`;
}

override render() {
return html`
<div
part="badge"
id=${this.id}
class="${classMap({
'badge-indicator': true,
'badge-indicator--with-number': this.showCount(),
'badge-indicator--secondary': this.variant === 'secondary',
'badge-indicator--inverse': this.variant === 'inverse'
})}"
role="${this.getBadgeRole()}"
aria-labelledby="${this.altText ? this.getLabelId() : nothing}"
>
${this.renderBadgeContents()}
</div>
`;
}
}

if (!customElements.get('craft-badge-indicator')) {
customElements.define('craft-badge-indicator', CraftBadgeIndicator);
}

declare global {
interface HTMLElementTagNameMap {
'craft-badge-indicator': CraftBadgeIndicator;
}
}
4 changes: 3 additions & 1 deletion packages/craftcms-cp/src/components/indicator/indicator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ export default class CraftIndicator extends LitElement {
'indicator--info': this.variant === Variant.Info,
'indicator--empty': this.variant === 'empty',
})}"
></span>`;
>
<slot></slot>
</span>`;
}
}

Expand Down
20 changes: 7 additions & 13 deletions packages/craftcms-cp/src/components/nav-item/nav-item.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ export default css`
border-radius: var(--c-radius-md);
position: relative;
}

craft-badge-indicator::part(badge) {
position: absolute;
inset-inline-end: 0;
inset-block-end: 0;
}
}

.nav-item--prefixed {
padding-inline: var(--c-spacing-sm);
Expand Down Expand Up @@ -58,19 +65,6 @@ export default css`
}
}

.indicator {
display: inline-block;
aspect-ratio: 1;
width: calc(6rem / 16);
border-radius: var(--c-radius-full);
background-color: var(--c-color-accent-bg-emphasis);
border: 1px solid var(--c-color-accent-border-emphasis);
outline: 2px solid Canvas;
position: absolute;
inset-inline-end: 0;
inset-block-end: 0;
}

.subnav {
margin-block-start: var(--c-spacing-sm);
margin-inline-start: calc(
Expand Down
4 changes: 3 additions & 1 deletion packages/craftcms-cp/src/components/nav-item/nav-item.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import {html, LitElement, nothing} from 'lit';
import {styleMap} from 'lit/directives/style-map.js';
import {property, state} from 'lit/decorators.js';
import '../badge-indicator/badge-indicator';
import styles from './nav-item.styles';
import {t} from '@craftcms/cp';
import {classMap} from 'lit/directives/class-map.js';

/**
Expand Down Expand Up @@ -86,7 +88,7 @@ export default class CraftNavItem extends LitElement {
></craft-icon>`
: nothing}
</slot>
${this.indicator ? html`<span class="indicator"></span>` : nothing}
${this.indicator ? html`<craft-badge-indicator altText="${t('Has Notifications')}" />` : nothing}
</slot>
</span>
`;
Expand Down
1 change: 1 addition & 0 deletions packages/craftcms-cp/src/styles/shared/tokens.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
--c-text-lg: calc(16rem / 16);
--c-text-base: calc(14rem / 16);
--c-text-sm: calc(11rem / 16);
--c-text-xs: calc(9rem / 16);

--c-leading-normal: 1.42;

Expand Down
1 change: 1 addition & 0 deletions resources/translations/en/app.php
Original file line number Diff line number Diff line change
Expand Up @@ -801,6 +801,7 @@
'HTML Email Template' => 'HTML Email Template',
'Handle' => 'Handle',
'Has Descendants' => 'Has Descendants',
'Has Notifications' => 'Has Notifications',
'Has URL' => 'Has URL',
'Has alternative text' => 'Has alternative text',
'Heading' => 'Heading',
Expand Down
6 changes: 3 additions & 3 deletions tests/Feature/Element/Concerns/DisplayedInIndexTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ public static function hasStatuses(): bool

expect($complexOptions)->not->toBeEmpty();

$firstComplex = array_first($complexOptions);
$firstComplex = \Illuminate\Support\Arr::first($complexOptions);
expect($firstComplex)->toHaveKey('label');
expect($firstComplex)->toHaveKey('orderBy');
});
Expand Down Expand Up @@ -254,15 +254,15 @@ public static function hasStatuses(): bool

test('section sort option has callable orderBy', function () {
$options = TestEntryForDisplayedInIndex::exposeDefineSortOptions();
$sectionOption = array_first(array_filter($options, fn ($opt) => is_array($opt) && ($opt['attribute'] ?? null) === 'section')) ?? null;
$sectionOption = \Illuminate\Support\Arr::first(array_filter($options, fn ($opt) => is_array($opt) && ($opt['attribute'] ?? null) === 'section')) ?? null;

expect($sectionOption)->not->toBeNull();
expect($sectionOption['orderBy'])->toBeCallable();
});

test('entry type sort option has callable orderBy with database connection parameter', function () {
$options = TestEntryForDisplayedInIndex::exposeDefineSortOptions();
$typeOption = array_first(array_filter($options, fn ($opt) => is_array($opt) && ($opt['attribute'] ?? null) === 'type')) ?? null;
$typeOption = \Illuminate\Support\Arr::first(array_filter($options, fn ($opt) => is_array($opt) && ($opt['attribute'] ?? null) === 'type')) ?? null;

expect($typeOption)->not->toBeNull();
expect($typeOption['orderBy'])->toBeCallable();
Expand Down
Loading