@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.
- package/CHANGELOG.md +12 -0
- package/component-library.md +15 -14
- package/dist/components/articleCard/ArticleCard.d.ts +2 -2
- package/dist/components/articleCard/ArticleCard.d.ts.map +1 -1
- package/dist/components/articleCard/ArticleCard.js +3 -3
- package/dist/components/articleCard/ArticleCard.js.map +1 -1
- package/dist/components/articleCard/ArticleCard.stories.d.ts +11 -3
- package/dist/components/articleCard/ArticleCard.stories.d.ts.map +1 -1
- package/dist/components/articleCard/ArticleCard.stories.js +16 -11
- package/dist/components/articleCard/ArticleCard.stories.js.map +1 -1
- package/dist/components/iconText/IconText.d.ts +43 -0
- package/dist/components/iconText/IconText.d.ts.map +1 -0
- package/dist/components/iconText/IconText.js +29 -0
- package/dist/components/iconText/IconText.js.map +1 -0
- package/dist/components/{icoText/IcoText.stories.d.ts → iconText/IconText.stories.d.ts} +8 -9
- package/dist/components/iconText/IconText.stories.d.ts.map +1 -0
- package/dist/components/{icoText/IcoText.stories.js → iconText/IconText.stories.js} +81 -81
- package/dist/components/iconText/IconText.stories.js.map +1 -0
- package/dist/components/iconText/IconText.test.d.ts +2 -0
- package/dist/components/iconText/IconText.test.d.ts.map +1 -0
- package/dist/components/{icoText/IcoText.test.js → iconText/IconText.test.js} +6 -6
- package/dist/components/iconText/IconText.test.js.map +1 -0
- package/dist/components/modal/Modal.d.ts +1 -0
- package/dist/components/modal/Modal.d.ts.map +1 -1
- package/dist/components/modal/Modal.js +2 -2
- package/dist/components/modal/Modal.js.map +1 -1
- package/dist/components/tag/Tag.d.ts +14 -1
- package/dist/components/tag/Tag.d.ts.map +1 -1
- package/dist/components/tag/Tag.js +9 -3
- package/dist/components/tag/Tag.js.map +1 -1
- package/dist/components/tag/Tag.stories.d.ts +1 -1
- package/dist/components/tag/Tag.stories.d.ts.map +1 -1
- package/dist/components/tag/Tag.stories.js +3 -3
- package/dist/components/tag/Tag.stories.js.map +1 -1
- package/dist/components/tag/Tag.test.js +36 -5
- package/dist/components/tag/Tag.test.js.map +1 -1
- package/dist/components/tagList/TagList.d.ts +49 -0
- package/dist/components/tagList/TagList.d.ts.map +1 -0
- package/dist/components/tagList/TagList.js +114 -0
- package/dist/components/tagList/TagList.js.map +1 -0
- package/dist/components/tagList/TagList.stories.d.ts +130 -0
- package/dist/components/tagList/TagList.stories.d.ts.map +1 -0
- package/dist/components/tagList/TagList.stories.js +443 -0
- package/dist/components/tagList/TagList.stories.js.map +1 -0
- package/dist/components/{icoText/IcoText.test.d.ts → tagList/TagList.test.d.ts} +1 -1
- package/dist/components/tagList/TagList.test.d.ts.map +1 -0
- package/dist/components/tagList/TagList.test.js +246 -0
- package/dist/components/tagList/TagList.test.js.map +1 -0
- package/dist/components/tagList/useTagListCollapsedLayout.d.ts +19 -0
- package/dist/components/tagList/useTagListCollapsedLayout.d.ts.map +1 -0
- package/dist/components/tagList/useTagListCollapsedLayout.js +48 -0
- package/dist/components/tagList/useTagListCollapsedLayout.js.map +1 -0
- package/dist/components/tagList/useVisibleTags.d.ts +18 -0
- package/dist/components/tagList/useVisibleTags.d.ts.map +1 -0
- package/dist/components/tagList/useVisibleTags.js +41 -0
- package/dist/components/tagList/useVisibleTags.js.map +1 -0
- package/dist/index.css +130 -10
- package/dist/index.css.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/utils/hooks/useElementWidth.d.ts +2 -0
- package/dist/utils/hooks/useElementWidth.d.ts.map +1 -0
- package/dist/utils/hooks/useElementWidth.js +30 -0
- package/dist/utils/hooks/useElementWidth.js.map +1 -0
- package/dist/utils/hooks/useMeasuredChildWidths.d.ts +8 -0
- package/dist/utils/hooks/useMeasuredChildWidths.d.ts.map +1 -0
- package/dist/utils/hooks/useMeasuredChildWidths.js +26 -0
- package/dist/utils/hooks/useMeasuredChildWidths.js.map +1 -0
- package/dist/utils/hooks/useRovingFocus.d.ts +18 -0
- package/dist/utils/hooks/useRovingFocus.d.ts.map +1 -0
- package/dist/utils/hooks/useRovingFocus.js +130 -0
- package/dist/utils/hooks/useRovingFocus.js.map +1 -0
- package/dist/utils/hooks/useRovingFocus.test.d.ts +2 -0
- package/dist/utils/hooks/useRovingFocus.test.d.ts.map +1 -0
- package/dist/utils/hooks/useRovingFocus.test.js +59 -0
- package/dist/utils/hooks/useRovingFocus.test.js.map +1 -0
- package/dist/utils/spacedWidths.d.ts +3 -0
- package/dist/utils/spacedWidths.d.ts.map +1 -0
- package/dist/utils/spacedWidths.js +28 -0
- package/dist/utils/spacedWidths.js.map +1 -0
- package/dist/utils/spacedWidths.test.d.ts +2 -0
- package/dist/utils/spacedWidths.test.d.ts.map +1 -0
- package/dist/utils/spacedWidths.test.js +17 -0
- package/dist/utils/spacedWidths.test.js.map +1 -0
- package/package.json +1 -1
- package/src/components/articleCard/ArticleCard.stories.tsx +17 -12
- package/src/components/articleCard/ArticleCard.tsx +9 -9
- package/src/components/{icoText/IcoText.stories.tsx → iconText/IconText.stories.tsx} +112 -112
- package/src/components/{icoText/IcoText.test.tsx → iconText/IconText.test.tsx} +10 -10
- package/src/components/{icoText/IcoText.tsx → iconText/IconText.tsx} +27 -20
- package/src/components/modal/Modal.tsx +5 -1
- package/src/components/tag/Tag.stories.tsx +4 -4
- package/src/components/tag/Tag.test.tsx +62 -5
- package/src/components/tag/Tag.tsx +61 -3
- package/src/components/tag/tag.scss +80 -9
- package/src/components/tagList/TagList.stories.tsx +564 -0
- package/src/components/tagList/TagList.test.tsx +342 -0
- package/src/components/tagList/TagList.tsx +296 -0
- package/src/components/tagList/tagList.scss +56 -0
- package/src/components/tagList/useTagListCollapsedLayout.ts +83 -0
- package/src/components/tagList/useVisibleTags.ts +74 -0
- package/src/index.scss +2 -1
- package/src/index.ts +3 -1
- package/src/tokens.scss +2 -1
- package/src/utils/hooks/useElementWidth.ts +39 -0
- package/src/utils/hooks/useMeasuredChildWidths.ts +39 -0
- package/src/utils/hooks/useRovingFocus.test.tsx +105 -0
- package/src/utils/hooks/useRovingFocus.ts +163 -0
- package/src/utils/spacedWidths.test.ts +20 -0
- package/src/utils/spacedWidths.ts +37 -0
- package/dist/components/icoText/IcoText.d.ts +0 -37
- package/dist/components/icoText/IcoText.d.ts.map +0 -1
- package/dist/components/icoText/IcoText.js +0 -29
- package/dist/components/icoText/IcoText.js.map +0 -1
- package/dist/components/icoText/IcoText.stories.d.ts.map +0 -1
- package/dist/components/icoText/IcoText.stories.js.map +0 -1
- package/dist/components/icoText/IcoText.test.d.ts.map +0 -1
- package/dist/components/icoText/IcoText.test.js.map +0 -1
- /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/
|
|
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 {
|
|
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-
|
|
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"}
|