@tpzdsp/next-toolkit 1.12.1 → 1.14.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/README.md +4 -4
- package/package.json +13 -6
- package/src/assets/styles/ol.css +147 -176
- package/src/components/ButtonLink/ButtonLink.stories.tsx +72 -0
- package/src/components/ButtonLink/ButtonLink.test.tsx +154 -0
- package/src/components/ButtonLink/ButtonLink.tsx +33 -0
- package/src/components/InfoBox/InfoBox.stories.tsx +460 -0
- package/src/components/InfoBox/InfoBox.test.tsx +330 -0
- package/src/components/InfoBox/InfoBox.tsx +168 -0
- package/src/components/InfoBox/types.ts +6 -0
- package/src/components/LinkButton/LinkButton.stories.tsx +74 -0
- package/src/components/LinkButton/LinkButton.test.tsx +177 -0
- package/src/components/LinkButton/LinkButton.tsx +80 -0
- package/src/components/index.ts +5 -0
- package/src/components/link/ExternalLink.test.tsx +104 -0
- package/src/components/link/ExternalLink.tsx +1 -0
- package/src/map/FullScreenControl.ts +126 -0
- package/src/map/LayerSwitcherControl.ts +87 -181
- package/src/map/LayerSwitcherPanel.tsx +173 -0
- package/src/map/MapComponent.tsx +12 -46
- package/src/map/createControlButton.ts +72 -0
- package/src/map/geocoder/Geocoder.test.tsx +115 -0
- package/src/map/geocoder/Geocoder.tsx +393 -0
- package/src/map/geocoder/groupResults.ts +12 -0
- package/src/map/geocoder/index.ts +4 -0
- package/src/map/geocoder/types.ts +11 -0
- package/src/map/index.ts +4 -1
- package/src/map/osOpenNamesSearch.ts +112 -57
- package/src/test/renderers.tsx +9 -20
- package/src/map/geocoder.ts +0 -61
- package/src/ol-geocoder.d.ts +0 -1
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { LinkButton } from './LinkButton';
|
|
2
|
+
import { render, screen } from '../../test/renderers';
|
|
3
|
+
|
|
4
|
+
// Mock next/link
|
|
5
|
+
vi.mock('next/link', () => ({
|
|
6
|
+
default: ({
|
|
7
|
+
href,
|
|
8
|
+
children,
|
|
9
|
+
className,
|
|
10
|
+
...props
|
|
11
|
+
}: {
|
|
12
|
+
href: string;
|
|
13
|
+
children: React.ReactNode;
|
|
14
|
+
className?: string;
|
|
15
|
+
}) => (
|
|
16
|
+
<a href={href} className={className} data-testid="next-link" {...props}>
|
|
17
|
+
{children}
|
|
18
|
+
</a>
|
|
19
|
+
),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
describe('LinkButton', () => {
|
|
23
|
+
describe('rendering', () => {
|
|
24
|
+
it('should render with children', () => {
|
|
25
|
+
render(<LinkButton href="/test">Click me</LinkButton>);
|
|
26
|
+
|
|
27
|
+
expect(screen.getByRole('link', { name: 'Click me' })).toBeInTheDocument();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should render as a link with correct href', () => {
|
|
31
|
+
render(<LinkButton href="/explore">Explore</LinkButton>);
|
|
32
|
+
|
|
33
|
+
const link = screen.getByRole('link', { name: 'Explore' });
|
|
34
|
+
|
|
35
|
+
expect(link).toHaveAttribute('href', '/explore');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should apply primary variant by default', () => {
|
|
39
|
+
render(<LinkButton href="/test">Button</LinkButton>);
|
|
40
|
+
|
|
41
|
+
const link = screen.getByRole('link');
|
|
42
|
+
|
|
43
|
+
expect(link).toHaveClass('bg-brand');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should apply secondary variant when specified', () => {
|
|
47
|
+
render(
|
|
48
|
+
<LinkButton href="/test" variant="secondary">
|
|
49
|
+
Button
|
|
50
|
+
</LinkButton>,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const link = screen.getByRole('link');
|
|
54
|
+
|
|
55
|
+
expect(link).toHaveClass('bg-slate-100');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should apply inverse variant when specified', () => {
|
|
59
|
+
render(
|
|
60
|
+
<LinkButton href="/test" variant="inverse">
|
|
61
|
+
Button
|
|
62
|
+
</LinkButton>,
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const link = screen.getByRole('link');
|
|
66
|
+
|
|
67
|
+
expect(link).toHaveClass('bg-white');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should merge custom className', () => {
|
|
71
|
+
render(
|
|
72
|
+
<LinkButton href="/test" className="custom-class">
|
|
73
|
+
Button
|
|
74
|
+
</LinkButton>,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const link = screen.getByRole('link');
|
|
78
|
+
|
|
79
|
+
expect(link).toHaveClass('custom-class');
|
|
80
|
+
expect(link).toHaveClass('bg-brand'); // Still has base classes
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('external links', () => {
|
|
85
|
+
it('should automatically detect external URLs', () => {
|
|
86
|
+
render(<LinkButton href="https://example.com">External</LinkButton>);
|
|
87
|
+
|
|
88
|
+
const link = screen.getByRole('link');
|
|
89
|
+
|
|
90
|
+
expect(link).toHaveAttribute('target', '_blank');
|
|
91
|
+
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('should detect protocol-relative URLs as external', () => {
|
|
95
|
+
render(<LinkButton href="//example.com">External</LinkButton>);
|
|
96
|
+
|
|
97
|
+
const link = screen.getByRole('link');
|
|
98
|
+
|
|
99
|
+
expect(link).toHaveAttribute('target', '_blank');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should detect mailto links as external', () => {
|
|
103
|
+
render(<LinkButton href="mailto:test@example.com">Email</LinkButton>);
|
|
104
|
+
|
|
105
|
+
const link = screen.getByRole('link');
|
|
106
|
+
|
|
107
|
+
expect(link).toHaveAttribute('target', '_blank');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('should use Next.js Link for internal URLs', () => {
|
|
111
|
+
render(<LinkButton href="/internal">Internal</LinkButton>);
|
|
112
|
+
|
|
113
|
+
expect(screen.getByTestId('next-link')).toBeInTheDocument();
|
|
114
|
+
|
|
115
|
+
const link = screen.getByRole('link');
|
|
116
|
+
|
|
117
|
+
expect(link).not.toHaveAttribute('target');
|
|
118
|
+
expect(link).not.toHaveAttribute('rel');
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('openInNewTab', () => {
|
|
123
|
+
it('should open in new tab when openInNewTab is true', () => {
|
|
124
|
+
render(
|
|
125
|
+
<LinkButton href="/internal" openInNewTab>
|
|
126
|
+
Open in new tab
|
|
127
|
+
</LinkButton>,
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const link = screen.getByRole('link');
|
|
131
|
+
|
|
132
|
+
expect(link).toHaveAttribute('target', '_blank');
|
|
133
|
+
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('should use regular anchor tag when openInNewTab is true', () => {
|
|
137
|
+
render(
|
|
138
|
+
<LinkButton href="/internal" openInNewTab>
|
|
139
|
+
Open in new tab
|
|
140
|
+
</LinkButton>,
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
// Should not use Next.js Link when opening in new tab
|
|
144
|
+
expect(screen.queryByTestId('next-link')).not.toBeInTheDocument();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('should not open in new tab by default for internal links', () => {
|
|
148
|
+
render(<LinkButton href="/internal">Internal</LinkButton>);
|
|
149
|
+
|
|
150
|
+
const link = screen.getByRole('link');
|
|
151
|
+
|
|
152
|
+
expect(link).not.toHaveAttribute('target');
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe('accessibility', () => {
|
|
157
|
+
it('should have link role', () => {
|
|
158
|
+
render(<LinkButton href="/test">Button</LinkButton>);
|
|
159
|
+
|
|
160
|
+
const link = screen.getByRole('link');
|
|
161
|
+
|
|
162
|
+
expect(link).toBeInTheDocument();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('should accept aria attributes', () => {
|
|
166
|
+
render(
|
|
167
|
+
<LinkButton href="/test" aria-label="Custom label">
|
|
168
|
+
Button
|
|
169
|
+
</LinkButton>,
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
const link = screen.getByRole('link', { name: 'Custom label' });
|
|
173
|
+
|
|
174
|
+
expect(link).toBeInTheDocument();
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
import NextLink from 'next/link';
|
|
4
|
+
|
|
5
|
+
import type { ExtendProps } from '../../types/utils';
|
|
6
|
+
import { cn } from '../../utils';
|
|
7
|
+
|
|
8
|
+
type Props = {
|
|
9
|
+
/** URL to navigate to */
|
|
10
|
+
href: string;
|
|
11
|
+
/** Button variant styling */
|
|
12
|
+
variant?: 'primary' | 'secondary' | 'inverse';
|
|
13
|
+
/** Content to display in the button */
|
|
14
|
+
children: ReactNode;
|
|
15
|
+
/** Whether to open in a new tab (default: false) */
|
|
16
|
+
openInNewTab?: boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type LinkButtonProps = ExtendProps<'a', Props>;
|
|
20
|
+
|
|
21
|
+
const VARIANTS = {
|
|
22
|
+
primary: 'bg-brand hover-enabled:bg-green-600 text-white',
|
|
23
|
+
secondary: 'bg-slate-100 hover-enabled:bg-slate-200 text-black',
|
|
24
|
+
inverse: 'bg-white hover-enabled:bg-slate-50 text-brand',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Determines if a URL is external (different origin or absolute URL)
|
|
29
|
+
*/
|
|
30
|
+
const isExternalUrl = (href: string): boolean => {
|
|
31
|
+
// Absolute URLs (http://, https://, //)
|
|
32
|
+
if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('//')) {
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// mailto:, tel:, etc.
|
|
37
|
+
if (href.includes(':')) {
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return false;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// NOTE: some of the styles are applied in `tailwind.css`, under `Button Styles`
|
|
45
|
+
export const LinkButton = ({
|
|
46
|
+
href,
|
|
47
|
+
variant = 'primary',
|
|
48
|
+
openInNewTab = false,
|
|
49
|
+
className,
|
|
50
|
+
children,
|
|
51
|
+
...props
|
|
52
|
+
}: LinkButtonProps) => {
|
|
53
|
+
const buttonClasses = cn(
|
|
54
|
+
`sm:w-auto text-lg relative inline-flex w-full outline-none items-center border-transparent
|
|
55
|
+
justify-center border-2 text-center active-enabled:translate-y-[2px] focus:shadow-focus
|
|
56
|
+
focus:border-focus focus:shadow-[inset_0_0_0_1px] focus-idle:border-focus
|
|
57
|
+
focus-idle:text-focus-text focus-idle:bg-focus focus-idle:shadow-border-input
|
|
58
|
+
disabled:opacity-50 disabled:hover:cursor-not-allowed button`,
|
|
59
|
+
VARIANTS[variant],
|
|
60
|
+
className,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const isExternal = isExternalUrl(href);
|
|
64
|
+
|
|
65
|
+
// External link or forced new tab - use <a> tag with appropriate attributes
|
|
66
|
+
if (isExternal || openInNewTab) {
|
|
67
|
+
return (
|
|
68
|
+
<a href={href} target="_blank" rel="noopener noreferrer" className={buttonClasses} {...props}>
|
|
69
|
+
{children}
|
|
70
|
+
</a>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Internal link - use Next.js Link for client-side navigation
|
|
75
|
+
return (
|
|
76
|
+
<NextLink href={href} className={buttonClasses} {...props}>
|
|
77
|
+
{children}
|
|
78
|
+
</NextLink>
|
|
79
|
+
);
|
|
80
|
+
};
|
package/src/components/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Default components - these can be used in server or client-side rendering
|
|
2
2
|
export { BackToTop } from './backToTop/BackToTop';
|
|
3
3
|
export { Button } from './Button/Button';
|
|
4
|
+
export { ButtonLink } from './ButtonLink/ButtonLink';
|
|
4
5
|
export { Card } from './Card/Card';
|
|
5
6
|
export { Chip } from './chip/Chip';
|
|
6
7
|
export { CookieBanner } from './cookieBanner/CookieBanner';
|
|
@@ -16,6 +17,7 @@ export { EaLogo } from './images/EaLogo';
|
|
|
16
17
|
export { OglLogo } from './images/OglLogo';
|
|
17
18
|
export { ExternalLink } from './link/ExternalLink';
|
|
18
19
|
export { Link } from './link/Link';
|
|
20
|
+
export { LinkButton } from './LinkButton/LinkButton';
|
|
19
21
|
export { Paragraph } from './Paragraph/Paragraph';
|
|
20
22
|
export { RuleDivider } from './divider/RuleDivider';
|
|
21
23
|
export { SkipLink } from './skipLink/SkipLink';
|
|
@@ -25,6 +27,7 @@ export { TextArea } from './form/TextArea';
|
|
|
25
27
|
// Export default component types
|
|
26
28
|
export type { BackToTopProps } from './backToTop/BackToTop';
|
|
27
29
|
export type { ButtonProps } from './Button/Button';
|
|
30
|
+
export type { ButtonLinkProps } from './ButtonLink/ButtonLink';
|
|
28
31
|
export type { CardProps } from './Card/Card';
|
|
29
32
|
export type { ChipProps } from './chip/Chip';
|
|
30
33
|
export type { ContainerProps } from './container/Container';
|
|
@@ -34,6 +37,7 @@ export type { HintProps } from './Hint/Hint';
|
|
|
34
37
|
export type { NotificationBannerProps } from './NotificationBanner/NotificationBanner';
|
|
35
38
|
export type { ExternalLinkProps } from './link/ExternalLink';
|
|
36
39
|
export type { LinkProps } from './link/Link';
|
|
40
|
+
export type { LinkButtonProps } from './LinkButton/LinkButton';
|
|
37
41
|
export type { ParagraphProps } from './Paragraph/Paragraph';
|
|
38
42
|
export type { SlidingPanelProps } from './SlidingPanel/SlidingPanel';
|
|
39
43
|
export type { SkipLinkProps } from './skipLink/SkipLink';
|
|
@@ -51,6 +55,7 @@ export { ErrorFallback } from './ErrorBoundary/ErrorFallback';
|
|
|
51
55
|
// NOTE: Select components moved to separate entry point '@tpzdsp/next-toolkit/components/select'
|
|
52
56
|
// export { Select } from './select/Select';
|
|
53
57
|
// export { SelectSkeleton } from './select/SelectSkeleton';
|
|
58
|
+
// NOTE: InfoBox moved to separate entry point '@tpzdsp/next-toolkit/components/info-box'
|
|
54
59
|
|
|
55
60
|
// Export client component types
|
|
56
61
|
export type { AccordionProps } from './accordion/Accordion';
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { render, screen } from '@testing-library/react';
|
|
4
|
+
|
|
5
|
+
import { ExternalLink } from './ExternalLink';
|
|
6
|
+
|
|
7
|
+
describe('ExternalLink', () => {
|
|
8
|
+
it('renders with children', () => {
|
|
9
|
+
render(<ExternalLink href="https://example.com">Visit Example</ExternalLink>);
|
|
10
|
+
|
|
11
|
+
const link = screen.getByRole('link', { name: /Visit Example/i });
|
|
12
|
+
|
|
13
|
+
expect(link).toBeInTheDocument();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('sets target="_blank" to open in new tab', () => {
|
|
17
|
+
render(<ExternalLink href="https://example.com">External Link</ExternalLink>);
|
|
18
|
+
|
|
19
|
+
const link = screen.getByRole('link');
|
|
20
|
+
|
|
21
|
+
expect(link).toHaveAttribute('target', '_blank');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('sets rel="noopener noreferrer" for security', () => {
|
|
25
|
+
render(<ExternalLink href="https://example.com">External Link</ExternalLink>);
|
|
26
|
+
|
|
27
|
+
const link = screen.getByRole('link');
|
|
28
|
+
|
|
29
|
+
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('includes screen reader text indicating new tab', () => {
|
|
33
|
+
render(<ExternalLink href="https://example.com">Visit Site</ExternalLink>);
|
|
34
|
+
|
|
35
|
+
// The accessible name should include the "(opens in new tab)" text
|
|
36
|
+
const link = screen.getByRole('link', { name: /Visit Site\(opens in new tab\)/i });
|
|
37
|
+
|
|
38
|
+
expect(link).toBeInTheDocument();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('applies custom className', () => {
|
|
42
|
+
render(
|
|
43
|
+
<ExternalLink href="https://example.com" className="custom-class">
|
|
44
|
+
Link
|
|
45
|
+
</ExternalLink>,
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const link = screen.getByRole('link');
|
|
49
|
+
|
|
50
|
+
expect(link).toHaveClass('custom-class');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('preserves default link styling classes', () => {
|
|
54
|
+
render(<ExternalLink href="https://example.com">Link</ExternalLink>);
|
|
55
|
+
|
|
56
|
+
const link = screen.getByRole('link');
|
|
57
|
+
|
|
58
|
+
expect(link).toHaveClass('text-link');
|
|
59
|
+
expect(link).toHaveClass('underline');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('applies href attribute correctly', () => {
|
|
63
|
+
render(<ExternalLink href="https://example.com">Link</ExternalLink>);
|
|
64
|
+
|
|
65
|
+
const link = screen.getByRole('link');
|
|
66
|
+
|
|
67
|
+
expect(link).toHaveAttribute('href', 'https://example.com');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('forwards additional props to anchor element', () => {
|
|
71
|
+
render(
|
|
72
|
+
<ExternalLink href="https://example.com" data-testid="external-link">
|
|
73
|
+
Link
|
|
74
|
+
</ExternalLink>,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const link = screen.getByTestId('external-link');
|
|
78
|
+
|
|
79
|
+
expect(link).toBeInTheDocument();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('renders with complex children', () => {
|
|
83
|
+
render(
|
|
84
|
+
<ExternalLink href="https://example.com">
|
|
85
|
+
<span>Complex </span>
|
|
86
|
+
|
|
87
|
+
<strong>Content</strong>
|
|
88
|
+
</ExternalLink>,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const link = screen.getByRole('link');
|
|
92
|
+
|
|
93
|
+
expect(link).toBeInTheDocument();
|
|
94
|
+
expect(link).toHaveTextContent('Complex Content');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('screen reader text is visually hidden but accessible', () => {
|
|
98
|
+
render(<ExternalLink href="https://example.com">Link</ExternalLink>);
|
|
99
|
+
|
|
100
|
+
const srText = screen.getByText('(opens in new tab)', { exact: false });
|
|
101
|
+
|
|
102
|
+
expect(srText).toHaveClass('sr-only');
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/* eslint-disable no-restricted-syntax */
|
|
2
|
+
import { Map } from 'ol';
|
|
3
|
+
import { Control } from 'ol/control';
|
|
4
|
+
import type { Options as ControlOptions } from 'ol/control/Control';
|
|
5
|
+
|
|
6
|
+
import { CONTROL_ICONS, createControlButton } from './createControlButton';
|
|
7
|
+
|
|
8
|
+
const FULL_SCREEN_CLASS = 'map-fullscreen';
|
|
9
|
+
const ARIA_LABEL_TOGGLE = 'Toggle full screen';
|
|
10
|
+
const ARIA_LABEL_EXIT = 'Exit full screen';
|
|
11
|
+
|
|
12
|
+
export class FullScreenControl extends Control {
|
|
13
|
+
private isFullScreen = false;
|
|
14
|
+
private readonly button!: HTMLButtonElement;
|
|
15
|
+
liveRegion!: HTMLElement;
|
|
16
|
+
private mapContainer: HTMLElement | null = null;
|
|
17
|
+
|
|
18
|
+
constructor(options?: ControlOptions) {
|
|
19
|
+
const button = createControlButton({
|
|
20
|
+
ariaLabel: ARIA_LABEL_TOGGLE,
|
|
21
|
+
title: ARIA_LABEL_TOGGLE,
|
|
22
|
+
iconSvg: CONTROL_ICONS.EXPAND,
|
|
23
|
+
className: 'ol-fullscreen-toggle',
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const element = document.createElement('div');
|
|
27
|
+
|
|
28
|
+
element.className = 'ol-fullscreen ol-unselectable ol-control';
|
|
29
|
+
element.appendChild(button);
|
|
30
|
+
|
|
31
|
+
// Create a live region for screen reader announcements
|
|
32
|
+
const liveRegion = document.createElement('div');
|
|
33
|
+
|
|
34
|
+
liveRegion.setAttribute('aria-live', 'polite');
|
|
35
|
+
liveRegion.setAttribute('role', 'status');
|
|
36
|
+
liveRegion.className = 'ol-fullscreen-live-region';
|
|
37
|
+
liveRegion.style.position = 'absolute';
|
|
38
|
+
liveRegion.style.width = '1px';
|
|
39
|
+
liveRegion.style.height = '1px';
|
|
40
|
+
liveRegion.style.margin = '-1px';
|
|
41
|
+
liveRegion.style.border = '0';
|
|
42
|
+
liveRegion.style.padding = '0';
|
|
43
|
+
liveRegion.style.overflow = 'hidden';
|
|
44
|
+
liveRegion.style.clipPath = 'inset(50%)';
|
|
45
|
+
element.appendChild(liveRegion);
|
|
46
|
+
|
|
47
|
+
super({
|
|
48
|
+
element,
|
|
49
|
+
target: options?.target,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
this.button = button;
|
|
53
|
+
this.liveRegion = liveRegion;
|
|
54
|
+
|
|
55
|
+
button.addEventListener('click', this.toggleFullScreen, false);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
setMap(map: Map | null) {
|
|
59
|
+
super.setMap(map);
|
|
60
|
+
|
|
61
|
+
if (map) {
|
|
62
|
+
// Find the map container (the target element)
|
|
63
|
+
this.mapContainer = map.getTargetElement();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private readonly updateButtonIcon = (isFullScreen: boolean): void => {
|
|
68
|
+
// Update the SVG icon content
|
|
69
|
+
const svg = this.button.querySelector('svg');
|
|
70
|
+
|
|
71
|
+
if (svg) {
|
|
72
|
+
svg.innerHTML = isFullScreen ? CONTROL_ICONS.COLLAPSE : CONTROL_ICONS.EXPAND;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
toggleFullScreen = (): void => {
|
|
77
|
+
if (!this.mapContainer) {
|
|
78
|
+
console.warn('Map container not found');
|
|
79
|
+
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
this.isFullScreen = !this.isFullScreen;
|
|
84
|
+
|
|
85
|
+
if (this.isFullScreen) {
|
|
86
|
+
// Enter full screen mode
|
|
87
|
+
this.mapContainer.classList.add(FULL_SCREEN_CLASS);
|
|
88
|
+
this.updateButtonIcon(true);
|
|
89
|
+
this.button.setAttribute('aria-label', ARIA_LABEL_EXIT);
|
|
90
|
+
this.button.setAttribute('title', ARIA_LABEL_EXIT);
|
|
91
|
+
this.liveRegion.textContent = 'Full screen mode enabled';
|
|
92
|
+
} else {
|
|
93
|
+
// Exit full screen mode
|
|
94
|
+
this.mapContainer.classList.remove(FULL_SCREEN_CLASS);
|
|
95
|
+
this.updateButtonIcon(false);
|
|
96
|
+
this.button.setAttribute('aria-label', ARIA_LABEL_TOGGLE);
|
|
97
|
+
this.button.setAttribute('title', ARIA_LABEL_TOGGLE);
|
|
98
|
+
this.liveRegion.textContent = 'Full screen mode disabled';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Force map to update its size after the transition
|
|
102
|
+
const map = this.getMap();
|
|
103
|
+
|
|
104
|
+
if (map) {
|
|
105
|
+
// Wait for CSS transition to complete before updating size
|
|
106
|
+
setTimeout(() => {
|
|
107
|
+
map.updateSize();
|
|
108
|
+
}, 300);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// Public method to exit full screen programmatically
|
|
113
|
+
exitFullScreen(): void {
|
|
114
|
+
if (this.isFullScreen) {
|
|
115
|
+
this.toggleFullScreen();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
dispose(): void {
|
|
120
|
+
if (this.button) {
|
|
121
|
+
this.button.removeEventListener('click', this.toggleFullScreen);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
super.dispose?.();
|
|
125
|
+
}
|
|
126
|
+
}
|