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/**/*"]
}