Skip to content

Commit

Permalink
feat(js): inbox tabs (#6149)
Browse files Browse the repository at this point in the history
  • Loading branch information
LetItRock committed Aug 1, 2024
1 parent f89547d commit 229b3c1
Show file tree
Hide file tree
Showing 27 changed files with 542 additions and 31 deletions.
File renamed without changes.
4 changes: 3 additions & 1 deletion packages/js/.eslintrc.cjs → packages/js/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module.exports = {
extends: ['../../.eslintrc.js'],
plugins: ['local-rules'],
plugins: ['import', 'promise', '@typescript-eslint', 'prettier', 'local-rules'],
rules: {
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/naming-convention': [
Expand All @@ -20,5 +20,7 @@ module.exports = {
project: './tsconfig.json',
ecmaVersion: 2020,
sourceType: 'module',
tsconfigRootDir: __dirname,
},
ignorePatterns: '*.test.ts',
};
15 changes: 12 additions & 3 deletions packages/js/src/ui/components/Inbox.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { Accessor, createSignal, JSX, Match, Switch } from 'solid-js';
import { Accessor, createSignal, JSX, Match, Show, Switch } from 'solid-js';
import { useStyle } from '../helpers';
import { NotificationMounter } from '../types';
import { Bell, Footer, Header, Settings, SettingsHeader } from './elements';
import { InboxTabs } from './InboxTabs';
import { NotificationList } from './Notification';
import { Button, Popover } from './primitives';

export type InboxProps = {
open?: boolean;
tabs?: Array<{ label: string; value: Array<string> }>;
mountNotification?: NotificationMounter;
renderBell?: ({ unreadCount }: { unreadCount: Accessor<number> }) => JSX.Element;
};
Expand All @@ -17,8 +19,10 @@ enum Screen {
}

type InboxContentProps = {
tabs?: InboxProps['tabs'];
mountNotification?: NotificationMounter;
};

const InboxContent = (props: InboxContentProps) => {
const [currentScreen, setCurrentScreen] = createSignal<Screen>(Screen.Inbox);

Expand All @@ -27,7 +31,12 @@ const InboxContent = (props: InboxContentProps) => {
<Switch>
<Match when={currentScreen() === Screen.Inbox}>
<Header updateScreen={setCurrentScreen} />
<NotificationList mountNotification={props.mountNotification} />
<Show
when={props.tabs && props.tabs.length > 0}
fallback={<NotificationList mountNotification={props.mountNotification} />}
>
<InboxTabs tabs={props.tabs ?? []} />
</Show>
</Match>
<Match when={currentScreen() === Screen.Settings}>
<SettingsHeader backAction={() => setCurrentScreen(Screen.Inbox)} />
Expand All @@ -52,7 +61,7 @@ export const Inbox = (props: InboxProps) => {
)}
/>
<Popover.Content appearanceKey="inbox__popoverContent">
<InboxContent mountNotification={props.mountNotification} />
<InboxContent tabs={props.tabs} mountNotification={props.mountNotification} />
</Popover.Content>
</Popover.Root>
);
Expand Down
31 changes: 31 additions & 0 deletions packages/js/src/ui/components/InboxTabs/InboxTab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/* eslint-disable local-rules/no-class-without-style */
import { cn, useStyle } from '../../helpers';
import { Tabs } from '../primitives';
import { tabsTriggerVariants } from '../primitives/Tabs/TabsTrigger';

export const InboxTab = (props: { label: string; class?: string }) => {
const style = useStyle();

// TODO: Replace with actual count from API
const count = Math.floor(Math.random() * 120 + 1);

return (
<Tabs.Trigger
value={props.label}
class={style(
'notificationsTabs__tabsTrigger',
cn(tabsTriggerVariants(), `nt-flex nt-gap-2 ${props.class ?? ''}`)
)}
>
<span class={style('notificationsTabsTriggerLabel', 'nt-text-sm nt-font-medium')}>{props.label}</span>
<span
class={style(
'notificationsTabsTriggerCount',
'nt-rounded-full nt-bg-primary nt-px-[6px] nt-text-primary-foreground nt-text-sm'
)}
>
{count >= 100 ? '99+' : count}
</span>
</Tabs.Trigger>
);
};
111 changes: 111 additions & 0 deletions packages/js/src/ui/components/InboxTabs/InboxTabs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/* eslint-disable local-rules/no-class-without-style */
import { createMemo, createSignal, For, Show } from 'solid-js';
import { Button, Dropdown, dropdownItemVariants, Tabs } from '../primitives';
import { NotificationList } from '../Notification';
import { InboxTab } from './InboxTab';
import { Check, DotsMenu } from '../../icons';
import { cn, useStyle } from '../../helpers';
import { tabsRootVariants } from '../primitives/Tabs/TabsRoot';
import { useTabsDropdown } from './useTabsDropdown';

const tabsDropdownTriggerVariants = () =>
`nt-relative after:nt-absolute after:nt-content-[''] after:nt-bottom-0 after:nt-left-0 ` +
`after:nt-w-full after:nt-h-[2px] after:nt-border-b-2 nt-pb-[0.625rem]`;

type InboxTabsProps = {
tabs: Array<{ label: string; value: Array<string> }>;
};

export const InboxTabs = (props: InboxTabsProps) => {
const style = useStyle();
const [activeTab, setActiveTab] = createSignal<string>((props.tabs[0] && props.tabs[0].label) ?? '');
const { dropdownTabs, setTabsList, visibleTabs } = useTabsDropdown({ tabs: props.tabs });

const options = createMemo(() =>
dropdownTabs().map((tab) => ({
label: tab.label,
rightIcon: tab.label === activeTab() ? <Check class={style('moreTabs__dropdownItemRightIcon')} /> : undefined,
}))
);

const isTabsDropdownActive = createMemo(() =>
dropdownTabs()
.map((tab) => tab.label)
.includes(activeTab())
);

return (
<Tabs.Root
class={style('notificationsTabs__tabsRoot', cn(tabsRootVariants(), 'nt-flex-1 nt-overflow-hidden'))}
value={activeTab()}
onChange={setActiveTab}
>
<Show
when={visibleTabs().length > 0}
fallback={
<Tabs.List ref={setTabsList} appearanceKey="notificationsTabs__tabsList">
{props.tabs.map((tab) => (
<InboxTab label={tab.label} class="nt-invisible" />
))}
</Tabs.List>
}
>
<Tabs.List appearanceKey="notificationsTabs__tabsList">
{visibleTabs().map((tab) => (
<InboxTab label={tab.label} />
))}
<Show when={dropdownTabs().length > 0}>
<Dropdown.Root fallbackPlacements={['bottom', 'top']} placement={'bottom-start'}>
<Dropdown.Trigger
appearanceKey="moreTabs__dropdownTrigger"
asChild={(triggerProps) => (
<Button
variant="unstyled"
size="none"
appearanceKey="moreTabs__button"
{...triggerProps}
class={cn(
tabsDropdownTriggerVariants(),
isTabsDropdownActive()
? 'after:nt-border-b-primary'
: 'after:nt-border-b-transparent nt-text-foreground-alpha-600'
)}
>
<DotsMenu appearanceKey="moreTabs__dots" />
</Button>
)}
/>
<Dropdown.Content appearanceKey="moreTabs__dropdownContent">
<For each={options()}>
{(option) => (
<Dropdown.Item
class={style(
'moreTabs__dropdownItem',
cn(dropdownItemVariants(), 'nt-flex nt-justify-between nt-gap-2')
)}
onClick={() => setActiveTab(option.label)}
>
<span class={style('moreTabs__dropdownItemLabel', 'nt-mr-auto')}>{option.label}</span>
{option.rightIcon}
</Dropdown.Item>
)}
</For>
</Dropdown.Content>
</Dropdown.Root>
</Show>
</Tabs.List>
</Show>
{props.tabs.map((tab) => (
<Tabs.Content
value={tab.label}
class={style(
'notificationsTabs__tabsContent',
cn(activeTab() === tab.label ? 'nt-block' : 'nt-hidden', 'nt-flex-1 nt-overflow-hidden')
)}
>
<NotificationList options={{ tags: tab.value }} />
</Tabs.Content>
))}
</Tabs.Root>
);
};
1 change: 1 addition & 0 deletions packages/js/src/ui/components/InboxTabs/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './InboxTabs';
43 changes: 43 additions & 0 deletions packages/js/src/ui/components/InboxTabs/useTabsDropdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { createSignal, onMount } from 'solid-js';

type TabsArray = Array<{ label: string; value: Array<string> }>;

export const useTabsDropdown = ({ tabs }: { tabs: TabsArray }) => {
const [tabsList, setTabsList] = createSignal<HTMLDivElement>();
const [visibleTabs, setVisibleTabs] = createSignal<TabsArray>([]);
const [dropdownTabs, setDropdownTabs] = createSignal<TabsArray>([]);

onMount(() => {
const tabsListEl = tabsList();
if (!tabsListEl) return;

const tabsElements = [...tabsListEl.querySelectorAll('[role="tab"]')];

const observer = new IntersectionObserver(
(entries) => {
let visibleTabIds = entries
.filter((entry) => entry.isIntersecting && entry.intersectionRatio === 1)
.map((entry) => entry.target.id);

if (tabsElements.length === visibleTabIds.length) {
setVisibleTabs(tabs.filter((tab) => visibleTabIds.includes(tab.label)));
observer.disconnect();

return;
}

visibleTabIds = visibleTabIds.slice(0, -1);
setVisibleTabs(tabs.filter((tab) => visibleTabIds.includes(tab.label)));
setDropdownTabs(tabs.filter((tab) => !visibleTabIds.includes(tab.label)));
observer.disconnect();
},
{ root: tabsListEl }
);

for (const tabElement of tabsElements) {
observer.observe(tabElement);
}
});

return { dropdownTabs, setTabsList, visibleTabs };
};
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,7 @@ export const NotificationListContainer = (props: ParentProps) => {
const style = useStyle();

return (
<div
class={style('notificationList', 'nt-flex nt-flex-col nt-min-h-full nt-w-full nt-h-[37.5rem] nt-overflow-auto')}
>
<div class={style('notificationList', 'nt-flex nt-flex-col nt-w-full nt-h-full nt-overflow-auto')}>
{props.children}
</div>
);
Expand Down
18 changes: 12 additions & 6 deletions packages/js/src/ui/components/Renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export const novuComponents = {
Bell,
};

export type NovuComponent = { name: NovuComponentName; props?: unknown };
export type NovuComponent = { name: NovuComponentName; props?: any };

export type NovuMounterProps = NovuComponent & { element: MountableElement };

Expand Down Expand Up @@ -68,11 +68,17 @@ export const Renderer = (props: RendererProps) => {
<FocusManagerProvider>
<InboxNotificationStatusProvider>
<For each={[...props.nodes]}>
{([node, component]) => (
<Portal mount={node}>
<Root>{novuComponents[component.name](component.props || {})}</Root>
</Portal>
)}
{([node, component]) => {
const Component = novuComponents[component.name];

return (
<Portal mount={node}>
<Root>
<Component {...component.props} />
</Root>
</Portal>
);
}}
</For>
</InboxNotificationStatusProvider>
</FocusManagerProvider>
Expand Down
2 changes: 1 addition & 1 deletion packages/js/src/ui/components/elements/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Novu } from '../../icons';

export const Footer = () => {
return (
<div class="nt-flex nt-justify-center nt-items-center nt-gap-1 nt-mt-auto nt-py-3 nt-text-foreground-alpha-200">
<div class="nt-flex nt-justify-center nt-items-center nt-gap-1 nt-mt-auto nt-pt-9 nt-pb-3 nt-text-foreground-alpha-200">
<Novu />
<span class="nt-text-xs">Powered by Novu</span>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { splitProps } from 'solid-js';
import { JSX } from 'solid-js/jsx-runtime';
import { AppearanceKey } from '../../../context';
import { useStyle } from '../../../helpers';
import { cn, useStyle } from '../../../helpers';
import { Popover, usePopover } from '../Popover';

export const dropdownItemVariants = () =>
Expand All @@ -10,12 +10,12 @@ export const dropdownItemVariants = () =>
type DropdownItemProps = JSX.IntrinsicElements['button'] & { appearanceKey?: AppearanceKey };
export const DropdownItem = (props: DropdownItemProps) => {
const style = useStyle();
const [local, rest] = splitProps(props, ['appearanceKey', 'onClick']);
const [local, rest] = splitProps(props, ['appearanceKey', 'onClick', 'class']);
const { onClose } = usePopover();

return (
<Popover.Close
class={style(local.appearanceKey || 'dropdownItem', dropdownItemVariants())}
class={local.class ? local.class : style(local.appearanceKey || 'dropdownItem', dropdownItemVariants())}
onClick={(e) => {
if (typeof local.onClick === 'function') {
local.onClick(e);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,18 @@ import { AppearanceKey } from '../../../context';
import { useStyle } from '../../../helpers';
import { Popover } from '../Popover';

export const dropdownTriggerButtonVariants = () =>
`nt-relative nt-transition nt-outline-none focus-visible:nt-outline-none` +
`focus-visible:nt-ring-2 focus-visible:nt-ring-primary focus-visible:nt-ring-offset-2`;

export const DropdownTrigger = (props: ComponentProps<typeof Popover.Trigger> & { appearanceKey?: AppearanceKey }) => {
const style = useStyle();
const [local, rest] = splitProps(props, ['appearanceKey']);

return <Popover.Trigger class={style(local.appearanceKey || 'dropdownTrigger')} {...rest} />;
return (
<Popover.Trigger
class={style(local.appearanceKey || 'dropdownTrigger', dropdownTriggerButtonVariants())}
{...rest}
/>
);
};
3 changes: 2 additions & 1 deletion packages/js/src/ui/components/primitives/Dropdown/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { Popover } from '../Popover';
import { DropdownContent } from './DropdownContent';
export { dropdownItemVariants } from './DropdownItem';
import { DropdownItem } from './DropdownItem';
import { DropdownRoot } from './DropdownRoot';
import { DropdownTrigger } from './DropdownTrigger';

export { dropdownContentVariants } from './DropdownContent';
export { dropdownItemVariants } from './DropdownItem';
export { dropdownTriggerButtonVariants } from './DropdownTrigger';

export const Dropdown = {
Root: DropdownRoot,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import { Root } from '../../elements';
import { usePopover } from './PopoverRoot';

export const popoverContentVariants = () =>
'nt-flex-col nt-gap-4 nt-max-w-96 nt-w-full max-h-[600px] nt-rounded-xl nt-bg-background nt-shadow-[0_5px_15px_0_rgba(122,133,153,0.25)] nt-z-10 nt-cursor-default nt-flex nt-flex-col nt-overflow-hidden';
'nt-flex-col nt-gap-4 nt-w-[400px] nt-h-[600px] nt-rounded-xl nt-bg-background ' +
'nt-shadow-[0_5px_15px_0_rgba(122,133,153,0.25)] nt-z-10 nt-cursor-default nt-flex nt-flex-col nt-overflow-hidden';

const PopoverContentBody = (props: PopoverContentProps) => {
const { open, setFloating, floating, floatingStyles } = usePopover();
Expand Down
Loading

0 comments on commit 229b3c1

Please sign in to comment.