@griddo/ax 11.11.7 → 11.11.8-rc.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 (99) hide show
  1. package/config/jest/componentsMock.js +7 -5
  2. package/package.json +2 -2
  3. package/src/__tests__/components/Browser/Browser.test.tsx +438 -87
  4. package/src/__tests__/components/Browser/Browser.utils.test.ts +55 -0
  5. package/src/__tests__/components/ConfigPanel/ConfigPanel.test.tsx +1 -3
  6. package/src/__tests__/components/Fields/Button/Button.test.tsx +29 -27
  7. package/src/__tests__/components/HeadingsPreviewModal/ErrorsBanner/ErrorItem/ErrorItem.test.tsx +158 -0
  8. package/src/__tests__/components/HeadingsPreviewModal/ErrorsBanner/ErrorsBanner.test.tsx +90 -0
  9. package/src/__tests__/components/HeadingsPreviewModal/HeadingsPreviewModal.test.tsx +178 -0
  10. package/src/__tests__/components/HeadingsPreviewModal/HeadingsPreviewModal.utils.test.tsx +150 -0
  11. package/src/__tests__/components/KeywordsPreviewModal/KeywordItem/KeywordItem.test.tsx +91 -0
  12. package/src/__tests__/components/KeywordsPreviewModal/KeywordsPreviewModal.test.tsx +122 -0
  13. package/src/__tests__/components/KeywordsPreviewModal/KeywordsPreviewModal.utils.test.ts +15 -0
  14. package/src/__tests__/components/KeywordsPreviewModal/atoms.test.tsx +101 -0
  15. package/src/__tests__/components/ResizePanel/ResizePanel.test.tsx +1 -1
  16. package/src/__tests__/modules/FramePreview/FramePreview.test.tsx +318 -0
  17. package/src/__tests__/modules/FramePreview/FramePreview.utils.test.ts +242 -0
  18. package/src/__tests__/modules/FramePreview/HeadingsOverlay/HeadingsOverlay.test.tsx +185 -0
  19. package/src/components/Browser/index.tsx +301 -134
  20. package/src/components/Browser/style.tsx +75 -6
  21. package/src/components/Browser/utils.tsx +13 -0
  22. package/src/components/Button/index.tsx +2 -1
  23. package/src/components/ConfigPanel/Form/ConnectedField/PageConnectedField/Field/index.tsx +2 -4
  24. package/src/components/Fields/AsyncSelect/style.tsx +13 -0
  25. package/src/components/Fields/FieldGroup/index.tsx +5 -2
  26. package/src/components/Fields/FieldGroup/style.tsx +32 -7
  27. package/src/components/Fields/HeadingField/index.tsx +2 -2
  28. package/src/components/Fields/HiddenField/style.tsx +1 -1
  29. package/src/components/Fields/NumberField/index.tsx +15 -16
  30. package/src/components/Fields/NumberField/style.tsx +2 -0
  31. package/src/components/Fields/ReferenceField/index.tsx +1 -1
  32. package/src/components/Fields/SEOPreview/index.tsx +36 -0
  33. package/src/components/Fields/SEOPreview/style.tsx +24 -0
  34. package/src/components/Fields/Select/index.tsx +5 -1
  35. package/src/components/Fields/Select/style.tsx +56 -0
  36. package/src/components/Fields/SummaryButton/index.tsx +18 -9
  37. package/src/components/Fields/SummaryButton/style.tsx +1 -2
  38. package/src/components/Fields/TagsField/index.tsx +8 -9
  39. package/src/components/Fields/UrlField/index.tsx +26 -27
  40. package/src/components/Fields/index.tsx +2 -0
  41. package/src/components/FloatingNote/index.tsx +35 -0
  42. package/src/components/FloatingNote/style.tsx +26 -0
  43. package/src/components/FloatingPanel/index.tsx +5 -2
  44. package/src/components/FloatingPanel/style.tsx +2 -1
  45. package/src/components/HeadingsPreviewModal/ErrorsBanner/ErrorItem/index.tsx +85 -0
  46. package/src/components/HeadingsPreviewModal/ErrorsBanner/ErrorItem/style.tsx +80 -0
  47. package/src/components/HeadingsPreviewModal/ErrorsBanner/index.tsx +57 -0
  48. package/src/components/HeadingsPreviewModal/ErrorsBanner/style.tsx +82 -0
  49. package/src/components/HeadingsPreviewModal/HeadingItem/index.tsx +71 -0
  50. package/src/components/HeadingsPreviewModal/HeadingItem/style.tsx +77 -0
  51. package/src/components/HeadingsPreviewModal/index.tsx +146 -0
  52. package/src/components/HeadingsPreviewModal/style.tsx +82 -0
  53. package/src/components/HeadingsPreviewModal/utils.tsx +257 -0
  54. package/src/components/IconAction/index.tsx +1 -1
  55. package/src/components/KeywordsPreviewModal/KeywordItem/index.tsx +46 -0
  56. package/src/components/KeywordsPreviewModal/KeywordItem/style.tsx +64 -0
  57. package/src/components/KeywordsPreviewModal/atoms.tsx +96 -0
  58. package/src/components/KeywordsPreviewModal/index.tsx +99 -0
  59. package/src/components/KeywordsPreviewModal/style.tsx +87 -0
  60. package/src/components/KeywordsPreviewModal/utils.tsx +22 -0
  61. package/src/components/MainWrapper/AppBar/index.tsx +8 -1
  62. package/src/components/MainWrapper/index.tsx +7 -1
  63. package/src/components/Notification/index.tsx +2 -2
  64. package/src/components/PageFinder/index.tsx +1 -1
  65. package/src/components/ResizePanel/index.tsx +4 -3
  66. package/src/components/ResizePanel/style.tsx +1 -1
  67. package/src/components/SearchField/style.tsx +2 -2
  68. package/src/components/SideModal/index.tsx +2 -1
  69. package/src/components/Tabs/index.tsx +13 -4
  70. package/src/components/Tabs/style.tsx +7 -8
  71. package/src/components/Toast/index.tsx +4 -2
  72. package/src/components/Tooltip/index.tsx +4 -3
  73. package/src/components/index.tsx +8 -0
  74. package/src/forms/fields.tsx +70 -68
  75. package/src/hooks/forms.tsx +22 -1
  76. package/src/hooks/index.tsx +13 -3
  77. package/src/hooks/modals.tsx +103 -15
  78. package/src/hooks/users.tsx +25 -8
  79. package/src/modules/Forms/atoms.tsx +2 -2
  80. package/src/modules/FramePreview/HeadingsOverlay/index.tsx +113 -0
  81. package/src/modules/FramePreview/HeadingsOverlay/style.tsx +24 -0
  82. package/src/modules/FramePreview/index.tsx +55 -16
  83. package/src/modules/FramePreview/style.tsx +34 -2
  84. package/src/modules/FramePreview/utils.tsx +140 -0
  85. package/src/modules/GlobalEditor/Editor/index.tsx +37 -3
  86. package/src/modules/GlobalEditor/PageBrowser/index.tsx +19 -2
  87. package/src/modules/GlobalEditor/Preview/index.tsx +0 -2
  88. package/src/modules/GlobalEditor/Preview/style.tsx +1 -1
  89. package/src/modules/GlobalEditor/index.tsx +119 -57
  90. package/src/modules/PageEditor/Editor/index.tsx +33 -2
  91. package/src/modules/PageEditor/PageBrowser/index.tsx +20 -2
  92. package/src/modules/PageEditor/Preview/index.tsx +0 -2
  93. package/src/modules/PageEditor/Preview/style.tsx +1 -1
  94. package/src/modules/PageEditor/atoms.tsx +1 -1
  95. package/src/modules/PageEditor/index.tsx +130 -66
  96. package/src/modules/PublicPreview/index.tsx +5 -2
  97. package/src/schemas/pages/GlobalPage.ts +87 -70
  98. package/src/schemas/pages/Page.ts +87 -70
  99. package/src/types/index.tsx +12 -0
@@ -0,0 +1,77 @@
1
+ import styled from "styled-components";
2
+
3
+ import Tooltip from "../../Tooltip";
4
+
5
+ import { getHeadColor } from "../utils";
6
+
7
+ const HeadTag = styled.div`
8
+ display: flex;
9
+ text-transform: uppercase;
10
+ flex-shrink: 0;
11
+ padding: ${(p) => `0 ${p.theme.spacing.xs}`};
12
+ align-items: center;
13
+ div {
14
+ transform: rotate(-90deg);
15
+ }
16
+ `;
17
+
18
+ const HeadText = styled.div`
19
+ display: flex;
20
+ align-items: center;
21
+ padding: ${(p) => `${p.theme.spacing.xs} ${p.theme.spacing.s}`};
22
+ width: 100%;
23
+ `;
24
+
25
+ const HeadItem = styled.div<{ level: number; tag: string; isSelected: boolean; isHidden: boolean }>`
26
+ position: relative;
27
+ ${(p) => p.theme.textStyle.uiS};
28
+ color: ${(p) => p.theme.colors.textHighEmphasis};
29
+ background-color: ${(p) => p.theme.colors.uiBackground02};
30
+ display: flex;
31
+ margin-left: ${(p) => `${(p.level - 1) * 16}px`};
32
+ margin-bottom: ${(p) => p.theme.spacing.xs};
33
+ min-height: 40px;
34
+ border: ${(p) => `1px solid ${getHeadColor(p.tag)}`};
35
+ border-radius: ${(p) => p.theme.radii.s};
36
+ cursor: pointer;
37
+ box-shadow: ${(p) => p.theme.shadow.shadowS};
38
+ outline: ${(p) => (p.isSelected ? `1px solid ${p.theme.colors.interactive01}` : "none")};
39
+
40
+ &:before {
41
+ content: "";
42
+ border-radius: ${(p) => p.theme.radii.s};
43
+ position: absolute;
44
+ top: 0;
45
+ left: 0;
46
+ width: 100%;
47
+ height: 100%;
48
+ opacity: 0;
49
+ transition: opacity 0.1s;
50
+ }
51
+
52
+ &:hover:before {
53
+ background-color: ${(p) => p.theme.colors.overlayHoverPrimary};
54
+ opacity: 1;
55
+ }
56
+
57
+ ${HeadTag} {
58
+ background-color: ${(p) => getHeadColor(p.tag)};
59
+ }
60
+
61
+ ${HeadText} {
62
+ color: ${(p) => (p.isHidden ? p.theme.colors.interactiveDisabled : p.theme.colors.textHighEmphasis)};
63
+ }
64
+ `;
65
+
66
+ const HiddenIcon = styled.div`
67
+ margin-left: auto;
68
+ svg path {
69
+ fill: ${(p) => p.theme.colors.interactiveDisabled};
70
+ }
71
+ `;
72
+
73
+ const StyledTooltip = styled(Tooltip)`
74
+ width: 100%;
75
+ `;
76
+
77
+ export { HeadItem, HeadTag, HeadText, HiddenIcon, StyledTooltip };
@@ -0,0 +1,146 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+
3
+ import { EmptyState, FloatingPanel } from "@ax/components";
4
+ import type { HeadingFilter, HeadingNode } from "@ax/types";
5
+
6
+ import ErrorsBanner from "./ErrorsBanner";
7
+ import HeadingItem from "./HeadingItem";
8
+ import { analyzeHeadings, extractUniqueHeadingTypes, filterHeadings, parseHeadingsTree } from "./utils";
9
+
10
+ import * as S from "./style";
11
+
12
+ const HeadingsPreviewModal = (props: IHeadingsPreviewProps) => {
13
+ const { isOpen, browserRef, headingsFilter, setHeadingsFilter, toggleModal } = props;
14
+
15
+ const isFiltering = headingsFilter !== "all";
16
+ const counter = { value: 0 };
17
+
18
+ const [headings, setHeadings] = useState<HeadingNode[]>([]);
19
+ const [selected, setSelected] = useState<number | null>(null);
20
+ const [isErrorsBannerOpen, setIsErrorsBannerOpen] = useState(false);
21
+ const [errorsResetKey, setErrorsResetKey] = useState(0);
22
+ const listRef = useRef<HTMLDivElement>(null);
23
+
24
+ const filters = useMemo<HeadingFilter[]>(() => ["all", ...extractUniqueHeadingTypes(headings)], [headings]);
25
+ const filteredHeadings = useMemo(() => filterHeadings(headings, headingsFilter), [headings, headingsFilter]);
26
+ const errors = useMemo(() => analyzeHeadings(filteredHeadings, isFiltering), [filteredHeadings, isFiltering]);
27
+
28
+ const getSEOHeadings = useCallback(() => {
29
+ return browserRef.current ? parseHeadingsTree(browserRef.current) : [];
30
+ }, [browserRef]);
31
+
32
+ useEffect(() => {
33
+ if (isOpen) {
34
+ const headings = getSEOHeadings();
35
+ setHeadings(headings);
36
+ }
37
+ }, [isOpen, getSEOHeadings]);
38
+
39
+ const closeErrorsBanner = () => {
40
+ setIsErrorsBannerOpen(false);
41
+ setErrorsResetKey((k) => k + 1);
42
+ };
43
+
44
+ const handleFilterClick = (value: HeadingFilter) => () => {
45
+ setSelected(null);
46
+ setHeadingsFilter(value);
47
+ closeErrorsBanner();
48
+ };
49
+
50
+ const scrollToHeadingInIframe = (id: number) => {
51
+ const iframe = browserRef.current?.querySelector("iframe");
52
+ const iframeDocument = iframe?.contentDocument || iframe?.contentWindow?.document;
53
+ const heading = iframeDocument?.querySelector(`[data-griddoid="heading-${id}"]`);
54
+ heading?.scrollIntoView({ behavior: "smooth", block: "center" });
55
+ };
56
+
57
+ const handleHeadingClick = (id: number) => () => {
58
+ if (id !== selected) {
59
+ setSelected(id);
60
+ scrollToHeadingInIframe(id);
61
+ } else {
62
+ setSelected(null);
63
+ }
64
+ closeErrorsBanner();
65
+ };
66
+
67
+ const handleHeadingNavigate = (id: number) => () => {
68
+ setSelected(id);
69
+ scrollToHeadingInIframe(id);
70
+
71
+ const listItem = listRef.current?.querySelector(`[data-heading-id="${id}"]`);
72
+ listItem?.scrollIntoView({ behavior: "smooth", block: "center" });
73
+ };
74
+
75
+ return (
76
+ <S.Wrapper>
77
+ <FloatingPanel title="Headings" toggleModal={toggleModal} closeOnOutsideClick={false} isOpen={isOpen} width={358}>
78
+ {isOpen &&
79
+ (!filteredHeadings.length ? (
80
+ <S.EmptyWrapper>
81
+ <EmptyState
82
+ message={
83
+ <>
84
+ There are no headings available to display.
85
+ <br />
86
+ Feel free to add some to get started!
87
+ </>
88
+ }
89
+ />
90
+ </S.EmptyWrapper>
91
+ ) : (
92
+ <S.HeadingsWrapper>
93
+ {errors.length > 0 && (
94
+ <ErrorsBanner
95
+ errors={errors}
96
+ onSelectHeading={handleHeadingNavigate}
97
+ isOpen={isErrorsBannerOpen}
98
+ setIsOpen={setIsErrorsBannerOpen}
99
+ resetKey={errorsResetKey}
100
+ />
101
+ )}
102
+ <S.FiltersWrapper>
103
+ <S.FilterText>Show headings:</S.FilterText>
104
+ {filters.map((type) => (
105
+ <S.FilterItem
106
+ key={type}
107
+ type="button"
108
+ tabIndex={0}
109
+ isSelected={headingsFilter === type}
110
+ onClick={handleFilterClick(type)}
111
+ tag={type}
112
+ >
113
+ {type}
114
+ </S.FilterItem>
115
+ ))}
116
+ </S.FiltersWrapper>
117
+ <S.HeadingsListWrapper ref={listRef}>
118
+ {filteredHeadings.map((head, index) => (
119
+ <HeadingItem
120
+ key={`${head.tag}-${head.level}-${index}-${head.text.slice(0, 20)}`}
121
+ head={head}
122
+ index={index}
123
+ isFiltering={isFiltering}
124
+ selected={selected}
125
+ onHeadingClick={handleHeadingClick}
126
+ counter={counter}
127
+ parentPath=""
128
+ />
129
+ ))}
130
+ </S.HeadingsListWrapper>
131
+ </S.HeadingsWrapper>
132
+ ))}
133
+ </FloatingPanel>
134
+ </S.Wrapper>
135
+ );
136
+ };
137
+
138
+ interface IHeadingsPreviewProps {
139
+ isOpen: boolean;
140
+ browserRef: React.RefObject<HTMLDivElement>;
141
+ toggleModal: () => void;
142
+ headingsFilter: HeadingFilter;
143
+ setHeadingsFilter: (value: HeadingFilter) => void;
144
+ }
145
+
146
+ export default HeadingsPreviewModal;
@@ -0,0 +1,82 @@
1
+ import styled from "styled-components";
2
+
3
+ import { getHeadColor } from "./utils";
4
+
5
+ const Wrapper = styled.div``;
6
+
7
+ const HeadingsWrapper = styled.div`
8
+ padding: ${(p) => `${p.theme.spacing.m} ${p.theme.spacing.m} 80px ${p.theme.spacing.s}`};
9
+ overflow-y: auto;
10
+ height: 100%;
11
+ width: 100%;
12
+ position: relative;
13
+
14
+ ::-webkit-scrollbar {
15
+ -webkit-appearance: none;
16
+ width: 4px;
17
+ height: 100%;
18
+ }
19
+
20
+ ::-webkit-scrollbar-thumb {
21
+ border-radius: 4px;
22
+ background-color: ${(p) => p.theme.colors.iconNonActive};
23
+ }
24
+ `;
25
+
26
+ const FiltersWrapper = styled.div`
27
+ display: flex;
28
+ background-color: ${(p) => p.theme.colors.uiBackground03};
29
+ border-radius: ${(p) => p.theme.radii.s};
30
+ padding: ${(p) => p.theme.spacing.xs};
31
+ margin-bottom: ${(p) => p.theme.spacing.xs};
32
+ gap: ${(p) => p.theme.spacing.xs};
33
+ overflow-x: auto;
34
+ width: 100%;
35
+
36
+ ::-webkit-scrollbar {
37
+ -webkit-appearance: none;
38
+ width: 100%;
39
+ height: 4px;
40
+ }
41
+
42
+ ::-webkit-scrollbar-thumb {
43
+ border-radius: 4px;
44
+ background-color: ${(p) => p.theme.color.iconNonActive};
45
+ }
46
+ `;
47
+
48
+ const FilterText = styled.div`
49
+ ${(p) => p.theme.textStyle.uiS};
50
+ color: ${(p) => p.theme.colors.textHighEmphasis};
51
+ flex-shrink: 0;
52
+ `;
53
+
54
+ const FilterItem = styled.button<{ isSelected: boolean; tag: string }>`
55
+ ${(p) => p.theme.textStyle.uiXS};
56
+ color: ${(p) => (p.isSelected ? p.theme.colors.textHighEmphasis : p.theme.colors.textMediumEmphasis)};
57
+ text-transform: uppercase;
58
+ border-radius: ${(p) => p.theme.radii.xs};
59
+ background-color: ${(p) => getHeadColor(p.tag, !p.isSelected ? 0.5 : undefined)};
60
+ padding: ${(p) => `0 ${p.theme.spacing.xs}`};
61
+ outline: ${(p) => (p.isSelected ? `1px solid ${p.theme.colors.interactive01}` : "none")};
62
+ font-weight: ${(p) => (p.isSelected ? "700" : "500")};
63
+ flex-shrink: 0;
64
+ `;
65
+
66
+ const HeadingsListWrapper = styled.div``;
67
+
68
+ const EmptyWrapper = styled.div`
69
+ height: 100%;
70
+ display: flex;
71
+ align-items: center;
72
+ `;
73
+
74
+ export {
75
+ Wrapper,
76
+ HeadingsWrapper,
77
+ FiltersWrapper,
78
+ FilterText,
79
+ FilterItem,
80
+ HeadingsListWrapper,
81
+ EmptyWrapper,
82
+ };
@@ -0,0 +1,257 @@
1
+ import type { HeadingFilter, HeadingLevel, HeadingNode } from "@ax/types";
2
+
3
+ const MAX_HEADING_LENGTH = 70;
4
+
5
+ const headColorsRGB: Record<string, { r: number; g: number; b: number }> = {
6
+ h1: { r: 255, g: 240, b: 109 },
7
+ h2: { r: 255, g: 184, b: 248 },
8
+ h3: { r: 115, g: 248, b: 200 },
9
+ h4: { r: 155, g: 237, b: 255 },
10
+ h5: { r: 198, g: 193, b: 255 },
11
+ h6: { r: 255, g: 206, b: 149 },
12
+ };
13
+
14
+ const getHeadColor = (tag: string, opacity?: number): string => {
15
+ const color = headColorsRGB[tag];
16
+ if (!color) return "rgb(255, 255, 255)";
17
+ if (opacity !== undefined) {
18
+ return `rgba(${color.r}, ${color.g}, ${color.b}, ${opacity})`;
19
+ }
20
+ return `rgb(${color.r}, ${color.g}, ${color.b})`;
21
+ };
22
+
23
+ const flattenHeadings = (headings: HeadingNode[], counter = { value: 0 }): IFlatHeading[] => {
24
+ const result: IFlatHeading[] = [];
25
+
26
+ for (const heading of headings) {
27
+ counter.value += 1;
28
+ result.push({
29
+ id: counter.value,
30
+ level: heading.level,
31
+ tag: heading.tag,
32
+ text: heading.text,
33
+ isHidden: heading.isHidden,
34
+ });
35
+
36
+ if (heading.children.length > 0) {
37
+ result.push(...flattenHeadings(heading.children, counter));
38
+ }
39
+ }
40
+
41
+ return result;
42
+ };
43
+
44
+ const analyzeHeadings = (headings: HeadingNode[], isFiltering = false): IHeadingError[] => {
45
+ const errors: IHeadingError[] = [];
46
+ const flatHeadings = flattenHeadings(headings);
47
+
48
+ if (flatHeadings.length === 0) {
49
+ return errors;
50
+ }
51
+
52
+ // 1. Check for missing H1
53
+ const hasH1 = flatHeadings.some((h) => h.tag === "h1");
54
+ if (!isFiltering && !hasH1) {
55
+ errors.push({
56
+ message: "No H1 in this page",
57
+ description: (
58
+ <>
59
+ Add a H1 tag to improve page
60
+ <br />
61
+ structure and SEO visibility.
62
+ </>
63
+ ),
64
+ headingIds: [],
65
+ });
66
+ }
67
+
68
+ // 2. Check for incorrect nesting (skipped levels)
69
+ const nestingErrorIds: number[] = [];
70
+ for (let i = 1; i < flatHeadings.length; i++) {
71
+ const prevLevel = flatHeadings[i - 1].level;
72
+ const currLevel = flatHeadings[i].level;
73
+
74
+ if (currLevel > prevLevel && currLevel - prevLevel > 1) {
75
+ nestingErrorIds.push(flatHeadings[i].id);
76
+ }
77
+ }
78
+ if (nestingErrorIds.length > 0) {
79
+ errors.push({
80
+ message: "Check heading structure for SEO compliance",
81
+ description: (
82
+ <>
83
+ Ensure each heading is properly nested
84
+ <br />
85
+ and follows SEO best practices.
86
+ </>
87
+ ),
88
+ headingIds: nestingErrorIds,
89
+ });
90
+ }
91
+
92
+ // 3. Check for duplicate texts
93
+ const textMap = new Map<string, number[]>();
94
+ for (const heading of flatHeadings) {
95
+ const normalizedText = heading.text.toLowerCase().trim();
96
+ if (normalizedText) {
97
+ const ids = textMap.get(normalizedText) || [];
98
+ ids.push(heading.id);
99
+ textMap.set(normalizedText, ids);
100
+ }
101
+ }
102
+ const duplicateIds: number[] = [];
103
+ for (const ids of textMap.values()) {
104
+ if (ids.length > 1) {
105
+ duplicateIds.push(...ids);
106
+ }
107
+ }
108
+ if (duplicateIds.length > 0) {
109
+ errors.push({
110
+ message: "Avoid header repetitions for SEO",
111
+ description: (
112
+ <>
113
+ Diversify your headings to enhance SEO.
114
+ <br />
115
+ Avoid using the same heading multiple times.
116
+ </>
117
+ ),
118
+ headingIds: duplicateIds,
119
+ });
120
+ }
121
+
122
+ // 4. Check for excessive length
123
+ const excessiveLengthIds: number[] = [];
124
+ for (const heading of flatHeadings) {
125
+ if (heading.text.length > MAX_HEADING_LENGTH) {
126
+ excessiveLengthIds.push(heading.id);
127
+ }
128
+ }
129
+ if (excessiveLengthIds.length > 0) {
130
+ errors.push({
131
+ message: "Some headings are too long",
132
+ description: (
133
+ <>
134
+ Optimize SEO by shortening
135
+ <br />
136
+ headings to ensure clarity.
137
+ </>
138
+ ),
139
+ headingIds: excessiveLengthIds,
140
+ });
141
+ }
142
+
143
+ return errors;
144
+ };
145
+
146
+ const parseHeadingsTree = (html: HTMLDivElement): HeadingNode[] => {
147
+ const frameObject = html.querySelector<HTMLIFrameElement>(".frame-content");
148
+ const frameContent = frameObject?.contentWindow?.document.getElementById("___griddo") as HTMLElement;
149
+
150
+ if (!frameContent) {
151
+ return [];
152
+ }
153
+
154
+ const iframeWindow = frameObject?.contentWindow;
155
+ const headings = Array.from(frameContent.querySelectorAll("h1, h2, h3, h4, h5, h6")) as HTMLHeadingElement[];
156
+
157
+ const root: HeadingNode[] = [];
158
+ const stack: HeadingNode[] = [];
159
+
160
+ const hasOpacityZeroAncestor = (el: HTMLElement): boolean => {
161
+ let node: HTMLElement | null = el.parentElement;
162
+ while (node && node !== frameContent) {
163
+ if (iframeWindow) {
164
+ const style = iframeWindow.getComputedStyle(node);
165
+ if (parseFloat(style.opacity) === 0) {
166
+ const hasAnimation = style.animationName !== "none";
167
+ const transitionProps = style.transitionProperty.split(",").map((p) => p.trim());
168
+ const hasOpacityTransition = transitionProps.includes("opacity") || transitionProps.includes("all");
169
+ if (!hasAnimation && !hasOpacityTransition) return true;
170
+ }
171
+ }
172
+ node = node.parentElement;
173
+ }
174
+ return false;
175
+ };
176
+
177
+ for (const heading of headings) {
178
+ const level = Number(heading.tagName[1]) as HeadingLevel;
179
+ const style = iframeWindow?.getComputedStyle(heading);
180
+ const isFixedPosition = style?.position === "fixed";
181
+ const isHidden =
182
+ heading.hidden ||
183
+ (heading.offsetParent === null && !isFixedPosition) ||
184
+ style?.visibility === "hidden" ||
185
+ hasOpacityZeroAncestor(heading);
186
+
187
+ const node: HeadingNode = {
188
+ level,
189
+ tag: heading.tagName.toLowerCase() as HeadingNode["tag"],
190
+ text: heading.textContent?.trim() ?? "",
191
+ isHidden: !!isHidden,
192
+ children: [],
193
+ };
194
+
195
+ // Subir en la jerarquía si el nivel actual es menor o igual
196
+ while (stack.length > 0 && stack[stack.length - 1].level >= level) {
197
+ stack.pop();
198
+ }
199
+
200
+ if (stack.length === 0) {
201
+ root.push(node);
202
+ } else {
203
+ stack[stack.length - 1].children.push(node);
204
+ }
205
+
206
+ stack.push(node);
207
+ }
208
+
209
+ return root;
210
+ };
211
+
212
+ const extractUniqueHeadingTypes = (headings: HeadingNode[]): HeadingFilter[] => {
213
+ const types = new Set<HeadingFilter>();
214
+
215
+ const traverse = (nodes: HeadingNode[]) => {
216
+ for (const node of nodes) {
217
+ types.add(node.tag);
218
+ if (node.children && node.children.length > 0) {
219
+ traverse(node.children);
220
+ }
221
+ }
222
+ };
223
+
224
+ traverse(headings);
225
+ return Array.from(types).sort();
226
+ };
227
+
228
+ const filterHeadings = (headings: HeadingNode[], selectedType: string): HeadingNode[] => {
229
+ if (selectedType === "all") return headings;
230
+
231
+ return flattenHeadings(headings)
232
+ .filter((h) => h.tag === selectedType)
233
+ .map((h) => ({
234
+ level: h.level,
235
+ tag: h.tag,
236
+ text: h.text,
237
+ isHidden: h.isHidden,
238
+ children: [],
239
+ }));
240
+ };
241
+
242
+ interface IHeadingError {
243
+ message: string;
244
+ description: React.ReactNode;
245
+ headingIds: number[];
246
+ }
247
+
248
+ interface IFlatHeading {
249
+ id: number;
250
+ level: HeadingLevel;
251
+ tag: HeadingNode["tag"];
252
+ text: string;
253
+ isHidden: boolean;
254
+ }
255
+
256
+ export { parseHeadingsTree, extractUniqueHeadingTypes, filterHeadings, analyzeHeadings, getHeadColor };
257
+ export type { IHeadingError };
@@ -1,4 +1,3 @@
1
- import React from "react";
2
1
  import { Icon } from "@ax/components";
3
2
 
4
3
  import * as S from "./style";
@@ -20,6 +19,7 @@ const IconAction = (props: IIconActionProps): JSX.Element => {
20
19
  size={size}
21
20
  inversed={inversed}
22
21
  active={active}
22
+ tabIndex={0}
23
23
  >
24
24
  <S.Icon data-testid={`icon-action-${icon}`}>
25
25
  <Icon name={icon} />
@@ -0,0 +1,46 @@
1
+ import { IconAction } from "@ax/components";
2
+ import { useModal } from "@ax/hooks";
3
+
4
+ import { DeleteKeywordsModal } from "../atoms";
5
+
6
+ import * as S from "./style";
7
+
8
+ const KeywordItem = (props: IKeywordItemProps) => {
9
+ const { keyword, count, isSelected, onClick, deleteKeyword } = props;
10
+
11
+ const { isOpen, toggleModal } = useModal();
12
+
13
+ const handleDeleteKeyword = (e: React.MouseEvent) => {
14
+ e.stopPropagation();
15
+ if (count > 0) {
16
+ toggleModal();
17
+ } else {
18
+ deleteKeyword(keyword);
19
+ }
20
+ };
21
+
22
+ return (
23
+ <>
24
+ <S.KeywordItem tabIndex={0} role="button" onClick={onClick} isSelected={isSelected}>
25
+ <S.KeyName>{keyword}</S.KeyName>
26
+ <S.ActionsWrapper>
27
+ <S.Counter isZero={!count}>{count} used</S.Counter>
28
+ <S.IconWrapper>
29
+ <IconAction icon="delete" size="s" onClick={handleDeleteKeyword} />
30
+ </S.IconWrapper>
31
+ </S.ActionsWrapper>
32
+ </S.KeywordItem>
33
+ <DeleteKeywordsModal isOpen={isOpen} toggleModal={toggleModal} deleteKeyword={() => deleteKeyword(keyword)} />
34
+ </>
35
+ );
36
+ };
37
+
38
+ interface IKeywordItemProps {
39
+ keyword: string;
40
+ count: number;
41
+ isSelected: boolean;
42
+ onClick: () => void;
43
+ deleteKeyword: (keyword: string) => void;
44
+ }
45
+
46
+ export default KeywordItem;
@@ -0,0 +1,64 @@
1
+ import styled from "styled-components";
2
+
3
+ const IconWrapper = styled.div`
4
+ margin-left: ${(p) => p.theme.spacing.xs};
5
+ opacity: 0;
6
+ `;
7
+
8
+ const KeywordItem = styled.div<{ isSelected: boolean }>`
9
+ position: relative;
10
+ background-color: ${(p) => p.theme.colors.uiBackground02};
11
+ display: flex;
12
+ margin-bottom: ${(p) => p.theme.spacing.xs};
13
+ min-height: ${(p) => p.theme.spacing.l};
14
+ border-radius: ${(p) => p.theme.radii.s};
15
+ cursor: pointer;
16
+ box-shadow: ${(p) => p.theme.shadow.shadowS};
17
+ padding-left: ${(p) => p.theme.spacing.s};
18
+ padding-right: ${(p) => p.theme.spacing.xs};
19
+ align-items: center;
20
+ justify-content: space-between;
21
+ outline: ${(p) => (p.isSelected ? `2px solid ${p.theme.colors.interactive01}` : "none")};
22
+
23
+ &:before {
24
+ content: "";
25
+ border-radius: ${(p) => p.theme.radii.s};
26
+ position: absolute;
27
+ top: 0;
28
+ left: 0;
29
+ width: 100%;
30
+ height: 100%;
31
+ opacity: 0;
32
+ transition: opacity 0.1s;
33
+ }
34
+
35
+ &:hover {
36
+ ${IconWrapper} {
37
+ opacity: 1;
38
+ }
39
+ }
40
+
41
+ &:hover:before {
42
+ background-color: ${(p) => p.theme.colors.overlayHoverPrimary};
43
+ opacity: 1;
44
+ }
45
+ `;
46
+
47
+ const KeyName = styled.div`
48
+ ${(p) => p.theme.textStyle.fieldLabel};
49
+ color: ${(p) => p.theme.colors.textHighEmphasis};
50
+ `;
51
+
52
+ const Counter = styled.div<{ isZero: boolean }>`
53
+ ${(p) => p.theme.textStyle.uiXS};
54
+ color: ${(p) => (p.isZero ? p.theme.colors.error : p.theme.colors.textLowEmphasis)};
55
+ font-weight: 600;
56
+ padding-left: ${(p) => p.theme.spacing.xxs};
57
+ `;
58
+
59
+ const ActionsWrapper = styled.div`
60
+ display: flex;
61
+ align-items: center;
62
+ `;
63
+
64
+ export { KeywordItem, KeyName, Counter, ActionsWrapper, IconWrapper };