← All proposals

Menu Button

Starting point for a MenuButton proposal.

Extends a PDS componentOpen in Storybook

proposals/MenuButton/MenuButton.tsx901 lines · View on GitHub
import {
	ComponentPropsWithRef,
	JSXElementConstructor,
	ReactElement,
	ReactNode,
	RefObject,
	useEffect,
	useRef,
	useState,
} from 'react';

import {
	FloatingNode,
	FloatingPortal,
	FloatingTree,
	type Placement,
	useFloatingParentNodeId,
} from '@floating-ui/react';
import {
	HeadingItemType,
	isHeadingItemType,
	isLinkItemType,
	isNodeItemType,
	isSeparatorItemType,
	LinkItemType,
	MenuItem,
	MenuItemType,
	NodeItemType,
	PDSIcon,
	SeparatorItemType,
	Tally,
	TallyProps,
	useDropdown,
	useIsMobile,
} from '@pantheon-systems/pds-toolkit-react';

import { Button } from '../Button/Button';
import {
	ButtonVariant,
	extractText,
	mergeClasses,
	StatusDotType,
} from '../Button/utils';
import { Icon } from '../Icon/Icon';
import { IconButton } from '../IconButton/IconButton';
import { NavAccordion } from './NavAccordion';

import './menu-button.css';

type MenuButtonSize = 'xs' | 's' | 'm';

/**
 * Prop types for MenuButton
 */
export interface MenuButtonProps extends ComponentPropsWithRef<'span'> {
	/**
	 * Additional class names
	 */
	className?: string;
	/**
	 * Is the button disabled?
	 */
	disabled?: boolean;
	/**
	 * Icon name for icon-only mode.
	 */
	iconName?: PDSIcon;
	/**
	 * Render as an icon-only button (no label, no angle-down). Not compatible with `tally` or `statusDot`.
	 */
	iconOnly?: boolean;
	/**
	 * ID value for this component.
	 */
	id: string;
	/**
	 * Is the trigger agentic? Gives the trigger the AI gradient border, like
	 * `isAgentic` on `Button`. Only valid on the `primary` and `secondary`
	 * variants; in `iconOnly` mode it also turns the icon button's border on.
	 */
	isAgentic?: boolean;
	/**
	 * Opt in to swapping the trigger + floating dropdown panel for an inline
	 * accordion at the mobile breakpoint. Defaults to `false` — MenuButton
	 * always renders its normal trigger + dropdown otherwise, regardless of
	 * viewport. Set this explicitly when placing MenuButton inside a mobile
	 * nav menu that provides the accordion's expected layout (e.g. `Navbar`'s
	 * own mobile menu) — typically `isMobileAccordion={useIsMobile()}`, using
	 * the `useIsMobile` hook, so the swap only takes effect at the mobile
	 * breakpoint.
	 */
	isMobileAccordion?: boolean;
	/**
	 * Will this be used for a split button?
	 */
	isSplitButton?: boolean;
	/**
	 * The text of the button
	 */
	label: ReactNode;
	/**
	 * Array of menu items
	 */
	menuItems: Array<
		| HeadingItemType
		| LinkItemType
		| MenuItemType
		| NodeItemType
		| SeparatorItemType
	>;
	/**
	 * Optional callback function for when the button is clicked or activated by the keyboard.
	 */
	onClick?: () => void;
	/**
	 * Optional callback fired whenever the menu's open state changes. Use
	 * this to manage focus deliberately when this MenuButton is nested
	 * inside another floating panel — e.g. return focus to the row that
	 * owns this menu when it closes, rather than leaving it to Floating UI's
	 * default (which can otherwise leave focus on an ancestor panel).
	 */
	onOpenChange?: (isOpen: boolean) => void;
	/**
	 * Preferred placement of the dropdown panel relative to its anchor.
	 * Defaults to `bottom-start` (current behavior). `SplitButton` passes
	 * this through unchanged — see its own `placement` prop.
	 */
	placement?: Placement;
	/**
	 * Ref to an ancestor element that Floating UI should use as the
	 * *position* reference (while the trigger button remains the
	 * *interaction* reference for click/keyboard). Set internally by
	 * `SplitButton` when `isSplitButton` is true, so the panel anchors to
	 * the whole split-button control instead of just the caret half. Not
	 * intended for direct use outside `SplitButton`.
	 */
	positionReferenceRef?: RefObject<HTMLElement | null>;
	/**
	 * Which size of button to render.
	 */
	size?: MenuButtonSize;
	/**
	 * A status dot displayed in the left icon slot. Not available in `iconOnly` mode.
	 */
	statusDot?: StatusDotType;
	/**
	 * A tally badge displayed between the label and the angle-down icon. Not available in `iconOnly` mode.
	 */
	tally?: {
		label: TallyProps['label'];
		type: TallyProps['type'];
	};
	/**
	 *  Optional `data-testid` value for this component.
	 */
	testId?: string;
	/**
	 *  Translatable string for description of unlabeled group of menu items.
	 */
	undefinedLabel?: string;
	/**
	 * Which variant of button to render. Not available in `iconOnly` mode.
	 * `navbar` is visually identical to `subtle` except it has no background
	 * at rest — only on hover/expanded — for triggers that sit directly on a
	 * nav/toolbar surface rather than a plain page background. `reverse-secondary`
	 * is for triggers on a dark/reverse surface — passed straight through to
	 * the underlying `Button`, which already supports it.
	 */
	variant?:
		| Extract<
				ButtonVariant,
				'primary' | 'secondary' | 'subtle' | 'reverse-secondary'
		  >
		| 'navbar';
}

/**
 * MenuButton UI component.
 *
 * Wraps the actual implementation in a Floating UI tree boundary: when
 * nested inside another floating panel (e.g. a `Dropdown`'s panel), it joins
 * that panel's tree so outside-clicks and Escape inside this menu resolve to
 * this (innermost) menu without wrongly dismissing the parent. When not
 * nested, it provides its own single-node tree — no consumer opt-in needed.
 */
export const MenuButton = (props: MenuButtonProps) => {
	const parentId = useFloatingParentNodeId();

	if (parentId === null) {
		return (
			<FloatingTree>
				<MenuButtonImpl {...props} />
			</FloatingTree>
		);
	}

	return <MenuButtonImpl {...props} />;
};

const MenuButtonImpl = ({
	className,
	disabled,
	iconName = 'ellipsisVertical',
	iconOnly = false,
	id,
	isAgentic = false,
	isMobileAccordion = false,
	isSplitButton = false,
	label,
	menuItems,
	onClick,
	onOpenChange,
	placement = 'bottom-start',
	positionReferenceRef,
	size = 's',
	statusDot,
	tally,
	testId,
	undefinedLabel = 'Unlabeled group',
	variant = 'primary',
	...props
}: MenuButtonProps) => {
	// Shared dropdown hook.
	const {
		activeIndex,
		floatingStyles,
		getFloatingProps,
		getItemProps,
		getReferenceProps,
		isMounted,
		isOpen,
		listRef,
		nodeId,
		refs,
		setIsOpen,
		transitionStyles,
	} = useDropdown({
		enableClick: !disabled,
		enableTypeahead: true,
		offsetValue: 8,
		placement,
		role: 'menu',
	});
	// v2 raises this above Modals/Drawers when nested in one (`useOverlayZIndex`,
	// not exported by the package). Proposals have no overlay context.
	const zIndex = 'var(--pds-z-index-dropdown)';

	// Anchors panel position to the split-button root; refs.setReference below still owns click/keyboard interaction.
	useEffect(() => {
		if (isSplitButton && positionReferenceRef?.current) {
			refs.setPositionReference(positionReferenceRef.current);
		}
	}, [isSplitButton, positionReferenceRef, refs]);

	// Notify the consumer whenever open state changes, so it can manage
	// focus deliberately (e.g. when nested inside another floating panel).
	// Skips the initial mount — `isOpen` starts `false`, and this effect
	// would otherwise fire an unwanted "closed" notification on every mount,
	// not just on an actual open-to-closed transition.
	const hasMountedRef = useRef(false);
	useEffect(() => {
		if (!hasMountedRef.current) {
			hasMountedRef.current = true;
			return;
		}
		onOpenChange?.(isOpen);
	}, [isOpen]);

	// Only the active responsive variant is mounted (see render), so per-item ids
	// exist once rather than in both the mobile accordion and desktop dropdown.
	// `isMobileAccordion` opts in to the accordion swap at all — off by default,
	// MenuButton always renders its normal trigger + dropdown regardless of
	// viewport, so `data-pds-mobile` has no effect unless a consumer (typically
	// Navbar, injecting this automatically) explicitly opts in.
	const isMobile = useIsMobile();
	const showMobileAccordion = isMobileAccordion && isMobile;

	// Set up classes.
	const baseClass = 'pds-menu-button';

	// `navbar` renders as the underlying Button's `subtle` variant (same
	// hover/active/foreground tokens) with an additional modifier class that
	// resets the rest-state background to transparent.
	const isNavbar = variant === 'navbar';
	const buttonVariant = isNavbar ? 'subtle' : variant;

	// IDs.
	const triggerID = id;
	const [listboxID] = useState(`menu-button-${id}`);

	// Tracking for grouped items.
	const groupIDs: string[] = [];

	// Flatten menu items to get only focusable items (for index mapping).
	const focusableItems: MenuItem[] = [];
	const flattenItems = (items: MenuItem[]) => {
		items.forEach((item) => {
			if (
				!isSeparatorItemType(item) &&
				!isHeadingItemType(item) &&
				!isNodeItemType(item)
			) {
				focusableItems.push(item);
			}
		});
	};
	flattenItems(menuItems);

	// Handle menu item activation.
	const activateItem = (item: MenuItem) => {
		if ('disabled' in item && item.disabled) return;

		if ('callback' in item && item.callback) {
			item.callback(item);
		}

		setIsOpen(false);
	};

	// Handle trigger click callback.
	const handleTriggerClick = () => {
		if (onClick) {
			onClick();
		}
	};

	// Function to render each menu item for the Floating UI dropdown.
	const renderItem = (
		item: MenuItem,
		flatIndex: number,
		groupID: string = '',
		groupHeadingID: string = '',
	) => {
		const groupIDString = groupID ? `-group-${groupID}` : '';
		const fallbackItemID =
			triggerID + groupIDString + '-item-' + (flatIndex + 1);
		const itemID = 'id' in item && item.id ? item.id : fallbackItemID;

		if (isSeparatorItemType(item)) {
			return (
				<li key={itemID} className='pds-dropdown__separator' role='separator' />
			);
		}

		if (isHeadingItemType(item)) {
			return (
				<li
					key={groupHeadingID || itemID}
					className={`pds-dropdown__heading ${baseClass}__listbox-heading`}
					id={groupHeadingID || undefined}
					role='presentation'
				>
					{item.label}
				</li>
			);
		}

		if (isNodeItemType(item)) {
			return (
				<li key={itemID} role='presentation'>
					{item.nodeContent}
				</li>
			);
		}

		if (isLinkItemType(item) || ('label' in item && item.label)) {
			const focusableIndex = focusableItems.indexOf(item);
			const isFocused = activeIndex === focusableIndex;
			const isCritical = 'isCritical' in item && item.isCritical;
			const criticalLabel =
				'criticalLabel' in item ? item.criticalLabel : undefined;

			const itemLabel: ReactNode[] = [];

			if ('label' in item && item.label) {
				itemLabel.push(item.label);
			}

			if (isLinkItemType(item) && item.linkContent) {
				// eslint-disable-next-line @typescript-eslint/no-explicit-any
				const linkElement = item.linkContent as ReactElement<any>;
				itemLabel.push(linkElement.props.children);
			}

			if (item.iconName) {
				itemLabel.unshift(
					<Icon
						key={`icon-${triggerID}`}
						iconName={item.iconName}
						size={item.iconSize ?? 'xs'}
					/>,
				);
			}

			if (isCritical && criticalLabel) {
				itemLabel.unshift(
					<span key={`critical-${triggerID}`} className='visually-hidden'>
						{criticalLabel}
					</span>,
				);
			}

			const itemDescription = item.description ? (
				<span className='pds-dropdown__item-description'>
					{item.description}
				</span>
			) : null;

			let itemContent: ReactNode;

			if (isLinkItemType(item) && item.linkContent) {
				const linkElement = item.linkContent as ReactElement<
					// eslint-disable-next-line @typescript-eslint/no-explicit-any
					any,
					// eslint-disable-next-line @typescript-eslint/no-explicit-any
					JSXElementConstructor<any>
				>;
				const LinkElement = linkElement.type;

				itemContent = (
					<LinkElement
						tabIndex={-1}
						{...linkElement.props}
						onClick={() => setIsOpen(false)}
					>
						<span className='pds-dropdown__item-label'>
							<span className={`${baseClass}__item-label`}>{itemLabel}</span>
							{itemDescription}
						</span>
					</LinkElement>
				);
			} else {
				itemContent = (
					<span className='pds-dropdown__item-label'>
						<span className={`${baseClass}__item-label`}>{itemLabel}</span>
						{itemDescription}
					</span>
				);
			}

			// `isSelected` is an extension to the v2 item type (see decisions.md):
			// when set (true or false) the item is a `menuitemradio` and a check
			// trails the label on the selected one. Unselected items keep a hidden
			// check so the panel width doesn't change with the selection.
			const isSelected = (item as { isSelected?: boolean }).isSelected;
			const isCheckable = isSelected !== undefined;

			const menuItemClasses = mergeClasses([
				'pds-dropdown__item',
				isFocused ? 'pds-dropdown__item--focused' : null,
				isCritical ? 'pds-dropdown__item--critical' : null,
			]);

			return (
				<li
					key={itemID}
					ref={(node) => {
						listRef.current[focusableIndex] = node;
					}}
					aria-checked={isCheckable ? isSelected : undefined}
					aria-disabled={item.disabled || undefined}
					className={menuItemClasses}
					data-testid={'testId' in item ? item.testId : undefined}
					id={itemID}
					role={isCheckable ? 'menuitemradio' : 'menuitem'}
					tabIndex={isFocused ? 0 : -1}
					{...getItemProps({
						onClick: () => {
							if (!isLinkItemType(item)) {
								activateItem(item);
							}
							setIsOpen(false);
						},
						onKeyDown: (e) => {
							if (e.key === 'Enter' || e.key === ' ') {
								e.preventDefault();
								if (isLinkItemType(item) && item.linkContent) {
									const link = (e.currentTarget as HTMLElement).querySelector(
										'a',
									) as HTMLAnchorElement | null;
									link?.click();
								} else {
									activateItem(item);
								}
							}
						},
					})}
				>
					{itemContent}
					{isCheckable && (
						<Icon
							className={mergeClasses([
								`${baseClass}__item-check`,
								isSelected ? null : `${baseClass}__item-check--hidden`,
							])}
							iconName='check'
							size='s'
						/>
					)}
				</li>
			);
		}
	};

	// Simplified item renderer for the mobile accordion — no Floating UI bindings.
	const renderAccordionItem = (item: MenuItem, index: number) => {
		const itemID =
			'id' in item && item.id
				? item.id
				: `${triggerID}-accordion-item-${index + 1}`;

		if (isSeparatorItemType(item)) {
			return (
				<li key={itemID} className='pds-dropdown__separator' role='separator' />
			);
		}

		if (isHeadingItemType(item)) {
			return (
				<li
					key={itemID}
					className={`pds-dropdown__heading ${baseClass}__listbox-heading`}
					role='presentation'
				>
					{item.label}
				</li>
			);
		}

		if (isNodeItemType(item)) {
			return (
				<li key={itemID} role='presentation'>
					{item.nodeContent}
				</li>
			);
		}

		if (isLinkItemType(item) || ('label' in item && item.label)) {
			const isCritical = 'isCritical' in item && item.isCritical;
			const criticalLabel =
				'criticalLabel' in item ? item.criticalLabel : undefined;

			const itemLabel: ReactNode[] = [];

			if ('label' in item && item.label) {
				itemLabel.push(item.label);
			}

			if (isLinkItemType(item) && item.linkContent) {
				// eslint-disable-next-line @typescript-eslint/no-explicit-any
				const linkElement = item.linkContent as ReactElement<any>;
				itemLabel.push(linkElement.props.children);
			}

			if (item.iconName) {
				itemLabel.unshift(
					<Icon
						key={`icon-${triggerID}-${index}`}
						iconName={item.iconName}
						size={item.iconSize ?? 'xs'}
					/>,
				);
			}

			if (isCritical && criticalLabel) {
				itemLabel.unshift(
					<span
						key={`critical-${triggerID}-${index}`}
						className='visually-hidden'
					>
						{criticalLabel}
					</span>,
				);
			}

			const itemDescription = item.description ? (
				<span className='pds-dropdown__item-description'>
					{item.description}
				</span>
			) : null;

			let itemContent: ReactNode;

			if (isLinkItemType(item) && item.linkContent) {
				const linkElement = item.linkContent as ReactElement<
					// eslint-disable-next-line @typescript-eslint/no-explicit-any
					any,
					// eslint-disable-next-line @typescript-eslint/no-explicit-any
					JSXElementConstructor<any>
				>;
				const LinkElement = linkElement.type;

				itemContent = (
					<LinkElement tabIndex={-1} {...linkElement.props}>
						<span className='pds-dropdown__item-label'>
							<span className={`${baseClass}__item-label`}>{itemLabel}</span>
							{itemDescription}
						</span>
					</LinkElement>
				);
			} else {
				itemContent = (
					<span className='pds-dropdown__item-label'>
						<span className={`${baseClass}__item-label`}>{itemLabel}</span>
						{itemDescription}
					</span>
				);
			}

			return (
				<li
					key={itemID}
					aria-disabled={item.disabled || undefined}
					className={mergeClasses([
						'pds-dropdown__item',
						isCritical ? 'pds-dropdown__item--critical' : null,
					])}
					id={itemID}
					onClick={() => {
						if (!isLinkItemType(item)) {
							activateItem(item);
						}
					}}
					role='menuitem'
					tabIndex={item.disabled ? -1 : 0}
				>
					{itemContent}
				</li>
			);
		}
	};

	// Render menu items with group support (Floating UI dropdown).
	const renderMenuItems = (items: MenuItem[]) => {
		const menuItemBreaks = items.filter(
			isHeadingItemType || isSeparatorItemType,
		);
		const haveBreaks = menuItemBreaks.length > 0;

		const listboxClasses = mergeClasses([
			'pds-dropdown__panel',
			`${baseClass}__listbox`,
			iconOnly ? `${baseClass}__listbox--icon-only` : null,
		]);

		let flatIndex = 0;

		if (haveBreaks) {
			const groupedItems = renderMenuItemsGrouped(items, menuItemBreaks);

			groupedItems.forEach((_, index) => {
				groupIDs.push(`${index}`);
			});

			return (
				<div
					ref={refs.setFloating}
					aria-labelledby={triggerID}
					className={listboxClasses}
					id={listboxID}
					role='menu'
					style={{
						...floatingStyles,
						...transitionStyles,
						zIndex,
					}}
					{...getFloatingProps()}
				>
					{groupedItems.map((group: MenuItem[], groupIndex: number) => {
						const currentGroupID = groupIDs[groupIndex];
						const hasHeading = 'isHeading' in group[0] && group[0].isHeading;
						const groupHeadingID = hasHeading
							? `${triggerID}-group-${currentGroupID}-heading`
							: '';
						const keyID = `${triggerID}-group-${currentGroupID}`;
						const groupLabel = hasHeading
							? undefined
							: `${undefinedLabel} ${groupIndex + 1}`;

						return (
							<ul
								key={keyID}
								aria-label={groupLabel}
								aria-labelledby={groupHeadingID || undefined}
								role='group'
							>
								{group.map((item: MenuItem) => {
									const rendered = renderItem(
										item,
										flatIndex,
										currentGroupID,
										groupHeadingID,
									);
									if (
										!isSeparatorItemType(item) &&
										!isHeadingItemType(item) &&
										!isNodeItemType(item)
									) {
										flatIndex++;
									}
									return rendered;
								})}
							</ul>
						);
					})}
				</div>
			);
		} else {
			return (
				<ul
					ref={refs.setFloating}
					aria-labelledby={triggerID}
					className={listboxClasses}
					id={listboxID}
					role='menu'
					style={{
						...floatingStyles,
						...transitionStyles,
						zIndex,
					}}
					{...getFloatingProps()}
				>
					{items.map((item: MenuItem) => {
						const rendered = renderItem(item, flatIndex);
						if (
							!isSeparatorItemType(item) &&
							!isHeadingItemType(item) &&
							!isNodeItemType(item)
						) {
							flatIndex++;
						}
						return rendered;
					})}
				</ul>
			);
		}
	};

	// Group items at separator/heading boundaries.
	const renderMenuItemsGrouped = (
		items: MenuItem[],
		menuItemBreaks: MenuItem[],
	) => {
		const groupedItems: MenuItem[][] = [];
		const breaks = [...menuItemBreaks];
		let breakItem = breaks.shift();
		let group: MenuItem[] = [];

		items.forEach((item: MenuItem, index: number) => {
			if (item !== breakItem) {
				group.push(item);
				if (index + 1 === items.length) {
					groupedItems.push(group);
				}
			} else {
				groupedItems.push(group);
				group = [];
				group.push(item);
				breakItem = breaks.shift();
			}
		});

		return groupedItems.filter((group) => group.length > 0);
	};

	// Floating UI trigger button.
	let triggerButton: ReactElement | undefined;

	// Tally size scaled to button size — 'm' gets the larger tally, 'xs'/'s'
	// both get the smallest since Tally has no size below 'xs'.
	const tallySize = size === 'm' ? 's' : 'xs';

	// Label for the desktop trigger — tally sits between label text and angle-down.
	const triggerLabelWithTally = tally ? (
		<>
			{label}
			<Tally
				className='pds-button__tally'
				label={tally.label}
				size={tallySize}
				type={tally.type}
			/>
		</>
	) : (
		label
	);

	// Label for the mobile accordion — wrapped in a span to keep text and tally
	// together as one flex item, and uses a larger tally size to match the mobile text size.
	const accordionLabelWithTally = tally ? (
		<span className={`${baseClass}__accordion-label`}>
			{label}
			<Tally
				className={`${baseClass}__tally`}
				label={tally.label}
				size='s'
				type={tally.type}
			/>
		</span>
	) : (
		label
	);

	if (!iconOnly) {
		triggerButton = (
			<Button
				ref={refs.setReference}
				aria-controls={listboxID}
				aria-expanded={isOpen}
				aria-haspopup='true'
				className={mergeClasses([
					`${baseClass}__trigger`,
					isNavbar ? `${baseClass}__trigger--navbar` : null,
				])}
				disabled={disabled}
				displayType='icon-end'
				iconName='angleDown'
				id={triggerID}
				isAgentic={isAgentic}
				isSplitButton={isSplitButton}
				label={triggerLabelWithTally}
				size={size}
				statusDot={statusDot}
				type='button'
				variant={buttonVariant}
				{...getReferenceProps({
					onClick: handleTriggerClick,
				})}
			/>
		);
	}

	if (iconOnly) {
		const labelText = extractText(label);

		triggerButton = (
			<IconButton
				ref={refs.setReference}
				aria-controls={listboxID}
				aria-expanded={isOpen}
				aria-haspopup='true'
				ariaLabel={labelText}
				className={`${baseClass}__trigger ${baseClass}__trigger--icon-only`}
				disabled={disabled}
				hasBorder={isAgentic}
				hasTooltip={false}
				iconName={iconName}
				id={triggerID}
				isAgentic={isAgentic}
				size={size}
				type='button'
				variant='standard'
				{...getReferenceProps({
					onClick: handleTriggerClick,
				})}
			/>
		);
	}

	// Label content for the mobile accordion toggle.
	const accordionLabel = iconOnly ? (
		<span className={`${baseClass}__icon-label`}>
			<Icon iconName={iconName} /> {label}
		</span>
	) : (
		accordionLabelWithTally
	);

	// Set up expanded class.
	const expandedClass = isOpen ? `${baseClass}--expanded` : null;

	// Render only the active responsive variant so per-item ids exist once. The
	// mobile presentation is an inline accordion; the desktop presentation is a
	// Floating UI dropdown. Only mounts the accordion when `isMobileAccordion`
	// is set — otherwise always renders the normal trigger + dropdown.
	return (
		<span
			className={mergeClasses([baseClass, expandedClass, className])}
			data-testid={testId}
			{...props}
		>
			{showMobileAccordion ? (
				<NavAccordion disabled={disabled} label={accordionLabel}>
					<ul className={`${baseClass}__mobile-menu`} role='menu'>
						{menuItems.map((item, index) => renderAccordionItem(item, index))}
					</ul>
				</NavAccordion>
			) : (
				<>
					{triggerButton}
					<FloatingNode id={nodeId}>
						{isMounted && (
							<FloatingPortal>{renderMenuItems(menuItems)}</FloatingPortal>
						)}
					</FloatingNode>
				</>
			)}
		</span>
	);
};