From 542df72bc4b1be3d8d2bd522fd0e68194494379f Mon Sep 17 00:00:00 2001 From: Klink <85062+dogmar@users.noreply.github.com> Date: Mon, 10 Apr 2023 08:19:15 -0700 Subject: [PATCH] feat: Breadcrumbs (#451) --- .prettierrc.yaml | 2 +- src/components/Breadcrumbs.tsx | 375 ++++++++++++++++++ src/components/ComboBox.tsx | 2 +- src/components/Select.tsx | 4 +- .../contexts/BreadcrumbsContext.tsx | 71 ++++ src/index.ts | 8 + src/stories/Breadcrumbs.stories.tsx | 125 ++++++ src/stories/NavigationContextStub.tsx | 13 +- tsconfig.json | 2 +- 9 files changed, 596 insertions(+), 6 deletions(-) create mode 100644 src/components/Breadcrumbs.tsx create mode 100644 src/components/contexts/BreadcrumbsContext.tsx create mode 100644 src/stories/Breadcrumbs.stories.tsx diff --git a/.prettierrc.yaml b/.prettierrc.yaml index 04faf6f1..62347ea3 100644 --- a/.prettierrc.yaml +++ b/.prettierrc.yaml @@ -1,4 +1,4 @@ -trailingComma: "es5" +trailingComma: 'es5' tabWidth: 2 semi: false singleQuote: true diff --git a/src/components/Breadcrumbs.tsx b/src/components/Breadcrumbs.tsx new file mode 100644 index 00000000..294cf398 --- /dev/null +++ b/src/components/Breadcrumbs.tsx @@ -0,0 +1,375 @@ +import React, { + MutableRefObject, + ReactNode, + forwardRef, + useCallback, + useEffect, + useId, + useRef, + useState, +} from 'react' +import { Div, Flex, FlexProps } from 'honorable' +import styled from 'styled-components' +import classNames from 'classnames' +import { SwitchTransition, Transition } from 'react-transition-group' + +import useResizeObserver from '../hooks/useResizeObserver' +import usePrevious from '../hooks/usePrevious' + +import { Select } from './Select' +import { ListBoxItem } from './ListBoxItem' +import { useNavigationContext } from './contexts/NavigationContext' +import { Breadcrumb, useBreadcrumbs } from './contexts/BreadcrumbsContext' + +function getCrumbKey(crumb: Breadcrumb) { + const maybeKey = crumb?.key + + return typeof maybeKey === 'string' + ? maybeKey + : `${typeof crumb.label === 'string' ? crumb.label : crumb.textValue}-${ + crumb.url + }` +} + +const CrumbSeparator = styled(({ className }: { className?: string }) => ( +
/
+))(({ theme }) => ({ + ...theme.partials.text.caption, + color: theme.colors['text-input-disabled'], +})) + +function CrumbLink({ + crumb, + isLast = true, +}: { + crumb: Breadcrumb + isLast?: boolean +}) { + const { Link } = useNavigationContext() + + return ( + + + {isLast || typeof crumb.url !== 'string' ? ( + crumb.label + ) : ( + {crumb.label} + )} + + {!isLast && } + + ) +} + +const CrumbLinkWrap = styled.div(({ theme }) => ({ + display: 'flex', + flexDirection: 'row', + gap: theme.spacing.small, +})) + +const CrumbLinkText = styled.span(({ theme }) => ({ + whiteSpace: 'nowrap', + ...theme.partials.text.caption, + color: theme.colors['text-xlight'], + '&.isLast': { + color: theme.colors.text, + }, + 'a:any-link': { + textDecoration: 'none', + color: theme.colors['text-xlight'], + cursor: 'pointer', + '&:focus, &:focus-visible': { + outline: 'none', + }, + '&:focus-visible': { + textDecoration: 'underline', + textDecorationColor: theme.colors['border-outline-focused'], + }, + '&:hover': { + color: theme.colors.text, + textDecoration: 'underline', + }, + }, +})) + +const CrumbSelectTriggerUnstyled = forwardRef( + ({ className, ...props }: { className?: string }, ref) => ( +
+ ... +
+ ) +) + +const CrumbSelectTrigger = styled(CrumbSelectTriggerUnstyled)<{ + isOpen?: boolean +}>(({ theme }) => ({ + ...theme.partials.text.caption, + cursor: 'pointer', + color: theme.colors['text-xlight'], + '&:focus, &:focus-visible': { + outline: 'none', + }, + '&:focus-visible': { + textDecoration: 'underline', + textDecorationColor: theme.colors['border-outline-focused'], + }, +})) + +function CrumbSelect({ + breadcrumbs, + isLast, +}: { + breadcrumbs: Breadcrumb[] + isLast: boolean +}) { + const { useNavigate } = useNavigationContext() + const navigate = useNavigate() + + return ( + + + {!isLast && } + + ) +} + +function CrumbListRef( + { + breadcrumbs, + maxLength, + visibleListId, + ...props + }: { + breadcrumbs: Breadcrumb[] + maxLength: number + visibleListId: string + } & FlexProps, + ref: MutableRefObject +) { + const id = useId() + + if (breadcrumbs?.length < 1) { + return null + } + maxLength = Math.min(maxLength, breadcrumbs.length) + const hidden = visibleListId !== id + + const head = maxLength > 1 ? [breadcrumbs[0]] : [] + const middle = breadcrumbs.slice( + head.length, + breadcrumbs.length + head.length - maxLength + ) + const tail = breadcrumbs.slice( + breadcrumbs.length + head.length - maxLength, + breadcrumbs.length + ) + + return ( + + {head.map((headCrumb) => ( + + ))} + {middle.length > 0 && ( + + )} + + {tail.map((crumb, i) => ( + + ))} + + ) +} + +const CrumbList = forwardRef(CrumbListRef) + +const transitionStyles = { + entering: { opacity: 0, height: 0 }, + entered: { opacity: 1 }, + exiting: { display: 'none' }, + exited: { display: 'none' }, +} + +type BreadcrumbsProps = { + minLength?: number + maxLength?: number + collapsible?: boolean +} & FlexProps + +export function BreadcrumbsInside({ + minLength = 0, + maxLength = Infinity, + collapsible = true, + breadcrumbs, + wrapperRef: transitionRef, + ...props +}: BreadcrumbsProps & { + breadcrumbs: Breadcrumb[] + wrapperRef?: MutableRefObject +}) { + const wrapperRef = useRef() + const [visibleListId, setVisibleListId] = useState('') + const children: ReactNode[] = [] + + if (!collapsible) { + minLength = breadcrumbs.length + maxLength = breadcrumbs.length + } else { + minLength = Math.min(Math.max(minLength, 0), breadcrumbs.length) + maxLength = Math.min(maxLength, breadcrumbs.length) + } + + for (let i = minLength; i <= maxLength; ++i) { + children.push( + + ) + } + + const refitCrumbList = useCallback( + ({ width: wrapperWidth }: { width: number }) => { + const lists = Array.from( + wrapperRef?.current?.getElementsByClassName('crumbList') + ) + const { id } = lists.reduce( + (prev, next) => { + const prevWidth = prev.width + const nextWidth = next?.scrollWidth + + if ( + (prevWidth > wrapperWidth && + (nextWidth <= prevWidth || nextWidth < wrapperWidth)) || + nextWidth <= wrapperWidth + ) { + return { width: nextWidth, id: next.id } + } + + return prev + }, + { width: Infinity, id: '' } + ) + + setVisibleListId(id) + }, + [wrapperRef] + ) + + // Refit breadcrumb list on resize + useResizeObserver(wrapperRef, refitCrumbList) + + // Make sure to also refit if breadcrumbs data changes + useEffect(() => { + const wrapperWidth = + wrapperRef?.current?.getBoundingClientRect?.()?.width || 0 + + refitCrumbList({ width: wrapperWidth }) + }, [breadcrumbs, refitCrumbList, wrapperRef]) + + useEffect(() => { + if (visibleListId) { + wrapperRef.current?.dispatchEvent(new Event('refitdone')) + } + }, [visibleListId]) + + return ( + { + wrapperRef.current = elt + if (transitionRef) transitionRef.current = elt + }} + {...props} + > + {children} + + ) +} + +export function Breadcrumbs({ + minLength = 0, + maxLength = Infinity, + collapsible = true, + ...props +}: BreadcrumbsProps) { + const { breadcrumbs } = useBreadcrumbs() + const prevBreadcrumbs = usePrevious(breadcrumbs) + const transitionKey = useRef(0) + + if (prevBreadcrumbs !== breadcrumbs) { + transitionKey.current++ + } + + return ( +
+ + { + node?.addEventListener('refitdone', done, false) + }} + > + {(state) => ( + + )} + + +
+ ) +} diff --git a/src/components/ComboBox.tsx b/src/components/ComboBox.tsx index 2158ca8d..0f7e08a9 100644 --- a/src/components/ComboBox.tsx +++ b/src/components/ComboBox.tsx @@ -389,6 +389,7 @@ function ComboBox({ triggerRef: inputRef, width, maxHeight, + placement, }) outerInputProps = { @@ -426,7 +427,6 @@ function ComboBox({ dropdownHeaderFixed={dropdownHeaderFixed} dropdownFooterFixed={dropdownFooterFixed} width={width} - placement={placement} floating={floating} /> diff --git a/src/components/Select.tsx b/src/components/Select.tsx index 0e87dd3c..d7498c41 100644 --- a/src/components/Select.tsx +++ b/src/components/Select.tsx @@ -35,7 +35,7 @@ const parentFillLevelToBackground = { 1: 'fill-two', 2: 'fill-three', 3: 'fill-three', -} +} as const satisfies Record type Placement = 'left' | 'right' type Size = 'small' | 'medium' | 'large' @@ -319,6 +319,7 @@ function Select({ triggerRef: ref, width, maxHeight, + placement, }) return ( @@ -343,7 +344,6 @@ function Select({ dropdownHeaderFixed={dropdownHeaderFixed} dropdownFooterFixed={dropdownFooterFixed} width={width} - placement={placement} floating={floating} /> diff --git a/src/components/contexts/BreadcrumbsContext.tsx b/src/components/contexts/BreadcrumbsContext.tsx new file mode 100644 index 00000000..b80aacb8 --- /dev/null +++ b/src/components/contexts/BreadcrumbsContext.tsx @@ -0,0 +1,71 @@ +import React, { + PropsWithChildren, + ReactNode, + useContext, + useEffect, + useState, +} from 'react' + +export type BreadcrumbBase = { + url?: string + key?: string +} + +export type BreadcrumbsContextT = { + breadcrumbs: Breadcrumb[] + setBreadcrumbs: (crumbs: Breadcrumb[]) => void +} + +export type Breadcrumb = BreadcrumbBase & + ( + | { + label: Exclude + textValue: string + } + | { + label: string + textValue?: string + } + ) + +const BreadcrumbsContext = React.createContext(null) + +export function BreadcrumbsProvider({ children }: PropsWithChildren) { + const [breadcrumbs, setBreadcrumbs] = useState([]) + + return ( + // eslint-disable-next-line react/jsx-no-constructed-context-values + + {children} + + ) +} + +export function useBreadcrumbs() { + const ctx = useContext(BreadcrumbsContext) + + if (!ctx) { + throw Error('useBreadcrumbs() must be used inside a ') + } + + return ctx +} + +export function useSetBreadcrumbs(breadcrumbs?: Breadcrumb[]) { + const ctx = useContext(BreadcrumbsContext) + const { setBreadcrumbs } = ctx + + useEffect(() => { + if (setBreadcrumbs && Array.isArray(breadcrumbs)) { + setBreadcrumbs(breadcrumbs) + } + }, [breadcrumbs, setBreadcrumbs]) + + if (!ctx) { + throw Error( + 'useSetBreadcrumbs() must be used inside a ' + ) + } + + return ctx +} diff --git a/src/index.ts b/src/index.ts index 1bccbcec..89a35a9a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -93,6 +93,8 @@ export { default as Slider } from './components/Slider' export { default as PricingCalculator } from './components/pricingcalculator/PricingCalculator' export { default as PricingCalculatorExtended } from './components/pricingcalculator/PricingCalculatorExtended' export { default as Layer } from './components/Layer' +export { Breadcrumbs } from './components/Breadcrumbs' + // Hooks export { default as usePrevious } from './hooks/usePrevious' export { default as useUnmount } from './hooks/useUnmount' @@ -107,6 +109,12 @@ export { } from './components/contexts/FillLevelContext' export * from './components/contexts/NavigationContext' export * from './components/TreeNavigation' +export { + BreadcrumbsProvider, + useBreadcrumbs, + useSetBreadcrumbs, + type Breadcrumb, +} from './components/contexts/BreadcrumbsContext' // Theme export { default as theme, styledTheme } from './theme' diff --git a/src/stories/Breadcrumbs.stories.tsx b/src/stories/Breadcrumbs.stories.tsx new file mode 100644 index 00000000..d24f8115 --- /dev/null +++ b/src/stories/Breadcrumbs.stories.tsx @@ -0,0 +1,125 @@ +import { Flex, Span } from 'honorable' + +import { useState } from 'react' + +import { + Breadcrumb, + BreadcrumbsProvider, + useSetBreadcrumbs, +} from '../components/contexts/BreadcrumbsContext' +import { Breadcrumbs } from '../components/Breadcrumbs' +import { Select } from '../components/Select' +import { ListBoxItem } from '../components/ListBoxItem' +import FormField from '../components/FormField' + +import { NavContextProviderStub } from './NavigationContextStub' + +export default { + title: 'Breadcrumbs', + component: 'Breadcrumbs', + argTypes: { + maxLength: {}, + }, +} + +const crumbList: Breadcrumb[] = [ + { + url: 'http://stuff.com/link1', + label: 'Root level', + }, + { + url: 'http://stuff.com/link1/link2', + label: Level 2, + textValue: 'Level 2', + }, + { + url: 'http://stuff.com/link1/link2/link3', + label: 'Another', + }, + { + url: 'http://stuff.com/link1/link2/link3/link4', + label: ( + <> + Yet another level + + ), + textValue: 'Yet another level', + }, + { + url: 'http://stuff.com/link1/link2/link3/link4/link5', + label: 'Are well still going?', + }, + { + url: 'http://stuff.com/link1/link2/link3/link4/link5', + label: ( + <> + You bet we are! + + ), + textValue: 'You bet we are', + }, + { + url: 'http://stuff.com/link1/link2/link3/link4/link5/link6', + label: 'This is getting out of hand', + }, +] + +const crumbLists = crumbList.map((_, i) => crumbList.slice(0, i + 1)) + +function CrumbSetter() { + const [selectedList, setSelectedList] = useState( + (crumbLists.length - 1).toString() + ) + + useSetBreadcrumbs(crumbLists[selectedList]) + + return ( + + + + ) +} + +function Template(args: any) { + return ( + + + + {/* SINGLE SELECT */} + + + + + + ) +} + +export const Default = Template.bind({}) + +Default.args = { + minLength: undefined, + maxLength: undefined, + collapsible: true, +} diff --git a/src/stories/NavigationContextStub.tsx b/src/stories/NavigationContextStub.tsx index 94b98bfc..f49f8f8a 100644 --- a/src/stories/NavigationContextStub.tsx +++ b/src/stories/NavigationContextStub.tsx @@ -6,7 +6,18 @@ import { } from '../components/contexts/NavigationContext' export function Link({ children, ...props }: LinkProps) { - return {children} + return ( + { + e.preventDefault() + console.info('Link clicked to:', props?.href) + props.onClick?.(e) + }} + > + {children} + + ) } const currentPathReducer = (_: string | null, newPath: string | null) => { diff --git a/tsconfig.json b/tsconfig.json index 1bcfdba2..99b63e88 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,5 +16,5 @@ "resolveJsonModule": true, "suppressImplicitAnyIndexErrors": true }, - "include": ["src/**/*"], + "include": ["src/**/*"] }