@centreon/ui 26.11.8 → 26.11.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@centreon/ui",
3
- "version": "26.11.8",
3
+ "version": "26.11.9",
4
4
  "description": "Centreon UI Components",
5
5
  "scripts": {
6
6
  "update:deps": "pnpx npm-check-updates -i --format group",
@@ -1,3 +1,4 @@
1
+ import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined';
1
2
  import EditIcon from '@mui/icons-material/Edit';
2
3
  import VisibilityIcon from '@mui/icons-material/Visibility';
3
4
 
@@ -205,7 +206,7 @@ describe('SortableEntriesList - single input rows', () => {
205
206
  });
206
207
  });
207
208
 
208
- it('removes a row and moves the focus to the delete button of the row taking its place', () => {
209
+ it('removes a row and moves the focus to the first input of the row taking its place', () => {
209
210
  const { onChange } = initializeTemplates({
210
211
  templates: [options[0], options[1], options[2]]
211
212
  });
@@ -217,9 +218,7 @@ describe('SortableEntriesList - single input rows', () => {
217
218
  expect(getLastValues(onChange)).to.deep.equal([options[1], options[2]]);
218
219
  });
219
220
  cy.focused()
220
- .should('have.attr', 'data-testid', 'delete-row')
221
- .closest('li')
222
- .find('[data-testid="Template"]')
221
+ .should('have.attr', 'data-testid', 'Template')
223
222
  .should('have.value', options[1].name);
224
223
  });
225
224
 
@@ -326,15 +325,17 @@ const renderMacroRow = ({
326
325
  setField,
327
326
  value: macro
328
327
  }: RenderRowParams<MacroRow>): JSX.Element => (
329
- <div className="flex gap-2">
328
+ <div className="grid grid-cols-2 gap-2 @max-[600px]:grid-cols-1">
330
329
  <TextField
331
330
  dataTestId="macro-name"
331
+ fullWidth
332
332
  label="Name"
333
333
  onChange={(event): void => setField('name', event.target.value)}
334
334
  value={macro.name}
335
335
  />
336
336
  <TextField
337
337
  dataTestId="macro-value"
338
+ fullWidth
338
339
  label="Value"
339
340
  onChange={(event): void => setField('value', event.target.value)}
340
341
  type={macro.isPassword ? 'password' : 'text'}
@@ -504,3 +505,288 @@ describe('SortableEntriesList - multiple input rows', () => {
504
505
  getRows().eq(1).should('have.class', 'origin-fromTpl');
505
506
  });
506
507
  });
508
+
509
+ const buildMacros = (count: number): Array<MacroRow> =>
510
+ Array.from({ length: count }, (_, index) => ({
511
+ ...emptyMacro,
512
+ name: `MACRO_${index + 1}`
513
+ }));
514
+
515
+ const panelWidths = [1400, 900, 720, 560];
516
+
517
+ // The panel width is state, so changing it resizes the mounted list at runtime.
518
+ const ResizablePanel = ({
519
+ count,
520
+ maxVisibleRows,
521
+ onChangeSpy
522
+ }: {
523
+ count: number;
524
+ maxVisibleRows?: number;
525
+ onChangeSpy: Stub;
526
+ }): JSX.Element => {
527
+ const [width, setWidth] = useState(panelWidths[0]);
528
+
529
+ return (
530
+ <div className="p-4">
531
+ {panelWidths.map((panelWidth) => (
532
+ <button
533
+ data-testid={`width-${panelWidth}`}
534
+ key={panelWidth}
535
+ onClick={(): void => setWidth(panelWidth)}
536
+ type="button"
537
+ >
538
+ {panelWidth}
539
+ </button>
540
+ ))}
541
+ <div data-testid="panel" style={{ width }}>
542
+ <ControlledList<MacroRow>
543
+ actions={(): Array<SortableRowAction> => [
544
+ {
545
+ icon: <VisibilityIcon />,
546
+ id: 'toggle-password',
547
+ label: 'Toggle password',
548
+ onClick: () => undefined
549
+ },
550
+ {
551
+ icon: <DescriptionOutlinedIcon />,
552
+ id: 'macro-description',
553
+ label: 'Description',
554
+ onClick: () => undefined
555
+ }
556
+ ]}
557
+ createValue={(): MacroRow => emptyMacro}
558
+ initialValues={buildMacros(count)}
559
+ maxVisibleRows={maxVisibleRows}
560
+ onChangeSpy={onChangeSpy}
561
+ renderRow={renderMacroRow}
562
+ />
563
+ </div>
564
+ </div>
565
+ );
566
+ };
567
+
568
+ const initializePanel = ({
569
+ count,
570
+ maxVisibleRows
571
+ }: {
572
+ count: number;
573
+ maxVisibleRows?: number;
574
+ }): { onChange: Stub } => {
575
+ const onChange = cy.stub();
576
+
577
+ cy.viewport(1600, 900);
578
+ cy.mount({
579
+ Component: (
580
+ <ResizablePanel
581
+ count={count}
582
+ maxVisibleRows={maxVisibleRows}
583
+ onChangeSpy={onChange}
584
+ />
585
+ )
586
+ });
587
+
588
+ return { onChange };
589
+ };
590
+
591
+ const getScrollArea = (): Cypress.Chainable =>
592
+ cy.findByTestId('sortable-entries-scroll');
593
+
594
+ const getRect = (element: Element): DOMRect => element.getBoundingClientRect();
595
+
596
+ const getLastNames = (onChange: Stub): Array<string> =>
597
+ getLastValues<MacroRow>(onChange).map(({ name }) => name);
598
+
599
+ describe('SortableEntriesList - capped list', () => {
600
+ it('shows maxVisibleRows rows plus half of the next one, and keeps the add button outside the scroll area', () => {
601
+ initializePanel({ count: 15, maxVisibleRows: 10 });
602
+
603
+ getScrollArea().should(($scrollArea) => {
604
+ const scrollArea = $scrollArea[0];
605
+ const rows = scrollArea.querySelectorAll('li');
606
+ const visibleBottom = getRect(scrollArea).bottom;
607
+
608
+ expect(scrollArea.scrollHeight).to.be.greaterThan(
609
+ scrollArea.clientHeight
610
+ );
611
+ expect(getRect(rows[9]).bottom).to.be.at.most(visibleBottom);
612
+ expect(visibleBottom - getRect(rows[10]).top).to.be.closeTo(
613
+ getRect(rows[10]).height / 2,
614
+ 1
615
+ );
616
+ });
617
+
618
+ cy.findByTestId('Add')
619
+ .should('be.visible')
620
+ .closest('[data-testid="sortable-entries-scroll"]')
621
+ .should('not.exist');
622
+ });
623
+
624
+ it('fades the cut-off row only while rows are hidden below', () => {
625
+ initializePanel({ count: 15, maxVisibleRows: 10 });
626
+
627
+ getScrollArea().should('not.have.css', 'mask-image', 'none');
628
+ getScrollArea().scrollTo('bottom');
629
+ getScrollArea().should('have.css', 'mask-image', 'none');
630
+ });
631
+
632
+ it('does not cap a list shorter than maxVisibleRows', () => {
633
+ initializePanel({ count: 5, maxVisibleRows: 10 });
634
+
635
+ getScrollArea()
636
+ .should('have.css', 'max-height', 'none')
637
+ .should(($scrollArea) => {
638
+ expect($scrollArea[0].scrollHeight).to.equal(
639
+ $scrollArea[0].clientHeight
640
+ );
641
+ });
642
+ });
643
+
644
+ it('focuses and scrolls to a row added at the end of a capped list', () => {
645
+ initializePanel({ count: 15, maxVisibleRows: 10 });
646
+
647
+ cy.findByTestId('Add').click();
648
+
649
+ getRows().should('have.length', 16);
650
+ cy.focused()
651
+ .should('have.attr', 'data-testid', 'macro-name')
652
+ .should(($input) => {
653
+ const scrollAreaRect = getRect(
654
+ $input[0].closest(
655
+ '[data-testid="sortable-entries-scroll"]'
656
+ ) as Element
657
+ );
658
+ const inputRect = getRect($input[0]);
659
+
660
+ expect(inputRect.top).to.be.at.least(scrollAreaRect.top);
661
+ expect(inputRect.bottom).to.be.at.most(scrollAreaRect.bottom);
662
+ });
663
+ });
664
+
665
+ it('scrolls to the focused new row when adding it makes the list reach the cap', () => {
666
+ initializePanel({ count: 10, maxVisibleRows: 10 });
667
+
668
+ getScrollArea().should('have.css', 'max-height', 'none');
669
+ cy.findByTestId('Add').click();
670
+
671
+ getRows().should('have.length', 11);
672
+ getScrollArea().should('not.have.css', 'max-height', 'none');
673
+ cy.focused()
674
+ .should('have.attr', 'data-testid', 'macro-name')
675
+ .closest('li')
676
+ .should(($row) => {
677
+ const scrollAreaRect = getRect(
678
+ $row[0].closest('[data-testid="sortable-entries-scroll"]') as Element
679
+ );
680
+
681
+ // 1px tolerance for sub-pixel row heights.
682
+ expect(getRect($row[0]).bottom).to.be.at.most(
683
+ scrollAreaRect.bottom + 1
684
+ );
685
+ });
686
+ getScrollArea().should('have.css', 'mask-image', 'none');
687
+ });
688
+
689
+ it('moves a row past the visible area with the keyboard, scrolling the list', () => {
690
+ const { onChange } = initializePanel({ count: 15, maxVisibleRows: 10 });
691
+
692
+ cy.moveSortableElement({
693
+ direction: 'down',
694
+ element: cy.findAllByTestId('drag-handle').eq(0),
695
+ times: 12
696
+ });
697
+
698
+ cy.wrap(onChange).should(() => {
699
+ expect(getLastNames(onChange).indexOf('MACRO_1')).to.equal(12);
700
+ });
701
+ getScrollArea().its('0.scrollTop').should('be.greaterThan', 0);
702
+ });
703
+
704
+ it('auto-scrolls while a row is dragged with the pointer near the bottom edge', () => {
705
+ const { onChange } = initializePanel({ count: 15, maxVisibleRows: 10 });
706
+
707
+ cy.findAllByTestId('drag-handle')
708
+ .eq(0)
709
+ .then(($handle) => {
710
+ const { left, top, width, height } = getRect($handle[0]);
711
+ const pointer = {
712
+ button: 0,
713
+ clientX: left + width / 2,
714
+ isPrimary: true,
715
+ pointerId: 1
716
+ };
717
+
718
+ cy.wrap($handle).trigger('pointerdown', {
719
+ ...pointer,
720
+ clientY: top + height / 2
721
+ });
722
+
723
+ getScrollArea().then(($scrollArea) => {
724
+ const clientY = getRect($scrollArea[0]).bottom - 5;
725
+
726
+ cy.document().trigger('pointermove', { ...pointer, clientY });
727
+ getScrollArea().its('0.scrollTop').should('be.greaterThan', 100);
728
+ cy.document().trigger('pointermove', { ...pointer, clientY });
729
+ cy.document().trigger('pointerup', { ...pointer, clientY });
730
+ });
731
+ });
732
+
733
+ cy.wrap(onChange).should(() => {
734
+ expect(getLastNames(onChange).indexOf('MACRO_1')).to.be.greaterThan(10);
735
+ });
736
+ });
737
+ });
738
+
739
+ describe('SortableEntriesList - container responsive rows', () => {
740
+ const getFieldRects = (): Cypress.Chainable<Array<DOMRect>> =>
741
+ getRows()
742
+ .eq(0)
743
+ .find('.MuiFormControl-root')
744
+ .then(($fields) => $fields.toArray().map(getRect));
745
+
746
+ it('keeps the fields and the four buttons on one line at 1400, 900 and 720px, resized at runtime', () => {
747
+ initializePanel({ count: 3 });
748
+
749
+ [1400, 900, 720, 1400].forEach((width) => {
750
+ cy.findByTestId(`width-${width}`).click();
751
+ cy.findByTestId('panel').should('have.css', 'width', `${width}px`);
752
+
753
+ getFieldRects().should(([name, value]) => {
754
+ expect(value.top).to.equal(name.top);
755
+ expect(name.width).to.be.at.least(275);
756
+ expect(value.width).to.be.at.least(275);
757
+ });
758
+
759
+ cy.get('[data-row-fields]')
760
+ .eq(0)
761
+ .next()
762
+ .find('button')
763
+ .should('have.length', 4)
764
+ .should(($buttons) => {
765
+ const rects = $buttons.toArray().map(getRect);
766
+ const panelRight = getRect(
767
+ Cypress.$('[data-testid="panel"]')[0]
768
+ ).right;
769
+
770
+ rects.slice(1).forEach((rect, index) => {
771
+ expect(rect.top).to.equal(rects[index].top);
772
+ expect(rect.left - rects[index].left).to.equal(32);
773
+ });
774
+ expect(rects[3].right).to.be.at.most(panelRight);
775
+ });
776
+ });
777
+ });
778
+
779
+ it('stacks the fields when the list gets narrower than 600px, whatever the viewport', () => {
780
+ initializePanel({ count: 3 });
781
+
782
+ cy.findByTestId('width-560').click();
783
+ getFieldRects().should(([name, value]) => {
784
+ expect(value.top).to.be.greaterThan(name.bottom);
785
+ });
786
+
787
+ cy.findByTestId('width-900').click();
788
+ getFieldRects().should(([name, value]) => {
789
+ expect(value.top).to.equal(name.top);
790
+ });
791
+ });
792
+ });
@@ -1,3 +1,4 @@
1
+ import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined';
1
2
  import EditIcon from '@mui/icons-material/Edit';
2
3
  import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
3
4
  import VisibilityIcon from '@mui/icons-material/Visibility';
@@ -90,7 +91,10 @@ const renderTemplateRow = ({
90
91
  };
91
92
 
92
93
  type TemplatesListArgs = Partial<
93
- Pick<SortableEntriesListProps<TemplateRow>, 'addLabel' | 'draggable'>
94
+ Pick<
95
+ SortableEntriesListProps<TemplateRow>,
96
+ 'addLabel' | 'draggable' | 'maxVisibleRows'
97
+ >
94
98
  >;
95
99
 
96
100
  const TemplatesList = (args: TemplatesListArgs): JSX.Element => {
@@ -132,10 +136,11 @@ const TemplatesList = (args: TemplatesListArgs): JSX.Element => {
132
136
  };
133
137
 
134
138
  export const Templates: Story = {
135
- args: { draggable: true },
139
+ args: { draggable: true, maxVisibleRows: 10 },
136
140
  argTypes: {
137
141
  addLabel: { control: 'text' },
138
- draggable: { control: 'boolean' }
142
+ draggable: { control: 'boolean' },
143
+ maxVisibleRows: { control: 'number' }
139
144
  },
140
145
  parameters: { mockData: templatesMockData },
141
146
  render: (args) => <TemplatesList {...(args as TemplatesListArgs)} />
@@ -167,7 +172,7 @@ const renderMacroRow = ({
167
172
  setField,
168
173
  value: macro
169
174
  }: RenderRowParams<MacroRow>): JSX.Element => (
170
- <div className="grid grid-cols-2 gap-2">
175
+ <div className="grid grid-cols-2 gap-2 @max-[600px]:grid-cols-1">
171
176
  <TextField
172
177
  dataTestId="macro-name"
173
178
  fullWidth
@@ -197,12 +202,22 @@ const initialMacros: Array<MacroRow> = [
197
202
  { isPassword: false, name: 'PORT', origin: 'fromCommand', value: '443' }
198
203
  ];
199
204
 
205
+ const buildMacros = (count: number): Array<MacroRow> =>
206
+ Array.from({ length: count }, (_, index) => ({
207
+ isPassword: false,
208
+ name: `MACRO_${index + 1}`,
209
+ origin: 'direct',
210
+ value: `value ${index + 1}`
211
+ }));
212
+
200
213
  const MacrosList = ({
201
214
  draggable,
202
- initialValues = initialMacros
215
+ initialValues = initialMacros,
216
+ maxVisibleRows
203
217
  }: {
204
218
  draggable: boolean;
205
219
  initialValues?: Array<MacroRow>;
220
+ maxVisibleRows?: number;
206
221
  }): JSX.Element => {
207
222
  const [macros, setMacros] = useState(initialValues);
208
223
 
@@ -216,7 +231,7 @@ const MacrosList = ({
216
231
  );
217
232
 
218
233
  return (
219
- <div className="w-[640px]">
234
+ <>
220
235
  <SortableEntriesList<MacroRow>
221
236
  actions={({ index, value }) => [
222
237
  {
@@ -224,29 +239,87 @@ const MacrosList = ({
224
239
  id: 'toggle-password',
225
240
  label: value.isPassword ? 'Show value' : 'Hide value',
226
241
  onClick: () => toggleIsPassword(index)
242
+ },
243
+ {
244
+ icon: <DescriptionOutlinedIcon />,
245
+ id: 'macro-description',
246
+ label: 'Description',
247
+ onClick: () => undefined
227
248
  }
228
249
  ]}
229
250
  createValue={createMacroRow}
230
251
  draggable={draggable}
231
252
  getRowClassName={({ value }) => macroRowClassName[value.origin]}
232
253
  label="Custom macros"
254
+ maxVisibleRows={maxVisibleRows}
233
255
  onChange={setMacros}
234
256
  renderRow={renderMacroRow}
235
257
  values={macros}
236
258
  />
237
259
  <ValuesPreview values={macros} />
238
- </div>
260
+ </>
239
261
  );
240
262
  };
241
263
 
242
264
  export const CustomMacros: Story = {
243
- render: () => <MacrosList draggable />
265
+ render: () => (
266
+ <div className="w-[640px]">
267
+ <MacrosList draggable />
268
+ </div>
269
+ )
244
270
  };
245
271
 
246
272
  export const NonDraggable: Story = {
247
- render: () => <MacrosList draggable={false} />
273
+ render: () => (
274
+ <div className="w-[640px]">
275
+ <MacrosList draggable={false} />
276
+ </div>
277
+ )
248
278
  };
249
279
 
250
280
  export const Empty: Story = {
251
- render: () => <MacrosList draggable initialValues={[]} />
281
+ render: () => (
282
+ <div className="w-[640px]">
283
+ <MacrosList draggable initialValues={[]} />
284
+ </div>
285
+ )
286
+ };
287
+
288
+ // Capped list in a resizable container: drag the bottom-right corner or use the
289
+ // presets to check the row layout against the list width, not the viewport.
290
+ const panelWidths = [1400, 900, 720, 560];
291
+
292
+ const ScrollableMacrosList = (): JSX.Element => {
293
+ const [width, setWidth] = useState(panelWidths[0]);
294
+
295
+ return (
296
+ <div className="flex flex-col gap-2">
297
+ <div className="flex gap-2">
298
+ {panelWidths.map((panelWidth) => (
299
+ <button
300
+ className="rounded border px-2 py-1"
301
+ key={panelWidth}
302
+ onClick={(): void => setWidth(panelWidth)}
303
+ type="button"
304
+ >
305
+ {panelWidth}px
306
+ </button>
307
+ ))}
308
+ </div>
309
+ <div
310
+ className="max-w-full resize-x overflow-auto border border-dashed p-2"
311
+ style={{ width }}
312
+ >
313
+ <MacrosList
314
+ draggable
315
+ initialValues={buildMacros(15)}
316
+ maxVisibleRows={10}
317
+ />
318
+ </div>
319
+ </div>
320
+ );
321
+ };
322
+
323
+ export const ScrollableAndResizable: Story = {
324
+ render: () => <ScrollableMacrosList />
252
325
  };
@@ -29,6 +29,7 @@ import {
29
29
  labelRowMovedOver,
30
30
  labelRowPickedUp
31
31
  } from './translatedLabels';
32
+ import { useMaxVisibleRows } from './useMaxVisibleRows';
32
33
  import { useSortableEntries } from './useSortableEntries';
33
34
 
34
35
  export interface Props<T> {
@@ -42,8 +43,17 @@ export interface Props<T> {
42
43
  getRowClassName?: (params: RowParams<T>) => string | undefined;
43
44
  /** Accessible name of the list. */
44
45
  label?: string;
46
+ /**
47
+ * Caps the list to this many rows, the next one half visible, and scrolls
48
+ * the rest. The add button stays outside the scroll area.
49
+ */
50
+ maxVisibleRows?: number;
45
51
  onChange: (values: Array<T>) => void;
46
- /** Renders the inputs of one row. `values` gives access to sibling rows. */
52
+ /**
53
+ * Renders the inputs of one row. `values` gives access to sibling rows. The
54
+ * list is a CSS container, so the row layout can adapt to the list width
55
+ * with container query classes (e.g. `@max-[600px]:grid-cols-1`).
56
+ */
47
57
  renderRow: (params: RenderRowParams<T>) => ReactNode;
48
58
  values: Array<T>;
49
59
  }
@@ -60,6 +70,7 @@ export const SortableEntriesList = <T,>({
60
70
  draggable = true,
61
71
  getRowClassName,
62
72
  label,
73
+ maxVisibleRows,
63
74
  onChange,
64
75
  renderRow,
65
76
  values
@@ -81,10 +92,21 @@ export const SortableEntriesList = <T,>({
81
92
  values
82
93
  });
83
94
 
95
+ const {
96
+ hasHiddenRowsBelow,
97
+ listRef,
98
+ maxHeight,
99
+ scrollRef,
100
+ updateHiddenRowsBelow
101
+ } = useMaxVisibleRows(maxVisibleRows);
102
+
84
103
  const sensors = useSensors(
85
104
  useSensor(PointerSensor),
86
105
  useSensor(KeyboardSensor, {
87
- coordinateGetter: sortableKeyboardCoordinates
106
+ coordinateGetter: sortableKeyboardCoordinates,
107
+ // Instant scroll keeps each key press based on up-to-date positions in a
108
+ // capped list.
109
+ scrollBehavior: 'auto'
88
110
  })
89
111
  );
90
112
 
@@ -113,7 +135,7 @@ export const SortableEntriesList = <T,>({
113
135
  };
114
136
 
115
137
  return (
116
- <div ref={containerRef}>
138
+ <div className="@container w-full" ref={containerRef}>
117
139
  <ItemComposition labelAdd={translatedAddLabel} onAddItem={addEntry}>
118
140
  {[
119
141
  <DndContext
@@ -133,28 +155,41 @@ export const SortableEntriesList = <T,>({
133
155
  items={entryIds}
134
156
  strategy={verticalListSortingStrategy}
135
157
  >
136
- <ul
137
- aria-label={label}
138
- className="m-0 flex w-full list-none flex-col gap-2 p-0"
158
+ <div
159
+ className={
160
+ maxVisibleRows
161
+ ? `relative w-full overflow-y-auto pt-2 [scrollbar-gutter:stable] [scrollbar-width:thin] ${hasHiddenRowsBelow ? '[mask-image:linear-gradient(to_bottom,#000_calc(100%-2rem),transparent)]' : ''}`
162
+ : 'w-full'
163
+ }
164
+ data-testid="sortable-entries-scroll"
165
+ onScroll={updateHiddenRowsBelow}
166
+ ref={scrollRef}
167
+ style={{ maxHeight }}
139
168
  >
140
- {entries.map(({ id, value }, index) => {
141
- const rowParams = { index, value, values };
169
+ <ul
170
+ aria-label={label}
171
+ className="m-0 flex w-full list-none flex-col gap-2 p-0"
172
+ ref={listRef}
173
+ >
174
+ {entries.map(({ id, value }, index) => {
175
+ const rowParams = { index, value, values };
142
176
 
143
- return (
144
- <Row<T>
145
- actions={actions?.(rowParams) ?? []}
146
- className={getRowClassName?.(rowParams)}
147
- draggable={draggable}
148
- id={id}
149
- key={id}
150
- onDelete={removeEntry}
151
- onUpdate={updateEntry}
152
- renderRow={renderRow}
153
- rowParams={rowParams}
154
- />
155
- );
156
- })}
157
- </ul>
177
+ return (
178
+ <Row<T>
179
+ actions={actions?.(rowParams) ?? []}
180
+ className={getRowClassName?.(rowParams)}
181
+ draggable={draggable}
182
+ id={id}
183
+ key={id}
184
+ onDelete={removeEntry}
185
+ onUpdate={updateEntry}
186
+ renderRow={renderRow}
187
+ rowParams={rowParams}
188
+ />
189
+ );
190
+ })}
191
+ </ul>
192
+ </div>
158
193
  </SortableContext>
159
194
  </DndContext>
160
195
  ]}
@@ -0,0 +1,77 @@
1
+ import { type RefObject, useLayoutEffect, useRef, useState } from 'react';
2
+
3
+ interface Result {
4
+ hasHiddenRowsBelow: boolean;
5
+ listRef: RefObject<HTMLUListElement | null>;
6
+ maxHeight?: number;
7
+ scrollRef: RefObject<HTMLDivElement | null>;
8
+ updateHiddenRowsBelow: () => void;
9
+ }
10
+
11
+ // Caps the list height to `maxVisibleRows` rows, measured from the rendered
12
+ // rows so it stays right whatever the row height (content, container width).
13
+ export const useMaxVisibleRows = (maxVisibleRows?: number): Result => {
14
+ const scrollRef = useRef<HTMLDivElement>(null);
15
+ const listRef = useRef<HTMLUListElement>(null);
16
+ const [maxHeight, setMaxHeight] = useState<number>();
17
+ const [hasHiddenRowsBelow, setHasHiddenRowsBelow] = useState(false);
18
+
19
+ const updateHiddenRowsBelow = (): void => {
20
+ const container = scrollRef.current;
21
+
22
+ setHasHiddenRowsBelow(
23
+ !!container &&
24
+ container.scrollHeight - container.scrollTop - container.clientHeight >
25
+ 1
26
+ );
27
+ };
28
+
29
+ useLayoutEffect(() => {
30
+ const list = listRef.current;
31
+
32
+ if (!maxVisibleRows || !list) {
33
+ setMaxHeight(undefined);
34
+
35
+ return undefined;
36
+ }
37
+
38
+ const observer = new ResizeObserver(() => {
39
+ const cutOffRow = list.children[maxVisibleRows] as
40
+ | HTMLElement
41
+ | undefined;
42
+
43
+ // Half of the first row past the cap stays visible, so the cut reads as
44
+ // a deliberate hint that the list scrolls.
45
+ setMaxHeight(
46
+ cutOffRow
47
+ ? Math.round(cutOffRow.offsetTop + cutOffRow.offsetHeight / 2)
48
+ : undefined
49
+ );
50
+ updateHiddenRowsBelow();
51
+ });
52
+
53
+ observer.observe(list);
54
+
55
+ return () => observer.disconnect();
56
+ }, [maxVisibleRows]);
57
+
58
+ useLayoutEffect(() => {
59
+ // The cap can apply after a row was added and focused (e.g. the 11th row
60
+ // of a list capped to 10): bring the focused row back into view.
61
+ const focusedRow = document.activeElement?.closest('li');
62
+
63
+ if (focusedRow && scrollRef.current?.contains(focusedRow)) {
64
+ focusedRow.scrollIntoView({ block: 'nearest' });
65
+ }
66
+
67
+ updateHiddenRowsBelow();
68
+ }, [maxHeight]);
69
+
70
+ return {
71
+ hasHiddenRowsBelow,
72
+ listRef,
73
+ maxHeight,
74
+ scrollRef,
75
+ updateHiddenRowsBelow
76
+ };
77
+ };
@@ -77,7 +77,12 @@ export const useSortableEntries = <T>({
77
77
  }
78
78
 
79
79
  pendingFocusSelectorRef.current = null;
80
- containerRef.current?.querySelector<HTMLElement>(selector)?.focus();
80
+
81
+ const element = containerRef.current?.querySelector<HTMLElement>(selector);
82
+
83
+ element?.focus();
84
+ // In a capped list, show the whole row, not only the focused element.
85
+ element?.closest('li')?.scrollIntoView({ block: 'nearest' });
81
86
  }, [entries]);
82
87
 
83
88
  const commit = (nextEntries: Array<Entry<T>>): void => {
@@ -108,7 +113,7 @@ export const useSortableEntries = <T>({
108
113
  currentEntries[index + 1] ?? currentEntries[index - 1];
109
114
 
110
115
  pendingFocusSelectorRef.current = nextFocusedEntry
111
- ? `${getRowSelector(nextFocusedEntry.id)} [data-testid="delete-row"]`
116
+ ? `${getRowSelector(nextFocusedEntry.id)} [data-row-fields] :is(${focusableSelector})`
112
117
  : `button[aria-label="${CSS.escape(addLabel)}"]`;
113
118
  commit(currentEntries.filter((entry) => entry.id !== id));
114
119
  };