@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 CHANGED
@@ -139,7 +139,7 @@ interface MyComponentProps extends BaseProps {
139
139
 
140
140
  ```bash
141
141
  # Install OpenLayers dependencies
142
- npm install ol ol-geocoder ol-mapbox-style proj4
142
+ npm install ol ol-mapbox-style proj4
143
143
  ```
144
144
 
145
145
  ```typescript
@@ -152,14 +152,14 @@ import '@your-org/nextjs-library/styles/ol';
152
152
  Some components require additional peer dependencies that are marked as optional:
153
153
 
154
154
  - **Select components**: Require `react-select`
155
- - **Map components**: Require OpenLayers packages (`ol`, `ol-geocoder`, etc.)
155
+ - **Map components**: Require OpenLayers packages (`ol`, `ol-mapbox-style`, etc.)
156
156
 
157
157
  ```bash
158
158
  # For Select components
159
159
  npm install react-select
160
160
 
161
161
  # For Map components
162
- npm install ol ol-geocoder ol-mapbox-style proj4 @turf/turf geojson
162
+ npm install ol ol-mapbox-style proj4 @turf/turf geojson
163
163
  ```
164
164
 
165
165
  ```typescript
@@ -451,7 +451,7 @@ npm install react-select
451
451
 
452
452
  # Error: Cannot resolve module 'ol/ol.css'
453
453
  # Solution: Install OpenLayers dependencies
454
- npm install ol ol-geocoder ol-mapbox-style proj4
454
+ npm install ol ol-mapbox-style proj4
455
455
 
456
456
  # Or import components selectively to avoid these dependencies:
457
457
  import { Button, Card } from '@your-org/nextjs-library'; // ✅ No extra deps needed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpzdsp/next-toolkit",
3
- "version": "1.12.1",
3
+ "version": "1.14.0",
4
4
  "description": "A reusable React component library for Next.js applications",
5
5
  "engines": {
6
6
  "node": ">= 24.12.0",
@@ -57,6 +57,11 @@
57
57
  "import": "./src/components/select/index.ts",
58
58
  "require": "./src/components/select/index.ts"
59
59
  },
60
+ "./components/info-box": {
61
+ "types": "./src/components/InfoBox/InfoBox.tsx",
62
+ "import": "./src/components/InfoBox/InfoBox.tsx",
63
+ "require": "./src/components/InfoBox/InfoBox.tsx"
64
+ },
60
65
  "./http": {
61
66
  "types": "./src/http/index.ts",
62
67
  "import": "./src/http/index.ts",
@@ -171,7 +176,6 @@
171
176
  "jsonwebtoken": "^9.0.3",
172
177
  "next": "^16.1.4",
173
178
  "ol": "^10.6.1",
174
- "ol-geocoder": "^4.3.3",
175
179
  "ol-mapbox-style": "^13.0.1",
176
180
  "postcss": "^8.5.6",
177
181
  "prettier": "^3.8.1",
@@ -201,6 +205,7 @@
201
205
  },
202
206
  "peerDependencies": {
203
207
  "@better-fetch/fetch": "^1.1.21",
208
+ "@floating-ui/react": "^0.27.17",
204
209
  "@tanstack/react-query": "^5.90.19",
205
210
  "@testing-library/react": "^16.0.0",
206
211
  "@testing-library/user-event": "^14.6.1",
@@ -213,7 +218,6 @@
213
218
  "geojson": "^0.5.0",
214
219
  "next": "^16.1.4",
215
220
  "ol": "^10.6.1",
216
- "ol-geocoder": "^4.3.3",
217
221
  "ol-mapbox-style": "^13.0.1",
218
222
  "proj4": "^2.19.10",
219
223
  "react": "^19.2.1",
@@ -229,6 +233,9 @@
229
233
  "@better-fetch/fetch": {
230
234
  "optional": true
231
235
  },
236
+ "@floating-ui/react": {
237
+ "optional": true
238
+ },
232
239
  "@turf/turf": {
233
240
  "optional": true
234
241
  },
@@ -256,9 +263,6 @@
256
263
  "ol": {
257
264
  "optional": true
258
265
  },
259
- "ol-geocoder": {
260
- "optional": true
261
- },
262
266
  "ol-mapbox-style": {
263
267
  "optional": true
264
268
  },
@@ -277,5 +281,8 @@
277
281
  },
278
282
  "release": {
279
283
  "extends": "./release.config.js"
284
+ },
285
+ "dependencies": {
286
+ "@floating-ui/react": "^0.27.17"
280
287
  }
281
288
  }
@@ -1,42 +1,93 @@
1
1
  @import 'ol/ol.css';
2
- @import 'ol-geocoder/dist/ol-geocoder.min.css';
3
2
 
4
- /* Universal OpenLayers control focus style */
3
+ /**
4
+ * OpenLayers Control Styles
5
+ *
6
+ * This file contains styles for OpenLayers map controls (zoom, layer switcher, fullscreen).
7
+ *
8
+ * Design System:
9
+ * - All control buttons extend the `.ol-btn` base class
10
+ * - Control panels should be implemented as React components with Tailwind CSS
11
+ * - Use CSS custom properties (--focus-color, etc.) for consistent theming
12
+ *
13
+ * To add a new control:
14
+ * 1. Add the `ol-btn` class to your button element
15
+ * 2. Override only the properties you need (e.g., font-size, icon)
16
+ * 3. Position the control container with absolute positioning
17
+ */
18
+
19
+ :root {
20
+ --focus-color: #ffdd00;
21
+ --focus-outline-color: #ffbf47;
22
+ --control-bg: #fff;
23
+ --control-border: #505a5f;
24
+ --control-text: #0b0c0c;
25
+ --control-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
26
+ }
27
+
28
+ /* Base button class for all OpenLayers controls */
29
+ .ol-btn,
30
+ .ol-btn:hover,
31
+ .ol-btn:active,
32
+ .ol-btn:focus,
33
+ .ol-btn:focus-visible {
34
+ width: 40px !important;
35
+ height: 40px !important;
36
+ border: none !important;
37
+ outline: none !important;
38
+ }
39
+
40
+ .ol-btn {
41
+ background: var(--control-bg);
42
+ color: var(--control-text);
43
+ font-size: 1.5rem;
44
+ font-weight: bold;
45
+ line-height: 1;
46
+ box-shadow: var(--control-shadow);
47
+ cursor: pointer;
48
+ transition:
49
+ box-shadow 0.2s,
50
+ background 0.2s;
51
+ margin: 0;
52
+ padding: 0;
53
+ display: flex;
54
+ align-items: center;
55
+ justify-content: center;
56
+ }
57
+
58
+ /* Ensure SVG icons in buttons are consistently sized and centered */
59
+ .ol-btn svg {
60
+ width: 20px !important;
61
+ height: 20px !important;
62
+ flex-shrink: 0;
63
+ display: block;
64
+ margin: 0 auto;
65
+ }
66
+
67
+ .ol-btn:hover,
68
+ .ol-btn:active {
69
+ background: var(--focus-color) !important;
70
+ }
71
+
72
+ .ol-btn:focus,
73
+ .ol-btn:focus-visible {
74
+ outline: 3px solid var(--focus-outline-color) !important;
75
+ background: var(--focus-color) !important;
76
+ z-index: 2;
77
+ }
78
+
79
+ /* Universal OpenLayers control focus style for non-button elements */
5
80
  #map:focus,
6
81
  .ol-viewport:focus,
7
82
  .ol-control:focus,
8
- .ol-control button:focus,
9
- .ol-control button:focus-visible,
10
83
  .ol-attribution:focus,
11
- .ol-attribution button:focus,
12
- .ol-zoom:focus,
13
- .ol-zoom button:focus,
14
- .ol-layer-switcher-toggle:focus,
15
- .ol-layer-switcher-close:focus,
16
- .ol-layer-switcher-panel button:focus,
17
84
  .ol-attribution ul li a:focus,
18
- .ol-attribution ul li a:focus-visible,
19
- .ol-geocoder ul.gcd-txt-result > li > a:focus,
20
- .ol-geocoder ul.gcd-txt-result > li > a:focus-visible {
21
- outline: 3px solid #ffbf47 !important;
22
- border-color: #ffbf47 !important;
85
+ .ol-attribution ul li a:focus-visible {
86
+ outline: 3px solid var(--focus-outline-color) !important;
87
+ border-color: var(--focus-outline-color) !important;
23
88
  z-index: 2;
24
89
  }
25
90
 
26
- .ol-geocoder #gcd-input-search:focus,
27
- .ol-geocoder .gcd-txt-search:focus,
28
- .ol-geocoder .gcd-txt-search:focus-visible {
29
- background-color: #ffbf47 !important;
30
- z-index: 2;
31
- }
32
-
33
- .ol-geocoder .gcd-txt-input:focus,
34
- .ol-geocoder .gcd-txt-input:focus-visible {
35
- box-shadow:
36
- inset 0 0 0 1px #ffbf47,
37
- inset 0 0 6px #ffbf47;
38
- }
39
-
40
91
  /* Zoom control container */
41
92
  .ol-zoom {
42
93
  top: 1.5rem;
@@ -47,61 +98,26 @@
47
98
  z-index: 10;
48
99
  }
49
100
 
50
- /* Zoom buttons */
101
+ /* Apply base button styling to zoom buttons */
51
102
  .ol-zoom button,
52
- .ol-control button {
53
- width: 40px;
54
- height: 40px;
55
- background: #fff;
56
- color: #0b0c0c;
57
- font-size: 2rem;
58
- font-weight: bold;
59
- line-height: 1;
60
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
61
- cursor: pointer;
62
- transition:
63
- border-color 0.2s,
64
- box-shadow 0.2s,
65
- background 0.2s;
66
- margin: 0;
67
- padding: 0;
68
- display: flex;
69
- align-items: center;
70
- justify-content: center;
71
- }
72
-
73
103
  .ol-zoom button:hover,
74
- .ol-control button:hover {
75
- background: #f3f2f1;
76
- border-color: #1d70b8;
77
- outline: unset;
78
- }
79
-
80
- /* Layer switcher styles */
81
- .ol-layer-switcher {
82
- top: 0px;
83
- right: 1rem;
84
- }
85
-
86
- .ol-layer-switcher-toggle {
87
- position: absolute;
88
- top: 110px;
89
- right: 0px;
104
+ .ol-zoom button:active,
105
+ .ol-zoom button:focus,
106
+ .ol-zoom button:focus-visible {
107
+ width: 40px !important;
108
+ height: 40px !important;
109
+ border: none !important;
110
+ outline: none !important;
90
111
  }
91
112
 
92
- .ol-layer-switcher-toggle button {
93
- width: 40px;
94
- height: 40px;
95
- background: #fff;
96
- border: 2px solid #505a5f;
97
- color: #0b0c0c;
98
- font-size: 1.5rem;
113
+ .ol-zoom button {
114
+ background: var(--control-bg) !important;
115
+ color: var(--control-text) !important;
99
116
  font-weight: bold;
100
117
  line-height: 1;
101
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
118
+ box-shadow: var(--control-shadow);
102
119
  cursor: pointer;
103
120
  transition:
104
- border-color 0.2s,
105
121
  box-shadow 0.2s,
106
122
  background 0.2s;
107
123
  margin: 0;
@@ -109,124 +125,79 @@
109
125
  display: flex;
110
126
  align-items: center;
111
127
  justify-content: center;
128
+ font-size: 0 !important; /* Hide the default +/- text */
129
+ position: relative;
112
130
  }
113
131
 
114
- .ol-layer-switcher-panel {
115
- position: absolute;
116
- top: 110px;
117
- right: -100%;
118
- display: flex;
119
- flex-direction: column;
120
- align-items: stretch;
121
- padding: 1rem 1rem;
122
- min-width: 220px;
123
- background: #fff;
124
- box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
125
- gap: 0.5rem;
126
- transition: right 0.3s ease-in-out;
127
- }
128
-
129
- .ol-layer-switcher-panel.open {
130
- right: 3rem;
131
- }
132
-
133
- /* Header for the close button, aligns it to the top-right */
134
- .ol-layer-switcher-header {
135
- display: flex;
136
- justify-content: flex-end;
137
- align-items: center;
138
- width: 100%;
139
- }
140
-
141
- /* Small, circular close button */
142
- .ol-layer-switcher-close {
143
- font-size: 1.25rem;
144
- width: 2rem;
145
- height: 2rem;
146
- padding: 0;
147
- background: none;
148
- border: none;
149
- color: #505a5f;
150
- cursor: pointer;
151
- border-radius: 50%;
152
- transition: background 0.2s;
153
- }
154
-
155
- .ol-layer-switcher-close:hover,
156
- .ol-layer-switcher-close:focus {
157
- background: #e1e1e1;
158
- }
159
-
160
- /* Content area for basemap buttons */
161
- .ol-layer-switcher-content {
162
- display: flex;
163
- flex-direction: row;
164
- gap: 10px;
165
- }
166
-
167
- .ol-layer-switcher-content button {
168
- display: flex;
169
- flex-direction: column;
170
- align-items: center;
171
- justify-content: center;
172
- border: 2px solid #ccc;
173
- background-color: #fff;
174
- min-width: 135px;
175
- min-height: 135px;
176
- cursor: pointer;
177
- color: #000;
178
- }
179
-
180
- .ol-layer-switcher-content button:hover {
181
- background-color: #ddd;
132
+ .ol-zoom button:hover,
133
+ .ol-zoom button:active {
134
+ background: var(--focus-color) !important;
182
135
  }
183
136
 
184
- .ol-layer-switcher-content button.active {
185
- background-color: #007bff;
186
- color: white;
187
- border-color: #0056b3;
188
- box-shadow: 0 4px 10px rgba(0, 123, 255, 0.3);
189
- transform: scale(1.05);
190
- transition: all 0.2s ease;
137
+ .ol-zoom button:focus,
138
+ .ol-zoom button:focus-visible {
139
+ outline: 3px solid var(--focus-outline-color) !important;
140
+ background: var(--focus-color) !important;
141
+ z-index: 2;
191
142
  }
192
143
 
193
- .ol-layer-switcher-content button.active img {
194
- filter: brightness(1.2);
144
+ /* Override default zoom button text with SVG icons */
145
+ .ol-zoom-in::before,
146
+ .ol-zoom-out::before {
147
+ content: '';
148
+ display: block;
149
+ width: 20px;
150
+ height: 20px;
151
+ background-size: contain;
152
+ background-repeat: no-repeat;
153
+ background-position: center;
154
+ font-size: 0; /* Ensure no text shows */
195
155
  }
196
156
 
197
- .ol-layer-switcher-content button:not(.active) img {
198
- filter: grayscale(1);
157
+ .ol-zoom-in::before {
158
+ background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none"><path d="M12 5v14m-7-7h14" stroke="%230b0c0c" stroke-width="2" stroke-linecap="round"/></svg>');
199
159
  }
200
160
 
201
- .ol-layer-switcher-content button img {
202
- max-width: 80px;
203
- height: auto;
204
- border: 2px solid #000;
161
+ .ol-zoom-out::before {
162
+ background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none"><path d="M5 12h14" stroke="%230b0c0c" stroke-width="2" stroke-linecap="round"/></svg>');
205
163
  }
206
164
 
207
- .ol-layer-switcher-content button div {
208
- font-size: 14px;
209
- padding-top: 0.5rem;
165
+ /* Layer switcher control */
166
+ .ol-layer-switcher {
167
+ position: absolute;
168
+ top: 110px;
169
+ right: 1rem;
170
+ z-index: 9999;
210
171
  }
211
172
 
212
- /* Geocoder tweaks */
213
-
214
- #gcd-container,
215
- .ol-geocoder .gcd-txt-control {
216
- height: unset !important;
173
+ /* Full screen control */
174
+ .ol-fullscreen {
175
+ position: absolute;
176
+ top: 155px;
177
+ right: 1rem;
217
178
  }
218
179
 
219
- .ol-geocoder .gcd-txt-glass,
220
- .ol-geocoder ul.gcd-txt-result {
221
- top: unset !important;
180
+ /* Full screen mode for map container */
181
+ .map-fullscreen {
182
+ position: fixed !important;
183
+ top: 0 !important;
184
+ left: 0 !important;
185
+ right: 0 !important;
186
+ bottom: 0 !important;
187
+ width: 100vw !important;
188
+ height: 100vh !important;
189
+ z-index: 9999 !important;
190
+ margin: 0 !important;
191
+ padding: 0 !important;
222
192
  }
223
193
 
224
- .ol-geocoder ul.gcd-txt-result > li:nth-child(odd),
225
- .ol-geocoder ul.gcd-txt-result > li:nth-child(even) {
226
- background-color: #fff;
227
- background-color: #fff;
194
+ /* Ensure map fills container in full screen mode */
195
+ .map-fullscreen .ol-viewport {
196
+ width: 100% !important;
197
+ height: 100% !important;
228
198
  }
229
199
 
230
- .ol-geocoder ul.gcd-txt-result > li > a:hover {
231
- background-color: #f3f2f1;
200
+ /* Hide other UI elements when in full screen (optional) */
201
+ .map-fullscreen ~ * {
202
+ display: none;
232
203
  }
@@ -0,0 +1,72 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+
3
+ import { ButtonLink } from './ButtonLink';
4
+
5
+ const meta = {
6
+ title: 'Components/ButtonLink',
7
+ component: ButtonLink,
8
+ parameters: {
9
+ layout: 'centered',
10
+ },
11
+ tags: ['autodocs'],
12
+ argTypes: {
13
+ type: {
14
+ control: 'select',
15
+ options: ['button', 'submit', 'reset'],
16
+ },
17
+ },
18
+ } satisfies Meta<typeof ButtonLink>;
19
+
20
+ export default meta;
21
+ type Story = StoryObj<typeof meta>;
22
+
23
+ export const Default: Story = {
24
+ args: {
25
+ children: 'Click me',
26
+ type: 'button',
27
+ },
28
+ };
29
+
30
+ export const WithClickHandler: Story = {
31
+ args: {
32
+ children: 'Show alert',
33
+ onClick: () => alert('Button clicked!'),
34
+ },
35
+ };
36
+
37
+ export const Submit: Story = {
38
+ args: {
39
+ children: 'Submit form',
40
+ type: 'submit',
41
+ },
42
+ render: (args) => (
43
+ <form
44
+ onSubmit={(e) => {
45
+ e.preventDefault();
46
+ alert('Form submitted!');
47
+ }}
48
+ >
49
+ <ButtonLink {...args} />
50
+ </form>
51
+ ),
52
+ };
53
+
54
+ export const Disabled: Story = {
55
+ args: {
56
+ children: 'Disabled button link',
57
+ disabled: true,
58
+ },
59
+ };
60
+
61
+ export const WithCustomStyling: Story = {
62
+ args: {
63
+ children: 'Custom styled',
64
+ className: 'font-bold text-xl',
65
+ },
66
+ };
67
+
68
+ export const LongText: Story = {
69
+ args: {
70
+ children: 'This is a longer button link that demonstrates how text wraps',
71
+ },
72
+ };
@@ -0,0 +1,154 @@
1
+ import { ButtonLink } from './ButtonLink';
2
+ import { render, screen, userEvent } from '../../test/renderers';
3
+
4
+ describe('ButtonLink', () => {
5
+ describe('rendering', () => {
6
+ it('should render with children', () => {
7
+ render(<ButtonLink>Click me</ButtonLink>);
8
+
9
+ expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
10
+ });
11
+
12
+ it('should render as a button element', () => {
13
+ render(<ButtonLink>Button</ButtonLink>);
14
+
15
+ const button = screen.getByRole('button');
16
+
17
+ expect(button.tagName).toBe('BUTTON');
18
+ });
19
+
20
+ it('should have link styling classes', () => {
21
+ render(<ButtonLink>Button</ButtonLink>);
22
+
23
+ const button = screen.getByRole('button');
24
+
25
+ expect(button).toHaveClass('text-link', 'underline', 'cursor-pointer');
26
+ });
27
+
28
+ it('should merge custom className', () => {
29
+ render(<ButtonLink className="custom-class">Button</ButtonLink>);
30
+
31
+ const button = screen.getByRole('button');
32
+
33
+ expect(button).toHaveClass('custom-class');
34
+ expect(button).toHaveClass('text-link'); // Still has base classes
35
+ });
36
+ });
37
+
38
+ describe('button types', () => {
39
+ it('should default to type="button"', () => {
40
+ render(<ButtonLink>Button</ButtonLink>);
41
+
42
+ const button = screen.getByRole('button');
43
+
44
+ expect(button).toHaveAttribute('type', 'button');
45
+ });
46
+
47
+ it('should accept type="submit"', () => {
48
+ render(<ButtonLink type="submit">Submit</ButtonLink>);
49
+
50
+ const button = screen.getByRole('button');
51
+
52
+ expect(button).toHaveAttribute('type', 'submit');
53
+ });
54
+
55
+ it('should accept type="reset"', () => {
56
+ render(<ButtonLink type="reset">Reset</ButtonLink>);
57
+
58
+ const button = screen.getByRole('button');
59
+
60
+ expect(button).toHaveAttribute('type', 'reset');
61
+ });
62
+ });
63
+
64
+ describe('interactions', () => {
65
+ it('should handle click events', async () => {
66
+ const user = userEvent.setup();
67
+ const onClick = vi.fn();
68
+
69
+ render(<ButtonLink onClick={onClick}>Click me</ButtonLink>);
70
+
71
+ await user.click(screen.getByRole('button'));
72
+
73
+ expect(onClick).toHaveBeenCalledTimes(1);
74
+ });
75
+
76
+ it('should not trigger onClick when disabled', async () => {
77
+ const user = userEvent.setup();
78
+ const onClick = vi.fn();
79
+
80
+ render(
81
+ <ButtonLink onClick={onClick} disabled>
82
+ Disabled
83
+ </ButtonLink>,
84
+ );
85
+
86
+ await user.click(screen.getByRole('button'));
87
+
88
+ expect(onClick).not.toHaveBeenCalled();
89
+ });
90
+
91
+ it('should submit form when type="submit"', () => {
92
+ const onSubmit = vi.fn((e) => e.preventDefault());
93
+
94
+ render(
95
+ <form onSubmit={onSubmit}>
96
+ <ButtonLink type="submit">Submit</ButtonLink>
97
+ </form>,
98
+ );
99
+
100
+ const button = screen.getByRole('button');
101
+
102
+ button.click();
103
+
104
+ expect(onSubmit).toHaveBeenCalled();
105
+ });
106
+ });
107
+
108
+ describe('disabled state', () => {
109
+ it('should have disabled attribute when disabled', () => {
110
+ render(<ButtonLink disabled>Disabled</ButtonLink>);
111
+
112
+ const button = screen.getByRole('button');
113
+
114
+ expect(button).toBeDisabled();
115
+ });
116
+
117
+ it('should have reduced opacity when disabled', () => {
118
+ render(<ButtonLink disabled>Disabled</ButtonLink>);
119
+
120
+ const button = screen.getByRole('button');
121
+
122
+ expect(button).toHaveClass('disabled:opacity-50');
123
+ });
124
+
125
+ it('should have not-allowed cursor when disabled', () => {
126
+ render(<ButtonLink disabled>Disabled</ButtonLink>);
127
+
128
+ const button = screen.getByRole('button');
129
+
130
+ expect(button).toHaveClass('disabled:cursor-not-allowed');
131
+ });
132
+ });
133
+
134
+ describe('accessibility', () => {
135
+ it('should have button role', () => {
136
+ render(<ButtonLink>Button</ButtonLink>);
137
+
138
+ expect(screen.getByRole('button')).toBeInTheDocument();
139
+ });
140
+
141
+ it('should accept aria attributes', () => {
142
+ render(
143
+ <ButtonLink aria-label="Custom label" aria-describedby="description">
144
+ Button
145
+ </ButtonLink>,
146
+ );
147
+
148
+ const button = screen.getByRole('button', { name: 'Custom label' });
149
+
150
+ expect(button).toBeInTheDocument();
151
+ expect(button).toHaveAttribute('aria-describedby', 'description');
152
+ });
153
+ });
154
+ });