@diabolic/hangover 0.1.1 → 0.1.2

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 CHANGED
@@ -77,6 +77,7 @@ Root provider. All state lives here.
77
77
  | `darkMode` | `boolean` | `false` | Enable dark mode. Applies `hangoverDropdown--dark` CSS class which overrides all color tokens. |
78
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
79
  | `defaultSearchQuery` | `string` | `""` | Uncontrolled initial search query. Only applied on first render. |
80
+ | `useTranslationFunction` | `(text, payload?) => string` | — | Translation hook. When provided, **every** user-facing string — including built-in defaults — is routed through this function. See [Translation](#translation). |
80
81
  | `onEvent` | `(event) => any` | — | Central event handler. See [Events](#events). |
81
82
  | `ref` | `React.Ref` | — | Exposes imperative API. See [Imperative API](#imperative-api). |
82
83
  | `...rest` | `any` | — | Any additional props (e.g. `data-*`, `className`, `style`) are forwarded to the root `<div>`. |
@@ -274,6 +275,7 @@ const config = {
274
275
  defaultGroupExpanded: boolean | 'first',
275
276
  hideOnSelection: boolean,
276
277
  onEvent: ({ type, payload, prev }) => any,
278
+ useTranslationFunction: (text, payload) => string,
277
279
  // ...any extra props are spread onto <Dropdown>
278
280
 
279
281
  // Trigger — required
@@ -488,6 +490,58 @@ trigger.addEventListener('HO:select', (e) => {
488
490
 
489
491
  ---
490
492
 
493
+ ## Translation
494
+
495
+ Pass a `useTranslationFunction` to `<Dropdown>` to localize the UI. When
496
+ provided, **every** user-facing string is routed through it — including
497
+ built-in defaults like `"Search"`, `"Select all"`, `"No results"` and
498
+ `"Nothing to show here"`, as well as your own labels (group names, item
499
+ labels, nav items, section/panel titles).
500
+
501
+ The function receives the original string and returns the translated one:
502
+
503
+ ```jsx
504
+ const translations = {
505
+ 'Search': 'Ara',
506
+ 'Select all': 'Tümünü seç',
507
+ 'No results': 'Sonuç yok',
508
+ 'Nothing to show here': 'Gösterilecek bir şey yok',
509
+ 'Fruits': 'Meyveler',
510
+ 'Apple': 'Elma',
511
+ }
512
+
513
+ <Dropdown useTranslationFunction={(text) => translations[text] ?? text}>
514
+ {/* ... */}
515
+ </Dropdown>
516
+ ```
517
+
518
+ ### Dynamic values
519
+
520
+ When a string contains dynamic values, they are passed as a payload object
521
+ in the **second argument** instead of being concatenated into the string.
522
+ The default string uses `{placeholder}` tokens that map to payload keys:
523
+
524
+ ```jsx
525
+ <Dropdown
526
+ useTranslationFunction={(text, payload) => {
527
+ // e.g. text = "{label} — {action}", payload = { label: "Fruits", action: "collapse" }
528
+ if (text === '{label} — {action}') {
529
+ return `${payload.label}: ${payload.action === 'collapse' ? 'kapat' : 'aç'}`
530
+ }
531
+ return translations[text] ?? text
532
+ }}
533
+ >
534
+ {/* ... */}
535
+ </Dropdown>
536
+ ```
537
+
538
+ If no `useTranslationFunction` is provided, strings render as-is and any
539
+ `{placeholder}` tokens are interpolated from the payload automatically.
540
+
541
+ > Also available via [`fromConfig`](#fromconfig--config-driven-rendering) as `useTranslationFunction`.
542
+
543
+ ---
544
+
491
545
  ## Imperative API
492
546
 
493
547
  Attach a `ref` to `<Dropdown>` to control it programmatically from **outside** the tree:
package/dist/index.cjs.js CHANGED
@@ -342,6 +342,7 @@ function useOutsideClick(refs, callback) {
342
342
  function DropdownPanel({
343
343
  placement = 'bottom-start',
344
344
  offset = 8,
345
+ title,
345
346
  anchor,
346
347
  component: Comp,
347
348
  children,
@@ -353,7 +354,8 @@ function DropdownPanel({
353
354
  triggerRef,
354
355
  fireEvent,
355
356
  hasNav,
356
- darkMode
357
+ darkMode,
358
+ t
357
359
  } = useDropdownContext();
358
360
  const panelRef = react.useRef(null);
359
361
  const anchorRef = anchor ?? triggerRef;
@@ -393,7 +395,7 @@ function DropdownPanel({
393
395
  className: classNames,
394
396
  ...rest,
395
397
  children: children
396
- }) : /*#__PURE__*/jsxRuntime.jsx("div", {
398
+ }) : /*#__PURE__*/jsxRuntime.jsxs("div", {
397
399
  ref: panelRef,
398
400
  className: classNames,
399
401
  style: style,
@@ -401,10 +403,13 @@ function DropdownPanel({
401
403
  "aria-modal": "true",
402
404
  "aria-label": "Dropdown",
403
405
  ...rest,
404
- children: /*#__PURE__*/jsxRuntime.jsx("div", {
406
+ children: [title && /*#__PURE__*/jsxRuntime.jsx("div", {
407
+ className: "hangoverDropdown-panel-title",
408
+ children: t(title)
409
+ }), /*#__PURE__*/jsxRuntime.jsx("div", {
405
410
  className: "hangoverDropdown-panel-inner",
406
411
  children: children
407
- })
412
+ })]
408
413
  });
409
414
  return /*#__PURE__*/reactDom.createPortal(content, document.body);
410
415
  }
@@ -437,7 +442,8 @@ function DropdownNavItem({
437
442
  displayMode,
438
443
  contentRef,
439
444
  sectionRefs,
440
- registerNavLabel
445
+ registerNavLabel,
446
+ t
441
447
  } = useDropdownContext();
442
448
  const isActive = activeNavId === id;
443
449
  react.useEffect(() => {
@@ -493,7 +499,7 @@ function DropdownNavItem({
493
499
  handleClick();
494
500
  userOnClick?.();
495
501
  },
496
- title: typeof children === 'string' ? children : undefined,
502
+ title: typeof children === 'string' ? t(children) : undefined,
497
503
  "data-ho-active": isActive,
498
504
  ...navItemRest,
499
505
  children: [icon && /*#__PURE__*/jsxRuntime.jsx("span", {
@@ -502,7 +508,7 @@ function DropdownNavItem({
502
508
  children: renderIcon(icon)
503
509
  }), /*#__PURE__*/jsxRuntime.jsx("span", {
504
510
  className: "hangoverDropdown-nav-item-label",
505
- children: children
511
+ children: typeof children === 'string' ? t(children) : children
506
512
  })]
507
513
  });
508
514
  }
@@ -615,12 +621,15 @@ function DefaultSearchIcon() {
615
621
  *
616
622
  * Props:
617
623
  * searchPlaceholder string (default "Search")
624
+ * emptyText string (default "Nothing to show here") — shown when
625
+ * Content has no children; the search bar is hidden too
618
626
  * title string — overrides active nav label as section title
619
627
  * component custom wrapper component
620
628
  * children DropdownSection / DropdownGroup / DropdownItem elements
621
629
  */
622
630
  function DropdownContent({
623
631
  searchPlaceholder = 'Search',
632
+ emptyText = 'Nothing to show here',
624
633
  component: Comp,
625
634
  children,
626
635
  ...rest
@@ -631,7 +640,8 @@ function DropdownContent({
631
640
  contentRef,
632
641
  displayMode,
633
642
  activeNavId,
634
- setScrollSpyActive
643
+ setScrollSpyActive,
644
+ t
635
645
  } = useDropdownContext();
636
646
 
637
647
  // Scroll spy: update active nav based on scroll position
@@ -686,8 +696,9 @@ function DropdownContent({
686
696
  query: e.target.value
687
697
  });
688
698
  }
699
+ const isEmpty = react.Children.count(children) === 0;
689
700
  const inner = /*#__PURE__*/jsxRuntime.jsxs(jsxRuntime.Fragment, {
690
- children: [/*#__PURE__*/jsxRuntime.jsxs("label", {
701
+ children: [!isEmpty && /*#__PURE__*/jsxRuntime.jsxs("label", {
691
702
  className: "hangoverDropdown-search",
692
703
  children: [/*#__PURE__*/jsxRuntime.jsx("span", {
693
704
  className: "hangoverDropdown-search-icon",
@@ -695,8 +706,8 @@ function DropdownContent({
695
706
  }), /*#__PURE__*/jsxRuntime.jsx("input", {
696
707
  type: "text",
697
708
  className: "hangoverDropdown-search-input",
698
- placeholder: searchPlaceholder,
699
- "aria-label": searchPlaceholder,
709
+ placeholder: t(searchPlaceholder),
710
+ "aria-label": t(searchPlaceholder),
700
711
  value: searchQuery,
701
712
  onChange: handleSearch
702
713
  })]
@@ -704,7 +715,10 @@ function DropdownContent({
704
715
  role: "listbox",
705
716
  className: `hangoverDropdown-list${displayMode === 'tab' ? ' isTabMode' : ''}${displayMode === 'tab' && activeNavId === '__all__' ? ' isAllActive' : ''}`,
706
717
  ref: contentRef,
707
- children: children
718
+ children: isEmpty ? /*#__PURE__*/jsxRuntime.jsx("div", {
719
+ className: "hangoverDropdown-content-empty",
720
+ children: t(emptyText)
721
+ }) : children
708
722
  })]
709
723
  });
710
724
  if (Comp) {
@@ -734,7 +748,8 @@ function DropdownSection({
734
748
  activeNavId,
735
749
  displayMode,
736
750
  registerSectionRef,
737
- hasNav
751
+ hasNav,
752
+ t
738
753
  } = useDropdownContext();
739
754
  const sectionRef = react.useRef(null);
740
755
  const forId = forProp || forIdProp || '__all__';
@@ -799,7 +814,7 @@ function DropdownSection({
799
814
  children: [title && hasNav && !(displayMode === 'tab' && activeNavId === '__all__') && /*#__PURE__*/jsxRuntime.jsx("div", {
800
815
  className: `hangoverDropdown-section-title${hasGroups ? ' isClickable' : ''}`,
801
816
  onClick: hasGroups ? handleToggleAll : undefined,
802
- "aria-label": hasGroups ? allExpanded ? 'Collapse all groups' : 'Expand all groups' : undefined,
817
+ "aria-label": hasGroups ? allExpanded ? t('Collapse all groups') : t('Expand all groups') : undefined,
803
818
  role: hasGroups ? 'button' : undefined,
804
819
  tabIndex: hasGroups ? 0 : undefined,
805
820
  onKeyDown: hasGroups ? e => {
@@ -809,7 +824,7 @@ function DropdownSection({
809
824
  }
810
825
  } : undefined,
811
826
  children: /*#__PURE__*/jsxRuntime.jsx("span", {
812
- children: title
827
+ children: t(title)
813
828
  })
814
829
  }), children]
815
830
  })
@@ -3025,7 +3040,8 @@ function DropdownItem({
3025
3040
  selectedItem,
3026
3041
  checkedItems,
3027
3042
  searchQuery,
3028
- fireEvent
3043
+ fireEvent,
3044
+ t
3029
3045
  } = useDropdownContext();
3030
3046
  const groupCtx = react.useContext(GroupContext);
3031
3047
  const groupLabel = groupCtx?.groupLabel ?? '';
@@ -3063,6 +3079,7 @@ function DropdownItem({
3063
3079
  fireEvent('select', {
3064
3080
  id,
3065
3081
  label,
3082
+ groupId,
3066
3083
  groupLabel
3067
3084
  });
3068
3085
  }
@@ -3113,7 +3130,7 @@ function DropdownItem({
3113
3130
  "aria-checked": type === 'checkbox' ? isChecked : undefined,
3114
3131
  tabIndex: 0,
3115
3132
  className: classNames,
3116
- title: label || undefined,
3133
+ title: label ? t(label) : undefined,
3117
3134
  onClick: () => {
3118
3135
  handleClick();
3119
3136
  userOnClick?.();
@@ -3130,7 +3147,7 @@ function DropdownItem({
3130
3147
  children: renderIcon(icon)
3131
3148
  }), /*#__PURE__*/jsxRuntime.jsx("span", {
3132
3149
  className: "hangoverDropdown-item-label",
3133
- children: children
3150
+ children: typeof children === 'string' ? t(children) : children
3134
3151
  }), actionsNode && /*#__PURE__*/jsxRuntime.jsx("span", {
3135
3152
  className: "hangoverDropdown-item-actions",
3136
3153
  onClick: e => e.stopPropagation(),
@@ -3229,7 +3246,8 @@ function DropdownGroup({
3229
3246
  displayMode,
3230
3247
  activeNavId,
3231
3248
  registerGroupItems,
3232
- searchQuery
3249
+ searchQuery,
3250
+ t
3233
3251
  } = useDropdownContext();
3234
3252
 
3235
3253
  // Determine initial expanded state
@@ -3318,7 +3336,7 @@ function DropdownGroup({
3318
3336
  role: "checkbox",
3319
3337
  "aria-checked": selectAllChecked,
3320
3338
  tabIndex: 0,
3321
- title: "Select all",
3339
+ title: t('Select all'),
3322
3340
  className: `hangoverDropdown-item isCheckboxType${selectAllChecked ? ' isChecked' : ''}`,
3323
3341
  onClick: handleSelectAll,
3324
3342
  onKeyDown: e => {
@@ -3329,7 +3347,7 @@ function DropdownGroup({
3329
3347
  },
3330
3348
  children: [/*#__PURE__*/jsxRuntime.jsx("span", {
3331
3349
  className: "hangoverDropdown-item-label",
3332
- children: "Select all"
3350
+ children: t('Select all')
3333
3351
  }), /*#__PURE__*/jsxRuntime.jsx("span", {
3334
3352
  className: `hangoverDropdown-item-check-icon${selectAllChecked ? ' isVisible' : ''}`,
3335
3353
  children: selectAllChecked && /*#__PURE__*/jsxRuntime.jsx("svg", {
@@ -3371,8 +3389,11 @@ function DropdownGroup({
3371
3389
  }
3372
3390
  },
3373
3391
  "aria-expanded": isExpanded,
3374
- "aria-label": `${label} — ${isExpanded ? 'collapse' : 'expand'}`,
3375
- title: label,
3392
+ "aria-label": t('{label} — {action}', {
3393
+ label,
3394
+ action: t(isExpanded ? 'collapse' : 'expand')
3395
+ }),
3396
+ title: t(label),
3376
3397
  children: [/*#__PURE__*/jsxRuntime.jsx("div", {
3377
3398
  className: "hangoverDropdown-group-header-accent"
3378
3399
  }), /*#__PURE__*/jsxRuntime.jsx("div", {
@@ -3384,7 +3405,7 @@ function DropdownGroup({
3384
3405
  children: renderIcon(icon)
3385
3406
  }), /*#__PURE__*/jsxRuntime.jsx("span", {
3386
3407
  className: "hangoverDropdown-group-header-label",
3387
- children: label
3408
+ children: t(label)
3388
3409
  }), /*#__PURE__*/jsxRuntime.jsx("span", {
3389
3410
  className: "hangoverDropdown-group-header-chevron",
3390
3411
  children: /*#__PURE__*/jsxRuntime.jsx(Chevron, {})
@@ -3398,14 +3419,14 @@ function DropdownGroup({
3398
3419
  className: `hangoverDropdown-group-items-wrap${isExpanded ? ' isExpanded' : ''}`,
3399
3420
  children: /*#__PURE__*/jsxRuntime.jsxs("div", {
3400
3421
  role: "group",
3401
- "aria-label": label,
3422
+ "aria-label": t(label),
3402
3423
  className: "hangoverDropdown-group-items",
3403
3424
  children: [showSelectAll && selectAllPosition === 'top' && selectAllItem, hasChildren ? hasVisibleItems ? children : /*#__PURE__*/jsxRuntime.jsx("div", {
3404
3425
  className: "hangoverDropdown-group-empty",
3405
- children: noResultsText
3426
+ children: t(noResultsText)
3406
3427
  }) : /*#__PURE__*/jsxRuntime.jsx("div", {
3407
3428
  className: "hangoverDropdown-group-empty",
3408
- children: emptyText
3429
+ children: t(emptyText)
3409
3430
  }), showSelectAll && selectAllPosition === 'bottom' && selectAllItem]
3410
3431
  })
3411
3432
  });
@@ -3717,9 +3738,10 @@ const Dropdown$1 = /*#__PURE__*/react.forwardRef(function Dropdown({
3717
3738
  hideOnSelection: hideOnSelectionProp = true,
3718
3739
  onEvent: onEventProp,
3719
3740
  fromConfig,
3720
- darkMode = false,
3741
+ darkMode: darkModeProp = false,
3721
3742
  searchQuery: searchQueryProp,
3722
- defaultSearchQuery = '',
3743
+ defaultSearchQuery: defaultSearchQueryProp = '',
3744
+ useTranslationFunction: useTranslationFunctionProp,
3723
3745
  children,
3724
3746
  ...rest
3725
3747
  }, ref) {
@@ -3728,6 +3750,23 @@ const Dropdown$1 = /*#__PURE__*/react.forwardRef(function Dropdown({
3728
3750
  const defaultGroupExpanded = fromConfig?.defaultGroupExpanded ?? defaultGroupExpandedProp;
3729
3751
  const hideOnSelection = fromConfig?.hideOnSelection ?? hideOnSelectionProp;
3730
3752
  const onEvent = fromConfig?.onEvent ?? onEventProp;
3753
+ const darkMode = fromConfig?.darkMode ?? darkModeProp;
3754
+ const defaultSearchQuery = fromConfig?.defaultSearchQuery ?? defaultSearchQueryProp;
3755
+ const controlledSearchQuery = fromConfig?.searchQuery ?? searchQueryProp;
3756
+ const translationFn = fromConfig?.useTranslationFunction ?? useTranslationFunctionProp;
3757
+
3758
+ // Translation helper. Every user-facing string is routed through this.
3759
+ // - With a translation function: returns translationFn(str, payload).
3760
+ // - Without one: returns the string, interpolating any {placeholder}
3761
+ // tokens from the optional payload object.
3762
+ const t = react.useCallback((str, payload) => {
3763
+ if (typeof str !== 'string') return str;
3764
+ if (typeof translationFn === 'function') return translationFn(str, payload);
3765
+ if (payload) {
3766
+ return str.replace(/\{(\w+)\}/g, (match, key) => key in payload ? payload[key] : match);
3767
+ }
3768
+ return str;
3769
+ }, [translationFn]);
3731
3770
  const [isOpen, setIsOpen] = react.useState(defaultOpen);
3732
3771
  const [selectedItem, setSelectedItem] = react.useState(null);
3733
3772
  const [checkedItems, setCheckedItems] = react.useState(() => new Map());
@@ -3737,10 +3776,10 @@ const Dropdown$1 = /*#__PURE__*/react.forwardRef(function Dropdown({
3737
3776
  const [hasNav, setHasNav] = react.useState(false);
3738
3777
 
3739
3778
  // Controlled searchQuery — sync internal state whenever the prop changes
3740
- const isControlledSearch = searchQueryProp !== undefined;
3779
+ const isControlledSearch = controlledSearchQuery !== undefined;
3741
3780
  react.useEffect(() => {
3742
- if (isControlledSearch) setSearchQuery(searchQueryProp);
3743
- }, [isControlledSearch, searchQueryProp]);
3781
+ if (isControlledSearch) setSearchQuery(controlledSearchQuery);
3782
+ }, [isControlledSearch, controlledSearchQuery]);
3744
3783
  const triggerRef = react.useRef(null);
3745
3784
  const contentRef = react.useRef(null); // scroll container inside DropdownContent
3746
3785
  const firstGroupClaimedRef = react.useRef(false);
@@ -4005,6 +4044,8 @@ const Dropdown$1 = /*#__PURE__*/react.forwardRef(function Dropdown({
4005
4044
  hasNav,
4006
4045
  darkMode,
4007
4046
  setHasNav,
4047
+ // i18n
4048
+ t,
4008
4049
  // Refs
4009
4050
  triggerRef,
4010
4051
  contentRef,
@@ -4024,7 +4065,7 @@ const Dropdown$1 = /*#__PURE__*/react.forwardRef(function Dropdown({
4024
4065
  registerSectionRef
4025
4066
  }), [isOpen, selectedItem, checkedItems, activeNavId, activeNavLabel, searchQuery, hasNav, displayMode, defaultGroupExpanded, darkMode,
4026
4067
  // all others are stable references
4027
- fireEvent, registerGroupItems, setScrollSpyActive, registerNavLabel, registerSectionRef]);
4068
+ fireEvent, registerGroupItems, setScrollSpyActive, registerNavLabel, registerSectionRef, t]);
4028
4069
  const resolvedChildren = (() => {
4029
4070
  if (fromConfig && children) {
4030
4071
  console.warn('[Dropdown] `fromConfig` and `children` cannot be used together. ' + '`fromConfig` takes precedence — `children` will be ignored.');
package/dist/index.esm.js CHANGED
@@ -338,6 +338,7 @@ function useOutsideClick(refs, callback) {
338
338
  function DropdownPanel({
339
339
  placement = 'bottom-start',
340
340
  offset = 8,
341
+ title,
341
342
  anchor,
342
343
  component: Comp,
343
344
  children,
@@ -349,7 +350,8 @@ function DropdownPanel({
349
350
  triggerRef,
350
351
  fireEvent,
351
352
  hasNav,
352
- darkMode
353
+ darkMode,
354
+ t
353
355
  } = useDropdownContext();
354
356
  const panelRef = useRef(null);
355
357
  const anchorRef = anchor ?? triggerRef;
@@ -389,7 +391,7 @@ function DropdownPanel({
389
391
  className: classNames,
390
392
  ...rest,
391
393
  children: children
392
- }) : /*#__PURE__*/jsx("div", {
394
+ }) : /*#__PURE__*/jsxs("div", {
393
395
  ref: panelRef,
394
396
  className: classNames,
395
397
  style: style,
@@ -397,10 +399,13 @@ function DropdownPanel({
397
399
  "aria-modal": "true",
398
400
  "aria-label": "Dropdown",
399
401
  ...rest,
400
- children: /*#__PURE__*/jsx("div", {
402
+ children: [title && /*#__PURE__*/jsx("div", {
403
+ className: "hangoverDropdown-panel-title",
404
+ children: t(title)
405
+ }), /*#__PURE__*/jsx("div", {
401
406
  className: "hangoverDropdown-panel-inner",
402
407
  children: children
403
- })
408
+ })]
404
409
  });
405
410
  return /*#__PURE__*/createPortal(content, document.body);
406
411
  }
@@ -433,7 +438,8 @@ function DropdownNavItem({
433
438
  displayMode,
434
439
  contentRef,
435
440
  sectionRefs,
436
- registerNavLabel
441
+ registerNavLabel,
442
+ t
437
443
  } = useDropdownContext();
438
444
  const isActive = activeNavId === id;
439
445
  useEffect(() => {
@@ -489,7 +495,7 @@ function DropdownNavItem({
489
495
  handleClick();
490
496
  userOnClick?.();
491
497
  },
492
- title: typeof children === 'string' ? children : undefined,
498
+ title: typeof children === 'string' ? t(children) : undefined,
493
499
  "data-ho-active": isActive,
494
500
  ...navItemRest,
495
501
  children: [icon && /*#__PURE__*/jsx("span", {
@@ -498,7 +504,7 @@ function DropdownNavItem({
498
504
  children: renderIcon(icon)
499
505
  }), /*#__PURE__*/jsx("span", {
500
506
  className: "hangoverDropdown-nav-item-label",
501
- children: children
507
+ children: typeof children === 'string' ? t(children) : children
502
508
  })]
503
509
  });
504
510
  }
@@ -611,12 +617,15 @@ function DefaultSearchIcon() {
611
617
  *
612
618
  * Props:
613
619
  * searchPlaceholder string (default "Search")
620
+ * emptyText string (default "Nothing to show here") — shown when
621
+ * Content has no children; the search bar is hidden too
614
622
  * title string — overrides active nav label as section title
615
623
  * component custom wrapper component
616
624
  * children DropdownSection / DropdownGroup / DropdownItem elements
617
625
  */
618
626
  function DropdownContent({
619
627
  searchPlaceholder = 'Search',
628
+ emptyText = 'Nothing to show here',
620
629
  component: Comp,
621
630
  children,
622
631
  ...rest
@@ -627,7 +636,8 @@ function DropdownContent({
627
636
  contentRef,
628
637
  displayMode,
629
638
  activeNavId,
630
- setScrollSpyActive
639
+ setScrollSpyActive,
640
+ t
631
641
  } = useDropdownContext();
632
642
 
633
643
  // Scroll spy: update active nav based on scroll position
@@ -682,8 +692,9 @@ function DropdownContent({
682
692
  query: e.target.value
683
693
  });
684
694
  }
695
+ const isEmpty = Children.count(children) === 0;
685
696
  const inner = /*#__PURE__*/jsxs(Fragment, {
686
- children: [/*#__PURE__*/jsxs("label", {
697
+ children: [!isEmpty && /*#__PURE__*/jsxs("label", {
687
698
  className: "hangoverDropdown-search",
688
699
  children: [/*#__PURE__*/jsx("span", {
689
700
  className: "hangoverDropdown-search-icon",
@@ -691,8 +702,8 @@ function DropdownContent({
691
702
  }), /*#__PURE__*/jsx("input", {
692
703
  type: "text",
693
704
  className: "hangoverDropdown-search-input",
694
- placeholder: searchPlaceholder,
695
- "aria-label": searchPlaceholder,
705
+ placeholder: t(searchPlaceholder),
706
+ "aria-label": t(searchPlaceholder),
696
707
  value: searchQuery,
697
708
  onChange: handleSearch
698
709
  })]
@@ -700,7 +711,10 @@ function DropdownContent({
700
711
  role: "listbox",
701
712
  className: `hangoverDropdown-list${displayMode === 'tab' ? ' isTabMode' : ''}${displayMode === 'tab' && activeNavId === '__all__' ? ' isAllActive' : ''}`,
702
713
  ref: contentRef,
703
- children: children
714
+ children: isEmpty ? /*#__PURE__*/jsx("div", {
715
+ className: "hangoverDropdown-content-empty",
716
+ children: t(emptyText)
717
+ }) : children
704
718
  })]
705
719
  });
706
720
  if (Comp) {
@@ -730,7 +744,8 @@ function DropdownSection({
730
744
  activeNavId,
731
745
  displayMode,
732
746
  registerSectionRef,
733
- hasNav
747
+ hasNav,
748
+ t
734
749
  } = useDropdownContext();
735
750
  const sectionRef = useRef(null);
736
751
  const forId = forProp || forIdProp || '__all__';
@@ -795,7 +810,7 @@ function DropdownSection({
795
810
  children: [title && hasNav && !(displayMode === 'tab' && activeNavId === '__all__') && /*#__PURE__*/jsx("div", {
796
811
  className: `hangoverDropdown-section-title${hasGroups ? ' isClickable' : ''}`,
797
812
  onClick: hasGroups ? handleToggleAll : undefined,
798
- "aria-label": hasGroups ? allExpanded ? 'Collapse all groups' : 'Expand all groups' : undefined,
813
+ "aria-label": hasGroups ? allExpanded ? t('Collapse all groups') : t('Expand all groups') : undefined,
799
814
  role: hasGroups ? 'button' : undefined,
800
815
  tabIndex: hasGroups ? 0 : undefined,
801
816
  onKeyDown: hasGroups ? e => {
@@ -805,7 +820,7 @@ function DropdownSection({
805
820
  }
806
821
  } : undefined,
807
822
  children: /*#__PURE__*/jsx("span", {
808
- children: title
823
+ children: t(title)
809
824
  })
810
825
  }), children]
811
826
  })
@@ -3021,7 +3036,8 @@ function DropdownItem({
3021
3036
  selectedItem,
3022
3037
  checkedItems,
3023
3038
  searchQuery,
3024
- fireEvent
3039
+ fireEvent,
3040
+ t
3025
3041
  } = useDropdownContext();
3026
3042
  const groupCtx = useContext(GroupContext);
3027
3043
  const groupLabel = groupCtx?.groupLabel ?? '';
@@ -3059,6 +3075,7 @@ function DropdownItem({
3059
3075
  fireEvent('select', {
3060
3076
  id,
3061
3077
  label,
3078
+ groupId,
3062
3079
  groupLabel
3063
3080
  });
3064
3081
  }
@@ -3109,7 +3126,7 @@ function DropdownItem({
3109
3126
  "aria-checked": type === 'checkbox' ? isChecked : undefined,
3110
3127
  tabIndex: 0,
3111
3128
  className: classNames,
3112
- title: label || undefined,
3129
+ title: label ? t(label) : undefined,
3113
3130
  onClick: () => {
3114
3131
  handleClick();
3115
3132
  userOnClick?.();
@@ -3126,7 +3143,7 @@ function DropdownItem({
3126
3143
  children: renderIcon(icon)
3127
3144
  }), /*#__PURE__*/jsx("span", {
3128
3145
  className: "hangoverDropdown-item-label",
3129
- children: children
3146
+ children: typeof children === 'string' ? t(children) : children
3130
3147
  }), actionsNode && /*#__PURE__*/jsx("span", {
3131
3148
  className: "hangoverDropdown-item-actions",
3132
3149
  onClick: e => e.stopPropagation(),
@@ -3225,7 +3242,8 @@ function DropdownGroup({
3225
3242
  displayMode,
3226
3243
  activeNavId,
3227
3244
  registerGroupItems,
3228
- searchQuery
3245
+ searchQuery,
3246
+ t
3229
3247
  } = useDropdownContext();
3230
3248
 
3231
3249
  // Determine initial expanded state
@@ -3314,7 +3332,7 @@ function DropdownGroup({
3314
3332
  role: "checkbox",
3315
3333
  "aria-checked": selectAllChecked,
3316
3334
  tabIndex: 0,
3317
- title: "Select all",
3335
+ title: t('Select all'),
3318
3336
  className: `hangoverDropdown-item isCheckboxType${selectAllChecked ? ' isChecked' : ''}`,
3319
3337
  onClick: handleSelectAll,
3320
3338
  onKeyDown: e => {
@@ -3325,7 +3343,7 @@ function DropdownGroup({
3325
3343
  },
3326
3344
  children: [/*#__PURE__*/jsx("span", {
3327
3345
  className: "hangoverDropdown-item-label",
3328
- children: "Select all"
3346
+ children: t('Select all')
3329
3347
  }), /*#__PURE__*/jsx("span", {
3330
3348
  className: `hangoverDropdown-item-check-icon${selectAllChecked ? ' isVisible' : ''}`,
3331
3349
  children: selectAllChecked && /*#__PURE__*/jsx("svg", {
@@ -3367,8 +3385,11 @@ function DropdownGroup({
3367
3385
  }
3368
3386
  },
3369
3387
  "aria-expanded": isExpanded,
3370
- "aria-label": `${label} — ${isExpanded ? 'collapse' : 'expand'}`,
3371
- title: label,
3388
+ "aria-label": t('{label} — {action}', {
3389
+ label,
3390
+ action: t(isExpanded ? 'collapse' : 'expand')
3391
+ }),
3392
+ title: t(label),
3372
3393
  children: [/*#__PURE__*/jsx("div", {
3373
3394
  className: "hangoverDropdown-group-header-accent"
3374
3395
  }), /*#__PURE__*/jsx("div", {
@@ -3380,7 +3401,7 @@ function DropdownGroup({
3380
3401
  children: renderIcon(icon)
3381
3402
  }), /*#__PURE__*/jsx("span", {
3382
3403
  className: "hangoverDropdown-group-header-label",
3383
- children: label
3404
+ children: t(label)
3384
3405
  }), /*#__PURE__*/jsx("span", {
3385
3406
  className: "hangoverDropdown-group-header-chevron",
3386
3407
  children: /*#__PURE__*/jsx(Chevron, {})
@@ -3394,14 +3415,14 @@ function DropdownGroup({
3394
3415
  className: `hangoverDropdown-group-items-wrap${isExpanded ? ' isExpanded' : ''}`,
3395
3416
  children: /*#__PURE__*/jsxs("div", {
3396
3417
  role: "group",
3397
- "aria-label": label,
3418
+ "aria-label": t(label),
3398
3419
  className: "hangoverDropdown-group-items",
3399
3420
  children: [showSelectAll && selectAllPosition === 'top' && selectAllItem, hasChildren ? hasVisibleItems ? children : /*#__PURE__*/jsx("div", {
3400
3421
  className: "hangoverDropdown-group-empty",
3401
- children: noResultsText
3422
+ children: t(noResultsText)
3402
3423
  }) : /*#__PURE__*/jsx("div", {
3403
3424
  className: "hangoverDropdown-group-empty",
3404
- children: emptyText
3425
+ children: t(emptyText)
3405
3426
  }), showSelectAll && selectAllPosition === 'bottom' && selectAllItem]
3406
3427
  })
3407
3428
  });
@@ -3713,9 +3734,10 @@ const Dropdown$1 = /*#__PURE__*/forwardRef(function Dropdown({
3713
3734
  hideOnSelection: hideOnSelectionProp = true,
3714
3735
  onEvent: onEventProp,
3715
3736
  fromConfig,
3716
- darkMode = false,
3737
+ darkMode: darkModeProp = false,
3717
3738
  searchQuery: searchQueryProp,
3718
- defaultSearchQuery = '',
3739
+ defaultSearchQuery: defaultSearchQueryProp = '',
3740
+ useTranslationFunction: useTranslationFunctionProp,
3719
3741
  children,
3720
3742
  ...rest
3721
3743
  }, ref) {
@@ -3724,6 +3746,23 @@ const Dropdown$1 = /*#__PURE__*/forwardRef(function Dropdown({
3724
3746
  const defaultGroupExpanded = fromConfig?.defaultGroupExpanded ?? defaultGroupExpandedProp;
3725
3747
  const hideOnSelection = fromConfig?.hideOnSelection ?? hideOnSelectionProp;
3726
3748
  const onEvent = fromConfig?.onEvent ?? onEventProp;
3749
+ const darkMode = fromConfig?.darkMode ?? darkModeProp;
3750
+ const defaultSearchQuery = fromConfig?.defaultSearchQuery ?? defaultSearchQueryProp;
3751
+ const controlledSearchQuery = fromConfig?.searchQuery ?? searchQueryProp;
3752
+ const translationFn = fromConfig?.useTranslationFunction ?? useTranslationFunctionProp;
3753
+
3754
+ // Translation helper. Every user-facing string is routed through this.
3755
+ // - With a translation function: returns translationFn(str, payload).
3756
+ // - Without one: returns the string, interpolating any {placeholder}
3757
+ // tokens from the optional payload object.
3758
+ const t = useCallback((str, payload) => {
3759
+ if (typeof str !== 'string') return str;
3760
+ if (typeof translationFn === 'function') return translationFn(str, payload);
3761
+ if (payload) {
3762
+ return str.replace(/\{(\w+)\}/g, (match, key) => key in payload ? payload[key] : match);
3763
+ }
3764
+ return str;
3765
+ }, [translationFn]);
3727
3766
  const [isOpen, setIsOpen] = useState(defaultOpen);
3728
3767
  const [selectedItem, setSelectedItem] = useState(null);
3729
3768
  const [checkedItems, setCheckedItems] = useState(() => new Map());
@@ -3733,10 +3772,10 @@ const Dropdown$1 = /*#__PURE__*/forwardRef(function Dropdown({
3733
3772
  const [hasNav, setHasNav] = useState(false);
3734
3773
 
3735
3774
  // Controlled searchQuery — sync internal state whenever the prop changes
3736
- const isControlledSearch = searchQueryProp !== undefined;
3775
+ const isControlledSearch = controlledSearchQuery !== undefined;
3737
3776
  useEffect(() => {
3738
- if (isControlledSearch) setSearchQuery(searchQueryProp);
3739
- }, [isControlledSearch, searchQueryProp]);
3777
+ if (isControlledSearch) setSearchQuery(controlledSearchQuery);
3778
+ }, [isControlledSearch, controlledSearchQuery]);
3740
3779
  const triggerRef = useRef(null);
3741
3780
  const contentRef = useRef(null); // scroll container inside DropdownContent
3742
3781
  const firstGroupClaimedRef = useRef(false);
@@ -4001,6 +4040,8 @@ const Dropdown$1 = /*#__PURE__*/forwardRef(function Dropdown({
4001
4040
  hasNav,
4002
4041
  darkMode,
4003
4042
  setHasNav,
4043
+ // i18n
4044
+ t,
4004
4045
  // Refs
4005
4046
  triggerRef,
4006
4047
  contentRef,
@@ -4020,7 +4061,7 @@ const Dropdown$1 = /*#__PURE__*/forwardRef(function Dropdown({
4020
4061
  registerSectionRef
4021
4062
  }), [isOpen, selectedItem, checkedItems, activeNavId, activeNavLabel, searchQuery, hasNav, displayMode, defaultGroupExpanded, darkMode,
4022
4063
  // all others are stable references
4023
- fireEvent, registerGroupItems, setScrollSpyActive, registerNavLabel, registerSectionRef]);
4064
+ fireEvent, registerGroupItems, setScrollSpyActive, registerNavLabel, registerSectionRef, t]);
4024
4065
  const resolvedChildren = (() => {
4025
4066
  if (fromConfig && children) {
4026
4067
  console.warn('[Dropdown] `fromConfig` and `children` cannot be used together. ' + '`fromConfig` takes precedence — `children` will be ignored.');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diabolic/hangover",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "A headless-style, compound React dropdown/field-picker component library",
5
5
  "license": "MIT",
6
6
  "author": "bugrakaan",
@@ -27,7 +27,6 @@
27
27
  "import": "./dist/index.esm.js",
28
28
  "require": "./dist/index.cjs.js"
29
29
  },
30
- "prepublishOnly": "npm run build",
31
30
  "./styles": "./dist/hangover.css"
32
31
  },
33
32
  "files": [
@@ -49,6 +48,8 @@
49
48
  "dev": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\" && rollup -c -w",
50
49
  "lint": "eslint \"src/**/*.{js,jsx}\"",
51
50
  "lint:fix": "eslint --fix \"src/**/*.{js,jsx}\"",
51
+ "export": "node scripts/export-component.mjs",
52
+ "prepublishOnly": "npm run build",
52
53
  "storybook": "storybook dev -p 6006",
53
54
  "build-storybook": "storybook build"
54
55
  },
@@ -60,8 +61,8 @@
60
61
  "@rollup/plugin-babel": "^6.0.4",
61
62
  "@rollup/plugin-commonjs": "^25.0.7",
62
63
  "@rollup/plugin-node-resolve": "^15.2.3",
63
- "@storybook/addon-docs": "^10.3.6",
64
- "@storybook/react-vite": "^10.3.6",
64
+ "@storybook/addon-docs": "^10.4.6",
65
+ "@storybook/react-vite": "^10.4.6",
65
66
  "eslint": "^9.39.4",
66
67
  "eslint-plugin-react": "^7.37.5",
67
68
  "react": "^18.2.0",
@@ -69,7 +70,8 @@
69
70
  "rollup": "^4.13.0",
70
71
  "rollup-plugin-scss": "^4.0.0",
71
72
  "sass": "^1.99.0",
72
- "storybook": "^10.3.6"
73
+ "storybook": "^10.4.6",
74
+ "eslint-plugin-storybook": "10.4.6"
73
75
  },
74
76
  "dependencies": {
75
77
  "fuse.js": "^7.3.0"