Skip to content

bugrakaan/hangover

Repository files navigation

hangover

A React 18 compound-component Dropdown / Field Picker library.

npm version License: MIT

Live Demo

Features

  • Compound Components - Composable Dropdown.Trigger, Panel, Navigation, Section, Group, Item API
  • Fuzzy Search - Built-in fuzzy filtering across items (powered by fuse.js)
  • Keyboard Navigation - Arrow up/down (with wrap) to move between items, left/right to jump between the nav column and content, Enter to trigger
  • Two Display Modes - Scroll-spy with smooth scroll or one-section-at-a-time tab mode
  • Left Navigation - Optional nav column with auto-collapse and single-section auto-transform
  • Checkbox Items - Multi-select with select-all support
  • Dark Mode - Token-based theming with a built-in dark theme
  • Smart Positioning - Portal-rendered panel with placement variants and auto-placement
  • Controlled & Uncontrolled - Full control or sensible defaults out of the box
  • Imperative API - Open, close, and drive state via ref with an optional external anchor
  • Config-driven Rendering - Build entire menus from a single fromConfig object
  • React 18 - Compound, accessible, headless-style components

Installation

npm install @diabolic/hangover

Quick Start

import { Dropdown } from '@diabolic/hangover'

export default function App() {
  return (
    <Dropdown>
      <Dropdown.Trigger>
        <button>Open</button>
      </Dropdown.Trigger>
      <Dropdown.Panel>
        <Dropdown.Content>
          <Dropdown.Section>
            <Dropdown.Group label="Fruits">
              <Dropdown.Item id="apple">Apple</Dropdown.Item>
              <Dropdown.Item id="banana">Banana</Dropdown.Item>
            </Dropdown.Group>
          </Dropdown.Section>
        </Dropdown.Content>
      </Dropdown.Panel>
    </Dropdown>
  )
}

Styles are imported automatically with the component. Do not import from dist/... directly.


Component Reference

<Dropdown>

Root provider. All state lives here.

Prop Type Default Description
displayMode "scroll" | "tab" "scroll" How sections are navigated. "scroll" uses scroll-spy + smooth scroll; "tab" shows one section at a time.
defaultOpen boolean false Panel starts open.
defaultGroupExpanded "first" | true | false true Default expand state for all groups. true expands all; false collapses all; "first" expands only the first group across all sections.
hideOnSelection boolean true Close the panel automatically when a type="click" item is selected. Set to false to keep the panel open.
darkMode boolean false Enable dark mode. Applies hangoverDropdown--dark CSS class which overrides all color tokens.
searchQuery string Controlled search query. When provided, the internal search state is kept in sync with this value. Use together with onEvent (type: "search") to handle changes.
defaultSearchQuery string "" Uncontrolled initial search query. Only applied on first render.
useTranslationFunction (text, payload?) => string Translation hook. When provided, every user-facing string — including built-in defaults — is routed through this function. See Translation.
groupHeaderStyle "accent" | "light" "accent" Group header appearance. "accent" is the filled header with a colored accent bar; "light" renders headers like section titles (flat, uppercase, muted, no accent) — handy when there is no navigation column.
autoFocusSearch boolean true Focus the search input automatically when the panel opens. Set to false to leave focus untouched.
hideEmptyResults boolean true While searching, completely remove groups that have no matching items (instead of showing a "No results" placeholder), hide the titles of sections with no matches, and disable those categories in the navigation column. Set to false to keep every group/section rendered and show the per-group "No results" text instead.
onEvent (event) => any Central event handler. See Events.
ref React.Ref Exposes imperative API. See Imperative API.
...rest any Any additional props (e.g. data-*, className, style) are forwarded to the root <div>.

<Dropdown.Trigger>

Wraps any single child element and turns it into the toggle trigger.

Injects ref, onClick, aria-expanded, aria-haspopup onto the child automatically — no extra props needed.

<Dropdown.Trigger>
  <button>Open</button>
</Dropdown.Trigger>

Dropdown.Trigger is optional. When you cannot add markup inside <Dropdown>, use an external anchor ref with the imperative API instead. See Without a Trigger.


<Dropdown.Panel>

Renders into a portal on document.body. Handles positioning, outside-click, and Escape key closing.

Prop Type Default Description
placement string "bottom-start" Panel position relative to the trigger. Supported: "bottom-start", "bottom-end", "bottom", "top-start", "top-end", "top".
title string Optional title bar rendered at the top of the panel (above the nav/content area). Uses the same muted uppercase style as section headings.
offset number | string 8 Distance between trigger and panel. Accepts a number (10) or a px string ("10px").
anchor React.RefObject Ref to an external DOM element used as the positioning anchor. Overrides the built-in trigger ref. Use together with the imperative API when Dropdown.Trigger is not in the markup.
avoid string | Element | Ref | Array<string | Element | Ref> Elements the panel should not overlap. Accepts CSS selector strings, DOM elements, or refs (or an array of them). While auto-placing, the direction with the least overlap against these nodes is preferred.
placementPriority string[] Order in which sides/placements are tried when auto-placing, e.g. ['bottom', 'top', 'right', 'left']. The first side that fits and is clear (of the viewport edges and any avoid nodes) is used. Bare sides inherit the alignment from placement.
beforePlace (panelEl: HTMLElement) => Promise<void> Async hook called once, after the panel mounts but before the initial placement is calculated. The positioner waits for the returned promise to settle before measuring and placing — useful when panel content loads asynchronously or its height changes from an external source. Errors are swallowed and placement still proceeds. Subsequent scroll/resize/drag recalculations skip this hook.
component React component Custom wrapper component.
...rest any Any additional props are forwarded to the panel <div> (or component).

<Dropdown.Navigation>

Left navigation column. When present, the panel switches to a two-column layout.

Prop Type Default Description
showAll boolean false Automatically prepends an All nav item with id "__all__".
allLabel string "All" Label for the auto-prepended All item.
allIcon ReactNode | FC Icon for the auto-prepended All item.
collapsed boolean false Start the nav column in collapsed state.
autoCollapse boolean | "auto" false Automatically collapse the nav column when the viewport is too narrow to fit the full panel width (derived from --hangover-nav-width + --hangover-content-max-width). Use "auto" with collapsed to start collapsed and expand while the nav is hovered or keyboard-focused.
component React component Custom wrapper component.
...rest any Any additional props are forwarded to the nav column wrapper <div> (or component).

Single-section auto-transform — When <Dropdown.Navigation> has exactly one child item (excluding the auto-prepended All item), the nav column is hidden automatically and section titles are suppressed. No extra prop is needed; it is detected at render time.

{/* Nav column and section title disappear automatically */}
<Dropdown.Navigation showAll>
  <Dropdown.NavigationItem id="metrics">Metrics</Dropdown.NavigationItem>
</Dropdown.Navigation>

Renders Dropdown.NavigationItem children inside a forNavigation column.

Use showAll to automatically prepend an All item:

<Dropdown.Navigation showAll>
  <Dropdown.NavigationItem id="fruits" icon={<IconFruits />}>Fruits</Dropdown.NavigationItem>
</Dropdown.Navigation>

Or provide a custom label/icon for the All item:

<Dropdown.Navigation showAll allLabel="Everything" allIcon={IconAll}>
  <Dropdown.NavigationItem id="fruits">Fruits</Dropdown.NavigationItem>
</Dropdown.Navigation>

<Dropdown.NavigationItem>

A single item in the navigation column.

Prop Type Default Description
id string required Must match the forId of a Dropdown.Section.
icon ReactNode | FC Icon displayed left of the label.
component React component Custom component. Receives isActive, onClick, id.
...rest any Any additional props are forwarded to the <button> (or component). onClick is composed with the internal nav handler.

In displayMode="scroll", clicking a nav item smooth-scrolls to the matching section. The active item is updated automatically as the user scrolls (scroll spy).


<Dropdown.Content>

Right content column. Contains the search bar and the scrollable item list.

Prop Type Default Description
searchPlaceholder string "Search" Placeholder text for the search input.
emptyText string "Nothing to show here" Text shown when Content has no children at all (empty state). The search bar is also hidden in this state.
noMatchText string "No results found" Text shown when a search query returns no matching items in any section or group. Displayed as a single message covering the whole list area. Distinct from the per-group noResultsText (which is only shown when hideEmptyResults={false}).
component React component Custom wrapper component.
...rest any Any additional props are forwarded to the content column <div> (or component).

<Dropdown.Section>

Groups Dropdown.Group / Dropdown.Item elements under a nav scope.

Prop Type Default Description
forId string "__all__" Matches a Dropdown.NavigationItem id. Defaults to "__all__", so it can be omitted when there's no navigation. Also accepts for (JSX alias).
title string Section heading shown in displayMode="scroll". Sticks to the top of the scroll container while the section is in view.
...rest any Any additional props are forwarded to the section wrapper <div>.
{/* With nav */}
<Dropdown.Section forId="fruits" title="Fruits">
  ...
</Dropdown.Section>

{/* Without nav — forId can be omitted */}
<Dropdown.Section>
  ...
</Dropdown.Section>

<Dropdown.Group>

A collapsible group of items with a colored left-border accent.

Prop Type Default Description
label string required Group heading text.
id string auto Stable identifier for the imperative API (ref.current.selectAll(id)). Auto-derived from label if omitted ("Team Members""team_members"). Provide an explicit id when using the imperative API.
icon ReactNode | FC Icon displayed left of the group label in the header bar.
color string auto CSS color for the left accent bar. Auto-assigned from a built-in palette if omitted.
defaultExpanded boolean Override the root-level defaultGroupExpanded for this specific group.
showSelectAll boolean false Shows a "Select all" checkbox item inside the group.
selectAllPosition "top" | "bottom" "bottom" Position of the select-all item.
emptyText string "Nothing to show here" Text shown when the group has no children.
noResultsText string "No results" Text shown when a search query returns no matching items inside this group. Only applies when hideEmptyResults={false} on <Dropdown>; otherwise the group is removed entirely while filtering.
component React component Custom wrapper component.
...rest any Any additional props are forwarded to the group wrapper <div> (or component).

<Dropdown.Item>

A single selectable or checkable item.

Prop Type Default Description
id string required Unique identifier for this item.
type "click" | "checkbox" "click" Interaction mode. "click" triggers a select event; "checkbox" triggers a check event.
icon ReactNode | FC Icon displayed left of the item label.
defaultChecked boolean false Initial checked state (uncontrolled, type="checkbox" only).
checkIcon ReactNode | FC built-in ✓ Custom check icon.
component React component Custom component. Receives isSelected, isChecked, onClick.
...rest any Any additional props are forwarded to the item <div> (or component). onClick and onKeyDown are composed with the internal selection handlers.

Items are automatically filtered when the user types in the search box.


fromConfig — config-driven rendering

Pass a plain JS object to the fromConfig prop on <Dropdown> to render the entire tree without writing JSX. Useful for server-driven UIs or stored configurations.

fromConfig and children cannot be used together — fromConfig takes precedence.

<Dropdown fromConfig={config} />

Config schema

Any unknown props at any level are forwarded as-is to the underlying component. This means you can pass data-* attributes, onClick, onMouseEnter, or any other prop directly in the config object:

// Example — data-test and onClick on a specific item
{ id: 'name', label: 'Name', 'data-test': 'field-name', onClick: handleClick }
const config = {
  // Root props (all optional)
  displayMode: 'scroll' | 'tab',
  defaultOpen: boolean,
  defaultGroupExpanded: boolean | 'first',
  hideOnSelection: boolean,
  onEvent: ({ type, payload, prev }) => any,
  useTranslationFunction: (text, payload) => string,
  groupHeaderStyle: 'accent' | 'light',
  autoFocusSearch: boolean,   // default true
  hideEmptyResults: boolean,  // default true
  // ...any extra props are spread onto <Dropdown>

  // Trigger — required
  trigger: ReactNode | string | {
    label: string,
    className?: string,
    component?: ComponentType,
  },

  // Panel (optional)
  panel?: {
    placement?: string,  // default 'bottom-start'
    offset?: number,  // default 8
    avoid?: string | Element | Ref | Array<string | Element | Ref>,
    placementPriority?: string[],  // e.g. ['bottom','top','right','left']
    beforePlace?: (panelEl: HTMLElement) => Promise<void>,
  },

  // Navigation column (optional)
  navigation?: { ... },   // legacy alias, still supported
  items?: [               // preferred root config for navigation items
    {
      id?: string,
      label: string,
      icon?: ReactNode | FC,
      title?: string,
      items?: [
        {
          id?: string,
          label?: string,
          icon?: ReactNode | FC,
          color?: string,
          defaultExpanded?: boolean,
          showSelectAll?: boolean,
          selectAllPosition?: 'top' | 'bottom',
          emptyText?: string,
          noResultsText?: string,
          items: [
            {
              id: string,
              label: string,
              icon?: ReactNode | FC,
              type?: 'click' | 'checkbox',
              defaultChecked?: boolean,
              checkIcon?: ReactNode | FC,
              actions?: ReactNode | ((item) => ReactNode),
              component?: ComponentType,
            },
          ],
        },
      ],
    },
  ],
  showAll?: boolean,
  allLabel?: string,
  allIcon?: ReactNode | FC,
  collapsed?: boolean,
  autoCollapse?: boolean | 'auto',

  // Content column — required
  content: {
    searchPlaceholder?: string,
    emptyText?: string,   // shown when no sections/groups are provided
    sections: [
      {
        for?: string,   // optional when section content lives under items[]
        title?: string,
        groups?: [
          {
            id?: string,
            label?: string,
            icon?: ReactNode | FC,
            color?: string,
            defaultExpanded?: boolean,
            showSelectAll?: boolean,
            selectAllPosition?: 'top' | 'bottom',
            emptyText?: string,
            noResultsText?: string,
            items: [
              {
                id: string,
                label: string,
                icon?: ReactNode | FC,
                type?: 'click' | 'checkbox',
                defaultChecked?: boolean,
                checkIcon?: ReactNode | FC,
                actions?: ReactNode | ((item) => ReactNode),
                component?: ComponentType,
              },
            ],
          },
        ],
        items?: [
          {
            id?: string,
            label?: string,
            icon?: ReactNode | FC,
            color?: string,
            defaultExpanded?: boolean,
            showSelectAll?: boolean,
            selectAllPosition?: 'top' | 'bottom',
            emptyText?: string,
            noResultsText?: string,
            items: [
              {
                id: string,
                label: string,
                icon?: ReactNode | FC,
                type?: 'click' | 'checkbox',
                defaultChecked?: boolean,
                checkIcon?: ReactNode | FC,
                actions?: ReactNode | ((item) => ReactNode),
                component?: ComponentType,
                // ...any extra props (data-*, onClick, onMouseEnter, …) are forwarded to <DropdownItem>
              },
            ],
          },
        ],
      },
    ],
  },
}

Example

import { Dropdown } from '@diabolic/hangover'

const config = {
  trigger: 'Select fields',
  showAll: true,
  items: [
    {
      id: 'basic',
      label: 'Basic',
      title: 'Basic',
      items: [
        {
          label: 'Identity',
          items: [
            { id: 'name', label: 'Name' },
            { id: 'email', label: 'Email' },
          ],
        },
      ],
    },
    {
      id: 'advanced',
      label: 'Advanced',
      title: 'Advanced',
      items: [
        {
          label: 'System',
          items: [
            { id: 'created-at', label: 'Created at' },
            { id: 'updated-at', label: 'Updated at' },
          ],
        },
      ],
    },
  ],
  content: {
    searchPlaceholder: 'Search fields...',
  },
}

export default function App() {
  return <Dropdown fromConfig={config} />
}

Events

All events are delivered via the onEvent prop on <Dropdown>.

<Dropdown onEvent={({ type, payload, prev }) => {
  console.log(type, payload)
}}>
Event type payload prev Description
open { trigger } Panel opened. trigger: "click" | "imperative".
close { trigger } Panel closed. trigger: "click" | "outside" | "escape" | "imperative".
select { id, label, groupId, groupLabel } { id, label } | null An item was clicked (type="click").
check { id, label, groupId, groupLabel } { checked } A checkbox item was toggled.
selectAll { groupId, groupLabel, itemIds } { checked } Select-all was toggled.
navChange { id } { id } Active nav item changed.
search { query } { query } Search input changed.
groupToggle { groupId, groupLabel, expanded } A group was expanded or collapsed.

Cancelling an event

Return null from onEvent to cancel the state update:

<Dropdown onEvent={({ type, payload }) => {
  if (type === 'select' && payload.id === 'locked') return null // cancel
}}>

Native DOM events

Each event also fires a native CustomEvent on the trigger element:

trigger.addEventListener('HO:select', (e) => {
  console.log(e.detail) // { payload, prev }
})

Translation

Pass a useTranslationFunction to <Dropdown> to localize the UI. When provided, every user-facing string is routed through it — including built-in defaults like "Search", "Select all", "No results", "No results found" and "Nothing to show here", as well as your own labels (group names, item labels, nav items, section/panel titles).

The function receives the original string and returns the translated one:

const translations = {
  'Search': 'Ara',
  'Select all': 'Tümünü seç',
  'No results': 'Sonuç yok',
  'No results found': 'Hiç sonuç bulunamadı',
  'Nothing to show here': 'Gösterilecek bir şey yok',
  'Fruits': 'Meyveler',
  'Apple': 'Elma',
}

<Dropdown useTranslationFunction={(text) => translations[text] ?? text}>
  {/* ... */}
</Dropdown>

Dynamic values

When a string contains dynamic values, they are passed as a payload object in the second argument instead of being concatenated into the string. The default string uses {placeholder} tokens that map to payload keys:

<Dropdown
  useTranslationFunction={(text, payload) => {
    // e.g. text = "{label} — {action}", payload = { label: "Fruits", action: "collapse" }
    if (text === '{label} — {action}') {
      return `${payload.label}: ${payload.action === 'collapse' ? 'kapat' : 'aç'}`
    }
    return translations[text] ?? text
  }}
>
  {/* ... */}
</Dropdown>

If no useTranslationFunction is provided, strings render as-is and any {placeholder} tokens are interpolated from the payload automatically.

Also available via fromConfig as useTranslationFunction.


Imperative API

Attach a ref to <Dropdown> to control it programmatically from outside the tree:

const dropdownRef = useRef()

<Dropdown ref={dropdownRef}>
  ...
</Dropdown>
Method Returns Description
open() Open the panel.
close() Close the panel.
toggle() Toggle open/close.
isOpen() boolean Current open state.
getSelected() { id, label } | null Currently selected item.
getChecked() Map<id, boolean> All checkbox states.
getActiveNavItem() string Active nav item id.
setSearch(query) Programmatically set the search query.
selectAll(groupId, checked?) Toggle or force the select-all state of a group. groupId matches the id prop on <Dropdown.Group>. Omit checked to toggle; pass true/false to force.
const ref = useRef()

// toggle
ref.current.selectAll('metrics')

// force on / force off
ref.current.selectAll('metrics', true)
ref.current.selectAll('metrics', false)

useDropdown Hook

Use useDropdown() to read state and trigger actions from inside the dropdown tree — for example inside a custom component passed via the component prop.

import { useDropdown } from '@diabolic/hangover'

function MyCustomItem({ isSelected, onClick, children }) {
  const { searchQuery, activeNavId, close } = useDropdown()
  // ...
}

<Dropdown.Item id="foo" component={MyCustomItem}>Foo</Dropdown.Item>

Must be called inside a <Dropdown> subtree.

Returns

Reactive state — triggers re-render when the value changes:

Property Type Description
isOpen boolean Panel open state.
selectedItem { id, label } | null Currently selected item.
checkedItems Map<id, boolean> All checkbox states.
activeNavId string Active navigation item id.
activeNavLabel string Label of the active navigation item.
searchQuery string Current search input value.
displayMode "scroll" | "tab" Display mode of the dropdown.
darkMode boolean Dark mode state.

Actions — stable references, safe to use in useEffect / useCallback deps:

Method Description
open() Open the panel.
close() Close the panel.
toggle() Toggle open/closed.
setSearch(query) Update the search query.
fireEvent(type, payload) Fire any internal event. Useful for advanced / unforeseen scenarios. Return null from onEvent to cancel.

Example — custom trigger with current state

import { useDropdown } from '@diabolic/hangover'

function SmartTrigger() {
  const { isOpen, selectedItem, toggle } = useDropdown()

  return (
    <button onClick={toggle}>
      {selectedItem ? selectedItem.label : 'Select a field'}
      {isOpen ? ' ▲' : ' ▼'}
    </button>
  )
}

<Dropdown>
  <Dropdown.Trigger>
    <SmartTrigger />
  </Dropdown.Trigger>
  ...
</Dropdown>

Example — close panel from inside a custom item

function MyItem({ isSelected, onClick, children }) {
  const { close } = useDropdown()

  return (
    <div onClick={() => { onClick(); close() }}>
      {children}
    </div>
  )
}

Example — react to search query inside a custom component

function MyContent({ children }) {
  const { searchQuery, activeNavLabel } = useDropdown()

  return (
    <div>
      {searchQuery && <p>Results for "{searchQuery}" in {activeNavLabel}</p>}
      {children}
    </div>
  )
}

Recipes

With icons on groups and items

function IconUser() {
  return (
    <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
      <path fillRule="evenodd" clipRule="evenodd" d="M8 2a2.667 2.667 0 1 1 0 5.333A2.667 2.667 0 0 1 8 2Zm0 6.667c-3.2 0-5.333 1.6-5.333 2.666V12h10.666v-.667C13.333 10.267 11.2 8.667 8 8.667Z" fill="currentColor" />
    </svg>
  )
}

function IconMail() {
  return (
    <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
      <path fillRule="evenodd" clipRule="evenodd" d="M2 3.333A.667.667 0 0 1 2.667 2.667h10.666A.667.667 0 0 1 14 3.333v9.334a.667.667 0 0 1-.667.666H2.667A.667.667 0 0 1 2 12.667V3.333Zm1.333.92V12h9.334V4.253L8 7.92 3.333 4.253ZM12.24 4H3.76L8 6.747 12.24 4Z" fill="currentColor" />
    </svg>
  )
}

<Dropdown.Group label="Contact" icon={IconUser}>
  <Dropdown.Item id="name" icon={IconUser}>Full Name</Dropdown.Item>
  <Dropdown.Item id="email" icon={IconMail}>Email Address</Dropdown.Item>
</Dropdown.Group>

Icons inherit the item's text color via currentColor — they automatically adapt to hover, active, and dark mode states.


Dark mode

<Dropdown darkMode>
  <Dropdown.Trigger><button>Open</button></Dropdown.Trigger>
  <Dropdown.Panel>
    ...
  </Dropdown.Panel>
</Dropdown>

Panel title

<Dropdown.Panel title="Select a field">
  ...
</Dropdown.Panel>

Empty content state

When <Dropdown.Content> has no children, the search bar is hidden and an empty message is shown:

<Dropdown.Content emptyText="No fields available">
  {/* no children */}
</Dropdown.Content>

Search no-results per group

<Dropdown.Group label="Metrics" noResultsText="No metrics match your search">
  <Dropdown.Item id="revenue">Revenue</Dropdown.Item>
  <Dropdown.Item id="sessions">Sessions</Dropdown.Item>
</Dropdown.Group>

Controlled search query

Drive the search input from outside — connect it to your own state or a URL param:

const [query, setQuery] = useState('')

<input value={query} onChange={e => setQuery(e.target.value)} placeholder="Search..." />

<Dropdown
  searchQuery={query}
  onEvent={({ type, payload }) => {
    if (type === 'search') setQuery(payload.query)
  }}
>
  ...
</Dropdown>

Or use defaultSearchQuery to set the initial value without controlling it:

<Dropdown defaultSearchQuery="rev">
  ...
</Dropdown>

With left navigation (scroll mode)

<Dropdown displayMode="scroll">
  <Dropdown.Trigger><button>Browse</button></Dropdown.Trigger>
  <Dropdown.Panel>
    <Dropdown.Navigation showAll>
      <Dropdown.NavigationItem id="fruits">Fruits</Dropdown.NavigationItem>
      <Dropdown.NavigationItem id="vegetables">Vegetables</Dropdown.NavigationItem>
    </Dropdown.Navigation>
    <Dropdown.Content>
      <Dropdown.Section forId="fruits" title="Fruits">
        <Dropdown.Group label="Citrus">
          <Dropdown.Item id="orange">Orange</Dropdown.Item>
          <Dropdown.Item id="lemon">Lemon</Dropdown.Item>
        </Dropdown.Group>
      </Dropdown.Section>
      <Dropdown.Section forId="vegetables" title="Vegetables">
        <Dropdown.Group label="Leafy">
          <Dropdown.Item id="spinach">Spinach</Dropdown.Item>
        </Dropdown.Group>
      </Dropdown.Section>
    </Dropdown.Content>
  </Dropdown.Panel>
</Dropdown>

Checkbox mode with select-all

<Dropdown>
  <Dropdown.Trigger><button>Select fields</button></Dropdown.Trigger>
  <Dropdown.Panel>
    <Dropdown.Content>
      <Dropdown.Section>
        <Dropdown.Group label="Metrics" showSelectAll>
          <Dropdown.Item id="revenue" type="checkbox">Revenue</Dropdown.Item>
          <Dropdown.Item id="sessions" type="checkbox">Sessions</Dropdown.Item>
          <Dropdown.Item id="bounce" type="checkbox">Bounce rate</Dropdown.Item>
        </Dropdown.Group>
      </Dropdown.Section>
    </Dropdown.Content>
  </Dropdown.Panel>
</Dropdown>

Controlled — prevent selection

<Dropdown onEvent={({ type, payload }) => {
  if (type === 'select' && payload.id === 'locked') {
    alert('This item is locked')
    return null // cancel
  }
}}>
  ...
</Dropdown>

Collapse all groups by default

<Dropdown defaultGroupExpanded={false}>
  ...
</Dropdown>

Expand only the first group

<Dropdown defaultGroupExpanded="first">
  ...
</Dropdown>

Custom panel placement and offset

{/* Open above the trigger, aligned to the right edge, 16px gap */}
<Dropdown.Panel placement="top-end" offset={16}>
  ...
</Dropdown.Panel>

Without a Trigger

When you cannot wrap the toggle button inside <Dropdown>, use an external anchor ref and control the panel via the Imperative API:

const dropdownRef = useRef()
const buttonRef = useRef()

// The button lives anywhere in the tree — outside <Dropdown> if needed
<button ref={buttonRef} onClick={() => dropdownRef.current.toggle()}>
  Open
</button>

<Dropdown ref={dropdownRef}>
  <Dropdown.Panel anchor={buttonRef}>
    <Dropdown.Content>
      <Dropdown.Section>
        <Dropdown.Group label="Items">
          <Dropdown.Item id="one">One</Dropdown.Item>
        </Dropdown.Group>
      </Dropdown.Section>
    </Dropdown.Content>
  </Dropdown.Panel>
</Dropdown>

Dropdown.Trigger is not needed in this pattern. The anchor ref is used for positioning and outside-click detection.


Collapsible / auto-collapsing navigation

{/* Start collapsed */}
<Dropdown.Navigation collapsed>
  ...
</Dropdown.Navigation>

{/* Auto-collapse when the viewport is too narrow */}
<Dropdown.Navigation autoCollapse>
  ...
</Dropdown.Navigation>

{/* Start collapsed, expand on hover or keyboard focus */}
<Dropdown.Navigation collapsed autoCollapse="auto">
  ...
</Dropdown.Navigation>

The autoCollapse threshold is --hangover-nav-width + --hangover-content-max-width. Override either token to tune the breakpoint.


Custom component slot

Every compound component accepts a component prop to swap the root element:

<Dropdown.Item id="foo" component={MyCustomRow}>
  Foo
</Dropdown.Item>

CSS Tokens

All visual properties are configurable via CSS custom properties:

.hangoverDropdown {
  --hangover-font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
  --hangover-font-size-base: 14px;
  --hangover-font-size-sm: 12px;

  /* Text */
  --hangover-color-text: #0a1551;
  --hangover-color-text-muted: #8a9ab5;

  /* Backgrounds */
  --hangover-color-bg-panel: #ffffff;
  --hangover-color-bg-title: #eceef5;
  --hangover-color-bg-nav: #F5F6FC;
  --hangover-color-bg-nav-hover: #EAECF5;
  --hangover-color-bg-nav-active: #E0E3EF;
  --hangover-color-bg-hover: #f3f3fe;
  --hangover-color-bg-hover-dark: #eaeaf9;
  --hangover-color-bg-selected: #eef2ff;
  --hangover-color-bg-checked: #EDF8FF;

  /* Border & misc */
  --hangover-color-border: #e2e8f0;
  --hangover-color-search-ph: #979DC6;
  --hangover-color-search-icon: #343C6A;
  --hangover-color-focus: #3b82f6;
  --hangover-group-default-color: #16a34a;

  /* Shape */
  --hangover-radius-panel: 4px;
  --hangover-radius-item: 4px;
  --hangover-radius-nav-item: 4px;

  --hangover-shadow-panel:
    0 8px 16px 0 rgba(84, 95, 111, 0.16),
    0 2px 4px 0 rgba(37, 45, 91, 0.04);

  /* Layout */
  --hangover-nav-width: 172px;
  --hangover-content-max-width: 240px;
  --hangover-panel-nonav-width: 240px;
  --hangover-list-max-height: 280px;

  --hangover-transition: 330ms ease;
}

Dark mode

Pass darkMode to <Dropdown> to apply a pre-built dark palette:

<Dropdown darkMode>
  ...
</Dropdown>

To customise dark mode colors, override the tokens inside .hangoverDropdown--dark:

.hangoverDropdown--dark {
  --hangover-color-bg-panel: #1a1d2e;
  --hangover-color-text: #dde1f5;
  /* ... */
}

Storybook

Explore all components and interactions in the live demo.

Run locally:

npm run storybook

Build the static demo site (output in storybook-static/):

npm run build-storybook

Stories are located in src/stories/:

Story Description
1. Basic Basic usage, with/without nav, single-section auto-transform, open by default, empty state
2. Scroll Spy Scroll-spy with nav icons, collapsible navigation, long text stress test
3. Tab Mode displayMode="tab"
4. Checkbox Checkbox items, select-all
5. Controlled Controlled state, external anchor + imperative API
6. Events Live event stream, cancel event
7. Placement All panel placement variants, auto-placement with scroll
8. From Config fromConfig prop — config-driven rendering with and without auto-collapse

License

MIT

About

No description, website, or topics provided.

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors