playbook_ui 16.3.0.pre.rc.3 → 16.3.0.pre.rc.4

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4e153aae819ff62c66551a68563cda7597f5788d3971396767d1f8239b90d153
4
- data.tar.gz: 96276b46239d4b7eee81b0f0209e9b48ad8736277e1c8c39150e7346af24da30
3
+ metadata.gz: 2ed5ebc4bcaa2e9cec8f996f7709586bb131dd974b115837eecc0920b4a7cd0d
4
+ data.tar.gz: 558eacf65043d2b076fd8fc8c2783ec4e70573228f46bc7c68b4ff1f60dba608
5
5
  SHA512:
6
- metadata.gz: f9930c8405e22e1bed3b2b6e2e830b380c5656b522998ece950c7f40a6938ede92771b530bc9d6b19d6343ff21eeb5f9af11c7ea46adaa4e110a80cf2fccf11b
7
- data.tar.gz: d92e2c424880aec27fcc9f93aecc09a8d419a8bd521fdbb03e6b870e5d3b60f9c58ff7c7a4845fc84b41710991d0d4f9e2397d54c6a99a292803e6c2b001f8f9
6
+ metadata.gz: e0436ed47dbf37f88c8f4b98fa939b9c8835ac00124ee73eb8c05d539756eedbe7f95e8fbee85687c2f798221c5b86715afa83722d153ca82614760df715a6fd
7
+ data.tar.gz: aa23810833c0a3bbc70275084d7cb41a342812d31a97af269907f28ac0563afb825dc4c578c498e46e0af1112b3426ddb6a46bf1b74783c213dc70ff6dd89286
@@ -115,9 +115,10 @@ $pb_button_sizes: (
115
115
 
116
116
  // Icon-only button (icon prop set, no text) - square with equal padding
117
117
  // Rails: uses .pb_button_icon_only class
118
- // React: detects when pb_button_content has an empty text span
118
+ // React: when pb_button_content is empty (no text). Do not match when content has
119
+ // text + icon (e.g. "Exit Fullscreen" + FA icon) which can include empty spans.
119
120
  &.pb_button_icon_only,
120
- &:has(.pb_button_content > span:empty) {
121
+ &:has(.pb_button_content:empty) {
121
122
  aspect-ratio: 1;
122
123
  min-width: auto;
123
124
  width: auto;
@@ -2,10 +2,14 @@
2
2
 
3
3
  require "open-uri"
4
4
  require "json"
5
+ require "digest"
5
6
 
6
7
  module Playbook
7
8
  module PbIcon
8
9
  class Icon < Playbook::KitBase
10
+ ICON_PATH_DEV_CACHE_TTL_SECONDS = 2
11
+ ICON_PATH_PROD_CACHE_TTL_SECONDS = 60
12
+
9
13
  prop :border, type: Playbook::Props::Boolean,
10
14
  default: false
11
15
  prop :fixed_width, type: Playbook::Props::Boolean,
@@ -82,30 +86,35 @@ module Playbook
82
86
  )
83
87
  end
84
88
 
89
+ # Instance-level memoization of alias map lookup result
85
90
  def icon_alias_map
86
- return unless Rails.application.config.respond_to?(:icon_alias_path)
91
+ return @icon_alias_map if defined?(@icon_alias_map)
87
92
 
88
- base_path = Rails.application.config.icon_alias_path
89
- json = File.read(Rails.root.join(base_path))
90
- JSON.parse(json)["aliases"].freeze
93
+ @icon_alias_map = self.class.icon_alias_map
91
94
  end
92
95
 
96
+ # Instance-level memoization of resolved asset path
93
97
  def asset_path
94
- return unless Rails.application.config.respond_to?(:icon_path)
98
+ return @asset_path if defined?(@asset_path)
99
+
100
+ @asset_path =
101
+ if Rails.application.config.respond_to?(:icon_path)
102
+ resolved_icon = resolve_alias(icon)
103
+ path = self.class.icon_path_index[resolved_icon]
104
+ path if path && File.exist?(path)
105
+ end
106
+ end
107
+
108
+ def is_svg?
109
+ return @is_svg if defined?(@is_svg)
95
110
 
96
- base_path = Rails.application.config.icon_path
97
- resolved_icon = resolve_alias(icon)
98
- icon_path = Dir.glob(Rails.root.join(base_path, "**", "#{resolved_icon}.svg")).first
99
- icon_path if icon_path && File.exist?(icon_path)
111
+ @is_svg = (icon || custom_icon.to_s).include?(".svg") || asset_path.present?
100
112
  end
101
113
 
102
114
  def render_svg
103
115
  doc = Nokogiri::XML(URI.open(asset_path || icon || custom_icon)) # rubocop:disable Security/Open
104
- svg = doc.at_css "svg"
105
-
106
- unless svg
107
- return "" # Return an empty string if SVG element is not found
108
- end
116
+ svg = doc.at_css("svg")
117
+ return "" unless svg
109
118
 
110
119
  svg["class"] = %w[pb_custom_icon svg-inline--fa].concat([object.custom_icon_classname]).join(" ")
111
120
  svg["id"] = object.id
@@ -113,7 +122,9 @@ module Playbook
113
122
  svg["width"] = "auto"
114
123
  svg["tabindex"] = object.tabindex
115
124
  fill_color = object.color || "currentColor"
116
- doc.at_css("path")["fill"] = fill_color
125
+
126
+ # Safely apply fill to all paths (avoids nil errors + handles multi-path icons)
127
+ doc.css("path").each { |p| p["fill"] = fill_color }
117
128
 
118
129
  if object.data.present?
119
130
  object.data.each do |key, value|
@@ -135,14 +146,152 @@ module Playbook
135
146
  ""
136
147
  end
137
148
 
138
- def is_svg?
139
- (icon || custom_icon.to_s).include?(".svg") || asset_path.present?
149
+ # Class-level caches
150
+ class << self
151
+ @cache_mutex = Mutex.new
152
+
153
+ # Cache aliases.json across the process, but invalidate when the file changes (dev-safe)
154
+ def icon_alias_map
155
+ return @icon_alias_map if alias_cache_fresh?
156
+
157
+ cache_mutex.synchronize do
158
+ return @icon_alias_map if alias_cache_fresh?
159
+
160
+ @icon_alias_map =
161
+ if Rails.application.config.respond_to?(:icon_alias_path)
162
+ base_path = Rails.application.config.icon_alias_path
163
+ full_path = Rails.root.join(base_path)
164
+ @icon_alias_map_mtime = safe_mtime(full_path)
165
+
166
+ json = File.read(full_path)
167
+ parsed = JSON.parse(json)
168
+ parsed.fetch("aliases", {}).freeze
169
+ end
170
+ end
171
+
172
+ @icon_alias_map
173
+ end
174
+
175
+ # Cache an index of icon_name to file path for all SVGs in the configured directory, with invalidation based on directory mtime
176
+ # Avoids recursive Dir.glob for every icon render
177
+ def icon_path_index
178
+ return @icon_path_index if index_cache_fresh?
179
+
180
+ cache_mutex.synchronize do
181
+ return @icon_path_index if index_cache_fresh?
182
+
183
+ @icon_path_index =
184
+ if Rails.application.config.respond_to?(:icon_path)
185
+ base_path = Rails.application.config.icon_path
186
+ root = Rails.root.join(base_path)
187
+
188
+ # If path doesn't exist, keep behavior aligned (no path resolution)
189
+ if Dir.exist?(root)
190
+ @icon_path_index_cache_key = icon_path_cache_key(root)
191
+
192
+ # One scan builds the map for O(1) lookups
193
+ # Key is the filename (without .svg) to match existing usage
194
+ index = {}
195
+ Dir.glob(File.join(root.to_s, "**", "*.svg")).sort.each do |p|
196
+ name = File.basename(p, ".svg")
197
+ index[name] ||= p
198
+ end
199
+ index.freeze
200
+ else
201
+ @icon_path_index_cache_key = nil
202
+ {}
203
+ end
204
+ else
205
+ {}
206
+ end
207
+
208
+ @icon_path_index_checked_at = monotonic_now
209
+ end
210
+
211
+ @icon_path_index
212
+ end
213
+
214
+ private
215
+
216
+ def cache_mutex
217
+ @cache_mutex ||= Mutex.new
218
+ end
219
+
220
+ def alias_cache_fresh?
221
+ return false unless defined?(@icon_alias_map)
222
+
223
+ return true unless Rails.application.config.respond_to?(:icon_alias_path)
224
+
225
+ full_path = Rails.root.join(Rails.application.config.icon_alias_path)
226
+ safe_mtime(full_path) == @icon_alias_map_mtime
227
+ rescue
228
+ false
229
+ end
230
+
231
+ def index_cache_fresh?
232
+ return false unless defined?(@icon_path_index)
233
+
234
+ return true unless Rails.application.config.respond_to?(:icon_path)
235
+
236
+ # In development and production, skip re-checks for a short TTL window
237
+ # to avoid repeated tree scans on hot paths.
238
+ return true if Rails.env.development? && within_icon_index_ttl?(ICON_PATH_DEV_CACHE_TTL_SECONDS)
239
+ return true if Rails.env.production? && within_icon_index_ttl?(ICON_PATH_PROD_CACHE_TTL_SECONDS)
240
+
241
+ root = Rails.root.join(Rails.application.config.icon_path)
242
+ fresh = icon_path_cache_key(root) == @icon_path_index_cache_key
243
+ @icon_path_index_checked_at = monotonic_now
244
+ fresh
245
+ rescue
246
+ false
247
+ end
248
+
249
+ def within_icon_index_ttl?(ttl_seconds)
250
+ return false unless defined?(@icon_path_index_checked_at)
251
+
252
+ (monotonic_now - @icon_path_index_checked_at) < ttl_seconds
253
+ rescue
254
+ false
255
+ end
256
+
257
+ def monotonic_now
258
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
259
+ end
260
+
261
+ def icon_path_cache_key(root)
262
+ return safe_mtime(root) unless Rails.env.development? || Rails.env.production?
263
+
264
+ digest = Digest::SHA1.new
265
+ root_prefix = "#{root}/"
266
+
267
+ Dir.glob(File.join(root.to_s, "**", "*.svg")).sort.each do |path|
268
+ digest << path.delete_prefix(root_prefix)
269
+ next unless Rails.env.development?
270
+
271
+ # Development tracks file metadata for rapid local edits.
272
+ # Production only needs path-set change detection during periodic checks.
273
+ stat = File.stat(path)
274
+ digest << stat.mtime.to_f.to_s
275
+ digest << stat.size.to_s
276
+ end
277
+
278
+ digest.hexdigest
279
+ rescue
280
+ nil
281
+ end
282
+
283
+ def safe_mtime(path)
284
+ File.exist?(path) ? File.mtime(path) : nil
285
+ rescue
286
+ nil
287
+ end
140
288
  end
141
289
 
142
290
  private
143
291
 
144
292
  def resolve_alias(icon)
145
293
  return icon unless icon_alias_map
294
+ return icon if icon.nil?
146
295
 
147
296
  aliases = icon_alias_map[icon]
148
297
  return icon unless aliases
@@ -155,8 +304,8 @@ module Playbook
155
304
  end
156
305
 
157
306
  def file_exists?(alias_name)
158
- base_path = Rails.application.config.icon_path
159
- File.exist?(Dir.glob(Rails.root.join(base_path, "**", "#{alias_name}.svg")).first)
307
+ # Use the cached index (no recursive glob)
308
+ self.class.icon_path_index.key?(alias_name)
160
309
  end
161
310
 
162
311
  def svg_size
@@ -0,0 +1,100 @@
1
+ import React, { useState } from "react";
2
+ import MultiLevelSelect from "../_multi_level_select";
3
+ import { Button } from "playbook-ui";
4
+
5
+ const treeData = [
6
+ {
7
+ label: "Power Home Remodeling",
8
+ value: "powerHomeRemodeling",
9
+ id: "100",
10
+ expanded: true,
11
+ children: [
12
+ {
13
+ label: "People",
14
+ value: "people",
15
+ id: "101",
16
+ expanded: true,
17
+ children: [
18
+ {
19
+ label: "Talent Acquisition",
20
+ value: "talentAcquisition",
21
+ id: "102",
22
+ },
23
+ {
24
+ label: "Business Affairs",
25
+ value: "businessAffairs",
26
+ id: "103",
27
+ children: [
28
+ {
29
+ label: "Initiatives",
30
+ value: "initiatives",
31
+ id: "104",
32
+ },
33
+ {
34
+ label: "Learning & Development",
35
+ value: "learningAndDevelopment",
36
+ id: "105",
37
+ },
38
+ ],
39
+ },
40
+ {
41
+ label: "People Experience",
42
+ value: "peopleExperience",
43
+ id: "106",
44
+ },
45
+ ],
46
+ },
47
+ {
48
+ label: "Contact Center",
49
+ value: "contactCenter",
50
+ id: "107",
51
+ children: [
52
+ {
53
+ label: "Appointment Management",
54
+ value: "appointmentManagement",
55
+ id: "108",
56
+ },
57
+ {
58
+ label: "Customer Service",
59
+ value: "customerService",
60
+ id: "109",
61
+ },
62
+ {
63
+ label: "Energy",
64
+ value: "energy",
65
+ id: "110",
66
+ },
67
+ ],
68
+ },
69
+ ],
70
+ },
71
+ ];
72
+
73
+ const MultiLevelSelectReactResetKey = (props) => {
74
+ const [resetKey, setResetKey] = useState(0);
75
+
76
+ const handleReset = () => {
77
+ setResetKey((k) => k + 1);
78
+ };
79
+
80
+ return (
81
+ <>
82
+ <MultiLevelSelect
83
+ {...props}
84
+ id="multi-level-select-reset-example"
85
+ key={`multi-level-select-reset-${resetKey}`}
86
+ name="my_array"
87
+ returnAllSelected
88
+ treeData={treeData}
89
+ />
90
+ <Button
91
+ id="multilevelselect-reset-button"
92
+ marginTop="lg"
93
+ onClick={handleReset}
94
+ text="Reset"
95
+ />
96
+ </>
97
+ );
98
+ };
99
+
100
+ export default MultiLevelSelectReactResetKey;
@@ -0,0 +1 @@
1
+ When a parent resets a Multi Level Select (e.g., “Default” or “Clear”), the kit needs a `key` that changes with the selection because React uses the `key` to determine component identity and whether to preserve internal state. If the `key` doesn’t change, React reuses the existing instance and may keep showing the old selection instead of resetting to the new one.
@@ -39,3 +39,4 @@ examples:
39
39
  - multi_level_select_disabled_options_parent: Disabled Parent Option (Return All Selected)
40
40
  - multi_level_select_single_disabled: Disabled Options (Single Select)
41
41
  - multi_level_select_required_indicator: Required Indicator
42
+ - multi_level_select_react_reset_key: Reset with Key (React)
@@ -17,3 +17,4 @@ export { default as MultiLevelSelectDisabledOptionsDefault } from "./_multi_leve
17
17
  export { default as MultiLevelSelectLabel } from "./_multi_level_select_label.jsx"
18
18
  export { default as MultiLevelSelectSingleDisabled } from "./_multi_level_select_single_disabled.jsx"
19
19
  export { default as MultiLevelSelectRequiredIndicator } from "./_multi_level_select_required_indicator.jsx"
20
+ export { default as MultiLevelSelectReactResetKey } from "./_multi_level_select_react_reset_key.jsx"
@@ -73,8 +73,9 @@ $top_bottom_radius: 0px;
73
73
  }
74
74
  .disabled {
75
75
  pointer-events: none;
76
+ cursor: not-allowed;
76
77
  opacity: 0.5;
77
- color: grey;
78
+ color: #B0BDC7; // replace with $text_disabled once added to tokens
78
79
 
79
80
  & > span {
80
81
  padding: $pagination_padding;
@@ -104,4 +105,103 @@ $top_bottom_radius: 0px;
104
105
  border-right: none;
105
106
  margin-left: $space_xxs;
106
107
  }
108
+ }
109
+
110
+ .pb_pagination_mobile {
111
+ padding: $space_xxs 0px !important;
112
+ position: relative;
113
+
114
+ .pagination-right {
115
+ border-left: none !important;
116
+ padding: 6px 13px !important;
117
+ flex-shrink: 0;
118
+
119
+ @media (max-width: 435px) {
120
+ padding: 6px 10px !important;
121
+ }
122
+ }
123
+ .pagination-left {
124
+ border-right: none !important;
125
+ padding: 6px 13px !important;
126
+ flex-shrink: 0;
127
+
128
+ @media (max-width: 435px) {
129
+ padding: 6px 10px !important;
130
+ }
131
+ }
132
+ .pagination-dropdown {
133
+ position: static;
134
+ flex: 1;
135
+ margin: 0 $space_xxs;
136
+ min-width: 0;
137
+ overflow: hidden;
138
+ }
139
+
140
+ .pagination-dropdown-trigger {
141
+ padding: 6px 10px 6px 13px !important;
142
+ background-color: $bg_light;
143
+ border-radius: $border_rad_light;
144
+ cursor: pointer;
145
+ transition: all 0.2s ease;
146
+ white-space: nowrap;
147
+ overflow: hidden;
148
+
149
+ &:hover {
150
+ background-color: $active_light;
151
+ }
152
+
153
+ @media (max-width: 435px) {
154
+ padding: 6px 8px 6px 10px !important;
155
+ }
156
+ }
157
+
158
+ .pagination-dropdown-menu {
159
+ position: absolute;
160
+ left: 0;
161
+ right: 0;
162
+ background-color: $white;
163
+ border: $border_rad_lightest solid $border_light;
164
+ border-radius: $border_rad_light;
165
+ box-shadow: $shadow_deep;
166
+ max-height: 200px;
167
+ overflow-y: auto;
168
+ z-index: 1000;
169
+
170
+ &.below {
171
+ top: 100%;
172
+ margin-top: $space_xs;
173
+ }
174
+
175
+ &.above {
176
+ bottom: 100%;
177
+ margin-bottom: $space_xs;
178
+ }
179
+ }
180
+
181
+ .pagination-dropdown-option {
182
+ padding: $pagination_padding;
183
+ cursor: pointer;
184
+ transition: background-color 0.2s ease;
185
+
186
+ &:hover {
187
+ background-color: $active_light;
188
+ }
189
+
190
+ &.active {
191
+ background-color: $primary;
192
+ .pb_body_kit {
193
+ color: $white;
194
+ }
195
+ }
196
+
197
+ &:first-child {
198
+ border-top-left-radius: $border_rad_light;
199
+ border-top-right-radius: $border_rad_light;
200
+ }
201
+
202
+ &:last-child {
203
+ border-bottom-left-radius: $border_rad_light;
204
+ border-bottom-right-radius: $border_rad_light;
205
+ }
206
+ }
107
207
  }
@@ -209,4 +209,175 @@ describe('Pagination Component', () => {
209
209
 
210
210
  expect(screen.getByText('19')).toBeInTheDocument()
211
211
  })
212
- })
212
+ })
213
+
214
+ describe('Pagination Mobile View', () => {
215
+ let originalInnerWidth
216
+
217
+ beforeEach(() => {
218
+ // Store original value
219
+ originalInnerWidth = window.innerWidth
220
+
221
+ // Mock window.innerWidth for mobile
222
+ Object.defineProperty(window, 'innerWidth', {
223
+ writable: true,
224
+ configurable: true,
225
+ value: 767
226
+ })
227
+ })
228
+
229
+ afterEach(() => {
230
+ // Restore original value
231
+ Object.defineProperty(window, 'innerWidth', {
232
+ writable: true,
233
+ configurable: true,
234
+ value: originalInnerWidth
235
+ })
236
+ })
237
+
238
+ test('renders mobile layout on small screens', () => {
239
+ render(<Pagination {...defaultProps} />)
240
+
241
+ const mobilePagination = document.querySelector('.pb_pagination_mobile')
242
+ expect(mobilePagination).toBeInTheDocument()
243
+ })
244
+
245
+ test('renders Prev and Next buttons in mobile view', () => {
246
+ render(<Pagination {...defaultProps} />)
247
+
248
+ expect(screen.getByText('Prev')).toBeInTheDocument()
249
+ expect(screen.getByText('Next')).toBeInTheDocument()
250
+ })
251
+
252
+ test('displays current page and total in dropdown trigger', () => {
253
+ render(<Pagination {...defaultProps}
254
+ current={3}
255
+ total={10}
256
+ />)
257
+
258
+ expect(screen.getByText('3', { exact: false })).toBeInTheDocument()
259
+ expect(screen.getByText(/of 10/)).toBeInTheDocument()
260
+ })
261
+
262
+ test('opens dropdown when trigger is clicked', () => {
263
+ render(<Pagination {...defaultProps} />)
264
+
265
+ const dropdownTrigger = document.querySelector('.pagination-dropdown-trigger')
266
+ expect(dropdownTrigger).toBeInTheDocument()
267
+
268
+ let dropdownMenu = document.querySelector('.pagination-dropdown-menu')
269
+ expect(dropdownMenu).not.toBeInTheDocument()
270
+
271
+ fireEvent.click(dropdownTrigger)
272
+
273
+ dropdownMenu = document.querySelector('.pagination-dropdown-menu')
274
+ expect(dropdownMenu).toBeInTheDocument()
275
+ })
276
+
277
+ test('displays all page options in dropdown', () => {
278
+ render(<Pagination {...defaultProps}
279
+ total={5}
280
+ />)
281
+
282
+ const dropdownTrigger = document.querySelector('.pagination-dropdown-trigger')
283
+ fireEvent.click(dropdownTrigger)
284
+
285
+ expect(screen.getByText('Page 1')).toBeInTheDocument()
286
+ expect(screen.getByText('Page 2')).toBeInTheDocument()
287
+ expect(screen.getByText('Page 3')).toBeInTheDocument()
288
+ expect(screen.getByText('Page 4')).toBeInTheDocument()
289
+ expect(screen.getByText('Page 5')).toBeInTheDocument()
290
+ })
291
+
292
+ test('highlights current page in dropdown', () => {
293
+ render(<Pagination {...defaultProps}
294
+ current={3}
295
+ total={5}
296
+ />)
297
+
298
+ const dropdownTrigger = document.querySelector('.pagination-dropdown-trigger')
299
+ fireEvent.click(dropdownTrigger)
300
+
301
+ const activePage = screen.getByText('Page 3').parentElement
302
+ expect(activePage).toHaveClass('active')
303
+ })
304
+
305
+ test('changes page when dropdown option is clicked', () => {
306
+ const mockOnChange = jest.fn()
307
+ render(<Pagination {...defaultProps}
308
+ onChange={mockOnChange}
309
+ total={5}
310
+ />)
311
+
312
+ const dropdownTrigger = document.querySelector('.pagination-dropdown-trigger')
313
+ fireEvent.click(dropdownTrigger)
314
+
315
+ const page3Option = screen.getByText('Page 3')
316
+ fireEvent.click(page3Option)
317
+
318
+ expect(mockOnChange).toHaveBeenCalledWith(3)
319
+ })
320
+
321
+ test('closes dropdown after selecting a page', () => {
322
+ render(<Pagination {...defaultProps}
323
+ total={5}
324
+ />)
325
+
326
+ const dropdownTrigger = document.querySelector('.pagination-dropdown-trigger')
327
+ fireEvent.click(dropdownTrigger)
328
+
329
+ let dropdownMenu = document.querySelector('.pagination-dropdown-menu')
330
+ expect(dropdownMenu).toBeInTheDocument()
331
+
332
+ const page3Option = screen.getByText('Page 3')
333
+ fireEvent.click(page3Option)
334
+
335
+ dropdownMenu = document.querySelector('.pagination-dropdown-menu')
336
+ expect(dropdownMenu).not.toBeInTheDocument()
337
+ })
338
+
339
+ test('Prev button navigates to previous page', () => {
340
+ const mockOnChange = jest.fn()
341
+ render(<Pagination {...defaultProps}
342
+ current={3}
343
+ onChange={mockOnChange}
344
+ />)
345
+
346
+ const prevButton = document.querySelector('.pagination-left')
347
+ fireEvent.click(prevButton)
348
+
349
+ expect(mockOnChange).toHaveBeenCalledWith(2)
350
+ })
351
+
352
+ test('Next button navigates to next page', () => {
353
+ const mockOnChange = jest.fn()
354
+ render(<Pagination {...defaultProps}
355
+ current={3}
356
+ onChange={mockOnChange}
357
+ />)
358
+
359
+ const nextButton = document.querySelector('.pagination-right')
360
+ fireEvent.click(nextButton)
361
+
362
+ expect(mockOnChange).toHaveBeenCalledWith(4)
363
+ })
364
+
365
+ test('disables Prev button on first page', () => {
366
+ render(<Pagination {...defaultProps}
367
+ current={1}
368
+ />)
369
+
370
+ const prevButton = document.querySelector('.pagination-left')
371
+ expect(prevButton).toHaveClass('disabled')
372
+ })
373
+
374
+ test('disables Next button on last page', () => {
375
+ render(<Pagination {...defaultProps}
376
+ current={10}
377
+ />)
378
+
379
+ const nextButton = document.querySelector('.pagination-right')
380
+ expect(nextButton).toHaveClass('disabled')
381
+ })
382
+
383
+ })