@diabolic/hangover 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,980 @@
1
+ # hangover
2
+
3
+ A React 18 compound-component Dropdown / Field Picker library.
4
+
5
+ [![npm version](https://img.shields.io/npm/v/@diabolic/hangover.svg)](https://www.npmjs.com/package/@diabolic/hangover)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
+
8
+ [Live Demo](https://bugrakaan.github.io/hangover/)
9
+
10
+ ## Features
11
+
12
+ - **Compound Components** - Composable `Dropdown.Trigger`, `Panel`, `Navigation`, `Section`, `Group`, `Item` API
13
+ - **Fuzzy Search** - Built-in fuzzy filtering across items (powered by fuse.js)
14
+ - **Two Display Modes** - Scroll-spy with smooth scroll or one-section-at-a-time tab mode
15
+ - **Left Navigation** - Optional nav column with auto-collapse and single-section auto-transform
16
+ - **Checkbox Items** - Multi-select with select-all support
17
+ - **Dark Mode** - Token-based theming with a built-in dark theme
18
+ - **Smart Positioning** - Portal-rendered panel with placement variants and auto-placement
19
+ - **Controlled & Uncontrolled** - Full control or sensible defaults out of the box
20
+ - **Imperative API** - Open, close, and drive state via `ref` with an optional external anchor
21
+ - **Config-driven Rendering** - Build entire menus from a single `fromConfig` object
22
+ - **React 18** - Compound, accessible, headless-style components
23
+
24
+ ---
25
+
26
+ ## Installation
27
+
28
+ ```bash
29
+ npm install @diabolic/hangover
30
+ ```
31
+
32
+ ---
33
+
34
+ ## Quick Start
35
+
36
+ ```jsx
37
+ import { Dropdown } from '@diabolic/hangover'
38
+ import '@diabolic/hangover/styles'
39
+
40
+ export default function App() {
41
+ return (
42
+ <Dropdown>
43
+ <Dropdown.Trigger>
44
+ <button>Open</button>
45
+ </Dropdown.Trigger>
46
+ <Dropdown.Panel>
47
+ <Dropdown.Content>
48
+ <Dropdown.Section>
49
+ <Dropdown.Group label="Fruits">
50
+ <Dropdown.Item id="apple">Apple</Dropdown.Item>
51
+ <Dropdown.Item id="banana">Banana</Dropdown.Item>
52
+ </Dropdown.Group>
53
+ </Dropdown.Section>
54
+ </Dropdown.Content>
55
+ </Dropdown.Panel>
56
+ </Dropdown>
57
+ )
58
+ }
59
+ ```
60
+
61
+ Use the exported styles subpath above. Do not import from `dist/...` directly.
62
+
63
+ ---
64
+
65
+ ## Component Reference
66
+
67
+ ### `<Dropdown>`
68
+
69
+ Root provider. All state lives here.
70
+
71
+ | Prop | Type | Default | Description |
72
+ |---|---|---|---|
73
+ | `displayMode` | `"scroll" \| "tab"` | `"scroll"` | How sections are navigated. `"scroll"` uses scroll-spy + smooth scroll; `"tab"` shows one section at a time. |
74
+ | `defaultOpen` | `boolean` | `false` | Panel starts open. |
75
+ | `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. |
76
+ | `hideOnSelection` | `boolean` | `true` | Close the panel automatically when a `type="click"` item is selected. Set to `false` to keep the panel open. |
77
+ | `darkMode` | `boolean` | `false` | Enable dark mode. Applies `hangoverDropdown--dark` CSS class which overrides all color tokens. |
78
+ | `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. |
79
+ | `defaultSearchQuery` | `string` | `""` | Uncontrolled initial search query. Only applied on first render. |
80
+ | `onEvent` | `(event) => any` | — | Central event handler. See [Events](#events). |
81
+ | `ref` | `React.Ref` | — | Exposes imperative API. See [Imperative API](#imperative-api). |
82
+ | `...rest` | `any` | — | Any additional props (e.g. `data-*`, `className`, `style`) are forwarded to the root `<div>`. |
83
+
84
+ ---
85
+
86
+ ### `<Dropdown.Trigger>`
87
+
88
+ Wraps any single child element and turns it into the toggle trigger.
89
+
90
+ Injects `ref`, `onClick`, `aria-expanded`, `aria-haspopup` onto the child automatically — no extra props needed.
91
+
92
+ ```jsx
93
+ <Dropdown.Trigger>
94
+ <button>Open</button>
95
+ </Dropdown.Trigger>
96
+ ```
97
+
98
+ `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](#without-a-trigger).
99
+
100
+ ---
101
+
102
+ ### `<Dropdown.Panel>`
103
+
104
+ Renders into a portal on `document.body`. Handles positioning, outside-click, and Escape key closing.
105
+
106
+ | Prop | Type | Default | Description |
107
+ |---|---|---|---|
108
+ | `placement` | `string` | `"bottom-start"` | Panel position relative to the trigger. Supported: `"bottom-start"`, `"bottom-end"`, `"bottom"`, `"top-start"`, `"top-end"`, `"top"`. |
109
+ | `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. |
110
+ | `offset` | `number \| string` | `8` | Distance between trigger and panel. Accepts a number (`10`) or a px string (`"10px"`). |
111
+ | `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. |
112
+ | `component` | `React component` | — | Custom wrapper component. |
113
+ | `...rest` | `any` | — | Any additional props are forwarded to the panel `<div>` (or `component`). |
114
+
115
+ ---
116
+
117
+ ### `<Dropdown.Navigation>`
118
+
119
+ Left navigation column. When present, the panel switches to a two-column layout.
120
+
121
+ | Prop | Type | Default | Description |
122
+ |---|---|---|---|
123
+ | `showAll` | `boolean` | `false` | Automatically prepends an **All** nav item with id `"__all__"`. |
124
+ | `allLabel` | `string` | `"All"` | Label for the auto-prepended All item. |
125
+ | `allIcon` | `ReactNode \| FC` | — | Icon for the auto-prepended All item. |
126
+ | `collapsed` | `boolean` | `false` | Start the nav column in collapsed state. |
127
+ | `autoCollapse` | `boolean` | `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`). |
128
+ | `component` | `React component` | — | Custom wrapper component. |
129
+ | `...rest` | `any` | — | Any additional props are forwarded to the nav column wrapper `<div>` (or `component`). |
130
+
131
+ > **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.
132
+
133
+ ```jsx
134
+ {/* Nav column and section title disappear automatically */}
135
+ <Dropdown.Navigation showAll>
136
+ <Dropdown.NavigationItem id="metrics">Metrics</Dropdown.NavigationItem>
137
+ </Dropdown.Navigation>
138
+ ```
139
+
140
+ Renders `Dropdown.NavigationItem` children inside a `forNavigation` column.
141
+
142
+ Use `showAll` to automatically prepend an **All** item:
143
+
144
+ ```jsx
145
+ <Dropdown.Navigation showAll>
146
+ <Dropdown.NavigationItem id="fruits" icon={<IconFruits />}>Fruits</Dropdown.NavigationItem>
147
+ </Dropdown.Navigation>
148
+ ```
149
+
150
+ Or provide a custom label/icon for the All item:
151
+
152
+ ```jsx
153
+ <Dropdown.Navigation showAll allLabel="Everything" allIcon={IconAll}>
154
+ <Dropdown.NavigationItem id="fruits">Fruits</Dropdown.NavigationItem>
155
+ </Dropdown.Navigation>
156
+ ```
157
+
158
+ ---
159
+
160
+ ### `<Dropdown.NavigationItem>`
161
+
162
+ A single item in the navigation column.
163
+
164
+ | Prop | Type | Default | Description |
165
+ |---|---|---|---|
166
+ | `id` | `string` | **required** | Must match the `forId` of a `Dropdown.Section`. |
167
+ | `icon` | `ReactNode \| FC` | — | Icon displayed left of the label. |
168
+ | `component` | `React component` | — | Custom component. Receives `isActive`, `onClick`, `id`. |
169
+ | `...rest` | `any` | — | Any additional props are forwarded to the `<button>` (or `component`). `onClick` is composed with the internal nav handler. |
170
+
171
+ 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).
172
+
173
+ ---
174
+
175
+ ### `<Dropdown.Content>`
176
+
177
+ Right content column. Contains the search bar and the scrollable item list.
178
+
179
+ | Prop | Type | Default | Description |
180
+ |---|---|---|---|
181
+ | `searchPlaceholder` | `string` | `"Search"` | Placeholder text for the search input. |
182
+ | `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. |
183
+ | `component` | `React component` | — | Custom wrapper component. |
184
+ | `...rest` | `any` | — | Any additional props are forwarded to the content column `<div>` (or `component`). |
185
+
186
+ ---
187
+
188
+ ### `<Dropdown.Section>`
189
+
190
+ Groups `Dropdown.Group` / `Dropdown.Item` elements under a nav scope.
191
+
192
+ | Prop | Type | Default | Description |
193
+ |---|---|---|---|
194
+ | `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). |
195
+ | `title` | `string` | — | Section heading shown in `displayMode="scroll"`. Sticks to the top of the scroll container while the section is in view. |
196
+ | `...rest` | `any` | — | Any additional props are forwarded to the section wrapper `<div>`. |
197
+
198
+ ```jsx
199
+ {/* With nav */}
200
+ <Dropdown.Section forId="fruits" title="Fruits">
201
+ ...
202
+ </Dropdown.Section>
203
+
204
+ {/* Without nav — forId can be omitted */}
205
+ <Dropdown.Section>
206
+ ...
207
+ </Dropdown.Section>
208
+ ```
209
+
210
+ ---
211
+
212
+ ### `<Dropdown.Group>`
213
+
214
+ A collapsible group of items with a colored left-border accent.
215
+
216
+ | Prop | Type | Default | Description |
217
+ |---|---|---|---|
218
+ | `label` | `string` | **required** | Group heading text. |
219
+ | `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. |
220
+ | `icon` | `ReactNode \| FC` | — | Icon displayed left of the group label in the header bar. |
221
+ | `color` | `string` | auto | CSS color for the left accent bar. Auto-assigned from a built-in palette if omitted. |
222
+ | `defaultExpanded` | `boolean` | — | Override the root-level `defaultGroupExpanded` for this specific group. |
223
+ | `showSelectAll` | `boolean` | `false` | Shows a "Select all" checkbox item inside the group. |
224
+ | `selectAllPosition` | `"top" \| "bottom"` | `"bottom"` | Position of the select-all item. |
225
+ | `emptyText` | `string` | `"Nothing to show here"` | Text shown when the group has no children. |
226
+ | `noResultsText` | `string` | `"No results"` | Text shown when a search query returns no matching items inside this group. |
227
+ | `component` | `React component` | — | Custom wrapper component. |
228
+ | `...rest` | `any` | — | Any additional props are forwarded to the group wrapper `<div>` (or `component`). |
229
+
230
+ ---
231
+
232
+ ### `<Dropdown.Item>`
233
+
234
+ A single selectable or checkable item.
235
+
236
+ | Prop | Type | Default | Description |
237
+ |---|---|---|---|
238
+ | `id` | `string` | **required** | Unique identifier for this item. |
239
+ | `type` | `"click" \| "checkbox"` | `"click"` | Interaction mode. `"click"` triggers a `select` event; `"checkbox"` triggers a `check` event. |
240
+ | `icon` | `ReactNode \| FC` | — | Icon displayed left of the item label. |
241
+ | `defaultChecked` | `boolean` | `false` | Initial checked state (uncontrolled, `type="checkbox"` only). |
242
+ | `checkIcon` | `ReactNode \| FC` | built-in ✓ | Custom check icon. |
243
+ | `component` | `React component` | — | Custom component. Receives `isSelected`, `isChecked`, `onClick`. |
244
+ | `...rest` | `any` | — | Any additional props are forwarded to the item `<div>` (or `component`). `onClick` and `onKeyDown` are composed with the internal selection handlers. |
245
+
246
+ Items are automatically filtered when the user types in the search box.
247
+
248
+ ---
249
+
250
+ ## `fromConfig` — config-driven rendering
251
+
252
+ 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.
253
+
254
+ > `fromConfig` and `children` cannot be used together — `fromConfig` takes precedence.
255
+
256
+ ```jsx
257
+ <Dropdown fromConfig={config} />
258
+ ```
259
+
260
+ ### Config schema
261
+
262
+ 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:
263
+
264
+ ```js
265
+ // Example — data-test and onClick on a specific item
266
+ { id: 'name', label: 'Name', 'data-test': 'field-name', onClick: handleClick }
267
+ ```
268
+
269
+ ```js
270
+ const config = {
271
+ // Root props (all optional)
272
+ displayMode: 'scroll' | 'tab',
273
+ defaultOpen: boolean,
274
+ defaultGroupExpanded: boolean | 'first',
275
+ hideOnSelection: boolean,
276
+ onEvent: ({ type, payload, prev }) => any,
277
+ // ...any extra props are spread onto <Dropdown>
278
+
279
+ // Trigger — required
280
+ trigger: ReactNode | string | {
281
+ label: string,
282
+ className?: string,
283
+ component?: ComponentType,
284
+ },
285
+
286
+ // Panel (optional)
287
+ panel?: {
288
+ placement?: string, // default 'bottom-start'
289
+ offset?: number, // default 8
290
+ },
291
+
292
+ // Navigation column (optional)
293
+ navigation?: { ... }, // legacy alias, still supported
294
+ items?: [ // preferred root config for navigation items
295
+ {
296
+ id?: string,
297
+ label: string,
298
+ icon?: ReactNode | FC,
299
+ title?: string,
300
+ items?: [
301
+ {
302
+ id?: string,
303
+ label?: string,
304
+ icon?: ReactNode | FC,
305
+ color?: string,
306
+ defaultExpanded?: boolean,
307
+ showSelectAll?: boolean,
308
+ selectAllPosition?: 'top' | 'bottom',
309
+ emptyText?: string,
310
+ noResultsText?: string,
311
+ items: [
312
+ {
313
+ id: string,
314
+ label: string,
315
+ icon?: ReactNode | FC,
316
+ type?: 'click' | 'checkbox',
317
+ defaultChecked?: boolean,
318
+ checkIcon?: ReactNode | FC,
319
+ actions?: ReactNode | ((item) => ReactNode),
320
+ component?: ComponentType,
321
+ },
322
+ ],
323
+ },
324
+ ],
325
+ },
326
+ ],
327
+ showAll?: boolean,
328
+ allLabel?: string,
329
+ allIcon?: ReactNode | FC,
330
+ collapsed?: boolean,
331
+ autoCollapse?: boolean,
332
+
333
+ // Content column — required
334
+ content: {
335
+ searchPlaceholder?: string,
336
+ emptyText?: string, // shown when no sections/groups are provided
337
+ sections: [
338
+ {
339
+ for?: string, // optional when section content lives under items[]
340
+ title?: string,
341
+ groups?: [
342
+ {
343
+ id?: string,
344
+ label?: string,
345
+ icon?: ReactNode | FC,
346
+ color?: string,
347
+ defaultExpanded?: boolean,
348
+ showSelectAll?: boolean,
349
+ selectAllPosition?: 'top' | 'bottom',
350
+ emptyText?: string,
351
+ noResultsText?: string,
352
+ items: [
353
+ {
354
+ id: string,
355
+ label: string,
356
+ icon?: ReactNode | FC,
357
+ type?: 'click' | 'checkbox',
358
+ defaultChecked?: boolean,
359
+ checkIcon?: ReactNode | FC,
360
+ actions?: ReactNode | ((item) => ReactNode),
361
+ component?: ComponentType,
362
+ },
363
+ ],
364
+ },
365
+ ],
366
+ items?: [
367
+ {
368
+ id?: string,
369
+ label?: string,
370
+ icon?: ReactNode | FC,
371
+ color?: string,
372
+ defaultExpanded?: boolean,
373
+ showSelectAll?: boolean,
374
+ selectAllPosition?: 'top' | 'bottom',
375
+ emptyText?: string,
376
+ noResultsText?: string,
377
+ items: [
378
+ {
379
+ id: string,
380
+ label: string,
381
+ icon?: ReactNode | FC,
382
+ type?: 'click' | 'checkbox',
383
+ defaultChecked?: boolean,
384
+ checkIcon?: ReactNode | FC,
385
+ actions?: ReactNode | ((item) => ReactNode),
386
+ component?: ComponentType,
387
+ // ...any extra props (data-*, onClick, onMouseEnter, …) are forwarded to <DropdownItem>
388
+ },
389
+ ],
390
+ },
391
+ ],
392
+ },
393
+ ],
394
+ },
395
+ }
396
+ ```
397
+
398
+ ### Example
399
+
400
+ ```jsx
401
+ import { Dropdown } from '@diabolic/hangover'
402
+
403
+ const config = {
404
+ trigger: 'Select fields',
405
+ showAll: true,
406
+ items: [
407
+ {
408
+ id: 'basic',
409
+ label: 'Basic',
410
+ title: 'Basic',
411
+ items: [
412
+ {
413
+ label: 'Identity',
414
+ items: [
415
+ { id: 'name', label: 'Name' },
416
+ { id: 'email', label: 'Email' },
417
+ ],
418
+ },
419
+ ],
420
+ },
421
+ {
422
+ id: 'advanced',
423
+ label: 'Advanced',
424
+ title: 'Advanced',
425
+ items: [
426
+ {
427
+ label: 'System',
428
+ items: [
429
+ { id: 'created-at', label: 'Created at' },
430
+ { id: 'updated-at', label: 'Updated at' },
431
+ ],
432
+ },
433
+ ],
434
+ },
435
+ ],
436
+ content: {
437
+ searchPlaceholder: 'Search fields...',
438
+ },
439
+ }
440
+
441
+ export default function App() {
442
+ return <Dropdown fromConfig={config} />
443
+ }
444
+ ```
445
+
446
+ ---
447
+
448
+ ## Events
449
+
450
+ All events are delivered via the `onEvent` prop on `<Dropdown>`.
451
+
452
+ ```jsx
453
+ <Dropdown onEvent={({ type, payload, prev }) => {
454
+ console.log(type, payload)
455
+ }}>
456
+ ```
457
+
458
+ | Event type | `payload` | `prev` | Description |
459
+ |---|---|---|---|
460
+ | `open` | `{ trigger }` | — | Panel opened. `trigger`: `"click" \| "imperative"`. |
461
+ | `close` | `{ trigger }` | — | Panel closed. `trigger`: `"click" \| "outside" \| "escape" \| "imperative"`. |
462
+ | `select` | `{ id, label, groupId, groupLabel }` | `{ id, label } \| null` | An item was clicked (`type="click"`). |
463
+ | `check` | `{ id, label, groupId, groupLabel }` | `{ checked }` | A checkbox item was toggled. |
464
+ | `selectAll` | `{ groupId, groupLabel, itemIds }` | `{ checked }` | Select-all was toggled. |
465
+ | `navChange` | `{ id }` | `{ id }` | Active nav item changed. |
466
+ | `search` | `{ query }` | `{ query }` | Search input changed. |
467
+ | `groupToggle` | `{ groupId, groupLabel, expanded }` | — | A group was expanded or collapsed. |
468
+
469
+ ### Cancelling an event
470
+
471
+ Return `null` from `onEvent` to cancel the state update:
472
+
473
+ ```jsx
474
+ <Dropdown onEvent={({ type, payload }) => {
475
+ if (type === 'select' && payload.id === 'locked') return null // cancel
476
+ }}>
477
+ ```
478
+
479
+ ### Native DOM events
480
+
481
+ Each event also fires a native `CustomEvent` on the trigger element:
482
+
483
+ ```js
484
+ trigger.addEventListener('HO:select', (e) => {
485
+ console.log(e.detail) // { payload, prev }
486
+ })
487
+ ```
488
+
489
+ ---
490
+
491
+ ## Imperative API
492
+
493
+ Attach a `ref` to `<Dropdown>` to control it programmatically from **outside** the tree:
494
+
495
+ ```jsx
496
+ const dropdownRef = useRef()
497
+
498
+ <Dropdown ref={dropdownRef}>
499
+ ...
500
+ </Dropdown>
501
+ ```
502
+
503
+ | Method | Returns | Description |
504
+ |---|---|---|
505
+ | `open()` | — | Open the panel. |
506
+ | `close()` | — | Close the panel. |
507
+ | `toggle()` | — | Toggle open/close. |
508
+ | `isOpen()` | `boolean` | Current open state. |
509
+ | `getSelected()` | `{ id, label } \| null` | Currently selected item. |
510
+ | `getChecked()` | `Map<id, boolean>` | All checkbox states. |
511
+ | `getActiveNavItem()` | `string` | Active nav item id. |
512
+ | `setSearch(query)` | — | Programmatically set the search query. |
513
+ | `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. |
514
+
515
+ ```jsx
516
+ const ref = useRef()
517
+
518
+ // toggle
519
+ ref.current.selectAll('metrics')
520
+
521
+ // force on / force off
522
+ ref.current.selectAll('metrics', true)
523
+ ref.current.selectAll('metrics', false)
524
+ ```
525
+
526
+ ---
527
+
528
+ ## `useDropdown` Hook
529
+
530
+ Use `useDropdown()` to read state and trigger actions from **inside** the dropdown tree — for example inside a custom component passed via the `component` prop.
531
+
532
+ ```jsx
533
+ import { useDropdown } from '@diabolic/hangover'
534
+
535
+ function MyCustomItem({ isSelected, onClick, children }) {
536
+ const { searchQuery, activeNavId, close } = useDropdown()
537
+ // ...
538
+ }
539
+
540
+ <Dropdown.Item id="foo" component={MyCustomItem}>Foo</Dropdown.Item>
541
+ ```
542
+
543
+ Must be called inside a `<Dropdown>` subtree.
544
+
545
+ ### Returns
546
+
547
+ **Reactive state** — triggers re-render when the value changes:
548
+
549
+ | Property | Type | Description |
550
+ |---|---|---|
551
+ | `isOpen` | `boolean` | Panel open state. |
552
+ | `selectedItem` | `{ id, label } \| null` | Currently selected item. |
553
+ | `checkedItems` | `Map<id, boolean>` | All checkbox states. |
554
+ | `activeNavId` | `string` | Active navigation item id. |
555
+ | `activeNavLabel` | `string` | Label of the active navigation item. |
556
+ | `searchQuery` | `string` | Current search input value. |
557
+ | `displayMode` | `"scroll" \| "tab"` | Display mode of the dropdown. |
558
+ | `darkMode` | `boolean` | Dark mode state. |
559
+
560
+ **Actions** — stable references, safe to use in `useEffect` / `useCallback` deps:
561
+
562
+ | Method | Description |
563
+ |---|---|
564
+ | `open()` | Open the panel. |
565
+ | `close()` | Close the panel. |
566
+ | `toggle()` | Toggle open/closed. |
567
+ | `setSearch(query)` | Update the search query. |
568
+ | `fireEvent(type, payload)` | Fire any internal event. Useful for advanced / unforeseen scenarios. Return `null` from `onEvent` to cancel. |
569
+
570
+ ### Example — custom trigger with current state
571
+
572
+ ```jsx
573
+ import { useDropdown } from '@diabolic/hangover'
574
+
575
+ function SmartTrigger() {
576
+ const { isOpen, selectedItem, toggle } = useDropdown()
577
+
578
+ return (
579
+ <button onClick={toggle}>
580
+ {selectedItem ? selectedItem.label : 'Select a field'}
581
+ {isOpen ? ' ▲' : ' ▼'}
582
+ </button>
583
+ )
584
+ }
585
+
586
+ <Dropdown>
587
+ <Dropdown.Trigger>
588
+ <SmartTrigger />
589
+ </Dropdown.Trigger>
590
+ ...
591
+ </Dropdown>
592
+ ```
593
+
594
+ ### Example — close panel from inside a custom item
595
+
596
+ ```jsx
597
+ function MyItem({ isSelected, onClick, children }) {
598
+ const { close } = useDropdown()
599
+
600
+ return (
601
+ <div onClick={() => { onClick(); close() }}>
602
+ {children}
603
+ </div>
604
+ )
605
+ }
606
+ ```
607
+
608
+ ### Example — react to search query inside a custom component
609
+
610
+ ```jsx
611
+ function MyContent({ children }) {
612
+ const { searchQuery, activeNavLabel } = useDropdown()
613
+
614
+ return (
615
+ <div>
616
+ {searchQuery && <p>Results for "{searchQuery}" in {activeNavLabel}</p>}
617
+ {children}
618
+ </div>
619
+ )
620
+ }
621
+ ```
622
+
623
+ ---
624
+
625
+ ## Recipes
626
+
627
+ ### With icons on groups and items
628
+
629
+ ```jsx
630
+ function IconUser() {
631
+ return (
632
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
633
+ <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" />
634
+ </svg>
635
+ )
636
+ }
637
+
638
+ function IconMail() {
639
+ return (
640
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
641
+ <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" />
642
+ </svg>
643
+ )
644
+ }
645
+
646
+ <Dropdown.Group label="Contact" icon={IconUser}>
647
+ <Dropdown.Item id="name" icon={IconUser}>Full Name</Dropdown.Item>
648
+ <Dropdown.Item id="email" icon={IconMail}>Email Address</Dropdown.Item>
649
+ </Dropdown.Group>
650
+ ```
651
+
652
+ Icons inherit the item's text color via `currentColor` — they automatically adapt to hover, active, and dark mode states.
653
+
654
+ ---
655
+
656
+ ### Dark mode
657
+
658
+ ```jsx
659
+ <Dropdown darkMode>
660
+ <Dropdown.Trigger><button>Open</button></Dropdown.Trigger>
661
+ <Dropdown.Panel>
662
+ ...
663
+ </Dropdown.Panel>
664
+ </Dropdown>
665
+ ```
666
+
667
+ ---
668
+
669
+ ### Panel title
670
+
671
+ ```jsx
672
+ <Dropdown.Panel title="Select a field">
673
+ ...
674
+ </Dropdown.Panel>
675
+ ```
676
+
677
+ ---
678
+
679
+ ### Empty content state
680
+
681
+ When `<Dropdown.Content>` has no children, the search bar is hidden and an empty message is shown:
682
+
683
+ ```jsx
684
+ <Dropdown.Content emptyText="No fields available">
685
+ {/* no children */}
686
+ </Dropdown.Content>
687
+ ```
688
+
689
+ ---
690
+
691
+ ### Search no-results per group
692
+
693
+ ```jsx
694
+ <Dropdown.Group label="Metrics" noResultsText="No metrics match your search">
695
+ <Dropdown.Item id="revenue">Revenue</Dropdown.Item>
696
+ <Dropdown.Item id="sessions">Sessions</Dropdown.Item>
697
+ </Dropdown.Group>
698
+ ```
699
+
700
+ ---
701
+
702
+ ### Controlled search query
703
+
704
+ Drive the search input from outside — connect it to your own state or a URL param:
705
+
706
+ ```jsx
707
+ const [query, setQuery] = useState('')
708
+
709
+ <input value={query} onChange={e => setQuery(e.target.value)} placeholder="Search..." />
710
+
711
+ <Dropdown
712
+ searchQuery={query}
713
+ onEvent={({ type, payload }) => {
714
+ if (type === 'search') setQuery(payload.query)
715
+ }}
716
+ >
717
+ ...
718
+ </Dropdown>
719
+ ```
720
+
721
+ Or use `defaultSearchQuery` to set the initial value without controlling it:
722
+
723
+ ```jsx
724
+ <Dropdown defaultSearchQuery="rev">
725
+ ...
726
+ </Dropdown>
727
+ ```
728
+
729
+ ---
730
+
731
+ ### With left navigation (scroll mode)
732
+
733
+ ```jsx
734
+ <Dropdown displayMode="scroll">
735
+ <Dropdown.Trigger><button>Browse</button></Dropdown.Trigger>
736
+ <Dropdown.Panel>
737
+ <Dropdown.Navigation showAll>
738
+ <Dropdown.NavigationItem id="fruits">Fruits</Dropdown.NavigationItem>
739
+ <Dropdown.NavigationItem id="vegetables">Vegetables</Dropdown.NavigationItem>
740
+ </Dropdown.Navigation>
741
+ <Dropdown.Content>
742
+ <Dropdown.Section forId="fruits" title="Fruits">
743
+ <Dropdown.Group label="Citrus">
744
+ <Dropdown.Item id="orange">Orange</Dropdown.Item>
745
+ <Dropdown.Item id="lemon">Lemon</Dropdown.Item>
746
+ </Dropdown.Group>
747
+ </Dropdown.Section>
748
+ <Dropdown.Section forId="vegetables" title="Vegetables">
749
+ <Dropdown.Group label="Leafy">
750
+ <Dropdown.Item id="spinach">Spinach</Dropdown.Item>
751
+ </Dropdown.Group>
752
+ </Dropdown.Section>
753
+ </Dropdown.Content>
754
+ </Dropdown.Panel>
755
+ </Dropdown>
756
+ ```
757
+
758
+ ### Checkbox mode with select-all
759
+
760
+ ```jsx
761
+ <Dropdown>
762
+ <Dropdown.Trigger><button>Select fields</button></Dropdown.Trigger>
763
+ <Dropdown.Panel>
764
+ <Dropdown.Content>
765
+ <Dropdown.Section>
766
+ <Dropdown.Group label="Metrics" showSelectAll>
767
+ <Dropdown.Item id="revenue" type="checkbox">Revenue</Dropdown.Item>
768
+ <Dropdown.Item id="sessions" type="checkbox">Sessions</Dropdown.Item>
769
+ <Dropdown.Item id="bounce" type="checkbox">Bounce rate</Dropdown.Item>
770
+ </Dropdown.Group>
771
+ </Dropdown.Section>
772
+ </Dropdown.Content>
773
+ </Dropdown.Panel>
774
+ </Dropdown>
775
+ ```
776
+
777
+ ### Controlled — prevent selection
778
+
779
+ ```jsx
780
+ <Dropdown onEvent={({ type, payload }) => {
781
+ if (type === 'select' && payload.id === 'locked') {
782
+ alert('This item is locked')
783
+ return null // cancel
784
+ }
785
+ }}>
786
+ ...
787
+ </Dropdown>
788
+ ```
789
+
790
+ ### Collapse all groups by default
791
+
792
+ ```jsx
793
+ <Dropdown defaultGroupExpanded={false}>
794
+ ...
795
+ </Dropdown>
796
+ ```
797
+
798
+ ### Expand only the first group
799
+
800
+ ```jsx
801
+ <Dropdown defaultGroupExpanded="first">
802
+ ...
803
+ </Dropdown>
804
+ ```
805
+
806
+ ### Custom panel placement and offset
807
+
808
+ ```jsx
809
+ {/* Open above the trigger, aligned to the right edge, 16px gap */}
810
+ <Dropdown.Panel placement="top-end" offset={16}>
811
+ ...
812
+ </Dropdown.Panel>
813
+ ```
814
+
815
+ ### Without a Trigger
816
+
817
+ When you cannot wrap the toggle button inside `<Dropdown>`, use an external `anchor` ref and control the panel via the [Imperative API](#imperative-api):
818
+
819
+ ```jsx
820
+ const dropdownRef = useRef()
821
+ const buttonRef = useRef()
822
+
823
+ // The button lives anywhere in the tree — outside <Dropdown> if needed
824
+ <button ref={buttonRef} onClick={() => dropdownRef.current.toggle()}>
825
+ Open
826
+ </button>
827
+
828
+ <Dropdown ref={dropdownRef}>
829
+ <Dropdown.Panel anchor={buttonRef}>
830
+ <Dropdown.Content>
831
+ <Dropdown.Section>
832
+ <Dropdown.Group label="Items">
833
+ <Dropdown.Item id="one">One</Dropdown.Item>
834
+ </Dropdown.Group>
835
+ </Dropdown.Section>
836
+ </Dropdown.Content>
837
+ </Dropdown.Panel>
838
+ </Dropdown>
839
+ ```
840
+
841
+ `Dropdown.Trigger` is not needed in this pattern. The `anchor` ref is used for positioning and outside-click detection.
842
+
843
+ ---
844
+
845
+ ### Collapsible / auto-collapsing navigation
846
+
847
+ ```jsx
848
+ {/* Start collapsed */}
849
+ <Dropdown.Navigation collapsed>
850
+ ...
851
+ </Dropdown.Navigation>
852
+
853
+ {/* Auto-collapse when the viewport is too narrow */}
854
+ <Dropdown.Navigation autoCollapse>
855
+ ...
856
+ </Dropdown.Navigation>
857
+ ```
858
+
859
+ The `autoCollapse` threshold is `--hangover-nav-width` + `--hangover-content-max-width`. Override either token to tune the breakpoint.
860
+
861
+ ---
862
+
863
+ ### Custom component slot
864
+
865
+ Every compound component accepts a `component` prop to swap the root element:
866
+
867
+ ```jsx
868
+ <Dropdown.Item id="foo" component={MyCustomRow}>
869
+ Foo
870
+ </Dropdown.Item>
871
+ ```
872
+
873
+ ---
874
+
875
+ ## CSS Tokens
876
+
877
+ All visual properties are configurable via CSS custom properties:
878
+
879
+ ```css
880
+ .hangoverDropdown {
881
+ --hangover-font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
882
+ --hangover-font-size-base: 14px;
883
+ --hangover-font-size-sm: 12px;
884
+
885
+ /* Text */
886
+ --hangover-color-text: #0a1551;
887
+ --hangover-color-text-muted: #8a9ab5;
888
+
889
+ /* Backgrounds */
890
+ --hangover-color-bg-panel: #ffffff;
891
+ --hangover-color-bg-title: #eceef5;
892
+ --hangover-color-bg-nav: #F5F6FC;
893
+ --hangover-color-bg-nav-hover: #EAECF5;
894
+ --hangover-color-bg-nav-active: #E0E3EF;
895
+ --hangover-color-bg-hover: #f3f3fe;
896
+ --hangover-color-bg-hover-dark: #eaeaf9;
897
+ --hangover-color-bg-selected: #eef2ff;
898
+ --hangover-color-bg-checked: #EDF8FF;
899
+
900
+ /* Border & misc */
901
+ --hangover-color-border: #e2e8f0;
902
+ --hangover-color-search-ph: #979DC6;
903
+ --hangover-color-search-icon: #343C6A;
904
+ --hangover-color-focus: #3b82f6;
905
+ --hangover-group-default-color: #16a34a;
906
+
907
+ /* Shape */
908
+ --hangover-radius-panel: 4px;
909
+ --hangover-radius-item: 4px;
910
+ --hangover-radius-nav-item: 4px;
911
+
912
+ --hangover-shadow-panel:
913
+ 0 8px 16px 0 rgba(84, 95, 111, 0.16),
914
+ 0 2px 4px 0 rgba(37, 45, 91, 0.04);
915
+
916
+ /* Layout */
917
+ --hangover-nav-width: 172px;
918
+ --hangover-content-max-width: 240px;
919
+ --hangover-list-max-height: 280px;
920
+
921
+ --hangover-transition: 330ms ease;
922
+ }
923
+ ```
924
+
925
+ ### Dark mode
926
+
927
+ Pass `darkMode` to `<Dropdown>` to apply a pre-built dark palette:
928
+
929
+ ```jsx
930
+ <Dropdown darkMode>
931
+ ...
932
+ </Dropdown>
933
+ ```
934
+
935
+ To customise dark mode colors, override the tokens inside `.hangoverDropdown--dark`:
936
+
937
+ ```css
938
+ .hangoverDropdown--dark {
939
+ --hangover-color-bg-panel: #1a1d2e;
940
+ --hangover-color-text: #dde1f5;
941
+ /* ... */
942
+ }
943
+ ```
944
+
945
+ ---
946
+
947
+ ## Storybook
948
+
949
+ Explore all components and interactions in the [live demo](https://bugrakaan.github.io/hangover/).
950
+
951
+ Run locally:
952
+
953
+ ```bash
954
+ npm run storybook
955
+ ```
956
+
957
+ Build the static demo site (output in `storybook-static/`):
958
+
959
+ ```bash
960
+ npm run build-storybook
961
+ ```
962
+
963
+ Stories are located in `src/stories/`:
964
+
965
+ | Story | Description |
966
+ |---|---|
967
+ | 1. Basic | Basic usage, with/without nav, single-section auto-transform, open by default, empty state |
968
+ | 2. Scroll Spy | Scroll-spy with nav icons, collapsible navigation, long text stress test |
969
+ | 3. Tab Mode | `displayMode="tab"` |
970
+ | 4. Checkbox | Checkbox items, select-all |
971
+ | 5. Controlled | Controlled state, external anchor + imperative API |
972
+ | 6. Events | Live event stream, cancel event |
973
+ | 7. Placement | All panel placement variants, auto-placement with scroll |
974
+ | 8. From Config | `fromConfig` prop — config-driven rendering with and without auto-collapse |
975
+
976
+ ---
977
+
978
+ ## License
979
+
980
+ MIT