@buerokratt-ria/common-gui-components 0.0.59 → 0.0.60

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/CHANGELOG.md CHANGED
@@ -4,6 +4,12 @@ All changes to this project will be documented in this file.
4
4
 
5
5
  ## Template [MajorVersion.MediterraneanVersion.MinorVersion] - DD-MM-YYYY
6
6
 
7
+
8
+ ## [0.0.60] - 09.06.2026
9
+
10
+ - History table: theme, follow up, quality sorting
11
+ - History table: header filter positioning
12
+
7
13
  ## [0.0.59] - 05.06.2026
8
14
 
9
15
  - Quality measurements in chat history view
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buerokratt-ria/common-gui-components",
3
- "version": "0.0.59",
3
+ "version": "0.0.60",
4
4
  "description": "Common GUI components and pre defined templates.",
5
5
  "main": "index.ts",
6
6
  "author": "ExiRai",
@@ -36,6 +36,7 @@ const HeaderCombobox: FC<HeaderComboboxProps> = (props) => {
36
36
  searchPlaceholder: label,
37
37
  options,
38
38
  isSearchEnabled,
39
+ isMenuPortaled: true,
39
40
  };
40
41
 
41
42
  return (
@@ -163,10 +163,15 @@ const formatChatAnalysisCell = (
163
163
  };
164
164
 
165
165
  const ALL_COLUMNS_VALUE = '__all__';
166
- const BOOLEAN_SORT_COLUMN_IDS = new Set([
166
+ // Boolean -> truthy values before falsy
167
+ // For other columns -> desc before asc - populated before empty
168
+ const NON_EMPTY_FIRST_SORT_COLUMN_IDS = new Set([
167
169
  'authenticatedPerson',
168
170
  'istest',
169
171
  'isPreserve',
172
+ 'followUpStatus',
173
+ 'responseQuality',
174
+ 'theme',
170
175
  ]);
171
176
  const CHAT_STATUSES = [
172
177
  CHAT_EVENTS.ACCEPTED,
@@ -184,7 +189,7 @@ const getEndedChatsSortBy = (sorting: SortingState) => {
184
189
  }
185
190
 
186
191
  const [sortingObject] = sorting;
187
- const sortType = BOOLEAN_SORT_COLUMN_IDS.has(sortingObject.id)
192
+ const sortType = NON_EMPTY_FIRST_SORT_COLUMN_IDS.has(sortingObject.id)
188
193
  ? sortingObject.desc ? 'asc' : 'desc'
189
194
  : sortingObject.desc ? 'desc' : 'asc';
190
195
 
@@ -1455,87 +1460,87 @@ const ChatHistory: FC<PropsWithChildren<HistoryProps>> = ({
1455
1460
  sortDescFirst: false,
1456
1461
  }),
1457
1462
  ...isChatAnalysisEnabled ? [
1458
- columnHelper.display({
1459
- id: 'theme',
1460
- header: () => (
1461
- <HeaderCombobox
1462
- label={t('chat.history.theme')}
1463
- options={themeOptions}
1464
- value={theme}
1465
- onChange={(value) => {
1466
- setTableHeaderValue(
1467
- 'theme',
1468
- normalizeAllOptionFilterValues(
1469
- value,
1470
- theme,
1471
- getAllStringFilterValues(realThemeOptions)
1472
- )
1473
- );
1474
- }}
1475
- />
1463
+ columnHelper.accessor(
1464
+ (row) => formatChatAnalysisCell(
1465
+ row.theme,
1466
+ t('chat.quality.selectionEmptied')
1476
1467
  ),
1477
- cell: (props) => {
1478
- return formatChatAnalysisCell(
1479
- props.row.original.theme,
1480
- t('chat.quality.selectionEmptied')
1481
- );
1482
- },
1483
- enableSorting: true,
1484
- }),
1485
- columnHelper.display({
1486
- id: 'responseQuality',
1487
- header: () => (
1488
- <HeaderCombobox
1489
- label={t('chat.history.responseQuality')}
1490
- options={responseQualityOptions}
1491
- value={responseQuality}
1492
- onChange={(value) => {
1493
- setTableHeaderValue(
1494
- 'responseQuality',
1495
- normalizeAllOptionFilterValues(
1496
- value,
1497
- responseQuality,
1498
- getAllStringFilterValues(realResponseQualityOptions)
1499
- )
1500
- );
1501
- }}
1502
- />
1468
+ {
1469
+ id: 'theme',
1470
+ header: () => (
1471
+ <HeaderCombobox
1472
+ label={t('chat.history.theme')}
1473
+ options={themeOptions}
1474
+ value={theme}
1475
+ onChange={(value) => {
1476
+ setTableHeaderValue(
1477
+ 'theme',
1478
+ normalizeAllOptionFilterValues(
1479
+ value,
1480
+ theme,
1481
+ getAllStringFilterValues(realThemeOptions)
1482
+ )
1483
+ );
1484
+ }}
1485
+ />
1486
+ ),
1487
+ enableSorting: true,
1488
+ }
1489
+ ),
1490
+ columnHelper.accessor(
1491
+ (row) => formatChatAnalysisCell(
1492
+ row.responseQuality,
1493
+ t('chat.quality.selectionEmptied')
1503
1494
  ),
1504
- cell: (props) => {
1505
- return formatChatAnalysisCell(
1506
- props.row.original.responseQuality,
1507
- t('chat.quality.selectionEmptied')
1508
- );
1509
- },
1510
- enableSorting: true,
1511
- }),
1512
- columnHelper.display({
1513
- id: 'followUpStatus',
1514
- header: () => (
1515
- <HeaderCombobox
1516
- label={t('chat.history.followUpStatus')}
1517
- options={followUpStatusOptions}
1518
- value={followUpStatus}
1519
- onChange={(value) => {
1520
- setTableHeaderValue(
1521
- 'followUpStatus',
1522
- normalizeAllOptionFilterValues(
1523
- value,
1524
- followUpStatus,
1525
- getAllStringFilterValues(realFollowUpStatusOptions)
1526
- )
1527
- );
1528
- }}
1529
- />
1495
+ {
1496
+ id: 'responseQuality',
1497
+ header: () => (
1498
+ <HeaderCombobox
1499
+ label={t('chat.history.responseQuality')}
1500
+ options={responseQualityOptions}
1501
+ value={responseQuality}
1502
+ onChange={(value) => {
1503
+ setTableHeaderValue(
1504
+ 'responseQuality',
1505
+ normalizeAllOptionFilterValues(
1506
+ value,
1507
+ responseQuality,
1508
+ getAllStringFilterValues(realResponseQualityOptions)
1509
+ )
1510
+ );
1511
+ }}
1512
+ />
1513
+ ),
1514
+ enableSorting: true,
1515
+ }
1516
+ ),
1517
+ columnHelper.accessor(
1518
+ (row) => formatChatAnalysisCell(
1519
+ row.followUpStatus,
1520
+ t('chat.quality.selectionEmptied')
1530
1521
  ),
1531
- cell: (props) => {
1532
- return formatChatAnalysisCell(
1533
- props.row.original.followUpStatus,
1534
- t('chat.quality.selectionEmptied')
1535
- );
1536
- },
1537
- enableSorting: true,
1538
- }),
1522
+ {
1523
+ id: 'followUpStatus',
1524
+ header: () => (
1525
+ <HeaderCombobox
1526
+ label={t('chat.history.followUpStatus')}
1527
+ options={followUpStatusOptions}
1528
+ value={followUpStatus}
1529
+ onChange={(value) => {
1530
+ setTableHeaderValue(
1531
+ 'followUpStatus',
1532
+ normalizeAllOptionFilterValues(
1533
+ value,
1534
+ followUpStatus,
1535
+ getAllStringFilterValues(realFollowUpStatusOptions)
1536
+ )
1537
+ );
1538
+ }}
1539
+ />
1540
+ ),
1541
+ enableSorting: true,
1542
+ }
1543
+ ),
1539
1544
  ] : [],
1540
1545
  columnHelper.display({
1541
1546
  id: 'detail',
@@ -210,7 +210,6 @@ const DataTable: FC<DataTableProps> = ({
210
210
  header.column.columnDef.meta?.sticky === 'right'
211
211
  ? `${header.column.getAfter('right') * 0.675}px`
212
212
  : undefined,
213
- backgroundColor: 'white',
214
213
  zIndex: header.column.columnDef.meta?.sticky ? 1 : 0,
215
214
  }}
216
215
  >
@@ -159,6 +159,17 @@
159
159
  margin-bottom: 3px;
160
160
  }
161
161
 
162
+ &__menu--portal {
163
+ display: block;
164
+ position: fixed;
165
+ top: 100%;
166
+ bottom: auto;
167
+ right: auto;
168
+ margin-top: 3px;
169
+ margin-bottom: 0;
170
+ z-index: 10000;
171
+ }
172
+
162
173
  &__options {
163
174
  max-height: 224px;
164
175
  overflow: auto;
@@ -5,10 +5,12 @@ import {
5
5
  ReactNode,
6
6
  useEffect,
7
7
  useId,
8
+ useLayoutEffect,
8
9
  useMemo,
9
10
  useRef,
10
11
  useState
11
12
  } from 'react';
13
+ import { createPortal } from 'react-dom';
12
14
  import clsx from 'clsx';
13
15
  import { useTranslation } from 'react-i18next';
14
16
  import { MdArrowDropDown, MdExpandMore, MdSearch } from 'react-icons/md';
@@ -32,6 +34,7 @@ type FormComboboxBaseProps = {
32
34
  readonly style?: CSSProperties;
33
35
  readonly isSearchEnabled?: boolean;
34
36
  readonly hideInputStyle?: boolean;
37
+ readonly isMenuPortaled?: boolean;
35
38
  };
36
39
 
37
40
  type FormComboboxSingleProps = FormComboboxBaseProps & {
@@ -132,6 +135,7 @@ export const FormCombobox: FC<FormComboboxProps> = ({
132
135
  style,
133
136
  isSearchEnabled = false,
134
137
  hideInputStyle = false,
138
+ isMenuPortaled = false,
135
139
  ...props
136
140
  }) => {
137
141
  const id = useId();
@@ -144,7 +148,10 @@ export const FormCombobox: FC<FormComboboxProps> = ({
144
148
  const [internalMultipleValue, setInternalMultipleValue] = useState<string[]>(
145
149
  getInitialMultipleValue(props)
146
150
  );
151
+ const [menuStyle, setMenuStyle] = useState<CSSProperties>({});
147
152
  const wrapperRef = useRef<HTMLDivElement | null>(null);
153
+ const triggerRef = useRef<HTMLButtonElement | null>(null);
154
+ const menuRef = useRef<HTMLDivElement | null>(null);
148
155
  const searchInputRef = useRef<HTMLInputElement | null>(null);
149
156
 
150
157
  const isMultiple = props.multiple === true;
@@ -167,6 +174,34 @@ export const FormCombobox: FC<FormComboboxProps> = ({
167
174
  options.filter((option) => selectedValues.includes(option.value))
168
175
  ), [options, selectedValues]);
169
176
 
177
+ const updateMenuPosition = () => {
178
+ const triggerRect = triggerRef.current?.getBoundingClientRect();
179
+
180
+ if (!triggerRect) return;
181
+
182
+ const offset = 3;
183
+
184
+ setMenuStyle({
185
+ left: triggerRect.left,
186
+ minWidth: hideInputStyle ? 296 : triggerRect.width,
187
+ top: triggerRect.bottom + offset,
188
+ });
189
+ };
190
+
191
+ useLayoutEffect(() => {
192
+ if (!isOpen || !isMenuPortaled) return;
193
+
194
+ updateMenuPosition();
195
+
196
+ window.addEventListener('resize', updateMenuPosition);
197
+ document.addEventListener('scroll', updateMenuPosition, true);
198
+
199
+ return () => {
200
+ window.removeEventListener('resize', updateMenuPosition);
201
+ document.removeEventListener('scroll', updateMenuPosition, true);
202
+ };
203
+ }, [hideInputStyle, isMenuPortaled, isOpen]);
204
+
170
205
  useEffect(() => {
171
206
  if (isOpen) {
172
207
  searchInputRef.current?.focus();
@@ -175,7 +210,12 @@ export const FormCombobox: FC<FormComboboxProps> = ({
175
210
 
176
211
  useEffect(() => {
177
212
  const handleDocumentClick = (event: MouseEvent) => {
178
- if (!wrapperRef.current?.contains(event.target as Node)) {
213
+ const target = event.target as Node;
214
+
215
+ if (
216
+ !wrapperRef.current?.contains(target) &&
217
+ !menuRef.current?.contains(target)
218
+ ) {
179
219
  setIsOpen(false);
180
220
  setQuery('');
181
221
  }
@@ -189,7 +229,12 @@ export const FormCombobox: FC<FormComboboxProps> = ({
189
229
  }, []);
190
230
 
191
231
  const closeOnFocusOutside = (event: ReactFocusEvent<HTMLDivElement>) => {
192
- if (!event.currentTarget.contains(event.relatedTarget)) {
232
+ const relatedTarget = event.relatedTarget as Node | null;
233
+
234
+ if (
235
+ !event.currentTarget.contains(relatedTarget) &&
236
+ !menuRef.current?.contains(relatedTarget)
237
+ ) {
193
238
  setIsOpen(false);
194
239
  setQuery('');
195
240
  }
@@ -254,11 +299,70 @@ export const FormCombobox: FC<FormComboboxProps> = ({
254
299
  hideInputStyle && 'select--plain',
255
300
  );
256
301
 
302
+ const menu = (
303
+ <div
304
+ ref={menuRef}
305
+ className={clsx(
306
+ 'select__menu select__menu--combobox',
307
+ isMenuPortaled && 'select__menu--portal'
308
+ )}
309
+ style={isMenuPortaled ? menuStyle : undefined}
310
+ >
311
+ {
312
+ isSearchEnabled && <div className='select__search'>
313
+ <Icon
314
+ label='Search icon'
315
+ size='medium'
316
+ className='select__search-icon'
317
+ icon={<MdSearch className='search__icon-size' color='#5D6071' />}
318
+ />
319
+ <input
320
+ ref={searchInputRef}
321
+ className='select__search-input'
322
+ value={query}
323
+ onChange={(event) => setQuery(event.target.value)}
324
+ placeholder={searchPlaceholder ?? t('global.search')}
325
+ disabled={disabled}
326
+ />
327
+ </div>
328
+ }
329
+
330
+ <ul className='select__options' role='listbox' aria-label={searchPlaceholder ?? t('global.search')}>
331
+ {filteredOptions.length > 0 ? (
332
+ filteredOptions.map((option) => {
333
+ const isSelected = selectedValues.includes(option.value);
334
+
335
+ return (
336
+ <li
337
+ key={option.value}
338
+ role='option'
339
+ aria-selected={isSelected}
340
+ className='select__option select__option--combobox'
341
+ onMouseDown={(event) => event.preventDefault()}
342
+ onClick={() => selectOption(option)}
343
+ >
344
+ <input
345
+ type={isMultiple ? 'checkbox' : 'radio'}
346
+ checked={isSelected}
347
+ value={option.value}
348
+ onChange={() => null}
349
+ onClick={(event) => event.preventDefault()}
350
+ />
351
+ <span>{option.label}</span>
352
+ </li>
353
+ );
354
+ })
355
+ ) : null}
356
+ </ul>
357
+ </div>
358
+ );
359
+
257
360
  return (
258
361
  <div ref={wrapperRef} className={selectClasses} style={style} onBlur={closeOnFocusOutside}>
259
362
  {label && !hideLabel && <label htmlFor={id} className='select__label'>{label}</label>}
260
363
  <div className='select__wrapper'>
261
364
  <button
365
+ ref={triggerRef}
262
366
  id={id}
263
367
  type='button'
264
368
  className='select__trigger'
@@ -277,54 +381,9 @@ export const FormCombobox: FC<FormComboboxProps> = ({
277
381
  </button>
278
382
 
279
383
  {isOpen && (
280
- <div className='select__menu select__menu--combobox'>
281
- {
282
- isSearchEnabled && <div className='select__search'>
283
- <Icon
284
- label='Search icon'
285
- size='medium'
286
- className='select__search-icon'
287
- icon={<MdSearch className='search__icon-size' color='#5D6071' />}
288
- />
289
- <input
290
- ref={searchInputRef}
291
- className='select__search-input'
292
- value={query}
293
- onChange={(event) => setQuery(event.target.value)}
294
- placeholder={searchPlaceholder ?? t('global.search')}
295
- disabled={disabled}
296
- />
297
- </div>
298
- }
299
-
300
- <ul className='select__options' role='listbox' aria-label={searchPlaceholder ?? t('global.search')}>
301
- {filteredOptions.length > 0 ? (
302
- filteredOptions.map((option) => {
303
- const isSelected = selectedValues.includes(option.value);
304
-
305
- return (
306
- <li
307
- key={option.value}
308
- role='option'
309
- aria-selected={isSelected}
310
- className='select__option select__option--combobox'
311
- onMouseDown={(event) => event.preventDefault()}
312
- onClick={() => selectOption(option)}
313
- >
314
- <input
315
- type={isMultiple ? 'checkbox' : 'radio'}
316
- checked={isSelected}
317
- value={option.value}
318
- onChange={() => null}
319
- onClick={(event) => event.preventDefault()}
320
- />
321
- <span>{option.label}</span>
322
- </li>
323
- );
324
- })
325
- ) : null}
326
- </ul>
327
- </div>
384
+ isMenuPortaled && typeof document !== 'undefined'
385
+ ? createPortal(menu, document.body)
386
+ : menu
328
387
  )}
329
388
  </div>
330
389
  </div>