Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import {
Badge,
ContextualHelp,
Label,
Text,
} from "@mittwald/flow-react-components";

<Badge>
<Label>Priorität</Label>
<Text>Hoch</Text>
<ContextualHelp>
<Text>
Tickets mit hoher Priorität werden zuerst bearbeitet.
</Text>
</ContextualHelp>
</Badge>;
14 changes: 12 additions & 2 deletions apps/docs/src/content/components/status/badge/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,19 @@ Informationen zu öffnen. Es sollte dabei nicht als Ersatz für einen

---

# Badge entfernen
# Mit ContextualHelp

Über `onClose` lässt sich ein Badge vom User aus der Oberfläche entfernen.
Eine [ContextualHelp](/components/overlays/contextual-help) im Badge wird am
Ende als Button dargestellt. Den Button stellt das Badge selbst.

<LiveCodeEditor example="contextualHelp" editorCollapsed />

---

# Mit onClose

Über `onClose` platzierst du einen X-Button im Badge. Nutze ihn z. B., um das
Badge entfernbar zu machen.

<LiveCodeEditor example="onClose" editorCollapsed />

Expand Down
42 changes: 42 additions & 0 deletions packages/components/src/components/Badge/Badge.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { expect, test } from "vitest";
import { render } from "vitest-browser-react";
import { page } from "vitest/browser";
import { Badge } from "@/components/Badge";
import { ContextualHelp } from "@/components/ContextualHelp";
import { Text } from "@/components/Text";

test("A contextual help in the content opens from a button of its own", async () => {
await render(
<Badge>
Value
<ContextualHelp>
<Text>Every value has a story to tell.</Text>
</ContextualHelp>
</Badge>,
);

await expect
.element(page.getByText("Every value has a story to tell."))
.not.toBeInTheDocument();

await page.getByRole("button", { name: "More information" }).click();

await expect
.element(page.getByText("Every value has a story to tell."))
.toBeInTheDocument();
});

test("A disabled badge disables the contextual help button", async () => {
const screen = await render(
<Badge isDisabled>
Value
<ContextualHelp>
<Text>Every value has a story to tell.</Text>
</ContextualHelp>
</Badge>,
);

await expect
.element(screen.getByRole("button", { name: "More information" }))
.toBeDisabled();
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ declare const classNames: {
readonly badge: "badge";
readonly content: "content";
readonly button: "button";
readonly action: "action";
readonly close: "close";
readonly scope: "scope";
readonly value: "value";
Expand Down
47 changes: 42 additions & 5 deletions packages/components/src/components/Badge/Badge.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -30,19 +30,28 @@
}

.button,
.action,
.close {
@include focus.focus;
}

@extend .neutral;

&:has(.close) {
&:has(.close),
&:has(.action) {
.button,
.content {
padding-inline-end: calc(var(--content-size) + var(--badge--spacing));
}
}

&:has(.action):has(.close) {
.button,
.content {
padding-inline-end: calc(var(--content-size) * 2 + var(--badge--spacing));
}
}

&:has(.scope) {
.content,
.button {
Expand All @@ -59,7 +68,8 @@
flex-shrink: 0;
}

&:has(.close) {
&:has(.close),
&:has(.action) {
.button,
.content {
padding-inline-end: var(--content-size);
Expand All @@ -69,20 +79,45 @@
}
}
}

&:has(.action):has(.close) {
.button,
.content {
padding-inline-end: calc(var(--content-size) * 2);
}
}
}

.action,
.close {
height: var(--content-size);
padding: calc(var(--badge--padding-y) - var(--badge--border-width));
border-radius: 0;
border-end-end-radius: var(--badge-size);
border-start-end-radius: var(--badge-size);
margin-inline-start: calc(
(var(--badge-size) - var(--badge--border-width)) * -1
(var(--content-size) + var(--badge--border-width)) * -1
);
margin-top: var(--badge--border-width);
}

/* The trailing button rounds off the badge */
.close,
&:not(:has(.close)) .action {
border-end-end-radius: var(--badge-size);
border-start-end-radius: var(--badge-size);
}

&:has(.action):has(.close) {
.action {
margin-inline-start: calc(
(var(--content-size) * 2 + var(--badge--border-width)) * -1
);
}

.close {
margin-inline-start: 0;
}
}

/* Colors */

@mixin color($color) {
Expand Down Expand Up @@ -135,6 +170,7 @@
color: var(--badge--disabled-content-color);
}

.action,
.close {
color: var(--badge--disabled-content-color);
}
Expand All @@ -156,6 +192,7 @@
color: var(--badge--disabled-#{$color}-content-color);
}

.action,
.close {
color: var(--badge--disabled-#{$color}-content-color);
}
Expand Down
23 changes: 22 additions & 1 deletion packages/components/src/components/Badge/Badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import type { PressEvent } from "@react-types/shared";
import { Button } from "@/components/Button";
import { IconClose } from "@/components/Icon/components/icons";
import { useLocalizedStringFormatter } from "@/components/TranslationProvider/useLocalizedStringFormatter";
import { UiComponentTunnelExit } from "@/components/UiComponentTunnel/UiComponentTunnelExit";
import { ContextualHelpButton } from "@/components/Badge/components/ContextualHelpButton";
import locales from "./locales/*.locale.json";

export const badgeColors = [
Expand Down Expand Up @@ -41,6 +43,11 @@ export interface BadgeProps
isDisabled?: boolean;
}

const contextualHelpTunnel = {
id: "contextualHelp",
component: "Badge",
} as const;

/** @flr-generate all */
export const Badge = flowComponent("Badge", (props) => {
const {
Expand All @@ -63,6 +70,8 @@ export const Badge = flowComponent("Badge", (props) => {
className,
);

const buttonColor = isAlphaColor(color) ? color : "dark";

const propsContext: PropsContext = {
Label: {
elementType: "span",
Expand All @@ -84,6 +93,16 @@ export const Badge = flowComponent("Badge", (props) => {
className: styles.value,
},
},
ContextualHelp: {
tunnel: contextualHelpTunnel,
wrapWith: (
<ContextualHelpButton
className={styles.action}
color={buttonColor}
isDisabled={isDisabled}
/>
),
},
};

return (
Expand All @@ -101,11 +120,13 @@ export const Badge = flowComponent("Badge", (props) => {
</Button>
)}

<UiComponentTunnelExit id={contextualHelpTunnel.id} component="Badge" />

{onClose && (
<Button
className={styles.close}
size="s"
color={isAlphaColor(color) ? color : "dark"}
color={buttonColor}
variant="plain"
onPress={onClose}
isDisabled={isDisabled}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { FC, PropsWithChildren } from "react";
import type { AlphaColor, PropsWithClassName } from "@/lib/types/props";
import ButtonView from "@/views/ButtonView";
import ContextualHelpTriggerView from "@/views/ContextualHelpTriggerView";

interface Props extends PropsWithChildren, PropsWithClassName {
color: AlphaColor;
isDisabled?: boolean;
}

/**
* Supplies the trigger button for a contextual help placed in a badge, so the
* consumer only writes the <ContextualHelp>. Icon and accessibility label come
* from the trigger.
*/
export const ContextualHelpButton: FC<Props> = (props) => {
const { className, color, isDisabled, children } = props;

return (
<ContextualHelpTriggerView>
<ButtonView
className={className}
color={color}
variant="plain"
size="s"
isDisabled={isDisabled}
/>
{children}
</ContextualHelpTriggerView>
);
};

export default ContextualHelpButton;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { ContextualHelpButton } from "./ContextualHelpButton";
export { default } from "./ContextualHelpButton";
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { ContextMenu } from "@/components/ContextMenu";
import MenuItem from "@/components/MenuItem";
import { useOverlayController } from "@/lib/controller";
import { StoryBackground } from "@/lib/dev/StoryBackground";
import { ContextualHelp } from "@/components/ContextualHelp";

const meta: Meta<typeof Badge> = {
title: "Status/Badge",
Expand Down Expand Up @@ -66,6 +67,20 @@ export const WithActions: Story = {
),
};

export const WithContextualHelp: Story = {
render: (props, context) => (
<StoryBackground color={props.color} theme={context.globals.theme}>
<Badge {...props}>
<Label>Scope</Label>
<Text>Value</Text>
<ContextualHelp>
<Text>Every value has a story to tell.</Text>
</ContextualHelp>
</Badge>
</StoryBackground>
),
};

export const WithContextMenu: Story = {
render: (props, context) => {
const controller = useOverlayController("ContextMenu");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,14 @@ test.each(testEnvironments)(
async ({
testScreenshot,
render,
components: { Badge, Flex, Label, Text, Wrap, AccentBox },
components: { AccentBox, Badge, ContextualHelp, Flex, Label, Text, Wrap },
}) => {
const contextualHelp = (
<ContextualHelp>
<Text>Every value has a story to tell.</Text>
</ContextualHelp>
);

await render(
<Flex direction="column" gap="m">
{colors.map((color) => (
Expand All @@ -52,6 +58,14 @@ test.each(testEnvironments)(
<Label>Scope</Label>
<Text>Value</Text>
</Badge>
<Badge color={color}>
Value
{contextualHelp}
</Badge>
<Badge color={color} onClose={() => console.log("onClose")}>
Value
{contextualHelp}
</Badge>
</Flex>
</AccentBox>
</Wrap>
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading