diff --git a/packages/embla-carousel-docs/src/components/Mdx/Styles/index.ts b/packages/embla-carousel-docs/src/components/Mdx/Styles/index.ts index 450ff4d84..ed3d230b9 100644 --- a/packages/embla-carousel-docs/src/components/Mdx/Styles/index.ts +++ b/packages/embla-carousel-docs/src/components/Mdx/Styles/index.ts @@ -2,7 +2,8 @@ import styled from 'styled-components' import { COLORS } from 'consts/themes' import { SPACINGS } from 'consts/spacings' import { PRISM_HIGHLIGHT_CLASS_NAME } from 'consts/prismHighlight' -import { TabsWrapper, TabPanel } from 'components/Tabs/Tabs' +import { TabsWrapper } from 'components/Tabs/Tabs' +import { TabsPanelWrapper } from 'components/Tabs/TabsPanel' import { AdmonitionWrapper, AdmonitionContent } from '../Components/Admonition' import { headingStyles } from './heading' import { blockquoteStyles } from './blockquote' @@ -19,7 +20,7 @@ export const MdxStyles = styled.div` color: ${COLORS.TEXT_BODY}; - ${TabPanel} >, + ${TabsPanelWrapper} >, ${AdmonitionContent} >, > { ${listStyles}; @@ -53,13 +54,13 @@ export const MdxStyles = styled.div` } } - ${TabPanel} > *:first-child, + ${TabsPanelWrapper} > *:first-child, ${AdmonitionContent} > *:first-child, > *:first-child { margin-top: 0; } - ${TabPanel} > *:last-child, + ${TabsPanelWrapper} > *:last-child, ${AdmonitionContent} > *:last-child, > *:last-child { margin-bottom: 0; diff --git a/packages/embla-carousel-docs/src/components/SiteNavigation/SiteNavigationMenuCompact.tsx b/packages/embla-carousel-docs/src/components/SiteNavigation/SiteNavigationMenuCompact.tsx index a97fc0fd8..d29f6ad4c 100644 --- a/packages/embla-carousel-docs/src/components/SiteNavigation/SiteNavigationMenuCompact.tsx +++ b/packages/embla-carousel-docs/src/components/SiteNavigation/SiteNavigationMenuCompact.tsx @@ -7,10 +7,14 @@ import { HEADER_HEIGHT } from 'components/Header/Header' import { PAGE_FRAME_SPACING } from 'components/Page/PageFrame' import { LAYERS } from 'consts/layers' import { BORDER_RADIUSES, BORDER_SIZES } from 'consts/border' +import { TABS_SITE_NAVIGATION } from 'consts/tabs' import { TableOfContents } from 'components/TableOfContents/TableOfContents' import { FooterLinks } from 'components/Footer/FooterLinks' import { TabsItem } from 'components/Tabs/TabsItem' -import { Tab, TabList, TabPanel, Tabs } from 'components/Tabs/Tabs' +import { Tabs } from 'components/Tabs/Tabs' +import { TabsList } from 'components/Tabs/TabsList' +import { TabsButtonWrapper } from 'components/Tabs/TabsButton' +import { TabsPanelWrapper } from 'components/Tabs/TabsPanel' import { SiteNavigationSubMenus } from './SiteNavigationSubMenus' import { useKeyNavigating } from 'hooks/useKeyNavigating' import { useTheme } from 'hooks/useTheme' @@ -49,7 +53,7 @@ const MenuTabs = styled(Tabs)<{ }>` height: 100%; - ${TabList} { + ${TabsList} { height: ${HEADER_HEIGHT}; z-index: ${LAYERS.STEP * 2}; position: absolute; @@ -65,7 +69,7 @@ const MenuTabs = styled(Tabs)<{ justify-content: center; } - ${TabPanel} { + ${TabsPanelWrapper} { position: relative; height: 100%; outline-offset: -${BORDER_SIZES.OUTLINE}; @@ -95,7 +99,7 @@ const MenuTabs = styled(Tabs)<{ } } - ${Tab} { + ${TabsButtonWrapper} { flex-grow: 1; justify-content: center; max-width: calc(${MAX_WIDTH_COMPACT} / 2); @@ -164,7 +168,7 @@ export const SiteNavigationMenuCompact = () => { return ( - +
    @@ -185,8 +189,7 @@ export const SiteNavigationMenuCompact = () => { diff --git a/packages/embla-carousel-docs/src/components/SiteNavigation/SiteNavigationSubMenu.tsx b/packages/embla-carousel-docs/src/components/SiteNavigation/SiteNavigationSubMenu.tsx index 3380c124c..1cda40766 100644 --- a/packages/embla-carousel-docs/src/components/SiteNavigation/SiteNavigationSubMenu.tsx +++ b/packages/embla-carousel-docs/src/components/SiteNavigation/SiteNavigationSubMenu.tsx @@ -33,7 +33,7 @@ const SiteNavigationSubMenuWrapper = styled.div` ` const Toggle = styled(ButtonBare)<{ $isActive: boolean }>` - font-weight: ${FONT_WEIGHTS.BOLD}; + font-weight: ${FONT_WEIGHTS.SEMI_BOLD}; color: ${COLORS.TEXT_BODY}; padding: ${ITEM_SPACING} 0 ${ITEM_SPACING} calc(${TOGGLE_SVG_SIZE} + ${SPACINGS.TWO}); diff --git a/packages/embla-carousel-docs/src/components/TableOfContents/TableOfContentsMenu.tsx b/packages/embla-carousel-docs/src/components/TableOfContents/TableOfContentsMenu.tsx index d3fe75432..401cc41f4 100644 --- a/packages/embla-carousel-docs/src/components/TableOfContents/TableOfContentsMenu.tsx +++ b/packages/embla-carousel-docs/src/components/TableOfContents/TableOfContentsMenu.tsx @@ -39,7 +39,7 @@ const Heading = styled.div` color: ${COLORS.TEXT_BODY}; padding-top: ${SPACINGS.ONE}; padding-bottom: ${SPACINGS.TWO}; - font-weight: ${FONT_WEIGHTS.BOLD}; + font-weight: ${FONT_WEIGHTS.SEMI_BOLD}; text-transform: uppercase; ${MEDIA.COMPACT} { diff --git a/packages/embla-carousel-docs/src/components/Tabs/Tabs.tsx b/packages/embla-carousel-docs/src/components/Tabs/Tabs.tsx index c5d737fb2..c7912e1b7 100644 --- a/packages/embla-carousel-docs/src/components/Tabs/Tabs.tsx +++ b/packages/embla-carousel-docs/src/components/Tabs/Tabs.tsx @@ -1,128 +1,56 @@ import React, { PropsWithChildren, useCallback, - useEffect, + useLayoutEffect, useMemo, useRef, useState } from 'react' import uniqueId from 'lodash/uniqueId' -import styled, { css } from 'styled-components' -import { isTabsItemProps, PropType as TabsItemPropType } from './TabsItem' -import { BRAND_GRADIENT_BACKGROUND_STYLES } from 'consts/gradients' -import { MAIN_CONTENT_ID } from 'components/KeyNavigating/KeyNavigatingSkipToContent' -import { ButtonBare, ButtonBareText } from 'components/Button/ButtonBare' -import { SPACINGS } from 'consts/spacings' -import { BORDER_SIZES } from 'consts/border' -import { COLORS } from 'consts/themes' -import { KEY_NAVIGATING_STYLES } from 'consts/keyNavigatingStyles' +import styled from 'styled-components' import { useTabs } from 'hooks/useTabs' import { useKeyNavigating } from 'hooks/useKeyNavigating' +import { TabsPanel } from './TabsPanel' +import { TabsButton } from './TabsButton' +import { TabsList } from './TabsList' +import { TabsItemType } from 'consts/tabs' import { - ActiveText as TabActiveText, - InactiveText as TabInactiveText -} from 'components/Link/LinkNavigation' - -const mapChildrenToTabs = (children: React.ReactNode): TabsItemPropType[] => { - return React.Children.toArray(children) - .map((child) => (React.isValidElement(child) ? child.props : {})) - .filter(isTabsItemProps) -} - -const pickDefaultTab = ( - tabs: TabsItemPropType[], - storedTabSelection: string -): TabsItemPropType => { - const storedTab = tabs.find((tab) => tab.value === storedTabSelection) - return storedTab || tabs.find((tab) => tab.default) || tabs[0] -} - -const getActiveTabIndex = ( - tabToFind: TabsItemPropType, - tabs: TabsItemPropType[] -): number => { - return tabs.findIndex((tab) => tab.value === tabToFind.value) -} + getDefaultTab, + getTabsPosition, + getTabsPositionDiff, + mapChildrenToTabs +} from 'utils/tabs' export const TabsWrapper = styled.div`` -export const TabList = styled.div` - margin-bottom: ${SPACINGS.FOUR}; - border-bottom: ${BORDER_SIZES.DETAIL} solid ${COLORS.DETAIL_LOW_CONTRAST}; - display: flex; - overflow-x: auto; -` - -export const TabPanel = styled.section` - ${KEY_NAVIGATING_STYLES}; -` - -export const Tab = styled(ButtonBare)<{ $selected: boolean }>` - padding: ${SPACINGS.TWO} ${SPACINGS.TWO}; - position: relative; - display: inline-flex; - align-items: center; - position: relative; - - &:disabled > ${ButtonBareText} > ${TabInactiveText} { - color: ${COLORS.DETAIL_HIGH_CONTRAST}; - } - - ${({ $selected }) => - $selected && - css` - &:before { - ${BRAND_GRADIENT_BACKGROUND_STYLES}; - content: ''; - position: absolute; - left: 0; - right: 0; - bottom: 0; - height: ${BORDER_SIZES.ACCENT_HORIZONTAL}; - pointer-events: none; - } - `}; -` - type PropType = PropsWithChildren<{ groupId?: string }> export const Tabs = (props: PropType) => { const { groupId = '', children, ...restProps } = props - const { isKeyNavigating, setIsKeyNavigating } = useKeyNavigating() + const { setIsKeyNavigating } = useKeyNavigating() const { storedTabSelections, storeTabSelection } = useTabs() - const storedTabSelection = storedTabSelections[groupId] + const localStorageTab = storedTabSelections[groupId] const allTabs = useMemo(() => mapChildrenToTabs(children), [children]) - const { tabs, tabsId, defaultTab } = useMemo(() => { - const enabledTabs = allTabs.filter((tab) => !tab.disabled) - return { - tabs: enabledTabs, - tabsId: uniqueId(), - defaultTab: pickDefaultTab(enabledTabs, storedTabSelection) - } - }, [allTabs, storedTabSelection]) - const [activeTab, setActiveTab] = useState(defaultTab) - const focusedTab = useRef() + const tabs = useMemo(() => allTabs.filter((tab) => !tab.disabled), [allTabs]) + const defaultTab = useMemo( + () => getDefaultTab(tabs, localStorageTab), + [tabs, localStorageTab] + ) + const [activeTab, setActiveTab] = useState(defaultTab) + const focusedTab = useRef(null) const tabRefs = useRef(tabs.map(() => React.createRef())) - const tabRefLoopIndex = useRef(0) + const tabsGroupId = useRef(uniqueId()) const tabsWrapper = useRef(null) - const tabsWrapperRectTop = useRef(0) - const activeTabIndex = useRef(getActiveTabIndex(defaultTab, tabs)) - - const readTabsRectTop = useCallback(() => { - return tabsWrapper.current?.getBoundingClientRect().top || 0 - }, []) + const tabsActiveIndex = useRef(activeTab.index) + const tabsPosition = useRef(getTabsPosition(tabsWrapper.current)) - const setActiveTabAndStoreSelection = useCallback( - (tab: TabsItemPropType) => { - tabsWrapperRectTop.current = readTabsRectTop() - activeTabIndex.current = getActiveTabIndex(tab, tabs) - setActiveTab(tab) - - if (groupId) storeTabSelection(groupId, tab.value) + const storeTabInLocalStorage = useCallback( + (tabValue: string) => { + if (groupId) storeTabSelection(groupId, tabValue) }, - [tabs, groupId, readTabsRectTop, storeTabSelection] + [groupId, storeTabSelection] ) const goToTab = useCallback( @@ -131,19 +59,19 @@ export const Tabs = (props: PropType) => { const tabElement = tabRefs.current[index].current if (tab && tabElement) { - setActiveTabAndStoreSelection(tab) - setIsKeyNavigating(true) focusedTab.current = tabElement + setActiveTab(tab) + setIsKeyNavigating(true) tabElement.focus() } }, - [tabs, setActiveTabAndStoreSelection, setIsKeyNavigating] + [tabs, setIsKeyNavigating] ) const onKeyDown = useCallback( (event: React.KeyboardEvent) => { const tabsCount = tabs.length - const activeIndex = activeTabIndex.current + const activeIndex = tabsActiveIndex.current const goToNextTab = (): void => { goToTab((activeIndex + 1) % tabsCount) @@ -175,104 +103,67 @@ export const Tabs = (props: PropType) => { [tabs, goToTab] ) - useEffect(() => { + const onClick = useCallback( + (tab: TabsItemType, element: EventTarget & HTMLButtonElement) => { + focusedTab.current = element + setActiveTab(tab) + }, + [] + ) + + useLayoutEffect(() => { + tabsActiveIndex.current = activeTab.index if (!groupId) return - const mainContentElement = document.getElementById(MAIN_CONTENT_ID) - let storedHeight = mainContentElement?.getBoundingClientRect().height + tabsPosition.current = getTabsPosition(tabsWrapper.current) + storeTabInLocalStorage(activeTab.value) - const resizeObserver = new ResizeObserver((entries) => { - const autoNavigated = !tabRefs.current.some( - (tabRef) => tabRef.current === focusedTab.current - ) + queueMicrotask(() => { + const focusedTabId = focusedTab.current?.id || '' + const autoNavigated = !focusedTabId.endsWith(tabsGroupId.current) + focusedTab.current = null if (autoNavigated) return - for (const entry of entries) { - if (entry.contentRect.height === storedHeight) return - storedHeight = entry.contentRect.height - const rectTopDiff = readTabsRectTop() - tabsWrapperRectTop.current - if (rectTopDiff) window.scrollBy(0, rectTopDiff) - } - }) - - if (mainContentElement) resizeObserver.observe(mainContentElement) - return () => { - if (mainContentElement) resizeObserver.disconnect() - } - }, [groupId, readTabsRectTop]) - - useEffect(() => { - const tabToActivate = tabs.find((tab) => tab.value === storedTabSelection) - if (tabToActivate?.value === activeTab.value) return - if (tabToActivate) setActiveTabAndStoreSelection(tabToActivate) - }, [activeTab, storedTabSelection, setActiveTabAndStoreSelection]) + const newTabsPosition = getTabsPosition(tabsWrapper.current) + const diff = getTabsPositionDiff(newTabsPosition, tabsPosition.current) + if (diff) window.scrollBy({ top: diff }) - useEffect(() => { - const hasActiveTab = tabs.find((tab) => tab.value === activeTab.value) - if (hasActiveTab) return + tabsPosition.current = getTabsPosition(tabsWrapper.current) + }) + }, [tabs, activeTab]) - const newDefaultTab = pickDefaultTab(tabs, storedTabSelection) - setActiveTab(newDefaultTab) - activeTabIndex.current = getActiveTabIndex(newDefaultTab, tabs) - }, [tabs, activeTab, storedTabSelection]) + useLayoutEffect(() => { + const tabToActivate = tabs.find((tab) => tab.value === localStorageTab) + if (!tabToActivate) return + if (tabToActivate.value === tabs[tabsActiveIndex.current].value) return + setActiveTab(tabToActivate) + }, [tabs, localStorageTab]) return ( - - {allTabs.map((tab) => { - const selected = activeTab.value === tab.value - const enabled = !tab.disabled - const tabRefIndex = tabRefLoopIndex.current - const tabElementRef = tabRefs.current[tabRefIndex] - - if (enabled) { - const isLastTab = tabRefIndex === tabs.length - 1 - tabRefLoopIndex.current = isLastTab ? 0 : tabRefIndex + 1 - } - - return ( - { - const tabElement = tabElementRef.current - if (tabElement) focusedTab.current = tabElement - setActiveTabAndStoreSelection(tab) - }} - $selected={selected} - disabled={!enabled} - > - - {tab.label} - - - - ) - })} - + + {allTabs.map((tab) => ( + + ))} + {tabs.map((tab) => ( - + ))} ) diff --git a/packages/embla-carousel-docs/src/components/Tabs/TabsButton.tsx b/packages/embla-carousel-docs/src/components/Tabs/TabsButton.tsx new file mode 100644 index 000000000..10245808c --- /dev/null +++ b/packages/embla-carousel-docs/src/components/Tabs/TabsButton.tsx @@ -0,0 +1,87 @@ +import React, { useCallback } from 'react' +import styled, { css } from 'styled-components' +import { BRAND_GRADIENT_BACKGROUND_STYLES } from 'consts/gradients' +import { SPACINGS } from 'consts/spacings' +import { TabsItemWithIndexType } from 'consts/tabs' +import { ButtonBare, ButtonBareText } from 'components/Button/ButtonBare' +import { COLORS } from 'consts/themes' +import { BORDER_SIZES } from 'consts/border' +import { PropType as ButtonPropType } from 'components/Button/ButtonBare' +import { + ActiveText as TabsButtonActiveText, + InactiveText as TabsButtonInactiveText +} from 'components/Link/LinkNavigation' + +export const TabsButtonWrapper = styled(ButtonBare)<{ $selected: boolean }>` + padding: ${SPACINGS.TWO} ${SPACINGS.TWO}; + position: relative; + display: inline-flex; + align-items: center; + position: relative; + + &:disabled > ${ButtonBareText} > ${TabsButtonInactiveText} { + color: ${COLORS.DETAIL_HIGH_CONTRAST}; + } + + ${({ $selected }) => + $selected && + css` + &:before { + ${BRAND_GRADIENT_BACKGROUND_STYLES}; + content: ''; + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: ${BORDER_SIZES.ACCENT_HORIZONTAL}; + pointer-events: none; + } + `}; +` + +type PropType = ButtonPropType & { + tab: TabsItemWithIndexType + activeTab: TabsItemWithIndexType + groupId: string + setActiveTab: ( + tab: TabsItemWithIndexType, + element: EventTarget & HTMLButtonElement + ) => void +} + +export const TabsButton = React.forwardRef(function TabsButton( + props: PropType, + ref: React.ForwardedRef +) { + const { tab, activeTab, groupId, setActiveTab, ...restProps } = props + const isActive = tab.value === activeTab.value + + const setTab = useCallback( + (event: React.MouseEvent) => { + setActiveTab(tab, event.currentTarget) + }, + [setActiveTab] + ) + + return ( + + + {tab.label} + + + + ) +}) diff --git a/packages/embla-carousel-docs/src/components/Tabs/TabsItem.tsx b/packages/embla-carousel-docs/src/components/Tabs/TabsItem.tsx index 50b84160a..077663c54 100644 --- a/packages/embla-carousel-docs/src/components/Tabs/TabsItem.tsx +++ b/packages/embla-carousel-docs/src/components/Tabs/TabsItem.tsx @@ -1,14 +1,8 @@ +import { TabsGroupItemType } from 'consts/tabs' import React, { PropsWithChildren } from 'react' -export const isTabsItemProps = ( - props: PropType | PropsWithChildren<{}> -): props is PropType => { - return 'value' in props && 'label' in props -} - export type PropType = PropsWithChildren<{ - value: string - label: string + tab: TabsGroupItemType default?: boolean disabled?: boolean }> diff --git a/packages/embla-carousel-docs/src/components/Tabs/TabsList.tsx b/packages/embla-carousel-docs/src/components/Tabs/TabsList.tsx new file mode 100644 index 000000000..7e6ee748f --- /dev/null +++ b/packages/embla-carousel-docs/src/components/Tabs/TabsList.tsx @@ -0,0 +1,11 @@ +import styled from 'styled-components' +import { BORDER_SIZES } from 'consts/border' +import { SPACINGS } from 'consts/spacings' +import { COLORS } from 'consts/themes' + +export const TabsList = styled.div` + margin-bottom: ${SPACINGS.FOUR}; + border-bottom: ${BORDER_SIZES.DETAIL} solid ${COLORS.DETAIL_LOW_CONTRAST}; + display: flex; + overflow-x: auto; +` diff --git a/packages/embla-carousel-docs/src/components/Tabs/TabsPanel.tsx b/packages/embla-carousel-docs/src/components/Tabs/TabsPanel.tsx new file mode 100644 index 000000000..1f2e984c9 --- /dev/null +++ b/packages/embla-carousel-docs/src/components/Tabs/TabsPanel.tsx @@ -0,0 +1,40 @@ +import React, { useCallback } from 'react' +import styled from 'styled-components' +import { KEY_NAVIGATING_STYLES } from 'consts/keyNavigatingStyles' +import { TabsItemWithIndexType } from 'consts/tabs' +import { useKeyNavigating } from 'hooks/useKeyNavigating' + +export const TabsPanelWrapper = styled.section` + ${KEY_NAVIGATING_STYLES}; +` + +type PropType = { + tab: TabsItemWithIndexType + activeTab: TabsItemWithIndexType + groupId: string + setActiveTab: (tab: TabsItemWithIndexType) => void +} + +export const TabsPanel = (props: PropType) => { + const { tab, activeTab, groupId, setActiveTab } = props + const { isKeyNavigating } = useKeyNavigating() + const isHidden = tab.value !== activeTab.value + + const setTab = useCallback(() => { + setActiveTab(tab) + }, [setActiveTab]) + + return ( + + ) +} diff --git a/packages/embla-carousel-docs/src/consts/tabs.ts b/packages/embla-carousel-docs/src/consts/tabs.ts new file mode 100644 index 000000000..604a34e8e --- /dev/null +++ b/packages/embla-carousel-docs/src/consts/tabs.ts @@ -0,0 +1,84 @@ +import { PropsWithChildren } from 'react' + +export type TabsGroupType = { + GROUP_ID: string + TABS: { + [key: string]: TabsGroupItemType + } +} + +export type TabsGroupItemType = { + LABEL: string + VALUE: string +} + +export type TabsItemType = PropsWithChildren<{ + value: string + label: string + index: number + default?: boolean + disabled?: boolean +}> + +export type TabsPositionType = { + offsetTop: number + rectTop: number +} + +export const TABS_SITE_NAVIGATION: TabsGroupType = { + GROUP_ID: '', + TABS: { + MAIN_MENU: { + LABEL: 'Main menu', + VALUE: 'main-menu' + }, + ON_THIS_PAGE: { + LABEL: 'On this page', + VALUE: 'table-of-contents' + } + } +} + +export const TABS_PACKAGE_MANAGER: TabsGroupType = { + GROUP_ID: 'package-manager', + TABS: { + NPM: { + LABEL: 'npm', + VALUE: 'npm' + }, + YARN: { + LABEL: 'yarn', + VALUE: 'yarn' + } + } +} + +export const TABS_LIBRARY: TabsGroupType = { + GROUP_ID: 'library', + TABS: { + VANILLA: { + LABEL: 'Vanilla', + VALUE: 'vanilla' + }, + REACT: { + LABEL: 'React', + VALUE: 'react' + }, + VUE: { + LABEL: 'Vue', + VALUE: 'vue' + }, + SVELTE: { + LABEL: 'Svelte', + VALUE: 'svelte' + }, + SOLID: { + LABEL: 'Solid', + VALUE: 'solid' + }, + ANGULAR: { + LABEL: 'Angular', + VALUE: 'angular' + } + } +} diff --git a/packages/embla-carousel-docs/src/content/pages/api/events.mdx b/packages/embla-carousel-docs/src/content/pages/api/events.mdx index a2345eb28..75a20bd3f 100644 --- a/packages/embla-carousel-docs/src/content/pages/api/events.mdx +++ b/packages/embla-carousel-docs/src/content/pages/api/events.mdx @@ -7,6 +7,7 @@ date: 2021-02-21 import { Tabs } from 'components/Tabs/Tabs' import { TabsItem } from 'components/Tabs/TabsItem' +import { TABS_LIBRARY } from 'consts/tabs' # Events @@ -22,8 +23,8 @@ You need an **initialized carousel** in order to **make use of events**. Events After initializing a carousel, we're going to **subscribe** to the [select](/api/events/#select) **event** in the following example: - - + + ```js highlight={9} import EmblaCarousel from 'embla-carousel' @@ -38,7 +39,7 @@ After initializing a carousel, we're going to **subscribe** to the [select](/api ``` - + ```jsx highlight={12} import { useCallback, useEffect } from 'react' @@ -60,7 +61,7 @@ After initializing a carousel, we're going to **subscribe** to the [select](/api ``` - + ```html highlight={14}