@diabolic/hangover 0.1.1 → 0.1.3

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
@@ -34,7 +34,8 @@ function DropdownTrigger({
34
34
  const {
35
35
  triggerRef,
36
36
  isOpen,
37
- fireEvent
37
+ fireEvent,
38
+ t
38
39
  } = useDropdownContext();
39
40
  const child = react.Children.only(children);
40
41
  function handleClick(e) {
@@ -49,12 +50,14 @@ function DropdownTrigger({
49
50
  }
50
51
  child.props.onClick?.(e);
51
52
  }
53
+ const childChildren = child.props.children;
54
+ const translatedChildren = typeof childChildren === 'string' ? t(childChildren) : childChildren;
52
55
  return /*#__PURE__*/react.cloneElement(child, {
53
56
  ref: triggerRef,
54
57
  onClick: handleClick,
55
58
  'aria-expanded': isOpen,
56
59
  'aria-haspopup': 'dialog'
57
- });
60
+ }, translatedChildren);
58
61
  }
59
62
 
60
63
  /**
@@ -342,6 +345,7 @@ function useOutsideClick(refs, callback) {
342
345
  function DropdownPanel({
343
346
  placement = 'bottom-start',
344
347
  offset = 8,
348
+ title,
345
349
  anchor,
346
350
  component: Comp,
347
351
  children,
@@ -353,7 +357,8 @@ function DropdownPanel({
353
357
  triggerRef,
354
358
  fireEvent,
355
359
  hasNav,
356
- darkMode
360
+ darkMode,
361
+ t
357
362
  } = useDropdownContext();
358
363
  const panelRef = react.useRef(null);
359
364
  const anchorRef = anchor ?? triggerRef;
@@ -391,20 +396,24 @@ function DropdownPanel({
391
396
  placement: actualPlacement,
392
397
  style: style,
393
398
  className: classNames,
399
+ title: t(title),
394
400
  ...rest,
395
401
  children: children
396
- }) : /*#__PURE__*/jsxRuntime.jsx("div", {
402
+ }) : /*#__PURE__*/jsxRuntime.jsxs("div", {
397
403
  ref: panelRef,
398
404
  className: classNames,
399
405
  style: style,
400
406
  role: "dialog",
401
407
  "aria-modal": "true",
402
- "aria-label": "Dropdown",
408
+ "aria-label": t('Dropdown'),
403
409
  ...rest,
404
- children: /*#__PURE__*/jsxRuntime.jsx("div", {
410
+ children: [title && /*#__PURE__*/jsxRuntime.jsx("div", {
411
+ className: "hangoverDropdown-panel-title",
412
+ children: t(title)
413
+ }), /*#__PURE__*/jsxRuntime.jsx("div", {
405
414
  className: "hangoverDropdown-panel-inner",
406
415
  children: children
407
- })
416
+ })]
408
417
  });
409
418
  return /*#__PURE__*/reactDom.createPortal(content, document.body);
410
419
  }
@@ -437,7 +446,8 @@ function DropdownNavItem({
437
446
  displayMode,
438
447
  contentRef,
439
448
  sectionRefs,
440
- registerNavLabel
449
+ registerNavLabel,
450
+ t
441
451
  } = useDropdownContext();
442
452
  const isActive = activeNavId === id;
443
453
  react.useEffect(() => {
@@ -493,7 +503,7 @@ function DropdownNavItem({
493
503
  handleClick();
494
504
  userOnClick?.();
495
505
  },
496
- title: typeof children === 'string' ? children : undefined,
506
+ title: typeof children === 'string' ? t(children) : undefined,
497
507
  "data-ho-active": isActive,
498
508
  ...navItemRest,
499
509
  children: [icon && /*#__PURE__*/jsxRuntime.jsx("span", {
@@ -502,7 +512,7 @@ function DropdownNavItem({
502
512
  children: renderIcon(icon)
503
513
  }), /*#__PURE__*/jsxRuntime.jsx("span", {
504
514
  className: "hangoverDropdown-nav-item-label",
505
- children: children
515
+ children: typeof children === 'string' ? t(children) : children
506
516
  })]
507
517
  });
508
518
  }
@@ -615,12 +625,15 @@ function DefaultSearchIcon() {
615
625
  *
616
626
  * Props:
617
627
  * searchPlaceholder string (default "Search")
628
+ * emptyText string (default "Nothing to show here") — shown when
629
+ * Content has no children; the search bar is hidden too
618
630
  * title string — overrides active nav label as section title
619
631
  * component custom wrapper component
620
632
  * children DropdownSection / DropdownGroup / DropdownItem elements
621
633
  */
622
634
  function DropdownContent({
623
635
  searchPlaceholder = 'Search',
636
+ emptyText = 'Nothing to show here',
624
637
  component: Comp,
625
638
  children,
626
639
  ...rest
@@ -631,7 +644,8 @@ function DropdownContent({
631
644
  contentRef,
632
645
  displayMode,
633
646
  activeNavId,
634
- setScrollSpyActive
647
+ setScrollSpyActive,
648
+ t
635
649
  } = useDropdownContext();
636
650
 
637
651
  // Scroll spy: update active nav based on scroll position
@@ -686,8 +700,9 @@ function DropdownContent({
686
700
  query: e.target.value
687
701
  });
688
702
  }
703
+ const isEmpty = react.Children.count(children) === 0;
689
704
  const inner = /*#__PURE__*/jsxRuntime.jsxs(jsxRuntime.Fragment, {
690
- children: [/*#__PURE__*/jsxRuntime.jsxs("label", {
705
+ children: [!isEmpty && /*#__PURE__*/jsxRuntime.jsxs("label", {
691
706
  className: "hangoverDropdown-search",
692
707
  children: [/*#__PURE__*/jsxRuntime.jsx("span", {
693
708
  className: "hangoverDropdown-search-icon",
@@ -695,8 +710,8 @@ function DropdownContent({
695
710
  }), /*#__PURE__*/jsxRuntime.jsx("input", {
696
711
  type: "text",
697
712
  className: "hangoverDropdown-search-input",
698
- placeholder: searchPlaceholder,
699
- "aria-label": searchPlaceholder,
713
+ placeholder: t(searchPlaceholder),
714
+ "aria-label": t(searchPlaceholder),
700
715
  value: searchQuery,
701
716
  onChange: handleSearch
702
717
  })]
@@ -704,7 +719,10 @@ function DropdownContent({
704
719
  role: "listbox",
705
720
  className: `hangoverDropdown-list${displayMode === 'tab' ? ' isTabMode' : ''}${displayMode === 'tab' && activeNavId === '__all__' ? ' isAllActive' : ''}`,
706
721
  ref: contentRef,
707
- children: children
722
+ children: isEmpty ? /*#__PURE__*/jsxRuntime.jsx("div", {
723
+ className: "hangoverDropdown-content-empty",
724
+ children: t(emptyText)
725
+ }) : children
708
726
  })]
709
727
  });
710
728
  if (Comp) {
@@ -734,7 +752,8 @@ function DropdownSection({
734
752
  activeNavId,
735
753
  displayMode,
736
754
  registerSectionRef,
737
- hasNav
755
+ hasNav,
756
+ t
738
757
  } = useDropdownContext();
739
758
  const sectionRef = react.useRef(null);
740
759
  const forId = forProp || forIdProp || '__all__';
@@ -799,7 +818,7 @@ function DropdownSection({
799
818
  children: [title && hasNav && !(displayMode === 'tab' && activeNavId === '__all__') && /*#__PURE__*/jsxRuntime.jsx("div", {
800
819
  className: `hangoverDropdown-section-title${hasGroups ? ' isClickable' : ''}`,
801
820
  onClick: hasGroups ? handleToggleAll : undefined,
802
- "aria-label": hasGroups ? allExpanded ? 'Collapse all groups' : 'Expand all groups' : undefined,
821
+ "aria-label": hasGroups ? allExpanded ? t('Collapse all groups') : t('Expand all groups') : undefined,
803
822
  role: hasGroups ? 'button' : undefined,
804
823
  tabIndex: hasGroups ? 0 : undefined,
805
824
  onKeyDown: hasGroups ? e => {
@@ -809,7 +828,7 @@ function DropdownSection({
809
828
  }
810
829
  } : undefined,
811
830
  children: /*#__PURE__*/jsxRuntime.jsx("span", {
812
- children: title
831
+ children: t(title)
813
832
  })
814
833
  }), children]
815
834
  })
@@ -3025,7 +3044,8 @@ function DropdownItem({
3025
3044
  selectedItem,
3026
3045
  checkedItems,
3027
3046
  searchQuery,
3028
- fireEvent
3047
+ fireEvent,
3048
+ t
3029
3049
  } = useDropdownContext();
3030
3050
  const groupCtx = react.useContext(GroupContext);
3031
3051
  const groupLabel = groupCtx?.groupLabel ?? '';
@@ -3063,6 +3083,7 @@ function DropdownItem({
3063
3083
  fireEvent('select', {
3064
3084
  id,
3065
3085
  label,
3086
+ groupId,
3066
3087
  groupLabel
3067
3088
  });
3068
3089
  }
@@ -3113,7 +3134,7 @@ function DropdownItem({
3113
3134
  "aria-checked": type === 'checkbox' ? isChecked : undefined,
3114
3135
  tabIndex: 0,
3115
3136
  className: classNames,
3116
- title: label || undefined,
3137
+ title: label ? t(label) : undefined,
3117
3138
  onClick: () => {
3118
3139
  handleClick();
3119
3140
  userOnClick?.();
@@ -3130,7 +3151,7 @@ function DropdownItem({
3130
3151
  children: renderIcon(icon)
3131
3152
  }), /*#__PURE__*/jsxRuntime.jsx("span", {
3132
3153
  className: "hangoverDropdown-item-label",
3133
- children: children
3154
+ children: typeof children === 'string' ? t(children) : children
3134
3155
  }), actionsNode && /*#__PURE__*/jsxRuntime.jsx("span", {
3135
3156
  className: "hangoverDropdown-item-actions",
3136
3157
  onClick: e => e.stopPropagation(),
@@ -3229,7 +3250,8 @@ function DropdownGroup({
3229
3250
  displayMode,
3230
3251
  activeNavId,
3231
3252
  registerGroupItems,
3232
- searchQuery
3253
+ searchQuery,
3254
+ t
3233
3255
  } = useDropdownContext();
3234
3256
 
3235
3257
  // Determine initial expanded state
@@ -3318,7 +3340,7 @@ function DropdownGroup({
3318
3340
  role: "checkbox",
3319
3341
  "aria-checked": selectAllChecked,
3320
3342
  tabIndex: 0,
3321
- title: "Select all",
3343
+ title: t('Select all'),
3322
3344
  className: `hangoverDropdown-item isCheckboxType${selectAllChecked ? ' isChecked' : ''}`,
3323
3345
  onClick: handleSelectAll,
3324
3346
  onKeyDown: e => {
@@ -3329,7 +3351,7 @@ function DropdownGroup({
3329
3351
  },
3330
3352
  children: [/*#__PURE__*/jsxRuntime.jsx("span", {
3331
3353
  className: "hangoverDropdown-item-label",
3332
- children: "Select all"
3354
+ children: t('Select all')
3333
3355
  }), /*#__PURE__*/jsxRuntime.jsx("span", {
3334
3356
  className: `hangoverDropdown-item-check-icon${selectAllChecked ? ' isVisible' : ''}`,
3335
3357
  children: selectAllChecked && /*#__PURE__*/jsxRuntime.jsx("svg", {
@@ -3350,10 +3372,10 @@ function DropdownGroup({
3350
3372
  const visibleItemIds = react.useMemo(() => {
3351
3373
  const searchableItems = react.Children.toArray(children).map(child => ({
3352
3374
  id: child?.props?.id,
3353
- label: typeof child?.props?.children === 'string' ? child.props.children : ''
3375
+ label: typeof child?.props?.children === 'string' ? t(child.props.children) : ''
3354
3376
  }));
3355
3377
  return getMatchingItemIds(searchableItems, searchQuery);
3356
- }, [children, searchQuery]);
3378
+ }, [children, searchQuery, t]);
3357
3379
  const groupContextValue = {
3358
3380
  groupLabel: label,
3359
3381
  groupId,
@@ -3371,8 +3393,11 @@ function DropdownGroup({
3371
3393
  }
3372
3394
  },
3373
3395
  "aria-expanded": isExpanded,
3374
- "aria-label": `${label} — ${isExpanded ? 'collapse' : 'expand'}`,
3375
- title: label,
3396
+ "aria-label": t('{label} — {action}', {
3397
+ label,
3398
+ action: t(isExpanded ? 'collapse' : 'expand')
3399
+ }),
3400
+ title: t(label),
3376
3401
  children: [/*#__PURE__*/jsxRuntime.jsx("div", {
3377
3402
  className: "hangoverDropdown-group-header-accent"
3378
3403
  }), /*#__PURE__*/jsxRuntime.jsx("div", {
@@ -3384,7 +3409,7 @@ function DropdownGroup({
3384
3409
  children: renderIcon(icon)
3385
3410
  }), /*#__PURE__*/jsxRuntime.jsx("span", {
3386
3411
  className: "hangoverDropdown-group-header-label",
3387
- children: label
3412
+ children: t(label)
3388
3413
  }), /*#__PURE__*/jsxRuntime.jsx("span", {
3389
3414
  className: "hangoverDropdown-group-header-chevron",
3390
3415
  children: /*#__PURE__*/jsxRuntime.jsx(Chevron, {})
@@ -3398,14 +3423,14 @@ function DropdownGroup({
3398
3423
  className: `hangoverDropdown-group-items-wrap${isExpanded ? ' isExpanded' : ''}`,
3399
3424
  children: /*#__PURE__*/jsxRuntime.jsxs("div", {
3400
3425
  role: "group",
3401
- "aria-label": label,
3426
+ "aria-label": t(label),
3402
3427
  className: "hangoverDropdown-group-items",
3403
3428
  children: [showSelectAll && selectAllPosition === 'top' && selectAllItem, hasChildren ? hasVisibleItems ? children : /*#__PURE__*/jsxRuntime.jsx("div", {
3404
3429
  className: "hangoverDropdown-group-empty",
3405
- children: noResultsText
3430
+ children: t(noResultsText)
3406
3431
  }) : /*#__PURE__*/jsxRuntime.jsx("div", {
3407
3432
  className: "hangoverDropdown-group-empty",
3408
- children: emptyText
3433
+ children: t(emptyText)
3409
3434
  }), showSelectAll && selectAllPosition === 'bottom' && selectAllItem]
3410
3435
  })
3411
3436
  });
@@ -3717,9 +3742,10 @@ const Dropdown$1 = /*#__PURE__*/react.forwardRef(function Dropdown({
3717
3742
  hideOnSelection: hideOnSelectionProp = true,
3718
3743
  onEvent: onEventProp,
3719
3744
  fromConfig,
3720
- darkMode = false,
3745
+ darkMode: darkModeProp = false,
3721
3746
  searchQuery: searchQueryProp,
3722
- defaultSearchQuery = '',
3747
+ defaultSearchQuery: defaultSearchQueryProp = '',
3748
+ useTranslationFunction: useTranslationFunctionProp,
3723
3749
  children,
3724
3750
  ...rest
3725
3751
  }, ref) {
@@ -3728,6 +3754,23 @@ const Dropdown$1 = /*#__PURE__*/react.forwardRef(function Dropdown({
3728
3754
  const defaultGroupExpanded = fromConfig?.defaultGroupExpanded ?? defaultGroupExpandedProp;
3729
3755
  const hideOnSelection = fromConfig?.hideOnSelection ?? hideOnSelectionProp;
3730
3756
  const onEvent = fromConfig?.onEvent ?? onEventProp;
3757
+ const darkMode = fromConfig?.darkMode ?? darkModeProp;
3758
+ const defaultSearchQuery = fromConfig?.defaultSearchQuery ?? defaultSearchQueryProp;
3759
+ const controlledSearchQuery = fromConfig?.searchQuery ?? searchQueryProp;
3760
+ const translationFn = fromConfig?.useTranslationFunction ?? useTranslationFunctionProp;
3761
+
3762
+ // Translation helper. Every user-facing string is routed through this.
3763
+ // - With a translation function: returns translationFn(str, payload).
3764
+ // - Without one: returns the string, interpolating any {placeholder}
3765
+ // tokens from the optional payload object.
3766
+ const t = react.useCallback((str, payload) => {
3767
+ if (typeof str !== 'string') return str;
3768
+ if (typeof translationFn === 'function') return translationFn(str, payload);
3769
+ if (payload) {
3770
+ return str.replace(/\{(\w+)\}/g, (match, key) => key in payload ? payload[key] : match);
3771
+ }
3772
+ return str;
3773
+ }, [translationFn]);
3731
3774
  const [isOpen, setIsOpen] = react.useState(defaultOpen);
3732
3775
  const [selectedItem, setSelectedItem] = react.useState(null);
3733
3776
  const [checkedItems, setCheckedItems] = react.useState(() => new Map());
@@ -3737,10 +3780,10 @@ const Dropdown$1 = /*#__PURE__*/react.forwardRef(function Dropdown({
3737
3780
  const [hasNav, setHasNav] = react.useState(false);
3738
3781
 
3739
3782
  // Controlled searchQuery — sync internal state whenever the prop changes
3740
- const isControlledSearch = searchQueryProp !== undefined;
3783
+ const isControlledSearch = controlledSearchQuery !== undefined;
3741
3784
  react.useEffect(() => {
3742
- if (isControlledSearch) setSearchQuery(searchQueryProp);
3743
- }, [isControlledSearch, searchQueryProp]);
3785
+ if (isControlledSearch) setSearchQuery(controlledSearchQuery);
3786
+ }, [isControlledSearch, controlledSearchQuery]);
3744
3787
  const triggerRef = react.useRef(null);
3745
3788
  const contentRef = react.useRef(null); // scroll container inside DropdownContent
3746
3789
  const firstGroupClaimedRef = react.useRef(false);
@@ -4005,6 +4048,8 @@ const Dropdown$1 = /*#__PURE__*/react.forwardRef(function Dropdown({
4005
4048
  hasNav,
4006
4049
  darkMode,
4007
4050
  setHasNav,
4051
+ // i18n
4052
+ t,
4008
4053
  // Refs
4009
4054
  triggerRef,
4010
4055
  contentRef,
@@ -4024,7 +4069,7 @@ const Dropdown$1 = /*#__PURE__*/react.forwardRef(function Dropdown({
4024
4069
  registerSectionRef
4025
4070
  }), [isOpen, selectedItem, checkedItems, activeNavId, activeNavLabel, searchQuery, hasNav, displayMode, defaultGroupExpanded, darkMode,
4026
4071
  // all others are stable references
4027
- fireEvent, registerGroupItems, setScrollSpyActive, registerNavLabel, registerSectionRef]);
4072
+ fireEvent, registerGroupItems, setScrollSpyActive, registerNavLabel, registerSectionRef, t]);
4028
4073
  const resolvedChildren = (() => {
4029
4074
  if (fromConfig && children) {
4030
4075
  console.warn('[Dropdown] `fromConfig` and `children` cannot be used together. ' + '`fromConfig` takes precedence — `children` will be ignored.');
package/dist/index.esm.js CHANGED
@@ -30,7 +30,8 @@ function DropdownTrigger({
30
30
  const {
31
31
  triggerRef,
32
32
  isOpen,
33
- fireEvent
33
+ fireEvent,
34
+ t
34
35
  } = useDropdownContext();
35
36
  const child = Children.only(children);
36
37
  function handleClick(e) {
@@ -45,12 +46,14 @@ function DropdownTrigger({
45
46
  }
46
47
  child.props.onClick?.(e);
47
48
  }
49
+ const childChildren = child.props.children;
50
+ const translatedChildren = typeof childChildren === 'string' ? t(childChildren) : childChildren;
48
51
  return /*#__PURE__*/cloneElement(child, {
49
52
  ref: triggerRef,
50
53
  onClick: handleClick,
51
54
  'aria-expanded': isOpen,
52
55
  'aria-haspopup': 'dialog'
53
- });
56
+ }, translatedChildren);
54
57
  }
55
58
 
56
59
  /**
@@ -338,6 +341,7 @@ function useOutsideClick(refs, callback) {
338
341
  function DropdownPanel({
339
342
  placement = 'bottom-start',
340
343
  offset = 8,
344
+ title,
341
345
  anchor,
342
346
  component: Comp,
343
347
  children,
@@ -349,7 +353,8 @@ function DropdownPanel({
349
353
  triggerRef,
350
354
  fireEvent,
351
355
  hasNav,
352
- darkMode
356
+ darkMode,
357
+ t
353
358
  } = useDropdownContext();
354
359
  const panelRef = useRef(null);
355
360
  const anchorRef = anchor ?? triggerRef;
@@ -387,20 +392,24 @@ function DropdownPanel({
387
392
  placement: actualPlacement,
388
393
  style: style,
389
394
  className: classNames,
395
+ title: t(title),
390
396
  ...rest,
391
397
  children: children
392
- }) : /*#__PURE__*/jsx("div", {
398
+ }) : /*#__PURE__*/jsxs("div", {
393
399
  ref: panelRef,
394
400
  className: classNames,
395
401
  style: style,
396
402
  role: "dialog",
397
403
  "aria-modal": "true",
398
- "aria-label": "Dropdown",
404
+ "aria-label": t('Dropdown'),
399
405
  ...rest,
400
- children: /*#__PURE__*/jsx("div", {
406
+ children: [title && /*#__PURE__*/jsx("div", {
407
+ className: "hangoverDropdown-panel-title",
408
+ children: t(title)
409
+ }), /*#__PURE__*/jsx("div", {
401
410
  className: "hangoverDropdown-panel-inner",
402
411
  children: children
403
- })
412
+ })]
404
413
  });
405
414
  return /*#__PURE__*/createPortal(content, document.body);
406
415
  }
@@ -433,7 +442,8 @@ function DropdownNavItem({
433
442
  displayMode,
434
443
  contentRef,
435
444
  sectionRefs,
436
- registerNavLabel
445
+ registerNavLabel,
446
+ t
437
447
  } = useDropdownContext();
438
448
  const isActive = activeNavId === id;
439
449
  useEffect(() => {
@@ -489,7 +499,7 @@ function DropdownNavItem({
489
499
  handleClick();
490
500
  userOnClick?.();
491
501
  },
492
- title: typeof children === 'string' ? children : undefined,
502
+ title: typeof children === 'string' ? t(children) : undefined,
493
503
  "data-ho-active": isActive,
494
504
  ...navItemRest,
495
505
  children: [icon && /*#__PURE__*/jsx("span", {
@@ -498,7 +508,7 @@ function DropdownNavItem({
498
508
  children: renderIcon(icon)
499
509
  }), /*#__PURE__*/jsx("span", {
500
510
  className: "hangoverDropdown-nav-item-label",
501
- children: children
511
+ children: typeof children === 'string' ? t(children) : children
502
512
  })]
503
513
  });
504
514
  }
@@ -611,12 +621,15 @@ function DefaultSearchIcon() {
611
621
  *
612
622
  * Props:
613
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
614
626
  * title string — overrides active nav label as section title
615
627
  * component custom wrapper component
616
628
  * children DropdownSection / DropdownGroup / DropdownItem elements
617
629
  */
618
630
  function DropdownContent({
619
631
  searchPlaceholder = 'Search',
632
+ emptyText = 'Nothing to show here',
620
633
  component: Comp,
621
634
  children,
622
635
  ...rest
@@ -627,7 +640,8 @@ function DropdownContent({
627
640
  contentRef,
628
641
  displayMode,
629
642
  activeNavId,
630
- setScrollSpyActive
643
+ setScrollSpyActive,
644
+ t
631
645
  } = useDropdownContext();
632
646
 
633
647
  // Scroll spy: update active nav based on scroll position
@@ -682,8 +696,9 @@ function DropdownContent({
682
696
  query: e.target.value
683
697
  });
684
698
  }
699
+ const isEmpty = Children.count(children) === 0;
685
700
  const inner = /*#__PURE__*/jsxs(Fragment, {
686
- children: [/*#__PURE__*/jsxs("label", {
701
+ children: [!isEmpty && /*#__PURE__*/jsxs("label", {
687
702
  className: "hangoverDropdown-search",
688
703
  children: [/*#__PURE__*/jsx("span", {
689
704
  className: "hangoverDropdown-search-icon",
@@ -691,8 +706,8 @@ function DropdownContent({
691
706
  }), /*#__PURE__*/jsx("input", {
692
707
  type: "text",
693
708
  className: "hangoverDropdown-search-input",
694
- placeholder: searchPlaceholder,
695
- "aria-label": searchPlaceholder,
709
+ placeholder: t(searchPlaceholder),
710
+ "aria-label": t(searchPlaceholder),
696
711
  value: searchQuery,
697
712
  onChange: handleSearch
698
713
  })]
@@ -700,7 +715,10 @@ function DropdownContent({
700
715
  role: "listbox",
701
716
  className: `hangoverDropdown-list${displayMode === 'tab' ? ' isTabMode' : ''}${displayMode === 'tab' && activeNavId === '__all__' ? ' isAllActive' : ''}`,
702
717
  ref: contentRef,
703
- children: children
718
+ children: isEmpty ? /*#__PURE__*/jsx("div", {
719
+ className: "hangoverDropdown-content-empty",
720
+ children: t(emptyText)
721
+ }) : children
704
722
  })]
705
723
  });
706
724
  if (Comp) {
@@ -730,7 +748,8 @@ function DropdownSection({
730
748
  activeNavId,
731
749
  displayMode,
732
750
  registerSectionRef,
733
- hasNav
751
+ hasNav,
752
+ t
734
753
  } = useDropdownContext();
735
754
  const sectionRef = useRef(null);
736
755
  const forId = forProp || forIdProp || '__all__';
@@ -795,7 +814,7 @@ function DropdownSection({
795
814
  children: [title && hasNav && !(displayMode === 'tab' && activeNavId === '__all__') && /*#__PURE__*/jsx("div", {
796
815
  className: `hangoverDropdown-section-title${hasGroups ? ' isClickable' : ''}`,
797
816
  onClick: hasGroups ? handleToggleAll : undefined,
798
- "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,
799
818
  role: hasGroups ? 'button' : undefined,
800
819
  tabIndex: hasGroups ? 0 : undefined,
801
820
  onKeyDown: hasGroups ? e => {
@@ -805,7 +824,7 @@ function DropdownSection({
805
824
  }
806
825
  } : undefined,
807
826
  children: /*#__PURE__*/jsx("span", {
808
- children: title
827
+ children: t(title)
809
828
  })
810
829
  }), children]
811
830
  })
@@ -3021,7 +3040,8 @@ function DropdownItem({
3021
3040
  selectedItem,
3022
3041
  checkedItems,
3023
3042
  searchQuery,
3024
- fireEvent
3043
+ fireEvent,
3044
+ t
3025
3045
  } = useDropdownContext();
3026
3046
  const groupCtx = useContext(GroupContext);
3027
3047
  const groupLabel = groupCtx?.groupLabel ?? '';
@@ -3059,6 +3079,7 @@ function DropdownItem({
3059
3079
  fireEvent('select', {
3060
3080
  id,
3061
3081
  label,
3082
+ groupId,
3062
3083
  groupLabel
3063
3084
  });
3064
3085
  }
@@ -3109,7 +3130,7 @@ function DropdownItem({
3109
3130
  "aria-checked": type === 'checkbox' ? isChecked : undefined,
3110
3131
  tabIndex: 0,
3111
3132
  className: classNames,
3112
- title: label || undefined,
3133
+ title: label ? t(label) : undefined,
3113
3134
  onClick: () => {
3114
3135
  handleClick();
3115
3136
  userOnClick?.();
@@ -3126,7 +3147,7 @@ function DropdownItem({
3126
3147
  children: renderIcon(icon)
3127
3148
  }), /*#__PURE__*/jsx("span", {
3128
3149
  className: "hangoverDropdown-item-label",
3129
- children: children
3150
+ children: typeof children === 'string' ? t(children) : children
3130
3151
  }), actionsNode && /*#__PURE__*/jsx("span", {
3131
3152
  className: "hangoverDropdown-item-actions",
3132
3153
  onClick: e => e.stopPropagation(),
@@ -3225,7 +3246,8 @@ function DropdownGroup({
3225
3246
  displayMode,
3226
3247
  activeNavId,
3227
3248
  registerGroupItems,
3228
- searchQuery
3249
+ searchQuery,
3250
+ t
3229
3251
  } = useDropdownContext();
3230
3252
 
3231
3253
  // Determine initial expanded state
@@ -3314,7 +3336,7 @@ function DropdownGroup({
3314
3336
  role: "checkbox",
3315
3337
  "aria-checked": selectAllChecked,
3316
3338
  tabIndex: 0,
3317
- title: "Select all",
3339
+ title: t('Select all'),
3318
3340
  className: `hangoverDropdown-item isCheckboxType${selectAllChecked ? ' isChecked' : ''}`,
3319
3341
  onClick: handleSelectAll,
3320
3342
  onKeyDown: e => {
@@ -3325,7 +3347,7 @@ function DropdownGroup({
3325
3347
  },
3326
3348
  children: [/*#__PURE__*/jsx("span", {
3327
3349
  className: "hangoverDropdown-item-label",
3328
- children: "Select all"
3350
+ children: t('Select all')
3329
3351
  }), /*#__PURE__*/jsx("span", {
3330
3352
  className: `hangoverDropdown-item-check-icon${selectAllChecked ? ' isVisible' : ''}`,
3331
3353
  children: selectAllChecked && /*#__PURE__*/jsx("svg", {
@@ -3346,10 +3368,10 @@ function DropdownGroup({
3346
3368
  const visibleItemIds = useMemo(() => {
3347
3369
  const searchableItems = Children.toArray(children).map(child => ({
3348
3370
  id: child?.props?.id,
3349
- label: typeof child?.props?.children === 'string' ? child.props.children : ''
3371
+ label: typeof child?.props?.children === 'string' ? t(child.props.children) : ''
3350
3372
  }));
3351
3373
  return getMatchingItemIds(searchableItems, searchQuery);
3352
- }, [children, searchQuery]);
3374
+ }, [children, searchQuery, t]);
3353
3375
  const groupContextValue = {
3354
3376
  groupLabel: label,
3355
3377
  groupId,
@@ -3367,8 +3389,11 @@ function DropdownGroup({
3367
3389
  }
3368
3390
  },
3369
3391
  "aria-expanded": isExpanded,
3370
- "aria-label": `${label} — ${isExpanded ? 'collapse' : 'expand'}`,
3371
- title: label,
3392
+ "aria-label": t('{label} — {action}', {
3393
+ label,
3394
+ action: t(isExpanded ? 'collapse' : 'expand')
3395
+ }),
3396
+ title: t(label),
3372
3397
  children: [/*#__PURE__*/jsx("div", {
3373
3398
  className: "hangoverDropdown-group-header-accent"
3374
3399
  }), /*#__PURE__*/jsx("div", {
@@ -3380,7 +3405,7 @@ function DropdownGroup({
3380
3405
  children: renderIcon(icon)
3381
3406
  }), /*#__PURE__*/jsx("span", {
3382
3407
  className: "hangoverDropdown-group-header-label",
3383
- children: label
3408
+ children: t(label)
3384
3409
  }), /*#__PURE__*/jsx("span", {
3385
3410
  className: "hangoverDropdown-group-header-chevron",
3386
3411
  children: /*#__PURE__*/jsx(Chevron, {})
@@ -3394,14 +3419,14 @@ function DropdownGroup({
3394
3419
  className: `hangoverDropdown-group-items-wrap${isExpanded ? ' isExpanded' : ''}`,
3395
3420
  children: /*#__PURE__*/jsxs("div", {
3396
3421
  role: "group",
3397
- "aria-label": label,
3422
+ "aria-label": t(label),
3398
3423
  className: "hangoverDropdown-group-items",
3399
3424
  children: [showSelectAll && selectAllPosition === 'top' && selectAllItem, hasChildren ? hasVisibleItems ? children : /*#__PURE__*/jsx("div", {
3400
3425
  className: "hangoverDropdown-group-empty",
3401
- children: noResultsText
3426
+ children: t(noResultsText)
3402
3427
  }) : /*#__PURE__*/jsx("div", {
3403
3428
  className: "hangoverDropdown-group-empty",
3404
- children: emptyText
3429
+ children: t(emptyText)
3405
3430
  }), showSelectAll && selectAllPosition === 'bottom' && selectAllItem]
3406
3431
  })
3407
3432
  });
@@ -3713,9 +3738,10 @@ const Dropdown$1 = /*#__PURE__*/forwardRef(function Dropdown({
3713
3738
  hideOnSelection: hideOnSelectionProp = true,
3714
3739
  onEvent: onEventProp,
3715
3740
  fromConfig,
3716
- darkMode = false,
3741
+ darkMode: darkModeProp = false,
3717
3742
  searchQuery: searchQueryProp,
3718
- defaultSearchQuery = '',
3743
+ defaultSearchQuery: defaultSearchQueryProp = '',
3744
+ useTranslationFunction: useTranslationFunctionProp,
3719
3745
  children,
3720
3746
  ...rest
3721
3747
  }, ref) {
@@ -3724,6 +3750,23 @@ const Dropdown$1 = /*#__PURE__*/forwardRef(function Dropdown({
3724
3750
  const defaultGroupExpanded = fromConfig?.defaultGroupExpanded ?? defaultGroupExpandedProp;
3725
3751
  const hideOnSelection = fromConfig?.hideOnSelection ?? hideOnSelectionProp;
3726
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 = 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]);
3727
3770
  const [isOpen, setIsOpen] = useState(defaultOpen);
3728
3771
  const [selectedItem, setSelectedItem] = useState(null);
3729
3772
  const [checkedItems, setCheckedItems] = useState(() => new Map());
@@ -3733,10 +3776,10 @@ const Dropdown$1 = /*#__PURE__*/forwardRef(function Dropdown({
3733
3776
  const [hasNav, setHasNav] = useState(false);
3734
3777
 
3735
3778
  // Controlled searchQuery — sync internal state whenever the prop changes
3736
- const isControlledSearch = searchQueryProp !== undefined;
3779
+ const isControlledSearch = controlledSearchQuery !== undefined;
3737
3780
  useEffect(() => {
3738
- if (isControlledSearch) setSearchQuery(searchQueryProp);
3739
- }, [isControlledSearch, searchQueryProp]);
3781
+ if (isControlledSearch) setSearchQuery(controlledSearchQuery);
3782
+ }, [isControlledSearch, controlledSearchQuery]);
3740
3783
  const triggerRef = useRef(null);
3741
3784
  const contentRef = useRef(null); // scroll container inside DropdownContent
3742
3785
  const firstGroupClaimedRef = useRef(false);
@@ -4001,6 +4044,8 @@ const Dropdown$1 = /*#__PURE__*/forwardRef(function Dropdown({
4001
4044
  hasNav,
4002
4045
  darkMode,
4003
4046
  setHasNav,
4047
+ // i18n
4048
+ t,
4004
4049
  // Refs
4005
4050
  triggerRef,
4006
4051
  contentRef,
@@ -4020,7 +4065,7 @@ const Dropdown$1 = /*#__PURE__*/forwardRef(function Dropdown({
4020
4065
  registerSectionRef
4021
4066
  }), [isOpen, selectedItem, checkedItems, activeNavId, activeNavLabel, searchQuery, hasNav, displayMode, defaultGroupExpanded, darkMode,
4022
4067
  // all others are stable references
4023
- fireEvent, registerGroupItems, setScrollSpyActive, registerNavLabel, registerSectionRef]);
4068
+ fireEvent, registerGroupItems, setScrollSpyActive, registerNavLabel, registerSectionRef, t]);
4024
4069
  const resolvedChildren = (() => {
4025
4070
  if (fromConfig && children) {
4026
4071
  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.3",
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"