From 229b3c17a3794ffa8c61767e9ef06e3b13951b37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Tymczuk?= Date: Thu, 1 Aug 2024 13:36:39 +0200 Subject: [PATCH] feat(js): inbox tabs (#6149) --- ...nt-local-rules.js => eslint-local-rules.js | 0 packages/js/{.eslintrc.cjs => .eslintrc.js} | 4 +- packages/js/src/ui/components/Inbox.tsx | 15 ++- .../src/ui/components/InboxTabs/InboxTab.tsx | 31 +++++ .../src/ui/components/InboxTabs/InboxTabs.tsx | 111 ++++++++++++++++++ .../js/src/ui/components/InboxTabs/index.ts | 1 + .../components/InboxTabs/useTabsDropdown.ts | 43 +++++++ .../Notification/NotificationList.tsx | 4 +- packages/js/src/ui/components/Renderer.tsx | 18 ++- .../js/src/ui/components/elements/Footer.tsx | 2 +- .../primitives/Dropdown/DropdownItem.tsx | 6 +- .../primitives/Dropdown/DropdownTrigger.tsx | 11 +- .../components/primitives/Dropdown/index.ts | 3 +- .../primitives/Popover/PopoverContent.tsx | 3 +- .../primitives/Tabs/TabsContent.tsx | 36 ++++++ .../components/primitives/Tabs/TabsList.tsx | 28 +++++ .../components/primitives/Tabs/TabsRoot.tsx | 75 ++++++++++++ .../primitives/Tabs/TabsTrigger.tsx | 47 ++++++++ .../ui/components/primitives/Tabs/index.ts | 11 ++ .../primitives/Tabs/useKeyboardNavigation.ts | 61 ++++++++++ .../js/src/ui/components/primitives/index.ts | 1 + .../js/src/ui/context/AppearanceContext.tsx | 25 +++- packages/js/src/ui/icons/Check.tsx | 6 +- packages/js/src/ui/icons/DotsMenu.tsx | 19 ++- packages/js/src/ui/index.ts | 1 + packages/js/src/ui/novuUI.tsx | 4 +- playground/nextjs/src/components/Inbox.tsx | 7 +- 27 files changed, 542 insertions(+), 31 deletions(-) rename packages/js/eslint-local-rules.js => eslint-local-rules.js (100%) rename packages/js/{.eslintrc.cjs => .eslintrc.js} (80%) create mode 100644 packages/js/src/ui/components/InboxTabs/InboxTab.tsx create mode 100644 packages/js/src/ui/components/InboxTabs/InboxTabs.tsx create mode 100644 packages/js/src/ui/components/InboxTabs/index.ts create mode 100644 packages/js/src/ui/components/InboxTabs/useTabsDropdown.ts create mode 100644 packages/js/src/ui/components/primitives/Tabs/TabsContent.tsx create mode 100644 packages/js/src/ui/components/primitives/Tabs/TabsList.tsx create mode 100644 packages/js/src/ui/components/primitives/Tabs/TabsRoot.tsx create mode 100644 packages/js/src/ui/components/primitives/Tabs/TabsTrigger.tsx create mode 100644 packages/js/src/ui/components/primitives/Tabs/index.ts create mode 100644 packages/js/src/ui/components/primitives/Tabs/useKeyboardNavigation.ts diff --git a/packages/js/eslint-local-rules.js b/eslint-local-rules.js similarity index 100% rename from packages/js/eslint-local-rules.js rename to eslint-local-rules.js diff --git a/packages/js/.eslintrc.cjs b/packages/js/.eslintrc.js similarity index 80% rename from packages/js/.eslintrc.cjs rename to packages/js/.eslintrc.js index 815a32547c0..44efdaf2faa 100644 --- a/packages/js/.eslintrc.cjs +++ b/packages/js/.eslintrc.js @@ -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': [ @@ -20,5 +20,7 @@ module.exports = { project: './tsconfig.json', ecmaVersion: 2020, sourceType: 'module', + tsconfigRootDir: __dirname, }, + ignorePatterns: '*.test.ts', }; diff --git a/packages/js/src/ui/components/Inbox.tsx b/packages/js/src/ui/components/Inbox.tsx index c80facd5a71..f0d0ad15bbb 100644 --- a/packages/js/src/ui/components/Inbox.tsx +++ b/packages/js/src/ui/components/Inbox.tsx @@ -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 }>; mountNotification?: NotificationMounter; renderBell?: ({ unreadCount }: { unreadCount: Accessor }) => JSX.Element; }; @@ -17,8 +19,10 @@ enum Screen { } type InboxContentProps = { + tabs?: InboxProps['tabs']; mountNotification?: NotificationMounter; }; + const InboxContent = (props: InboxContentProps) => { const [currentScreen, setCurrentScreen] = createSignal(Screen.Inbox); @@ -27,7 +31,12 @@ const InboxContent = (props: InboxContentProps) => {
- + 0} + fallback={} + > + + setCurrentScreen(Screen.Inbox)} /> @@ -52,7 +61,7 @@ export const Inbox = (props: InboxProps) => { )} /> - + ); diff --git a/packages/js/src/ui/components/InboxTabs/InboxTab.tsx b/packages/js/src/ui/components/InboxTabs/InboxTab.tsx new file mode 100644 index 00000000000..84484f70542 --- /dev/null +++ b/packages/js/src/ui/components/InboxTabs/InboxTab.tsx @@ -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 ( + + {props.label} + + {count >= 100 ? '99+' : count} + + + ); +}; diff --git a/packages/js/src/ui/components/InboxTabs/InboxTabs.tsx b/packages/js/src/ui/components/InboxTabs/InboxTabs.tsx new file mode 100644 index 00000000000..90eb7887daf --- /dev/null +++ b/packages/js/src/ui/components/InboxTabs/InboxTabs.tsx @@ -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 }>; +}; + +export const InboxTabs = (props: InboxTabsProps) => { + const style = useStyle(); + const [activeTab, setActiveTab] = createSignal((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() ? : undefined, + })) + ); + + const isTabsDropdownActive = createMemo(() => + dropdownTabs() + .map((tab) => tab.label) + .includes(activeTab()) + ); + + return ( + + 0} + fallback={ + + {props.tabs.map((tab) => ( + + ))} + + } + > + + {visibleTabs().map((tab) => ( + + ))} + 0}> + + ( + + )} + /> + + + {(option) => ( + setActiveTab(option.label)} + > + {option.label} + {option.rightIcon} + + )} + + + + + + + {props.tabs.map((tab) => ( + + + + ))} + + ); +}; diff --git a/packages/js/src/ui/components/InboxTabs/index.ts b/packages/js/src/ui/components/InboxTabs/index.ts new file mode 100644 index 00000000000..59c54109be6 --- /dev/null +++ b/packages/js/src/ui/components/InboxTabs/index.ts @@ -0,0 +1 @@ +export * from './InboxTabs'; diff --git a/packages/js/src/ui/components/InboxTabs/useTabsDropdown.ts b/packages/js/src/ui/components/InboxTabs/useTabsDropdown.ts new file mode 100644 index 00000000000..7a6472d6048 --- /dev/null +++ b/packages/js/src/ui/components/InboxTabs/useTabsDropdown.ts @@ -0,0 +1,43 @@ +import { createSignal, onMount } from 'solid-js'; + +type TabsArray = Array<{ label: string; value: Array }>; + +export const useTabsDropdown = ({ tabs }: { tabs: TabsArray }) => { + const [tabsList, setTabsList] = createSignal(); + const [visibleTabs, setVisibleTabs] = createSignal([]); + const [dropdownTabs, setDropdownTabs] = createSignal([]); + + 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 }; +}; diff --git a/packages/js/src/ui/components/Notification/NotificationList.tsx b/packages/js/src/ui/components/Notification/NotificationList.tsx index deaa96b2917..d32b161c94f 100644 --- a/packages/js/src/ui/components/Notification/NotificationList.tsx +++ b/packages/js/src/ui/components/Notification/NotificationList.tsx @@ -12,9 +12,7 @@ export const NotificationListContainer = (props: ParentProps) => { const style = useStyle(); return ( -
+
{props.children}
); diff --git a/packages/js/src/ui/components/Renderer.tsx b/packages/js/src/ui/components/Renderer.tsx index 809a681a457..6204d986bf5 100644 --- a/packages/js/src/ui/components/Renderer.tsx +++ b/packages/js/src/ui/components/Renderer.tsx @@ -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 }; @@ -68,11 +68,17 @@ export const Renderer = (props: RendererProps) => { - {([node, component]) => ( - - {novuComponents[component.name](component.props || {})} - - )} + {([node, component]) => { + const Component = novuComponents[component.name]; + + return ( + + + + + + ); + }} diff --git a/packages/js/src/ui/components/elements/Footer.tsx b/packages/js/src/ui/components/elements/Footer.tsx index b954839ea91..b899271f132 100644 --- a/packages/js/src/ui/components/elements/Footer.tsx +++ b/packages/js/src/ui/components/elements/Footer.tsx @@ -3,7 +3,7 @@ import { Novu } from '../../icons'; export const Footer = () => { return ( -
+
Powered by Novu
diff --git a/packages/js/src/ui/components/primitives/Dropdown/DropdownItem.tsx b/packages/js/src/ui/components/primitives/Dropdown/DropdownItem.tsx index 40751cf4f1c..6c5bbec12e7 100644 --- a/packages/js/src/ui/components/primitives/Dropdown/DropdownItem.tsx +++ b/packages/js/src/ui/components/primitives/Dropdown/DropdownItem.tsx @@ -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 = () => @@ -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 ( { if (typeof local.onClick === 'function') { local.onClick(e); diff --git a/packages/js/src/ui/components/primitives/Dropdown/DropdownTrigger.tsx b/packages/js/src/ui/components/primitives/Dropdown/DropdownTrigger.tsx index c380d2d8c12..e23f01a5e27 100644 --- a/packages/js/src/ui/components/primitives/Dropdown/DropdownTrigger.tsx +++ b/packages/js/src/ui/components/primitives/Dropdown/DropdownTrigger.tsx @@ -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 & { appearanceKey?: AppearanceKey }) => { const style = useStyle(); const [local, rest] = splitProps(props, ['appearanceKey']); - return ; + return ( + + ); }; diff --git a/packages/js/src/ui/components/primitives/Dropdown/index.ts b/packages/js/src/ui/components/primitives/Dropdown/index.ts index 6da127c1f9d..53a27d8a7a3 100644 --- a/packages/js/src/ui/components/primitives/Dropdown/index.ts +++ b/packages/js/src/ui/components/primitives/Dropdown/index.ts @@ -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, diff --git a/packages/js/src/ui/components/primitives/Popover/PopoverContent.tsx b/packages/js/src/ui/components/primitives/Popover/PopoverContent.tsx index 38fcc0dca69..60423df244c 100644 --- a/packages/js/src/ui/components/primitives/Popover/PopoverContent.tsx +++ b/packages/js/src/ui/components/primitives/Popover/PopoverContent.tsx @@ -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(); diff --git a/packages/js/src/ui/components/primitives/Tabs/TabsContent.tsx b/packages/js/src/ui/components/primitives/Tabs/TabsContent.tsx new file mode 100644 index 00000000000..ffbf3efee0e --- /dev/null +++ b/packages/js/src/ui/components/primitives/Tabs/TabsContent.tsx @@ -0,0 +1,36 @@ +import { JSX, ParentProps, Show, splitProps } from 'solid-js'; +import { AppearanceKey } from '../../../context'; +import { useStyle } from '../../../helpers'; +import { useTabsContext } from './TabsRoot'; + +type TabsContentProps = JSX.IntrinsicElements['div'] & + ParentProps & { + class?: string; + value: string; + appearanceKey?: AppearanceKey; + }; + +export const TabsContent = (props: TabsContentProps) => { + const [local, rest] = splitProps(props, ['value', 'class', 'appearanceKey', 'children']); + const style = useStyle(); + const { activeTab } = useTabsContext(); + + return ( + +
+ {local.children} +
+
+ ); +}; diff --git a/packages/js/src/ui/components/primitives/Tabs/TabsList.tsx b/packages/js/src/ui/components/primitives/Tabs/TabsList.tsx new file mode 100644 index 00000000000..914defa8299 --- /dev/null +++ b/packages/js/src/ui/components/primitives/Tabs/TabsList.tsx @@ -0,0 +1,28 @@ +/* eslint-disable local-rules/no-class-without-style */ +import { JSX, ParentProps, Ref, splitProps } from 'solid-js'; +import { AppearanceKey } from '../../../context'; +import { useStyle } from '../../../helpers'; + +export const tabsListVariants = () => 'nt-flex nt-gap-6 nt-px-6 nt-py-1 nt-overflow-hidden'; + +type TabsListProps = JSX.IntrinsicElements['div'] & + ParentProps & { class?: string; appearanceKey?: AppearanceKey; ref?: Ref }; + +export const TabsList = (props: TabsListProps) => { + const [local, rest] = splitProps(props, ['class', 'appearanceKey', 'ref', 'children']); + const style = useStyle(); + + return ( + <> +
+ {local.children} +
+
+ + ); +}; diff --git a/packages/js/src/ui/components/primitives/Tabs/TabsRoot.tsx b/packages/js/src/ui/components/primitives/Tabs/TabsRoot.tsx new file mode 100644 index 00000000000..327854481b9 --- /dev/null +++ b/packages/js/src/ui/components/primitives/Tabs/TabsRoot.tsx @@ -0,0 +1,75 @@ +import { + JSX, + Accessor, + createContext, + createEffect, + createSignal, + ParentProps, + Setter, + useContext, + splitProps, +} from 'solid-js'; +import type { AppearanceKey } from '../../../context'; +import { useStyle } from '../../../helpers'; +import { useKeyboardNavigation } from './useKeyboardNavigation'; + +type TabsRootProps = JSX.IntrinsicElements['div'] & + ParentProps & { + defaultValue?: string; + value?: string; + class?: string; + appearanceKey?: AppearanceKey; + onChange?: (value: string) => void; + }; + +type TabsContextValue = { + activeTab: Accessor; + setActiveTab: Setter; + visibleTabs: Accessor; + setVisibleTabs: Setter; +}; + +const TabsContext = createContext(undefined); + +export const useTabsContext = () => { + const context = useContext(TabsContext); + if (!context) { + throw new Error('useTabsContext must be used within an TabsContext.Provider'); + } + + return context; +}; + +export const tabsRootVariants = () => 'nt-flex nt-flex-col'; + +export const TabsRoot = (props: TabsRootProps) => { + const [local, rest] = splitProps(props, ['defaultValue', 'value', 'class', 'appearanceKey', 'onChange', 'children']); + const [tabsContainer, setTabsContainer] = createSignal(); + const [visibleTabs, setVisibleTabs] = createSignal>([]); + const [activeTab, setActiveTab] = createSignal(local.defaultValue ?? ''); + const style = useStyle(); + + useKeyboardNavigation({ tabsContainer, activeTab, setActiveTab }); + + createEffect(() => { + if (local.value) { + setActiveTab(local.value); + } + }); + + createEffect(() => { + local.onChange?.(activeTab()); + }); + + return ( + +
+ {local.children} +
+
+ ); +}; diff --git a/packages/js/src/ui/components/primitives/Tabs/TabsTrigger.tsx b/packages/js/src/ui/components/primitives/Tabs/TabsTrigger.tsx new file mode 100644 index 00000000000..7e8d3d23de4 --- /dev/null +++ b/packages/js/src/ui/components/primitives/Tabs/TabsTrigger.tsx @@ -0,0 +1,47 @@ +import { JSX, ParentProps, Ref, splitProps } from 'solid-js'; +import { useStyle } from 'src/ui/helpers'; +import { AppearanceKey } from '../../../context'; +import { Button } from '../Button'; +import { useTabsContext } from './TabsRoot'; + +type TabsTriggerProps = JSX.IntrinsicElements['button'] & + ParentProps & { + value: string; + class?: string; + appearanceKey?: AppearanceKey; + ref?: Ref; + onClick?: JSX.EventHandlerUnion; + }; + +export const tabsTriggerVariants = () => + `nt-relative nt-transition nt-outline-none nt-text-foreground-alpha-600 focus-visible:nt-outline-none ` + + `focus-visible:nt-ring-2 focus-visible:nt-ring-primary focus-visible:nt-ring-offset-2 nt-pb-[0.625rem] ` + + `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 data-[state=active]:after:nt-border-primary after:nt-border-b-transparent`; + +export const TabsTrigger = (props: TabsTriggerProps) => { + const [local, rest] = splitProps(props, ['value', 'class', 'appearanceKey', 'ref', 'onClick', 'children']); + const style = useStyle(); + const { activeTab, setActiveTab } = useTabsContext(); + const clickHandler = () => setActiveTab(local.value); + + return ( + + ); +}; diff --git a/packages/js/src/ui/components/primitives/Tabs/index.ts b/packages/js/src/ui/components/primitives/Tabs/index.ts new file mode 100644 index 00000000000..b21a3a8075b --- /dev/null +++ b/packages/js/src/ui/components/primitives/Tabs/index.ts @@ -0,0 +1,11 @@ +import { TabsList } from './TabsList'; +import { TabsContent } from './TabsContent'; +import { TabsRoot } from './TabsRoot'; +import { TabsTrigger } from './TabsTrigger'; + +export const Tabs = { + Root: TabsRoot, + List: TabsList, + Trigger: TabsTrigger, + Content: TabsContent, +}; diff --git a/packages/js/src/ui/components/primitives/Tabs/useKeyboardNavigation.ts b/packages/js/src/ui/components/primitives/Tabs/useKeyboardNavigation.ts new file mode 100644 index 00000000000..f792f057adf --- /dev/null +++ b/packages/js/src/ui/components/primitives/Tabs/useKeyboardNavigation.ts @@ -0,0 +1,61 @@ +import { Accessor, createEffect, createSignal, onCleanup, Setter } from 'solid-js'; + +export const useKeyboardNavigation = ({ + activeTab, + setActiveTab, + tabsContainer, +}: { + activeTab: Accessor; + setActiveTab: Setter; + tabsContainer: Accessor; +}) => { + const [keyboardNavigation, setKeyboardNavigation] = createSignal(false); + + createEffect(() => { + const handleTabKey = (event: KeyboardEvent) => { + if (event.key !== 'Tab') { + return; + } + + const tabs = tabsContainer()?.querySelectorAll('[role="tab"]'); + if (!tabs || !document.activeElement) { + return; + } + + setKeyboardNavigation(Array.from(tabs).includes(document.activeElement)); + }; + + document.addEventListener('keyup', handleTabKey); + + return onCleanup(() => document.removeEventListener('keyup', handleTabKey)); + }); + + createEffect(() => { + const handleArrowKeys = (event: KeyboardEvent) => { + if (!keyboardNavigation() || (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight')) { + return; + } + + const tabElements = Array.from(tabsContainer()?.querySelectorAll('[role="tab"]') ?? []); + const tabIds = tabElements.map((tab) => tab.id); + const currentIndex = tabIds.indexOf(activeTab()); + const length = tabIds.length; + let activeIndex = currentIndex; + let newTab = activeTab(); + if (event.key === 'ArrowLeft') { + activeIndex = currentIndex === 0 ? length - 1 : currentIndex - 1; + newTab = tabIds[activeIndex]; + } else if (event.key === 'ArrowRight') { + activeIndex = currentIndex === length - 1 ? 0 : currentIndex + 1; + newTab = tabIds[activeIndex]; + } + + tabElements[activeIndex].focus(); + setActiveTab(newTab); + }; + + document.addEventListener('keydown', handleArrowKeys); + + return onCleanup(() => document.removeEventListener('keydown', handleArrowKeys)); + }); +}; diff --git a/packages/js/src/ui/components/primitives/index.ts b/packages/js/src/ui/components/primitives/index.ts index 4a467258fcb..6ba3d30e092 100644 --- a/packages/js/src/ui/components/primitives/index.ts +++ b/packages/js/src/ui/components/primitives/index.ts @@ -1,3 +1,4 @@ export * from './Button'; export * from './Dropdown'; export * from './Popover'; +export * from './Tabs'; diff --git a/packages/js/src/ui/context/AppearanceContext.tsx b/packages/js/src/ui/context/AppearanceContext.tsx index 914758a92c4..b213e283f54 100644 --- a/packages/js/src/ui/context/AppearanceContext.tsx +++ b/packages/js/src/ui/context/AppearanceContext.tsx @@ -45,6 +45,11 @@ export const appearanceKeys = [ 'skeletonText', 'skeletonAvatar', + 'tabsRoot', + 'tabsList', + 'tabsContent', + 'tabsTrigger', + 'dots', //General 'root', @@ -81,6 +86,14 @@ export const appearanceKeys = [ 'notificationArchive__button', 'notificationUnarchive__button', + // Notifications tabs + 'notificationsTabs__tabsRoot', + 'notificationsTabs__tabsList', + 'notificationsTabs__tabsContent', + 'notificationsTabs__tabsTrigger', + 'notificationsTabsTriggerLabel', + 'notificationsTabsTriggerCount', + //Inbox status 'inboxStatus__title', 'inboxStatus__dropdownTrigger', @@ -91,15 +104,23 @@ export const appearanceKeys = [ 'inboxStatus__dropdownItemLeftIcon', 'inboxStatus__dropdownItemRightIcon', - //More actions + // More actions 'moreActionsContainer', 'moreActions__dropdownTrigger', 'moreActions__dropdownContent', 'moreActions__dropdownItem', 'moreActions__dropdownItemLabel', - 'moreActions__dropdownItemLabelContainer', 'moreActions__dropdownItemLeftIcon', + // More tabs + 'moreTabs__button', + 'moreTabs__dots', + 'moreTabs__dropdownTrigger', + 'moreTabs__dropdownContent', + 'moreTabs__dropdownItem', + 'moreTabs__dropdownItemLabel', + 'moreTabs__dropdownItemRightIcon', + //workflow 'workflowContainer', 'workflowLabel', diff --git a/packages/js/src/ui/icons/Check.tsx b/packages/js/src/ui/icons/Check.tsx index e191ca79156..8743059a0d8 100644 --- a/packages/js/src/ui/icons/Check.tsx +++ b/packages/js/src/ui/icons/Check.tsx @@ -1,6 +1,8 @@ -export const Check = () => { +import { JSX } from 'solid-js'; + +export const Check = (props: JSX.IntrinsicElements['svg']) => { return ( - + ); diff --git a/packages/js/src/ui/icons/DotsMenu.tsx b/packages/js/src/ui/icons/DotsMenu.tsx index 67c78d3af0e..07bc3f7dc4d 100644 --- a/packages/js/src/ui/icons/DotsMenu.tsx +++ b/packages/js/src/ui/icons/DotsMenu.tsx @@ -1,6 +1,21 @@ -export const DotsMenu = () => { +import { JSX, splitProps } from 'solid-js'; +import { AppearanceKey } from '../context'; +import { useStyle } from '../helpers'; + +export const DotsMenu = (props: JSX.IntrinsicElements['svg'] & { appearanceKey?: AppearanceKey }) => { + const [local, rest] = splitProps(props, ['class', 'appearanceKey']); + const style = useStyle(); + return ( - + ({ + mountComponent({ name, element, props: componentProps, diff --git a/playground/nextjs/src/components/Inbox.tsx b/playground/nextjs/src/components/Inbox.tsx index 30df9521515..5b1b369a135 100644 --- a/playground/nextjs/src/components/Inbox.tsx +++ b/playground/nextjs/src/components/Inbox.tsx @@ -1,11 +1,12 @@ import { Mounter } from '@/components/Mounter'; import { useRenderer } from '@/context/RendererContext'; import { InboxNotification } from '@novu/js/dist/types/types'; -import type { BaseNovuUIOptions } from '@novu/js/ui'; +import type { BaseNovuUIOptions, InboxProps as InternalInboxProps } from '@novu/js/ui'; import { ReactNode, useCallback } from 'react'; import { Renderer } from './Renderer'; type AllInOneInboxProps = { + tabs?: InternalInboxProps['tabs']; renderNotification?: (notification: InboxNotification) => ReactNode; }; const AllInOneInbox = (props: AllInOneInboxProps) => { @@ -48,10 +49,10 @@ export const Inbox = (props: InboxProps) => { return ; } - const { renderNotification, ...options } = props as BaseNovuUIOptions & AllInOneInboxProps; + const { renderNotification, tabs, ...options } = props as BaseNovuUIOptions & AllInOneInboxProps; return ( - + ); };