@arbor-education/design-system.components 0.21.1 → 0.22.0

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 (121) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/component-library.md +15 -14
  3. package/dist/components/articleCard/ArticleCard.d.ts +2 -2
  4. package/dist/components/articleCard/ArticleCard.d.ts.map +1 -1
  5. package/dist/components/articleCard/ArticleCard.js +3 -3
  6. package/dist/components/articleCard/ArticleCard.js.map +1 -1
  7. package/dist/components/articleCard/ArticleCard.stories.d.ts +11 -3
  8. package/dist/components/articleCard/ArticleCard.stories.d.ts.map +1 -1
  9. package/dist/components/articleCard/ArticleCard.stories.js +16 -11
  10. package/dist/components/articleCard/ArticleCard.stories.js.map +1 -1
  11. package/dist/components/iconText/IconText.d.ts +43 -0
  12. package/dist/components/iconText/IconText.d.ts.map +1 -0
  13. package/dist/components/iconText/IconText.js +29 -0
  14. package/dist/components/iconText/IconText.js.map +1 -0
  15. package/dist/components/{icoText/IcoText.stories.d.ts → iconText/IconText.stories.d.ts} +8 -9
  16. package/dist/components/iconText/IconText.stories.d.ts.map +1 -0
  17. package/dist/components/{icoText/IcoText.stories.js → iconText/IconText.stories.js} +81 -81
  18. package/dist/components/iconText/IconText.stories.js.map +1 -0
  19. package/dist/components/iconText/IconText.test.d.ts +2 -0
  20. package/dist/components/iconText/IconText.test.d.ts.map +1 -0
  21. package/dist/components/{icoText/IcoText.test.js → iconText/IconText.test.js} +6 -6
  22. package/dist/components/iconText/IconText.test.js.map +1 -0
  23. package/dist/components/modal/Modal.d.ts +1 -0
  24. package/dist/components/modal/Modal.d.ts.map +1 -1
  25. package/dist/components/modal/Modal.js +2 -2
  26. package/dist/components/modal/Modal.js.map +1 -1
  27. package/dist/components/tag/Tag.d.ts +14 -1
  28. package/dist/components/tag/Tag.d.ts.map +1 -1
  29. package/dist/components/tag/Tag.js +9 -3
  30. package/dist/components/tag/Tag.js.map +1 -1
  31. package/dist/components/tag/Tag.stories.d.ts +1 -1
  32. package/dist/components/tag/Tag.stories.d.ts.map +1 -1
  33. package/dist/components/tag/Tag.stories.js +3 -3
  34. package/dist/components/tag/Tag.stories.js.map +1 -1
  35. package/dist/components/tag/Tag.test.js +36 -5
  36. package/dist/components/tag/Tag.test.js.map +1 -1
  37. package/dist/components/tagList/TagList.d.ts +49 -0
  38. package/dist/components/tagList/TagList.d.ts.map +1 -0
  39. package/dist/components/tagList/TagList.js +114 -0
  40. package/dist/components/tagList/TagList.js.map +1 -0
  41. package/dist/components/tagList/TagList.stories.d.ts +130 -0
  42. package/dist/components/tagList/TagList.stories.d.ts.map +1 -0
  43. package/dist/components/tagList/TagList.stories.js +443 -0
  44. package/dist/components/tagList/TagList.stories.js.map +1 -0
  45. package/dist/components/{icoText/IcoText.test.d.ts → tagList/TagList.test.d.ts} +1 -1
  46. package/dist/components/tagList/TagList.test.d.ts.map +1 -0
  47. package/dist/components/tagList/TagList.test.js +246 -0
  48. package/dist/components/tagList/TagList.test.js.map +1 -0
  49. package/dist/components/tagList/useTagListCollapsedLayout.d.ts +19 -0
  50. package/dist/components/tagList/useTagListCollapsedLayout.d.ts.map +1 -0
  51. package/dist/components/tagList/useTagListCollapsedLayout.js +48 -0
  52. package/dist/components/tagList/useTagListCollapsedLayout.js.map +1 -0
  53. package/dist/components/tagList/useVisibleTags.d.ts +18 -0
  54. package/dist/components/tagList/useVisibleTags.d.ts.map +1 -0
  55. package/dist/components/tagList/useVisibleTags.js +41 -0
  56. package/dist/components/tagList/useVisibleTags.js.map +1 -0
  57. package/dist/index.css +130 -10
  58. package/dist/index.css.map +1 -1
  59. package/dist/index.d.ts +3 -1
  60. package/dist/index.d.ts.map +1 -1
  61. package/dist/index.js +2 -1
  62. package/dist/index.js.map +1 -1
  63. package/dist/utils/hooks/useElementWidth.d.ts +2 -0
  64. package/dist/utils/hooks/useElementWidth.d.ts.map +1 -0
  65. package/dist/utils/hooks/useElementWidth.js +30 -0
  66. package/dist/utils/hooks/useElementWidth.js.map +1 -0
  67. package/dist/utils/hooks/useMeasuredChildWidths.d.ts +8 -0
  68. package/dist/utils/hooks/useMeasuredChildWidths.d.ts.map +1 -0
  69. package/dist/utils/hooks/useMeasuredChildWidths.js +26 -0
  70. package/dist/utils/hooks/useMeasuredChildWidths.js.map +1 -0
  71. package/dist/utils/hooks/useRovingFocus.d.ts +18 -0
  72. package/dist/utils/hooks/useRovingFocus.d.ts.map +1 -0
  73. package/dist/utils/hooks/useRovingFocus.js +130 -0
  74. package/dist/utils/hooks/useRovingFocus.js.map +1 -0
  75. package/dist/utils/hooks/useRovingFocus.test.d.ts +2 -0
  76. package/dist/utils/hooks/useRovingFocus.test.d.ts.map +1 -0
  77. package/dist/utils/hooks/useRovingFocus.test.js +59 -0
  78. package/dist/utils/hooks/useRovingFocus.test.js.map +1 -0
  79. package/dist/utils/spacedWidths.d.ts +3 -0
  80. package/dist/utils/spacedWidths.d.ts.map +1 -0
  81. package/dist/utils/spacedWidths.js +28 -0
  82. package/dist/utils/spacedWidths.js.map +1 -0
  83. package/dist/utils/spacedWidths.test.d.ts +2 -0
  84. package/dist/utils/spacedWidths.test.d.ts.map +1 -0
  85. package/dist/utils/spacedWidths.test.js +17 -0
  86. package/dist/utils/spacedWidths.test.js.map +1 -0
  87. package/package.json +1 -1
  88. package/src/components/articleCard/ArticleCard.stories.tsx +17 -12
  89. package/src/components/articleCard/ArticleCard.tsx +9 -9
  90. package/src/components/{icoText/IcoText.stories.tsx → iconText/IconText.stories.tsx} +112 -112
  91. package/src/components/{icoText/IcoText.test.tsx → iconText/IconText.test.tsx} +10 -10
  92. package/src/components/{icoText/IcoText.tsx → iconText/IconText.tsx} +27 -20
  93. package/src/components/modal/Modal.tsx +5 -1
  94. package/src/components/tag/Tag.stories.tsx +4 -4
  95. package/src/components/tag/Tag.test.tsx +62 -5
  96. package/src/components/tag/Tag.tsx +61 -3
  97. package/src/components/tag/tag.scss +80 -9
  98. package/src/components/tagList/TagList.stories.tsx +564 -0
  99. package/src/components/tagList/TagList.test.tsx +342 -0
  100. package/src/components/tagList/TagList.tsx +296 -0
  101. package/src/components/tagList/tagList.scss +56 -0
  102. package/src/components/tagList/useTagListCollapsedLayout.ts +83 -0
  103. package/src/components/tagList/useVisibleTags.ts +74 -0
  104. package/src/index.scss +2 -1
  105. package/src/index.ts +3 -1
  106. package/src/tokens.scss +2 -1
  107. package/src/utils/hooks/useElementWidth.ts +39 -0
  108. package/src/utils/hooks/useMeasuredChildWidths.ts +39 -0
  109. package/src/utils/hooks/useRovingFocus.test.tsx +105 -0
  110. package/src/utils/hooks/useRovingFocus.ts +163 -0
  111. package/src/utils/spacedWidths.test.ts +20 -0
  112. package/src/utils/spacedWidths.ts +37 -0
  113. package/dist/components/icoText/IcoText.d.ts +0 -37
  114. package/dist/components/icoText/IcoText.d.ts.map +0 -1
  115. package/dist/components/icoText/IcoText.js +0 -29
  116. package/dist/components/icoText/IcoText.js.map +0 -1
  117. package/dist/components/icoText/IcoText.stories.d.ts.map +0 -1
  118. package/dist/components/icoText/IcoText.stories.js.map +0 -1
  119. package/dist/components/icoText/IcoText.test.d.ts.map +0 -1
  120. package/dist/components/icoText/IcoText.test.js.map +0 -1
  121. /package/src/components/{icoText/icoText.scss → iconText/iconText.scss} +0 -0
@@ -0,0 +1,83 @@
1
+ import { useMemo, useRef } from 'react';
2
+ import { useMeasuredChildWidths } from 'Utils/hooks/useMeasuredChildWidths';
3
+ import { useElementWidth } from 'Utils/hooks/useElementWidth';
4
+ import type { TagListItem, TagListOverflowRenderArgs } from './TagList.js';
5
+ import { useVisibleTags } from './useVisibleTags.js';
6
+
7
+ type UseTagListCollapsedLayoutParams = {
8
+ items: TagListItem[];
9
+ wrap: boolean;
10
+ collapseOverflow: boolean;
11
+ };
12
+
13
+ type TagListCollapsedLayout = {
14
+ contentRef: React.RefObject<HTMLDivElement | null>;
15
+ measureTrackRef: React.RefObject<HTMLUListElement | null>;
16
+ overflowProbeRef: React.RefObject<HTMLDivElement | null>;
17
+ shouldCollapse: boolean;
18
+ visibleItemIndices: number[];
19
+ visibleItems: TagListItem[];
20
+ hiddenItemCount: number;
21
+ overflowArgs: TagListOverflowRenderArgs;
22
+ };
23
+
24
+ export const useTagListCollapsedLayout = ({
25
+ items,
26
+ wrap,
27
+ collapseOverflow,
28
+ }: UseTagListCollapsedLayoutParams): TagListCollapsedLayout => {
29
+ const contentRef = useRef<HTMLDivElement>(null);
30
+ const measureTrackRef = useRef<HTMLUListElement>(null);
31
+ const overflowProbeRef = useRef<HTMLDivElement>(null);
32
+
33
+ const shouldCollapse = collapseOverflow && !wrap && items.length > 0;
34
+ const itemWatchKey = useMemo(() => items.map(item => item.id).join('|'), [items]);
35
+
36
+ const contentWidth = useElementWidth(contentRef, itemWatchKey);
37
+ const overflowProbeWidth = useElementWidth(overflowProbeRef, `${itemWatchKey}-overflow`);
38
+ const { childGap: itemGap, childWidths: itemWidths } = useMeasuredChildWidths(measureTrackRef, itemWatchKey);
39
+
40
+ const layout = useVisibleTags({
41
+ containerWidth: contentWidth,
42
+ itemWidths,
43
+ itemGap,
44
+ overflowWidth: overflowProbeWidth,
45
+ allowOverflow: shouldCollapse,
46
+ });
47
+
48
+ const canMeasureCollapsedLayout = shouldCollapse
49
+ && contentWidth > 0
50
+ && itemWidths.length === items.length
51
+ && overflowProbeWidth > 0;
52
+
53
+ const visibleItemIndices = useMemo(
54
+ () =>
55
+ canMeasureCollapsedLayout
56
+ ? layout.visibleItemIndices
57
+ : items.map((_, index) => index),
58
+ [canMeasureCollapsedLayout, items, layout.visibleItemIndices],
59
+ );
60
+ const hiddenItemCount = canMeasureCollapsedLayout ? layout.hiddenItemCount : 0;
61
+ const visibleItems = visibleItemIndices
62
+ .map(index => items[index])
63
+ .filter((item): item is TagListItem => item != null);
64
+ const overflowArgs = useMemo(
65
+ (): TagListOverflowRenderArgs => ({
66
+ hiddenItemCount,
67
+ totalItemCount: items.length,
68
+ visibleItemCount: visibleItems.length,
69
+ }),
70
+ [hiddenItemCount, items.length, visibleItems.length],
71
+ );
72
+
73
+ return {
74
+ contentRef,
75
+ measureTrackRef,
76
+ overflowProbeRef,
77
+ shouldCollapse,
78
+ visibleItemIndices,
79
+ visibleItems,
80
+ hiddenItemCount,
81
+ overflowArgs,
82
+ };
83
+ };
@@ -0,0 +1,74 @@
1
+ import { useMemo } from 'react';
2
+ import { sumSpacedWidths } from 'Utils/spacedWidths';
3
+
4
+ type ComputeVisibleTagsLayoutParams = {
5
+ containerWidth: number;
6
+ itemWidths: number[];
7
+ itemGap: number;
8
+ overflowWidth: number;
9
+ allowOverflow: boolean;
10
+ safetyBuffer?: number;
11
+ };
12
+
13
+ export type VisibleTagsLayout = {
14
+ visibleItemIndices: number[];
15
+ hiddenItemCount: number;
16
+ hasOverflow: boolean;
17
+ };
18
+
19
+ export const computeVisibleTagsLayout = ({
20
+ containerWidth,
21
+ itemWidths,
22
+ itemGap,
23
+ overflowWidth,
24
+ allowOverflow,
25
+ safetyBuffer = 1,
26
+ }: ComputeVisibleTagsLayoutParams): VisibleTagsLayout => {
27
+ const totalItemCount = itemWidths.length;
28
+
29
+ if (totalItemCount === 0 || containerWidth <= 0) {
30
+ return {
31
+ visibleItemIndices: [],
32
+ hiddenItemCount: 0,
33
+ hasOverflow: false,
34
+ };
35
+ }
36
+
37
+ for (let visibleItemCount = totalItemCount; visibleItemCount >= 0; visibleItemCount -= 1) {
38
+ const hiddenItemCount = totalItemCount - visibleItemCount;
39
+ const hasOverflow = hiddenItemCount > 0;
40
+ const overflowReserve = hasOverflow && allowOverflow
41
+ ? overflowWidth + (visibleItemCount > 0 ? itemGap : 0)
42
+ : 0;
43
+ const requiredWidth = sumSpacedWidths(itemWidths, itemGap, visibleItemCount) + overflowReserve;
44
+
45
+ if (requiredWidth <= Math.max(0, containerWidth - safetyBuffer)) {
46
+ return {
47
+ visibleItemIndices: Array.from({ length: visibleItemCount }, (_, index) => index),
48
+ hiddenItemCount,
49
+ hasOverflow,
50
+ };
51
+ }
52
+ }
53
+
54
+ return {
55
+ visibleItemIndices: [],
56
+ hiddenItemCount: totalItemCount,
57
+ hasOverflow: allowOverflow,
58
+ };
59
+ };
60
+
61
+ type UseVisibleTagsParams = ComputeVisibleTagsLayoutParams;
62
+
63
+ export const useVisibleTags = (params: UseVisibleTagsParams): VisibleTagsLayout =>
64
+ useMemo(
65
+ () => computeVisibleTagsLayout(params),
66
+ [
67
+ params.allowOverflow,
68
+ params.containerWidth,
69
+ params.itemGap,
70
+ params.itemWidths,
71
+ params.overflowWidth,
72
+ params.safetyBuffer,
73
+ ],
74
+ );
package/src/index.scss CHANGED
@@ -3,7 +3,7 @@
3
3
  @use "./global.scss";
4
4
  @use "components/button/button.scss";
5
5
  @use "components/icon/icon.scss";
6
- @use "components/icoText/icoText.scss";
6
+ @use "components/iconText/iconText.scss";
7
7
  @use "components/heading/heading.scss";
8
8
  @use "components/card/card.scss";
9
9
  @use "components/articleCard/articleCard.scss";
@@ -19,6 +19,7 @@
19
19
  @use "components/formField/inputs/selectDropdown/selectDropdown";
20
20
  @use "components/formField/inputs/colourPickerDropdown/colourPickerDropdown.scss";
21
21
  @use "components/tag/tag.scss";
22
+ @use "components/tagList/tagList.scss";
22
23
  @use "components/dot/dot.scss";
23
24
  @use "components/badge/badge.scss";
24
25
  @use "components/pill/pill.scss";
package/src/index.ts CHANGED
@@ -25,7 +25,7 @@ export { TextArea } from 'Components/formField/inputs/textArea/TextArea';
25
25
  export { TimeInput } from 'Components/formField/inputs/time/TimeInput';
26
26
  export { Heading } from 'Components/heading/Heading';
27
27
  export { Icon } from 'Components/icon/Icon';
28
- export { IcoText, type IcoTextHeadingProps, type IcoTextIconProps, type IcoTextParagraphProps, type IcoTextProps } from 'Components/icoText/IcoText';
28
+ export { IconText, type IconTextHeadingProps, type IconTextIconProps, type IconTextParagraphProps, type IconTextProps } from 'Components/iconText/IconText';
29
29
  export { KPICard, type KPICardProps } from 'Components/kpiCard/KPICard';
30
30
  export { KVPList, type KVPListDefinitionProps, type KVPListProps, type KVPListRowProps, type KVPListTermProps } from 'Components/kvpList/KVPList';
31
31
  export { Modal } from 'Components/modal/Modal';
@@ -48,6 +48,8 @@ export { CheckboxCellRenderer } from 'Components/table/cellRenderers/CheckboxCel
48
48
  export { Table } from 'Components/table/Table';
49
49
  export { Tabs } from 'Components/tabs/Tabs';
50
50
  export { Tag } from 'Components/tag/Tag';
51
+ export { TagList } from 'Components/tagList/TagList';
52
+ export type { TagListItem, TagListOverflowRenderArgs, TagListOverflowTarget, TagListProps } from 'Components/tagList/TagList';
51
53
  export { Toast } from 'Components/toast/Toast';
52
54
  export { Toggle } from 'Components/toggle/Toggle';
53
55
  export { Tooltip } from 'Components/tooltip/Tooltip';
package/src/tokens.scss CHANGED
@@ -20,6 +20,7 @@
20
20
  --font-weight-medium: 500;
21
21
  --font-weight-regular: 400;
22
22
  --font-weight-semi-bold: 600;
23
+ --size-control-2xs: 1.75rem;
23
24
  --size-control-xsmall: 2rem;
24
25
  --size-control-small: 2.25rem;
25
26
  --size-control-medium: 3rem;
@@ -239,7 +240,7 @@
239
240
  --tag-spacing-gap-horizontal: var(--spacing-small);
240
241
  --tag-selected-color-background: var(--color-grey-200);
241
242
  --tag-selected-color-border: var(--color-grey-200);
242
- --tag-height: var(--size-control-xsmall);
243
+ --tag-height: var(--size-control-2xs);
243
244
  --card-focus-color-icon: var(--color-grey-900);
244
245
  --card-focus-color-text: var(--color-grey-900);
245
246
  --card-focus-color-border: var(--color-grey-100);
@@ -0,0 +1,39 @@
1
+ import { useCallback, useLayoutEffect, useState } from 'react';
2
+
3
+ export const useElementWidth = (
4
+ ref: React.RefObject<HTMLElement | null>,
5
+ watchKey: string,
6
+ ): number => {
7
+ const [measurement, setMeasurement] = useState({ width: 0, hasMeasured: false });
8
+ const element = ref.current;
9
+
10
+ const update = useCallback(() => {
11
+ const currentElement = ref.current;
12
+ if (!currentElement) {
13
+ setMeasurement({ width: 0, hasMeasured: true });
14
+ return;
15
+ }
16
+
17
+ setMeasurement({
18
+ width: currentElement.getBoundingClientRect().width,
19
+ hasMeasured: true,
20
+ });
21
+ }, [ref]);
22
+
23
+ useLayoutEffect(() => {
24
+ update();
25
+ }, [update, watchKey]);
26
+
27
+ useLayoutEffect(() => {
28
+ if (!element) return;
29
+
30
+ const observer = new ResizeObserver(update);
31
+ observer.observe(element);
32
+
33
+ return () => {
34
+ observer.disconnect();
35
+ };
36
+ }, [element, update]);
37
+
38
+ return measurement.width;
39
+ };
@@ -0,0 +1,39 @@
1
+ import type { RefObject } from 'react';
2
+ import { useElementWidth } from './useElementWidth.js';
3
+
4
+ type MeasuredChildWidths = {
5
+ childGap: number;
6
+ childWidths: number[];
7
+ };
8
+
9
+ const getTrackGap = (trackElement: HTMLElement | null): number => {
10
+ if (!trackElement) return 0;
11
+
12
+ const styles = getComputedStyle(trackElement);
13
+ const parsedGap = Number.parseFloat(styles.columnGap || styles.gap || '0');
14
+ return Number.isFinite(parsedGap) ? parsedGap : 0;
15
+ };
16
+
17
+ const getChildWidths = (trackElement: HTMLElement | null): number[] => {
18
+ if (!trackElement) return [];
19
+
20
+ return Array.from(trackElement.children).map((child) => {
21
+ const width = (child as HTMLElement).getBoundingClientRect().width;
22
+ return Number.isFinite(width) ? width : 0;
23
+ });
24
+ };
25
+
26
+ export const useMeasuredChildWidths = <T extends HTMLElement>(
27
+ trackRef: RefObject<T | null>,
28
+ watchKey: string,
29
+ ): MeasuredChildWidths => {
30
+ useElementWidth(trackRef as RefObject<HTMLElement | null>, watchKey);
31
+
32
+ const childGap = getTrackGap(trackRef.current);
33
+ const childWidths = getChildWidths(trackRef.current);
34
+
35
+ return {
36
+ childGap,
37
+ childWidths,
38
+ };
39
+ };
@@ -0,0 +1,105 @@
1
+ import '@testing-library/jest-dom/vitest';
2
+ import { render, screen } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { useRef, useState } from 'react';
5
+ import { describe, expect, test } from 'vitest';
6
+ import { useRovingFocus } from './useRovingFocus.js';
7
+
8
+ type TestTarget = 'one' | 'two' | 'three';
9
+
10
+ const labels: Record<TestTarget, string> = {
11
+ one: 'One',
12
+ two: 'Two',
13
+ three: 'Three',
14
+ };
15
+
16
+ type RovingExampleProps = {
17
+ initialTargets?: TestTarget[];
18
+ returnFocusOnEmpty?: boolean;
19
+ };
20
+
21
+ const RovingExample = ({
22
+ initialTargets = ['one', 'two', 'three'],
23
+ returnFocusOnEmpty = false,
24
+ }: RovingExampleProps) => {
25
+ const [targets, setTargets] = useState<TestTarget[]>(initialTargets);
26
+ const returnFocusRef = useRef<HTMLButtonElement>(null);
27
+ const {
28
+ getTargetTabIndex,
29
+ handleTargetKeyDown,
30
+ registerTarget,
31
+ } = useRovingFocus<TestTarget>({
32
+ targets,
33
+ returnFocusRef,
34
+ onDeleteKey: (target) => {
35
+ setTargets(currentTargets => currentTargets.filter(currentTarget => currentTarget !== target));
36
+ return true;
37
+ },
38
+ });
39
+
40
+ return (
41
+ <>
42
+ {targets.map(target => (
43
+ <button
44
+ key={target}
45
+ ref={node => registerTarget(target, node)}
46
+ tabIndex={getTargetTabIndex(target)}
47
+ type="button"
48
+ onKeyDown={event => handleTargetKeyDown(event, target)}
49
+ >
50
+ {labels[target]}
51
+ </button>
52
+ ))}
53
+ {returnFocusOnEmpty && (
54
+ <button ref={returnFocusRef} type="button">
55
+ Return target
56
+ </button>
57
+ )}
58
+ </>
59
+ );
60
+ };
61
+
62
+ describe('useRovingFocus', () => {
63
+ test('moves focus through targets with arrow keys and Home/End', async () => {
64
+ const user = userEvent.setup();
65
+ render(<RovingExample />);
66
+
67
+ expect(screen.getByRole('button', { name: 'One' })).toHaveAttribute('tabindex', '0');
68
+ expect(screen.getByRole('button', { name: 'Two' })).toHaveAttribute('tabindex', '-1');
69
+
70
+ await user.tab();
71
+ expect(screen.getByRole('button', { name: 'One' })).toHaveFocus();
72
+
73
+ await user.keyboard('{ArrowRight}');
74
+ expect(screen.getByRole('button', { name: 'Two' })).toHaveFocus();
75
+ expect(screen.getByRole('button', { name: 'Two' })).toHaveAttribute('tabindex', '0');
76
+
77
+ await user.keyboard('{End}');
78
+ expect(screen.getByRole('button', { name: 'Three' })).toHaveFocus();
79
+
80
+ await user.keyboard('{Home}');
81
+ expect(screen.getByRole('button', { name: 'One' })).toHaveFocus();
82
+ });
83
+
84
+ test('recovers focus to the next target after deletion', async () => {
85
+ const user = userEvent.setup();
86
+ render(<RovingExample initialTargets={['one', 'two']} />);
87
+
88
+ await user.tab();
89
+ expect(screen.getByRole('button', { name: 'One' })).toHaveFocus();
90
+
91
+ await user.keyboard('{Delete}');
92
+ expect(screen.getByRole('button', { name: 'Two' })).toHaveFocus();
93
+ });
94
+
95
+ test('returns focus to the fallback element when all targets are removed', async () => {
96
+ const user = userEvent.setup();
97
+ render(<RovingExample initialTargets={['one']} returnFocusOnEmpty />);
98
+
99
+ await user.tab();
100
+ expect(screen.getByRole('button', { name: 'One' })).toHaveFocus();
101
+
102
+ await user.keyboard('{Delete}');
103
+ expect(screen.getByRole('button', { name: 'Return target' })).toHaveFocus();
104
+ });
105
+ });
@@ -0,0 +1,163 @@
1
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState, type RefObject } from 'react';
2
+
3
+ type PendingFocusRecovery = {
4
+ targetIndex: number;
5
+ };
6
+
7
+ type UseRovingFocusOptions<Target extends string> = {
8
+ targets: readonly Target[];
9
+ returnFocusRef?: RefObject<HTMLElement | null>;
10
+ onActiveTargetChange?: (target: Target | null) => void;
11
+ onDeleteKey?: (target: Target, event: React.KeyboardEvent<HTMLElement>) => boolean | void;
12
+ };
13
+
14
+ export const useRovingFocus = <Target extends string>({
15
+ targets,
16
+ returnFocusRef,
17
+ onActiveTargetChange,
18
+ onDeleteKey,
19
+ }: UseRovingFocusOptions<Target>) => {
20
+ const [activeTarget, setActiveTargetState] = useState<Target | null>(null);
21
+ const targetRefs = useRef(new Map<Target, HTMLElement>());
22
+ const pendingFocusRecoveryRef = useRef<PendingFocusRecovery | null>(null);
23
+ const returnFocusRefRef = useRef(returnFocusRef);
24
+
25
+ returnFocusRefRef.current = returnFocusRef;
26
+
27
+ const setActiveTarget = useCallback((target: Target | null) => {
28
+ setActiveTargetState(target);
29
+ onActiveTargetChange?.(target);
30
+ }, [onActiveTargetChange]);
31
+
32
+ const focusTarget = useCallback((target: Target) => {
33
+ targetRefs.current.get(target)?.focus();
34
+ }, []);
35
+
36
+ const moveFocus = useCallback((target: Target) => {
37
+ focusTarget(target);
38
+ setActiveTarget(target);
39
+ }, [focusTarget, setActiveTarget]);
40
+
41
+ const registerTarget = useCallback((target: Target, node: HTMLElement | null) => {
42
+ if (node) {
43
+ targetRefs.current.set(target, node);
44
+ }
45
+ else {
46
+ targetRefs.current.delete(target);
47
+ }
48
+ }, []);
49
+
50
+ const queueFocusRecovery = useCallback((target: Target) => {
51
+ const targetIndex = targets.indexOf(target);
52
+ if (targetIndex < 0) return;
53
+
54
+ pendingFocusRecoveryRef.current = { targetIndex };
55
+ }, [targets]);
56
+
57
+ useEffect(() => {
58
+ if (targets.length === 0) {
59
+ if (activeTarget !== null) {
60
+ setActiveTarget(null);
61
+ }
62
+ return;
63
+ }
64
+
65
+ if (activeTarget === null || !targets.includes(activeTarget)) {
66
+ setActiveTarget(targets[0] ?? null);
67
+ }
68
+ }, [activeTarget, setActiveTarget, targets]);
69
+
70
+ useLayoutEffect(() => {
71
+ const pendingFocusRecovery = pendingFocusRecoveryRef.current;
72
+ if (!pendingFocusRecovery) return;
73
+
74
+ pendingFocusRecoveryRef.current = null;
75
+
76
+ if (targets.length > 0) {
77
+ const nextTarget = targets[Math.min(pendingFocusRecovery.targetIndex, targets.length - 1)];
78
+
79
+ if (nextTarget !== undefined) {
80
+ moveFocus(nextTarget);
81
+ return;
82
+ }
83
+ }
84
+
85
+ returnFocusRef?.current?.focus();
86
+ setActiveTarget(null);
87
+ }, [moveFocus, returnFocusRef, setActiveTarget, targets]);
88
+
89
+ useLayoutEffect(() => () => {
90
+ if (!pendingFocusRecoveryRef.current) return;
91
+
92
+ pendingFocusRecoveryRef.current = null;
93
+ returnFocusRefRef.current?.current?.focus();
94
+ }, []);
95
+
96
+ const handleTargetKeyDown = useCallback((event: React.KeyboardEvent<HTMLElement>, target: Target) => {
97
+ const targetIndex = targets.indexOf(target);
98
+ if (targetIndex < 0) return;
99
+
100
+ switch (event.key) {
101
+ case 'ArrowLeft':
102
+ case 'ArrowUp': {
103
+ event.preventDefault();
104
+ const previousTarget = targets[Math.max(0, targetIndex - 1)];
105
+ if (previousTarget !== undefined) {
106
+ moveFocus(previousTarget);
107
+ }
108
+ break;
109
+ }
110
+ case 'ArrowRight':
111
+ case 'ArrowDown': {
112
+ event.preventDefault();
113
+ const nextTarget = targets[Math.min(targets.length - 1, targetIndex + 1)];
114
+ if (nextTarget !== undefined) {
115
+ moveFocus(nextTarget);
116
+ }
117
+ break;
118
+ }
119
+ case 'Home':
120
+ event.preventDefault();
121
+ if (targets[0] !== undefined) {
122
+ moveFocus(targets[0]);
123
+ }
124
+ break;
125
+ case 'End': {
126
+ event.preventDefault();
127
+ const lastTarget = targets[targets.length - 1];
128
+ if (lastTarget !== undefined) {
129
+ moveFocus(lastTarget);
130
+ }
131
+ break;
132
+ }
133
+ case 'Backspace':
134
+ case 'Delete': {
135
+ const shouldRecoverFocus = onDeleteKey?.(target, event);
136
+ if (shouldRecoverFocus) {
137
+ queueFocusRecovery(target);
138
+ }
139
+ break;
140
+ }
141
+ default:
142
+ break;
143
+ }
144
+ }, [moveFocus, onDeleteKey, queueFocusRecovery, targets]);
145
+
146
+ const getTargetTabIndex = useCallback((target: Target): 0 | -1 => {
147
+ if (activeTarget === null) {
148
+ return targets[0] === target ? 0 : -1;
149
+ }
150
+
151
+ return activeTarget === target ? 0 : -1;
152
+ }, [activeTarget, targets]);
153
+
154
+ return {
155
+ activeTarget,
156
+ focusTarget,
157
+ getTargetTabIndex,
158
+ handleTargetKeyDown,
159
+ queueFocusRecovery,
160
+ registerTarget,
161
+ setActiveTarget,
162
+ };
163
+ };
@@ -0,0 +1,20 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { fitSpacedWidths, sumSpacedWidths } from './spacedWidths.js';
3
+
4
+ describe('spacedWidths', () => {
5
+ test('sums widths with gaps between visible items', () => {
6
+ expect(sumSpacedWidths([40, 50, 60], 8, 2)).toBe(98);
7
+ });
8
+
9
+ test('returns zero summed width when no items are visible', () => {
10
+ expect(sumSpacedWidths([40, 50, 60], 8, 0)).toBe(0);
11
+ });
12
+
13
+ test('fits the number of items that can be shown within the available width', () => {
14
+ expect(fitSpacedWidths(102, [40, 50, 60], 8)).toBe(2);
15
+ });
16
+
17
+ test('returns zero fitted items when no width is available', () => {
18
+ expect(fitSpacedWidths(0, [40, 50], 8)).toBe(0);
19
+ });
20
+ });
@@ -0,0 +1,37 @@
1
+ export const sumSpacedWidths = (
2
+ widths: number[],
3
+ gap: number,
4
+ visibleCount: number = widths.length,
5
+ ): number => {
6
+ const boundedCount = Math.max(0, Math.min(visibleCount, widths.length));
7
+ if (boundedCount === 0) return 0;
8
+
9
+ let usedWidth = 0;
10
+ for (let index = 0; index < boundedCount; index += 1) {
11
+ usedWidth += widths[index] ?? 0;
12
+ if (index > 0) {
13
+ usedWidth += gap;
14
+ }
15
+ }
16
+
17
+ return usedWidth;
18
+ };
19
+
20
+ export const fitSpacedWidths = (
21
+ availableWidth: number,
22
+ widths: number[],
23
+ gap: number,
24
+ ): number => {
25
+ if (availableWidth <= 0 || widths.length === 0) return 0;
26
+
27
+ let usedWidth = 0;
28
+ let visibleCount = 0;
29
+ for (let index = 0; index < widths.length; index += 1) {
30
+ const requiredWidth = (widths[index] ?? 0) + (index > 0 ? gap : 0);
31
+ if (usedWidth + requiredWidth > availableWidth) break;
32
+ usedWidth += requiredWidth;
33
+ visibleCount += 1;
34
+ }
35
+
36
+ return visibleCount;
37
+ };
@@ -1,37 +0,0 @@
1
- import type { IconName } from '../icon/allowedIcons.js';
2
- import { Icon } from '../icon/Icon.js';
3
- export type IcoTextProps = {
4
- children?: React.ReactNode;
5
- className?: string;
6
- };
7
- export type IcoTextHeadingProps = React.HTMLAttributes<HTMLHeadingElement> & {
8
- children: React.ReactNode;
9
- };
10
- export type IcoTextParagraphProps = React.HTMLAttributes<HTMLParagraphElement> & {
11
- children: React.ReactNode;
12
- };
13
- export type IcoTextIconProps = {
14
- className?: string;
15
- color?: Icon.Props['color'];
16
- name: IconName;
17
- screenReaderText?: string;
18
- size?: 12 | 16 | 24;
19
- };
20
- export declare const IcoText: {
21
- ({ children, className }: IcoTextProps): React.JSX.Element;
22
- displayName: string;
23
- } & {
24
- Heading: {
25
- ({ children, className, ...rest }: IcoTextHeadingProps): React.JSX.Element;
26
- displayName: string;
27
- };
28
- Paragraph: {
29
- ({ children, className, ...rest }: IcoTextParagraphProps): React.JSX.Element;
30
- displayName: string;
31
- };
32
- Icon: {
33
- ({ className, color, name, screenReaderText, size, }: IcoTextIconProps): React.JSX.Element;
34
- displayName: string;
35
- };
36
- };
37
- //# sourceMappingURL=IcoText.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"IcoText.d.ts","sourceRoot":"","sources":["../../../src/components/icoText/IcoText.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,8BAA8B,CAAC;AAC7D,OAAO,EAAE,IAAI,EAAE,MAAM,sBAAsB,CAAC;AAG5C,MAAM,MAAM,YAAY,GAAG;IACzB,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IAC3B,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG,KAAK,CAAC,cAAc,CAAC,kBAAkB,CAAC,GAAG;IAC3E,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;CAC3B,CAAC;AAEF,MAAM,MAAM,qBAAqB,GAAG,KAAK,CAAC,cAAc,CAAC,oBAAoB,CAAC,GAAG;IAC/E,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;CAC3B,CAAC;AAEF,MAAM,MAAM,gBAAgB,GAAG;IAC7B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;IAC5B,IAAI,EAAE,QAAQ,CAAC;IACf,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,IAAI,CAAC,EAAE,EAAE,GAAG,EAAE,GAAG,EAAE,CAAC;CACrB,CAAC;AAgEF,eAAO,MAAM,OAAO;8BA1B0B,YAAY,GAAG,KAAK,CAAC,GAAG,CAAC,OAAO;;;;2CAhC3E,mBAAmB,GAAG,KAAK,CAAC,GAAG,CAAC,OAAO;;;;2CAUvC,qBAAqB,GAAG,KAAK,CAAC,GAAG,CAAC,OAAO;;;;8DAYzC,gBAAgB,GAAG,KAAK,CAAC,GAAG,CAAC,OAAO;;;CAwCrC,CAAC"}