diff --git a/.changeset/healthy-carrots-turn.md b/.changeset/healthy-carrots-turn.md new file mode 100644 index 0000000000..68cee6e66c --- /dev/null +++ b/.changeset/healthy-carrots-turn.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': patch +'@clerk/types': patch +--- + +Display a notification counter for organization invitations in OrganizationSwitcher diff --git a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcher.tsx b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcher.tsx index c0b08ad817..f2dc5c28a2 100644 --- a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcher.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcher.tsx @@ -1,11 +1,10 @@ import { withOrganizationsEnabledGuard } from '../../common'; -import { useCoreOrganizationList, withCoreUserGuard } from '../../contexts'; +import { withCoreUserGuard } from '../../contexts'; import { Flow } from '../../customizables'; import { Popover, withCardStateProvider, withFloatingTree } from '../../elements'; import { usePopover } from '../../hooks'; import { OrganizationSwitcherPopover } from './OrganizationSwitcherPopover'; import { OrganizationSwitcherTrigger } from './OrganizationSwitcherTrigger'; -import { organizationListParams } from './utils'; const _OrganizationSwitcher = withFloatingTree(() => { const { floating, reference, styles, toggle, isOpen, nodeId, context } = usePopover({ @@ -13,11 +12,6 @@ const _OrganizationSwitcher = withFloatingTree(() => { offset: 8, }); - /** - * Prefetch user invitations and suggestions - */ - useCoreOrganizationList(organizationListParams); - return ( & { isOpen: boolean; @@ -23,7 +31,7 @@ export const OrganizationSwitcherTrigger = withAvatarShimmer( elementDescriptor={descriptors.organizationSwitcherTrigger} variant='ghost' colorScheme='neutral' - sx={[t => ({ minHeight: 0, padding: `0 ${t.space.$2} 0 0` }), sx]} + sx={[t => ({ minHeight: 0, padding: `0 ${t.space.$2} 0 0`, position: 'relative' }), sx]} ref={ref} {...rest} > @@ -49,6 +57,9 @@ export const OrganizationSwitcherTrigger = withAvatarShimmer( } /> )} + + + { + const prefersReducedMotion = usePrefersReducedMotion(); + + /** + * Prefetch user invitations and suggestions + */ + const { userInvitations, userSuggestions } = useCoreOrganizationList(organizationListParams); + const notificationCount = (userInvitations.count || 0) + (userSuggestions.count || 0); + const showNotification = useDelayedVisibility(notificationCount > 0, 350) || false; + + const enterExitAnimation: ThemableCssProp = t => ({ + animation: prefersReducedMotion + ? 'none' + : `${notificationCount ? animations.notificationAnimation : animations.outAnimation} ${ + t.transitionDuration.$textField + } ${t.transitionTiming.$slowBezier} 0s 1 normal forwards`, + }); + + return ( + ({ + position: 'relative', + width: t.sizes.$4, + height: t.sizes.$4, + marginLeft: `${t.space.$2}`, + })} + > + {showNotification && {notificationCount}} + + ); +}; diff --git a/packages/clerk-js/src/ui/components/OrganizationSwitcher/__tests__/OrganizationSwitcher.test.tsx b/packages/clerk-js/src/ui/components/OrganizationSwitcher/__tests__/OrganizationSwitcher.test.tsx index 3b008495b8..ded0d57ea9 100644 --- a/packages/clerk-js/src/ui/components/OrganizationSwitcher/__tests__/OrganizationSwitcher.test.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationSwitcher/__tests__/OrganizationSwitcher.test.tsx @@ -1,7 +1,7 @@ import type { MembershipRole } from '@clerk/types'; import { describe } from '@jest/globals'; -import { render } from '../../../../testUtils'; +import { render, runFakeTimers, waitFor } from '../../../../testUtils'; import { bindCreateFixtures } from '../../../utils/test/createFixtures'; import { OrganizationSwitcher } from '../OrganizationSwitcher'; import { createFakeUserOrganizationInvitation, createFakeUserOrganizationSuggestion } from './utlis'; @@ -42,6 +42,37 @@ describe('OrganizationSwitcher', () => { }); }); + describe('OrganizationSwitcherTrigger', () => { + it('shows the counter for pending suggestions and invitations', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ email_addresses: ['test@clerk.dev'] }); + }); + + fixtures.clerk.user?.getOrganizationInvitations.mockReturnValueOnce( + Promise.resolve({ + data: [], + total_count: 2, + }), + ); + + fixtures.clerk.user?.getOrganizationSuggestions.mockReturnValueOnce( + Promise.resolve({ + data: [], + total_count: 3, + }), + ); + + await runFakeTimers(async () => { + const { getByText } = render(, { wrapper }); + + await waitFor(() => { + expect(getByText('5')).toBeInTheDocument(); + }); + }); + }); + }); + describe('OrganizationSwitcherPopover', () => { it('opens the organization switcher popover when clicked', async () => { const { wrapper, props } = await createFixtures(f => { diff --git a/packages/clerk-js/src/ui/customizables/elementDescriptors.ts b/packages/clerk-js/src/ui/customizables/elementDescriptors.ts index 27bef4e412..fbd12897e5 100644 --- a/packages/clerk-js/src/ui/customizables/elementDescriptors.ts +++ b/packages/clerk-js/src/ui/customizables/elementDescriptors.ts @@ -262,6 +262,7 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([ 'qrCodeContainer', 'badge', + 'notificationBadge', 'button', 'providerIcon', // Decide if we want to keep the keys as camel cased in HTML as well, diff --git a/packages/clerk-js/src/ui/customizables/index.ts b/packages/clerk-js/src/ui/customizables/index.ts index 353afc3b2a..44c9620c0c 100644 --- a/packages/clerk-js/src/ui/customizables/index.ts +++ b/packages/clerk-js/src/ui/customizables/index.ts @@ -44,6 +44,10 @@ export const Badge = makeCustomizable(makeLocalizable(sanitizeDomProps(Primitive defaultDescriptor: descriptors.badge, }); +export const NotificationBadge = makeCustomizable(makeLocalizable(sanitizeDomProps(Primitives.NotificationBadge)), { + defaultDescriptor: descriptors.notificationBadge, +}); + export const Table = makeCustomizable(sanitizeDomProps(Primitives.Table)); export const Thead = makeCustomizable(sanitizeDomProps(Primitives.Thead)); export const Tbody = makeCustomizable(sanitizeDomProps(Primitives.Tbody)); diff --git a/packages/clerk-js/src/ui/hooks/useDelayedVisibility.ts b/packages/clerk-js/src/ui/hooks/useDelayedVisibility.ts index ad6f1a04fa..49fd7373f5 100644 --- a/packages/clerk-js/src/ui/hooks/useDelayedVisibility.ts +++ b/packages/clerk-js/src/ui/hooks/useDelayedVisibility.ts @@ -1,5 +1,10 @@ import { useEffect, useState } from 'react'; +/** + * Utility hook for delaying mounting of components for enter and exit animations. + * Delays to update the state when is switched from/to undefined. + * Immediate change for in-between changes + */ export function useDelayedVisibility(valueToDelay: T, delayInMs: number) { const [isVisible, setVisible] = useState(); diff --git a/packages/clerk-js/src/ui/primitives/NotificationBadge.tsx b/packages/clerk-js/src/ui/primitives/NotificationBadge.tsx new file mode 100644 index 0000000000..265e1e71df --- /dev/null +++ b/packages/clerk-js/src/ui/primitives/NotificationBadge.tsx @@ -0,0 +1,49 @@ +import type { PropsOfComponent, StyleVariants } from '../styledSystem'; +import { common, createCssVariables, createVariants } from '../styledSystem'; +import { Flex } from './Flex'; + +const vars = createCssVariables('accent', 'bg'); + +const { applyVariants, filterProps } = createVariants(theme => ({ + base: { + color: vars.accent, + backgroundColor: vars.bg, + borderRadius: theme.radii.$sm, + height: theme.space.$4, + minWidth: theme.space.$4, + padding: `${theme.space.$0x5}`, + display: 'inline-flex', + }, + variants: { + textVariant: { ...common.textVariants(theme) }, + colorScheme: { + primary: { + [vars.accent]: theme.colors.$colorTextOnPrimaryBackground, + [vars.bg]: theme.colors.$primary500, + }, + }, + }, + defaultVariants: { + colorScheme: 'primary', + textVariant: 'extraSmallRegular', + }, +})); + +// @ts-ignore +export type NotificationBadgeProps = PropsOfComponent & StyleVariants; + +export const NotificationBadge = (props: NotificationBadgeProps) => { + return ( + + ); +}; diff --git a/packages/clerk-js/src/ui/primitives/index.ts b/packages/clerk-js/src/ui/primitives/index.ts index 9efed6bf07..fa81bf923f 100644 --- a/packages/clerk-js/src/ui/primitives/index.ts +++ b/packages/clerk-js/src/ui/primitives/index.ts @@ -26,3 +26,4 @@ export * from './Tbody'; export * from './Tr'; export * from './Th'; export * from './Td'; +export * from './NotificationBadge'; diff --git a/packages/clerk-js/src/ui/styledSystem/animations.ts b/packages/clerk-js/src/ui/styledSystem/animations.ts index 51e3074599..01f2f5e422 100644 --- a/packages/clerk-js/src/ui/styledSystem/animations.ts +++ b/packages/clerk-js/src/ui/styledSystem/animations.ts @@ -45,6 +45,23 @@ const inAnimation = keyframes` } `; +const notificationAnimation = keyframes` + 0% { + opacity: 0; + transform: translateY(5px) scale(.5); + } + + 50% { + opacity: 1; + transform: translateY(0px) scale(1.2); + } + + 100% { + opacity: 1; + transform: translateY(0px) scale(1); + } +`; + const outAnimation = keyframes` 20% { opacity: 1; @@ -102,4 +119,5 @@ export const animations = { navbarSlideIn, inAnimation, outAnimation, + notificationAnimation, }; diff --git a/packages/types/src/appearance.ts b/packages/types/src/appearance.ts index d3a1074e65..7e259e651d 100644 --- a/packages/types/src/appearance.ts +++ b/packages/types/src/appearance.ts @@ -418,6 +418,7 @@ export type ElementsConfig = { // default descriptors badge: WithOptions<'primary' | 'actionRequired', never, never>; + notificationBadge: WithOptions; button: WithOptions; providerIcon: WithOptions; };