@axinom/mosaic-ui 0.66.0-rc.2 → 0.66.0-rc.20

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.
Files changed (122) hide show
  1. package/dist/components/DynamicDataList/DynamicListHeader/DynamicListHeader.d.ts.map +1 -1
  2. package/dist/components/Explorer/BulkEdit/FormFieldsConfigConverter.d.ts.map +1 -1
  3. package/dist/components/FieldSelection/FieldSelection.d.ts.map +1 -1
  4. package/dist/components/Filters/Filter/Filter.d.ts.map +1 -1
  5. package/dist/components/Filters/Filters.model.d.ts +5 -0
  6. package/dist/components/Filters/Filters.model.d.ts.map +1 -1
  7. package/dist/components/Filters/SelectionTypes/DateTimeFilter/DateTimeFilter.d.ts +2 -0
  8. package/dist/components/Filters/SelectionTypes/DateTimeFilter/DateTimeFilter.d.ts.map +1 -1
  9. package/dist/components/Filters/SelectionTypes/FreeTextFilter/FreeTextFilter.d.ts +2 -0
  10. package/dist/components/Filters/SelectionTypes/FreeTextFilter/FreeTextFilter.d.ts.map +1 -1
  11. package/dist/components/Filters/SelectionTypes/MultiOptionFilter/MultiOptionFilter.d.ts +2 -0
  12. package/dist/components/Filters/SelectionTypes/MultiOptionFilter/MultiOptionFilter.d.ts.map +1 -1
  13. package/dist/components/Filters/SelectionTypes/NumericTextFilter/NumericTextFilter.d.ts +2 -0
  14. package/dist/components/Filters/SelectionTypes/NumericTextFilter/NumericTextFilter.d.ts.map +1 -1
  15. package/dist/components/Filters/SelectionTypes/OptionsFilter/OptionsFilter.d.ts +2 -0
  16. package/dist/components/Filters/SelectionTypes/OptionsFilter/OptionsFilter.d.ts.map +1 -1
  17. package/dist/components/Filters/SelectionTypes/SearcheableOptionsFilter/SearcheableOptionsFilter.d.ts +2 -0
  18. package/dist/components/Filters/SelectionTypes/SearcheableOptionsFilter/SearcheableOptionsFilter.d.ts.map +1 -1
  19. package/dist/components/FormElements/Radio/Radio.d.ts.map +1 -1
  20. package/dist/components/FormElements/ToggleButton/ToggleButton.d.ts.map +1 -1
  21. package/dist/components/Hub/Tile/Tile.d.ts.map +1 -1
  22. package/dist/components/Icons/Icons.d.ts +4 -9
  23. package/dist/components/Icons/Icons.d.ts.map +1 -1
  24. package/dist/components/LandingPageTiles/TileLarge/TileLarge.d.ts.map +1 -1
  25. package/dist/components/LandingPageTiles/TileSmall/TileSmall.d.ts.map +1 -1
  26. package/dist/components/List/ListCheckBox/ListCheckBox.d.ts.map +1 -1
  27. package/dist/components/List/ListHeader/ColumnLabel/ColumnLabel.d.ts.map +1 -1
  28. package/dist/components/List/ListHeader/ListHeader.d.ts.map +1 -1
  29. package/dist/components/List/ListRow/ListRow.d.ts.map +1 -1
  30. package/dist/components/List/ListRow/ListRowCell/ListRowCell.d.ts +15 -0
  31. package/dist/components/List/ListRow/ListRowCell/ListRowCell.d.ts.map +1 -0
  32. package/dist/components/List/ListRow/ListRowCell/renderData.d.ts +9 -0
  33. package/dist/components/List/ListRow/ListRowCell/renderData.d.ts.map +1 -0
  34. package/dist/components/List/ListRow/Renderers/TagsRenderer/TagsRenderer.d.ts.map +1 -1
  35. package/dist/components/Loaders/ImageLoader/ImageLoader.d.ts.map +1 -1
  36. package/dist/components/PageHeader/PageHeaderAction/PageHeaderAction.d.ts.map +1 -1
  37. package/dist/components/VisualElements/ImgElement.d.ts +50 -0
  38. package/dist/components/VisualElements/ImgElement.d.ts.map +1 -0
  39. package/dist/components/VisualElements/SvgElement.d.ts +14 -0
  40. package/dist/components/VisualElements/SvgElement.d.ts.map +1 -0
  41. package/dist/components/VisualElements/index.d.ts +3 -0
  42. package/dist/components/VisualElements/index.d.ts.map +1 -0
  43. package/dist/components/index.d.ts +1 -0
  44. package/dist/components/index.d.ts.map +1 -1
  45. package/dist/helpers/idleCallbackHelpers.d.ts +42 -0
  46. package/dist/helpers/idleCallbackHelpers.d.ts.map +1 -0
  47. package/dist/helpers/index.d.ts +1 -0
  48. package/dist/helpers/index.d.ts.map +1 -1
  49. package/dist/hooks/useResize/ResizeIndicator.d.ts +8 -0
  50. package/dist/hooks/useResize/ResizeIndicator.d.ts.map +1 -0
  51. package/dist/hooks/useResize/useResize.d.ts +5 -2
  52. package/dist/hooks/useResize/useResize.d.ts.map +1 -1
  53. package/dist/index.es.js +4 -4
  54. package/dist/index.es.js.map +1 -1
  55. package/dist/index.js +4 -4
  56. package/dist/index.js.map +1 -1
  57. package/package.json +2 -2
  58. package/src/components/DynamicDataList/DynamicListHeader/DynamicListHeader.spec.tsx +2 -0
  59. package/src/components/DynamicDataList/DynamicListHeader/DynamicListHeader.tsx +62 -50
  60. package/src/components/Explorer/BulkEdit/FormFieldsConfigConverter.tsx +5 -21
  61. package/src/components/Explorer/Explorer.stories.tsx +17 -0
  62. package/src/components/FieldSelection/FieldSelection.scss +4 -0
  63. package/src/components/FieldSelection/FieldSelection.tsx +1 -0
  64. package/src/components/Filters/Filter/Filter.scss +34 -15
  65. package/src/components/Filters/Filter/Filter.spec.tsx +1 -1
  66. package/src/components/Filters/Filter/Filter.tsx +46 -34
  67. package/src/components/Filters/Filters.model.ts +6 -0
  68. package/src/components/Filters/SelectionTypes/DateTimeFilter/DateTimeFilter.tsx +6 -1
  69. package/src/components/Filters/SelectionTypes/FreeTextFilter/FreeTextFilter.tsx +4 -0
  70. package/src/components/Filters/SelectionTypes/MultiOptionFilter/MultiOptionFilter.tsx +9 -1
  71. package/src/components/Filters/SelectionTypes/NumericTextFilter/NumericTextFilter.tsx +5 -0
  72. package/src/components/Filters/SelectionTypes/OptionsFilter/OptionsFilter.scss +6 -10
  73. package/src/components/Filters/SelectionTypes/OptionsFilter/OptionsFilter.tsx +8 -0
  74. package/src/components/Filters/SelectionTypes/SearcheableOptionsFilter/SearcheableOptionsFilter.tsx +6 -1
  75. package/src/components/FormElements/Radio/Radio.tsx +3 -2
  76. package/src/components/FormElements/Select/Select.scss +11 -6
  77. package/src/components/FormElements/ToggleButton/ToggleButton.tsx +32 -27
  78. package/src/components/Hub/Hub.stories.tsx +3 -2
  79. package/src/components/Hub/Tile/Tile.spec.tsx +7 -2
  80. package/src/components/Hub/Tile/Tile.tsx +2 -1
  81. package/src/components/Icons/Icons.scss +1 -0
  82. package/src/components/Icons/Icons.spec.tsx +90 -41
  83. package/src/components/Icons/Icons.tsx +357 -765
  84. package/src/components/InfoTooltip/InfoTooltip.scss +1 -1
  85. package/src/components/InlineMenu/InlineMenu.scss +1 -1
  86. package/src/components/LandingPageTiles/LandingPageTiles.stories.tsx +3 -2
  87. package/src/components/LandingPageTiles/TileLarge/TileLarge.spec.tsx +5 -1
  88. package/src/components/LandingPageTiles/TileLarge/TileLarge.tsx +2 -1
  89. package/src/components/LandingPageTiles/TileSmall/TileSmall.spec.tsx +7 -2
  90. package/src/components/LandingPageTiles/TileSmall/TileSmall.tsx +2 -1
  91. package/src/components/List/ListCheckBox/ListCheckBox.tsx +1 -0
  92. package/src/components/List/ListHeader/ColumnLabel/ColumnLabel.spec.tsx +6 -6
  93. package/src/components/List/ListHeader/ColumnLabel/ColumnLabel.tsx +10 -13
  94. package/src/components/List/ListHeader/ListHeader.scss +0 -1
  95. package/src/components/List/ListHeader/ListHeader.spec.tsx +2 -0
  96. package/src/components/List/ListHeader/ListHeader.tsx +57 -51
  97. package/src/components/List/ListRow/ListRow.scss +0 -27
  98. package/src/components/List/ListRow/ListRow.spec.tsx +10 -8
  99. package/src/components/List/ListRow/ListRow.tsx +20 -152
  100. package/src/components/List/ListRow/ListRowCell/ListRowCell.scss +26 -0
  101. package/src/components/List/ListRow/ListRowCell/ListRowCell.spec.tsx +491 -0
  102. package/src/components/List/ListRow/ListRowCell/ListRowCell.tsx +57 -0
  103. package/src/components/List/ListRow/ListRowCell/renderData.tsx +124 -0
  104. package/src/components/List/ListRow/Renderers/TagsRenderer/TagsRenderer.scss +2 -1
  105. package/src/components/List/ListRow/Renderers/TagsRenderer/TagsRenderer.spec.tsx +187 -104
  106. package/src/components/List/ListRow/Renderers/TagsRenderer/TagsRenderer.tsx +134 -80
  107. package/src/components/Loaders/ImageLoader/ImageLoader.spec.tsx +13 -14
  108. package/src/components/Loaders/ImageLoader/ImageLoader.tsx +5 -3
  109. package/src/components/PageHeader/PageHeaderAction/PageHeaderAction.tsx +13 -2
  110. package/src/components/Utils/Postgraphile/CreateConnectionRenderer.spec.ts +22 -75
  111. package/src/components/VisualElements/ImgElement.spec.tsx +92 -0
  112. package/src/components/VisualElements/ImgElement.tsx +72 -0
  113. package/src/components/VisualElements/SvgElement.spec.tsx +160 -0
  114. package/src/components/VisualElements/SvgElement.tsx +40 -0
  115. package/src/components/VisualElements/index.ts +7 -0
  116. package/src/components/index.ts +1 -0
  117. package/src/helpers/idleCallbackHelpers.ts +66 -0
  118. package/src/helpers/index.ts +5 -0
  119. package/src/hooks/useResize/ResizeIndicator.scss +7 -0
  120. package/src/hooks/useResize/ResizeIndicator.tsx +39 -0
  121. package/src/hooks/useResize/{useResize.ts → useResize.tsx} +38 -6
  122. package/src/styles/variables.scss +7 -6
@@ -1,153 +1,236 @@
1
1
  import '@testing-library/jest-dom';
2
- import { render, screen } from '@testing-library/react';
2
+ import { act, render, screen, waitFor } from '@testing-library/react';
3
3
  import React from 'react';
4
+ import * as idleCallbackHelpers from '../../../../../helpers/idleCallbackHelpers';
5
+ import { noop } from '../../../../../helpers/utils';
4
6
  import { TagsRenderer } from './TagsRenderer';
5
7
 
6
- // Mock ResizeObserver
7
- global.ResizeObserver = jest.fn().mockImplementation(() => ({
8
- observe: jest.fn(),
9
- unobserve: jest.fn(),
10
- disconnect: jest.fn(),
11
- }));
12
-
13
- interface RendererWrapperProps {
14
- /** Column data */
8
+ // Wrapper component to properly render TagsRenderer
9
+ interface TagsRendererWrapperProps {
15
10
  value: unknown;
16
11
  }
17
12
 
18
- const RendererWrapper: React.FC<RendererWrapperProps> = ({ value }) => {
13
+ const TagsRendererWrapper: React.FC<TagsRendererWrapperProps> = ({ value }) => {
19
14
  return <>{TagsRenderer(value)}</>;
20
15
  };
21
16
 
22
- const defaultProps: RendererWrapperProps = {
23
- value: [],
17
+ // Helper to trigger ResizeObserver callback
18
+ const triggerResizeObserver = (): void => {
19
+ const resizeObserverCallback = (global.ResizeObserver as jest.Mock).mock
20
+ .calls[0]?.[0];
21
+ if (resizeObserverCallback) {
22
+ act(() => {
23
+ resizeObserverCallback([]);
24
+ });
25
+ }
24
26
  };
25
27
 
26
28
  describe('TagsRenderer', () => {
29
+ let mockResizeObserver: {
30
+ observe: jest.Mock;
31
+ unobserve: jest.Mock;
32
+ disconnect: jest.Mock;
33
+ };
34
+ let mockScheduleIdleCallback: jest.SpyInstance;
35
+ let mockCancelScheduledCallback: jest.SpyInstance;
36
+
27
37
  beforeEach(() => {
28
- // Reset mocks
29
- jest.clearAllMocks();
30
- });
38
+ // Setup ResizeObserver mock
39
+ mockResizeObserver = {
40
+ observe: jest.fn(),
41
+ unobserve: jest.fn(),
42
+ disconnect: jest.fn(),
43
+ };
44
+
45
+ global.ResizeObserver = jest
46
+ .fn()
47
+ .mockImplementation(() => mockResizeObserver);
48
+
49
+ // Setup scheduleIdleCallback mock
50
+ mockScheduleIdleCallback = jest
51
+ .spyOn(idleCallbackHelpers, 'scheduleIdleCallback')
52
+ .mockImplementation((callback) => {
53
+ callback({
54
+ didTimeout: false,
55
+ timeRemaining: () => 50,
56
+ } as IdleDeadline);
57
+ return 1;
58
+ });
59
+
60
+ // Setup cancelScheduledCallback mock
61
+ mockCancelScheduledCallback = jest
62
+ .spyOn(idleCallbackHelpers, 'cancelScheduledCallback')
63
+ .mockImplementation(noop);
64
+
65
+ // Mock window.getComputedStyle for font size calculations
66
+ window.getComputedStyle = jest.fn().mockReturnValue({
67
+ fontSize: '14px',
68
+ });
31
69
 
32
- it('renders the component without crashing', () => {
33
- const { container } = render(<RendererWrapper value={['test']} />);
34
- expect(container.firstChild).toBeInTheDocument();
70
+ // Mock offsetWidth for container
71
+ Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
72
+ configurable: true,
73
+ writable: true,
74
+ value: 300,
75
+ });
35
76
  });
36
77
 
37
- it('returns empty content when value is not an array', () => {
38
- const { container } = render(
39
- <RendererWrapper {...defaultProps} value="not-an-array" />,
40
- );
41
- expect(container.firstChild).toBeNull();
78
+ afterEach(() => {
79
+ // Don't use jest.clearAllMocks() as it removes mock implementations
80
+ // Instead, manually clear just the mock call history
81
+ mockResizeObserver.observe.mockClear();
82
+ mockResizeObserver.unobserve.mockClear();
83
+ mockResizeObserver.disconnect.mockClear();
84
+ mockScheduleIdleCallback.mockClear();
85
+ mockCancelScheduledCallback.mockClear();
42
86
  });
43
87
 
44
- it('returns empty content when value is null', () => {
45
- const { container } = render(
46
- <RendererWrapper {...defaultProps} value={null} />,
47
- );
48
- expect(container.firstChild).toBeNull();
49
- });
88
+ describe('rendering', () => {
89
+ it('should render empty container when value is empty array', () => {
90
+ render(<TagsRendererWrapper value={[]} />);
50
91
 
51
- it('returns empty content when value is undefined', () => {
52
- const { container } = render(
53
- <RendererWrapper {...defaultProps} value={undefined} />,
54
- );
55
- expect(container.firstChild).toBeNull();
56
- });
92
+ const container = screen.getByTestId('tags-container');
93
+ expect(container).toBeInTheDocument();
94
+ expect(container).toBeEmptyDOMElement();
95
+ });
57
96
 
58
- it('renders tags when array is provided', () => {
59
- const mockTags = ['tag1', 'tag2', 'tag3'];
97
+ it('should render container with title attribute containing all tags', () => {
98
+ const tags = ['tag1', 'tag2', 'tag3'];
60
99
 
61
- // Mock container width measurement
62
- Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
63
- configurable: true,
64
- value: 300, // Assume container width of 300px
100
+ render(<TagsRendererWrapper value={tags} />);
101
+
102
+ const container = screen.getByTestId('tags-container');
103
+ expect(container).toHaveAttribute('title', 'tag1, tag2, tag3');
65
104
  });
66
105
 
67
- render(<RendererWrapper {...defaultProps} value={mockTags} />);
106
+ it('should render tags when array is provided', () => {
107
+ const tags = ['tag1', 'tag2', 'tag3'];
68
108
 
69
- // Since we're mocking ResizeObserver, simulate the callback
70
- const resizeObserverCallback = (global.ResizeObserver as jest.Mock).mock
71
- .calls[0]?.[0];
109
+ render(<TagsRendererWrapper value={tags} />);
72
110
 
73
- if (resizeObserverCallback) {
74
- resizeObserverCallback([]);
75
- }
111
+ const container = screen.getByTestId('tags-container');
112
+ expect(container).toBeInTheDocument();
76
113
 
77
- // Check that container exists
78
- const container = screen.getByTestId('tags-container');
79
- expect(container).toBeInTheDocument();
80
- });
114
+ triggerResizeObserver();
81
115
 
82
- it('displays overflow indicator when configured', () => {
83
- const mockTags = ['tag1', 'tag2', 'tag3', 'tag4', 'tag5'];
116
+ expect(mockScheduleIdleCallback).toHaveBeenCalled();
117
+ });
84
118
 
85
- // Mock container and tag measurements to force overflow
86
- Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
87
- configurable: true,
88
- value: 100, // Small container width to force overflow
119
+ it('should render tags with proper text content', async () => {
120
+ const tags = ['JavaScript', 'React', 'Testing'];
121
+ Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
122
+ configurable: true,
123
+ writable: true,
124
+ value: 500,
125
+ });
126
+
127
+ render(<TagsRendererWrapper value={tags} />);
128
+
129
+ triggerResizeObserver();
130
+
131
+ await waitFor(() => {
132
+ expect(screen.getByText('JavaScript')).toBeInTheDocument();
133
+ expect(screen.getByText('React')).toBeInTheDocument();
134
+ expect(screen.getByText('Testing')).toBeInTheDocument();
135
+ });
89
136
  });
137
+ });
90
138
 
91
- // Mock document.createElement to control tag width measurements
92
- const originalCreateElement = document.createElement;
93
- document.createElement = jest.fn().mockImplementation((tagName) => {
94
- const element = originalCreateElement.call(document, tagName);
95
- if (tagName === 'div') {
96
- Object.defineProperty(element, 'offsetWidth', {
97
- value: 50, // Each tag is 50px wide
98
- });
99
- }
100
- return element;
139
+ describe('overflow behavior', () => {
140
+ it('should handle overflow with small container width', () => {
141
+ const tags = ['tag1', 'tag2', 'tag3', 'tag4', 'tag5'];
142
+
143
+ Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
144
+ configurable: true,
145
+ writable: true,
146
+ value: 100,
147
+ });
148
+
149
+ render(<TagsRendererWrapper value={tags} />);
150
+
151
+ const container = screen.getByTestId('tags-container');
152
+ expect(container).toBeInTheDocument();
153
+
154
+ triggerResizeObserver();
155
+
156
+ expect(mockScheduleIdleCallback).toHaveBeenCalled();
101
157
  });
102
158
 
103
- render(<RendererWrapper {...defaultProps} value={mockTags} />);
159
+ it('should display overflow indicator when not all tags fit', async () => {
160
+ const tags = ['tag1', 'tag2', 'tag3', 'tag4', 'tag5'];
104
161
 
105
- // Simulate ResizeObserver callback
106
- const resizeObserverCallback = (global.ResizeObserver as jest.Mock).mock
107
- .calls[0]?.[0];
162
+ Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
163
+ configurable: true,
164
+ writable: true,
165
+ value: 150,
166
+ });
108
167
 
109
- if (resizeObserverCallback) {
110
- resizeObserverCallback([]);
111
- }
168
+ render(<TagsRendererWrapper value={tags} />);
112
169
 
113
- // Test passes if no errors occur - the overflow logic depends on real DOM measurements
114
- expect(screen.getByTestId('tags-container')).toBeInTheDocument();
170
+ triggerResizeObserver();
115
171
 
116
- // Restore
117
- document.createElement = originalCreateElement;
118
- });
172
+ // The overflow indicator may or may not appear depending on calculations
173
+ // This test verifies the component handles the scenario without errors
174
+ const container = screen.getByTestId('tags-container');
175
+ expect(container).toBeInTheDocument();
176
+ });
177
+
178
+ it('should handle empty tag list gracefully', () => {
179
+ render(<TagsRendererWrapper value={[]} />);
119
180
 
120
- it('handles overflow behavior', () => {
121
- const mockTags = ['tag1', 'tag2', 'tag3', 'tag4', 'tag5'];
181
+ const container = screen.getByTestId('tags-container');
182
+ expect(container).toBeInTheDocument();
183
+ });
122
184
 
123
- render(<RendererWrapper {...defaultProps} value={mockTags} />);
185
+ it('should handle non-array values gracefully', () => {
186
+ render(<TagsRendererWrapper value={null} />);
124
187
 
125
- // Test that the component renders without errors
126
- const container = screen.getByTestId('tags-container');
127
- expect(container).toBeInTheDocument();
188
+ const container = screen.getByTestId('tags-container');
189
+ expect(container).toBeInTheDocument();
190
+ });
128
191
  });
129
192
 
130
- it('handles empty array', () => {
131
- const { container } = render(
132
- <RendererWrapper {...defaultProps} value={[]} />,
133
- );
134
- expect(container.firstChild).toBeNull();
193
+ describe('ResizeObserver integration', () => {
194
+ it('should observe container on mount', () => {
195
+ render(<TagsRendererWrapper value={['tag1', 'tag2']} />);
196
+
197
+ expect(mockResizeObserver.observe).toHaveBeenCalledTimes(1);
198
+ });
199
+
200
+ it('should trigger calculations when ResizeObserver fires', () => {
201
+ render(<TagsRendererWrapper value={['tag1', 'tag2', 'tag3']} />);
202
+
203
+ expect(mockScheduleIdleCallback).not.toHaveBeenCalled();
204
+
205
+ triggerResizeObserver();
206
+
207
+ expect(mockScheduleIdleCallback).toHaveBeenCalled();
208
+ });
135
209
  });
136
210
 
137
- it('cleans up ResizeObserver on unmount', () => {
138
- const mockDisconnect = jest.fn();
139
- (global.ResizeObserver as jest.Mock).mockImplementation(() => ({
140
- observe: jest.fn(),
141
- unobserve: jest.fn(),
142
- disconnect: mockDisconnect,
143
- }));
211
+ describe('cleanup', () => {
212
+ it('should disconnect ResizeObserver on unmount', () => {
213
+ const { unmount } = render(
214
+ <TagsRendererWrapper value={['tag1', 'tag2']} />,
215
+ );
144
216
 
145
- const { unmount } = render(
146
- <RendererWrapper {...defaultProps} value={['tag1', 'tag2']} />,
147
- );
217
+ expect(mockResizeObserver.disconnect).not.toHaveBeenCalled();
148
218
 
149
- unmount();
219
+ unmount();
150
220
 
151
- expect(mockDisconnect).toHaveBeenCalled();
221
+ expect(mockResizeObserver.disconnect).toHaveBeenCalledTimes(1);
222
+ });
223
+
224
+ it('should cancel idle callback on unmount if pending', () => {
225
+ const { unmount } = render(
226
+ <TagsRendererWrapper value={['tag1', 'tag2']} />,
227
+ );
228
+
229
+ triggerResizeObserver();
230
+
231
+ unmount();
232
+
233
+ expect(mockCancelScheduledCallback).toHaveBeenCalled();
234
+ });
152
235
  });
153
236
  });
@@ -1,121 +1,175 @@
1
- import React, { useEffect, useRef, useState } from 'react';
1
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
2
+ import {
3
+ cancelScheduledCallback,
4
+ scheduleIdleCallback,
5
+ type IdleCallbackHandle,
6
+ } from '../../../../../helpers';
2
7
  import classes from './TagsRenderer.scss';
3
8
 
4
9
  export const TagsRenderer = (val: unknown): JSX.Element => {
5
10
  const containerRef = useRef<HTMLDivElement>(null);
6
- const resizeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
7
- const [visibleCount, setVisibleCount] = useState<number>(0);
11
+ const [visibleCount, setVisibleCount] = useState<number | null>(null);
8
12
 
9
- useEffect(() => {
10
- if (!Array.isArray(val) || !containerRef.current || val.length === 0) {
11
- setVisibleCount(0);
12
- return;
13
+ const prevValRef = useRef<string[]>([]);
14
+ const valRef = useRef(val);
15
+ valRef.current = val; // Update on every render
16
+
17
+ // Estimate tag width based on text content without DOM manipulation
18
+ const estimateTagWidth = useCallback((text: string): number => {
19
+ const container = containerRef.current;
20
+ if (!container) {
21
+ return 0;
13
22
  }
14
23
 
15
- const calculateVisibleItems = (): void => {
16
- const container = containerRef.current;
17
- if (!container) {
18
- return;
19
- }
24
+ // Get computed font size from the container
25
+ const computedStyle = window.getComputedStyle(container);
26
+ const fontSize = parseFloat(computedStyle.fontSize) || 14;
27
+
28
+ // Average character width is approximately 0.6 of font size for typical fonts
29
+ // This is a reasonable approximation for most monospace and sans-serif fonts
30
+ const avgCharWidth = fontSize * 0.55;
31
+
32
+ // Tag padding is 5px on each side (10px total) from SCSS
33
+ const padding = 10;
34
+
35
+ // Estimate text width
36
+ const textWidth = text.length * avgCharWidth;
20
37
 
21
- const containerWidth = container.offsetWidth;
22
- const gap = 5; // Gap between items from SCSS
23
- const overflowIndicatorWidth = 60; // Approximate width for "... +X"
24
-
25
- // Measure actual tag widths using hidden temporary elements in the actual container
26
- const tagWidths: number[] = [];
27
- const tempElements: HTMLDivElement[] = [];
28
-
29
- try {
30
- (val as string[]).forEach((item) => {
31
- const tempTag = document.createElement('div');
32
- tempTag.className = classes.tag;
33
- tempTag.textContent = item;
34
- tempTag.style.position = 'absolute';
35
- tempTag.style.visibility = 'hidden';
36
- tempTag.style.pointerEvents = 'none';
37
- container.appendChild(tempTag);
38
- tempElements.push(tempTag);
39
- tagWidths.push(tempTag.offsetWidth);
40
- });
41
- } finally {
42
- // Clean up temporary elements
43
- tempElements.forEach((el) => container.removeChild(el));
38
+ return textWidth + padding;
39
+ }, []);
40
+
41
+ // Shared calculation function that uses valRef for current value
42
+ const calculateVisibleItems = useCallback((): void => {
43
+ const currentVal = valRef.current;
44
+ const container = containerRef.current;
45
+
46
+ if (!container || !Array.isArray(currentVal) || currentVal.length === 0) {
47
+ return;
48
+ }
49
+
50
+ const containerWidth = container.offsetWidth;
51
+ const gap = 5; // Gap between items from SCSS
52
+ const overflowIndicatorWidth = 60; // Approximate width for "... +X"
53
+
54
+ // Estimate tag widths based on text content
55
+ const tagWidths: number[] = (currentVal as string[]).map((item) =>
56
+ estimateTagWidth(item),
57
+ );
58
+
59
+ // Calculate how many items can fit
60
+ let totalWidth = 0;
61
+ let newVisibleCount = 0;
62
+
63
+ for (let i = 0; i < (currentVal as string[]).length; i++) {
64
+ const itemWidth = tagWidths[i] + (i > 0 ? gap : 0);
65
+ const wouldNeedOverflow = i < (currentVal as string[]).length - 1;
66
+ const requiredWidth =
67
+ totalWidth +
68
+ itemWidth +
69
+ (wouldNeedOverflow ? overflowIndicatorWidth + gap : 0);
70
+
71
+ if (requiredWidth <= containerWidth) {
72
+ totalWidth += itemWidth;
73
+ newVisibleCount++;
74
+ } else {
75
+ break;
44
76
  }
77
+ }
45
78
 
46
- // Calculate how many items can fit
47
- let totalWidth = 0;
48
- let newVisibleCount = 0;
79
+ // If we can't fit all items, reserve space for overflow indicator
80
+ if (
81
+ newVisibleCount < (currentVal as string[]).length &&
82
+ newVisibleCount > 0
83
+ ) {
84
+ // Re-calculate with overflow indicator space reserved
85
+ totalWidth = 0;
86
+ newVisibleCount = 0;
49
87
 
50
- for (let i = 0; i < val.length; i++) {
88
+ for (let i = 0; i < (currentVal as string[]).length; i++) {
51
89
  const itemWidth = tagWidths[i] + (i > 0 ? gap : 0);
52
- const wouldNeedOverflow = i < val.length - 1;
53
90
  const requiredWidth =
54
- totalWidth +
55
- itemWidth +
56
- (wouldNeedOverflow ? overflowIndicatorWidth + gap : 0);
91
+ totalWidth + itemWidth + overflowIndicatorWidth + gap;
57
92
 
58
- if (requiredWidth <= containerWidth) {
93
+ if (
94
+ requiredWidth <= containerWidth &&
95
+ i < (currentVal as string[]).length - 1
96
+ ) {
59
97
  totalWidth += itemWidth;
60
98
  newVisibleCount++;
61
99
  } else {
62
100
  break;
63
101
  }
64
102
  }
103
+ }
65
104
 
66
- // If we can't fit all items, reserve space for overflow indicator
67
- if (newVisibleCount < val.length && newVisibleCount > 0) {
68
- // Re-calculate with overflow indicator space reserved
69
- totalWidth = 0;
70
- newVisibleCount = 0;
71
-
72
- for (let i = 0; i < val.length; i++) {
73
- const itemWidth = tagWidths[i] + (i > 0 ? gap : 0);
74
- const requiredWidth =
75
- totalWidth + itemWidth + overflowIndicatorWidth + gap;
76
-
77
- if (requiredWidth <= containerWidth && i < val.length - 1) {
78
- totalWidth += itemWidth;
79
- newVisibleCount++;
80
- } else {
81
- break;
82
- }
83
- }
84
- }
105
+ // Update state with the new visible count
106
+ setVisibleCount(newVisibleCount);
107
+ }, [estimateTagWidth]);
85
108
 
86
- // Update state with the new visible count
87
- setVisibleCount(newVisibleCount);
88
- };
109
+ // Effect 1: Handle tag changes (runs when val changes)
110
+ useEffect(() => {
111
+ if (!Array.isArray(val) || val.length === 0) {
112
+ setVisibleCount(0);
113
+ prevValRef.current = [];
114
+ return;
115
+ }
89
116
 
90
- // Initial calculation
91
- calculateVisibleItems();
117
+ // Check if array contents actually changed
118
+ const valArray = val as string[];
119
+ const hasChanged =
120
+ valArray.length !== prevValRef.current.length ||
121
+ valArray.some((item, index) => item !== prevValRef.current[index]);
122
+
123
+ if (hasChanged) {
124
+ calculateVisibleItems();
125
+ prevValRef.current = [...valArray];
126
+ }
127
+ }, [val, calculateVisibleItems]);
128
+
129
+ // Ref to store the idle callback ID
130
+ const idleCallbackRef = useRef<IdleCallbackHandle | null>(null);
131
+
132
+ // // Effect 2: Set up ResizeObserver (runs once on mount)
133
+ useEffect(() => {
134
+ if (!containerRef.current) {
135
+ return;
136
+ }
92
137
 
93
- // Debounced resize handler
94
138
  const debouncedCalculate = (): void => {
95
- if (resizeTimeoutRef.current) {
96
- clearTimeout(resizeTimeoutRef.current);
139
+ if (idleCallbackRef.current) {
140
+ cancelScheduledCallback(idleCallbackRef.current);
97
141
  }
98
- resizeTimeoutRef.current = setTimeout(() => {
142
+ idleCallbackRef.current = scheduleIdleCallback(() => {
99
143
  calculateVisibleItems();
100
- }, 100);
144
+ });
101
145
  };
102
146
 
147
+ // const resizeObserver = new ResizeObserver(calculateVisibleItems);
103
148
  const resizeObserver = new ResizeObserver(debouncedCalculate);
104
149
  resizeObserver.observe(containerRef.current);
105
150
 
106
151
  return () => {
107
- if (resizeTimeoutRef.current) {
108
- clearTimeout(resizeTimeoutRef.current);
152
+ if (idleCallbackRef.current) {
153
+ cancelScheduledCallback(idleCallbackRef.current);
109
154
  }
110
155
  resizeObserver.disconnect();
111
156
  };
112
- }, [val]);
113
-
114
- if (!Array.isArray(val) || val.length === 0) {
115
- return <></>;
157
+ }, [calculateVisibleItems]);
158
+
159
+ const stringArray = Array.isArray(val) ? (val as string[]) : [];
160
+
161
+ // Don't render items until we've calculated the visible count
162
+ if (visibleCount === null) {
163
+ return (
164
+ <div
165
+ ref={containerRef}
166
+ className={classes.container}
167
+ data-testid="tags-container"
168
+ title={stringArray.join(', ')}
169
+ />
170
+ );
116
171
  }
117
172
 
118
- const stringArray = val as string[];
119
173
  const visibleItems = stringArray.slice(0, visibleCount);
120
174
  const hiddenItems = stringArray.slice(visibleCount);
121
175