playbook_ui 16.13.0.pre.rc.2 → 16.13.0.pre.rc.3

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: 8d2a2e44dfde6c577166c36140a612798ecf4f3106169edd3120e744c9c962a3
4
- data.tar.gz: c73ab3491df19d80e5e72ae9d790ee91ed2874c6558e04ddbf2f6d184704c719
3
+ metadata.gz: 064a383e9d8fb6d5bb028d1343d3f2ba9dd4bf6eaed25dba6e74761d62f972ec
4
+ data.tar.gz: 668a7db3525d0b9f67e4b190f07dcb5108aec8d189e1980c2ea02ba23938e71f
5
5
  SHA512:
6
- metadata.gz: 6055ea0b1901f561eb1852420b5a06f8fd040f4711d3c687c21fccc3dc7504b55e6b0512f27e15ff1c94783e6a4bf2e6e09ee41a8914931249dc3e1103e804e3
7
- data.tar.gz: fe309cf65d1080f4e3ee3359d9db8145778e159359eef4af5cf92630a48ac486641a4d0f462f782254ac2e0321d9f358257781f072fae921c1eedb443937d753
6
+ metadata.gz: 858dbdb201bb04117d664d5052fa441d057ab42ae7cf7ce76df084428e1b3cec8f3b8f64a55a77433a2d168614f55c4663e35b97310c736113d99b1e340f5688
7
+ data.tar.gz: 0bcec11c51a3da8a729a931da49a416d06d729de3ad508bfba63e6817857fe3a8b9faa84c982163bf8bd979979054d80f267ac514d72177695b873578805fd42
@@ -202,23 +202,37 @@
202
202
  }
203
203
  }
204
204
 
205
- & > [class^=pb_typeahead_kit] {
205
+ & > [class^=pb_typeahead_kit],
206
+ & > [data-pb-react-component] > [class^=pb_typeahead_kit] {
206
207
  min-width: 200px;
207
208
  }
208
209
 
209
- & > [class^=pb_typeahead_kit]:not(:last-child) {
210
- [class^=pb_text_input_kit] .text_input_wrapper input, [class^=pb_text_input_kit] .text_input_wrapper .text_input {
211
- border-bottom-right-radius: 0;
212
- border-top-right-radius: 0;
213
- border-right-width: 0;
210
+ & > [class^=pb_typeahead_kit]:not(:last-child),
211
+ & > [data-pb-react-component]:not(:last-child) > [class^=pb_typeahead_kit] {
212
+ [class^=pb_text_input_kit] .text_input_wrapper {
213
+ input,
214
+ .text_input,
215
+ .typeahead-kit-select__control {
216
+ border-bottom-right-radius: 0 !important;
217
+ border-top-right-radius: 0 !important;
218
+ border-right-width: 0 !important;
219
+ }
220
+ }
221
+
222
+ [class^=pb_text_input_kit] .text_input_wrapper div.text_input {
214
223
  min-height: 26px;
215
224
  }
216
225
  }
217
226
 
218
- & > [class^=pb_typeahead_kit]:not(:first-child) {
219
- [class^=pb_text_input_kit] .text_input_wrapper input, [class^=pb_text_input_kit] .text_input_wrapper .text_input {
220
- border-bottom-left-radius: 0;
221
- border-top-left-radius: 0;
227
+ & > [class^=pb_typeahead_kit]:not(:first-child),
228
+ & > [data-pb-react-component]:not(:first-child) > [class^=pb_typeahead_kit] {
229
+ [class^=pb_text_input_kit] .text_input_wrapper {
230
+ input,
231
+ .text_input,
232
+ .typeahead-kit-select__control {
233
+ border-bottom-left-radius: 0 !important;
234
+ border-top-left-radius: 0 !important;
235
+ }
222
236
  }
223
237
  }
224
238
 
@@ -1,19 +1,24 @@
1
1
  <div>
2
+
3
+ <%
4
+ options = [
5
+ { label: 'Orange', value: '#FFA500' },
6
+ { label: 'Red', value: '#FF0000' },
7
+ { label: 'Green', value: '#00FF00' },
8
+ { label: 'Blue', value: '#0000FF' },
9
+ ]
10
+ %>
2
11
  <%= pb_rails("form_group") do %>
3
- <%
4
- options = [
5
- { label: 'Windows', value: 'windows' },
6
- { label: 'Roof', value: 'roof' },
7
- { label: 'Siding', value: 'siding' },
8
- { label: 'Doors', value: 'doors' },
9
- ]
10
- %>
11
- <%= pb_rails("typeahead", props: {
12
- default_options: [options.first],
13
- id: "form-group-typeahead-example",
14
- label: "Products",
15
- options: options,
16
- }) %>
12
+
13
+ <%= pb_rails("typeahead", props: {
14
+ id: "typeahead-default",
15
+ placeholder: "All Colors",
16
+ options: options,
17
+ label: "Colors",
18
+ name: :foo,
19
+ is_multi: false,
20
+ })
21
+ %>
17
22
  <%= pb_rails("button", props: {
18
23
  id: "form-group-button",
19
24
  text: "Add",
@@ -5,7 +5,7 @@ examples:
5
5
  - form_group_button: Button
6
6
  - form_group_full_width: Full Width
7
7
  - form_group_date_picker: Date Picker
8
- # - form_group_typeahead: Typeahead
8
+ - form_group_typeahead: Typeahead
9
9
  - form_group_select: Select
10
10
  - form_group_selectable_card: Selectable Card
11
11
  - form_group_selectable_card_icon: Selectable Card Icon
@@ -16,7 +16,7 @@ examples:
16
16
  - form_group_button: Button
17
17
  - form_group_full_width: Full Width
18
18
  - form_group_date_picker: Date Picker
19
- # - form_group_typeahead: Typeahead
19
+ - form_group_typeahead: Typeahead
20
20
  - form_group_select: Select
21
21
  - form_group_selectable_card: Selectable Card
22
22
  - form_group_selectable_card_icon: Selectable Card Icon
@@ -1,9 +1,8 @@
1
- /* eslint-disable react/no-multi-comp, flowtype/space-before-type-colon */
2
- import Highlighter from 'react-highlight-words'
3
1
  import React from 'react'
4
2
  import classnames from 'classnames'
5
3
  import { globalProps, GlobalProps } from '../utilities/globalProps'
6
- import { buildHtmlProps } from '../utilities/props'
4
+ import { buildDataProps, buildHtmlProps } from '../utilities/props'
5
+ import { findHighlightChunks } from './_highlightChunks'
7
6
 
8
7
  type HighlightProps = {
9
8
  className?: string,
@@ -26,20 +25,39 @@ const Highlight = (props: HighlightProps): React.ReactElement => {
26
25
  text = '',
27
26
  } = props
28
27
 
28
+ const dataProps = buildDataProps(data)
29
29
  const htmlProps = buildHtmlProps(htmlOptions)
30
- const highlightContent: any = text || children;
30
+ const highlightContent: string = (text || children || '') as string
31
+ const highlightClassName = classnames(globalProps(props), className)
32
+ const chunks = findHighlightChunks(highlightContent, highlightedText)
31
33
 
32
34
  return (
33
- <Highlighter
34
- autoEscape
35
- data={data}
36
- highlightClassName={classnames(globalProps(props), className)}
37
- highlightTag="mark"
38
- id={id}
39
- searchWords={highlightedText}
40
- textToHighlight={highlightContent}
35
+ <span
36
+ {...dataProps}
41
37
  {...htmlProps}
42
- />
38
+ id={id}
39
+ >
40
+ {chunks.map((chunk, index) => {
41
+ const chunkText = highlightContent.substring(chunk.start, chunk.end)
42
+
43
+ if (chunk.highlight) {
44
+ return (
45
+ <mark
46
+ className={highlightClassName}
47
+ key={index}
48
+ >
49
+ {chunkText}
50
+ </mark>
51
+ )
52
+ }
53
+
54
+ return (
55
+ <span key={index}>
56
+ {chunkText}
57
+ </span>
58
+ )
59
+ })}
60
+ </span>
43
61
  )
44
62
  }
45
63
 
@@ -0,0 +1,89 @@
1
+ type HighlightChunk = {
2
+ start: number,
3
+ end: number,
4
+ highlight: boolean,
5
+ }
6
+
7
+ const escapeRegExp = (value: string): string =>
8
+ value.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&')
9
+
10
+ /**
11
+ * Finds all case-insensitive, literal (auto-escaped) matches for the given
12
+ * search terms, merges overlapping/adjacent ranges, and returns a full set of
13
+ * highlight and non-highlight chunks covering the text.
14
+ *
15
+ * Mirrors highlight-words-core behavior used by react-highlight-words with
16
+ * autoEscape enabled and caseSensitive disabled.
17
+ */
18
+ export const findHighlightChunks = (
19
+ textToHighlight: string,
20
+ searchWords: string[],
21
+ ): HighlightChunk[] => {
22
+ const text = textToHighlight || ''
23
+
24
+ const matchRanges = searchWords
25
+ .filter((searchWord) => searchWord)
26
+ .reduce<{ start: number, end: number }[]>((chunks, searchWord) => {
27
+ const regex = new RegExp(escapeRegExp(searchWord), 'gi')
28
+ let match: RegExpExecArray | null
29
+
30
+ while ((match = regex.exec(text))) {
31
+ const start = match.index
32
+ const end = regex.lastIndex
33
+
34
+ if (end > start) {
35
+ chunks.push({ start, end })
36
+ }
37
+
38
+ // Prevent zero-length match infinite loops (e.g. Firefox)
39
+ if (match.index === regex.lastIndex) {
40
+ regex.lastIndex++
41
+ }
42
+ }
43
+
44
+ return chunks
45
+ }, [])
46
+
47
+ const combined = matchRanges
48
+ .sort((first, second) => first.start - second.start)
49
+ .reduce<{ start: number, end: number }[]>((processedChunks, nextChunk) => {
50
+ if (processedChunks.length === 0) {
51
+ return [nextChunk]
52
+ }
53
+
54
+ const prevChunk = processedChunks.pop() as { start: number, end: number }
55
+
56
+ // Merge overlapping or adjacent ranges (start <= prev end)
57
+ if (nextChunk.start <= prevChunk.end) {
58
+ processedChunks.push({
59
+ start: prevChunk.start,
60
+ end: Math.max(prevChunk.end, nextChunk.end),
61
+ })
62
+ } else {
63
+ processedChunks.push(prevChunk, nextChunk)
64
+ }
65
+
66
+ return processedChunks
67
+ }, [])
68
+
69
+ const allChunks: HighlightChunk[] = []
70
+ const append = (start: number, end: number, highlight: boolean) => {
71
+ if (end - start > 0) {
72
+ allChunks.push({ start, end, highlight })
73
+ }
74
+ }
75
+
76
+ if (combined.length === 0) {
77
+ append(0, text.length, false)
78
+ } else {
79
+ let lastIndex = 0
80
+ combined.forEach((chunk) => {
81
+ append(lastIndex, chunk.start, false)
82
+ append(chunk.start, chunk.end, true)
83
+ lastIndex = chunk.end
84
+ })
85
+ append(lastIndex, text.length, false)
86
+ }
87
+
88
+ return allChunks
89
+ }
@@ -0,0 +1,263 @@
1
+ import React from 'react'
2
+ import { render, screen } from '../utilities/test-utils'
3
+
4
+ import Highlight from './_highlight'
5
+ import Body from '../pb_body/_body'
6
+ import { findHighlightChunks } from './_highlightChunks'
7
+
8
+ describe('findHighlightChunks', () => {
9
+ test('returns a single non-highlight chunk when there are no matches', () => {
10
+ expect(findHighlightChunks('hello world', ['missing'])).toEqual([
11
+ { start: 0, end: 11, highlight: false },
12
+ ])
13
+ })
14
+
15
+ test('finds a single search term case-insensitively', () => {
16
+ expect(findHighlightChunks('Hello World', ['world'])).toEqual([
17
+ { start: 0, end: 6, highlight: false },
18
+ { start: 6, end: 11, highlight: true },
19
+ ])
20
+ })
21
+
22
+ test('finds multiple matches of the same term', () => {
23
+ expect(findHighlightChunks('foo bar foo', ['foo'])).toEqual([
24
+ { start: 0, end: 3, highlight: true },
25
+ { start: 3, end: 8, highlight: false },
26
+ { start: 8, end: 11, highlight: true },
27
+ ])
28
+ })
29
+
30
+ test('finds multiple search terms', () => {
31
+ expect(findHighlightChunks('red and blue', ['red', 'blue'])).toEqual([
32
+ { start: 0, end: 3, highlight: true },
33
+ { start: 3, end: 8, highlight: false },
34
+ { start: 8, end: 12, highlight: true },
35
+ ])
36
+ })
37
+
38
+ test('merges overlapping matches into one highlight chunk', () => {
39
+ expect(findHighlightChunks('highlight', ['high', 'highlight'])).toEqual([
40
+ { start: 0, end: 9, highlight: true },
41
+ ])
42
+ })
43
+
44
+ test('merges adjacent matches into one highlight chunk', () => {
45
+ expect(findHighlightChunks('foobar', ['foo', 'bar'])).toEqual([
46
+ { start: 0, end: 6, highlight: true },
47
+ ])
48
+ })
49
+
50
+ test('matches regex special characters literally', () => {
51
+ expect(findHighlightChunks('cost is $5.00 (approx)', ['$5.00', '(approx)'])).toEqual([
52
+ { start: 0, end: 8, highlight: false },
53
+ { start: 8, end: 13, highlight: true },
54
+ { start: 13, end: 14, highlight: false },
55
+ { start: 14, end: 22, highlight: true },
56
+ ])
57
+ })
58
+
59
+ test('ignores empty search terms', () => {
60
+ expect(findHighlightChunks('hello', ['', 'hello'])).toEqual([
61
+ { start: 0, end: 5, highlight: true },
62
+ ])
63
+ })
64
+ })
65
+
66
+ describe('Highlight', () => {
67
+ test('highlights a single search term from the text prop', () => {
68
+ render(
69
+ <Highlight
70
+ data={{ testid: 'highlight-single' }}
71
+ highlightedText={['Highlight']}
72
+ text="This is the Highlight Kit."
73
+ />
74
+ )
75
+
76
+ const kit = screen.getByTestId('highlight-single')
77
+ const marks = kit.querySelectorAll('mark')
78
+
79
+ expect(marks).toHaveLength(1)
80
+ expect(marks[0]).toHaveTextContent('Highlight')
81
+ expect(marks[0]).toHaveClass('pb_highlight_kit')
82
+ expect(kit).toHaveTextContent('This is the Highlight Kit.')
83
+ })
84
+
85
+ test('highlights content passed via children', () => {
86
+ render(
87
+ <Highlight
88
+ data={{ testid: 'highlight-children' }}
89
+ highlightedText={['children']}
90
+ >
91
+ Content via children works.
92
+ </Highlight>
93
+ )
94
+
95
+ const kit = screen.getByTestId('highlight-children')
96
+ const marks = kit.querySelectorAll('mark')
97
+
98
+ expect(marks).toHaveLength(1)
99
+ expect(marks[0]).toHaveTextContent('children')
100
+ expect(kit).toHaveTextContent('Content via children works.')
101
+ })
102
+
103
+ test('highlights multiple search terms and multiple matches', () => {
104
+ render(
105
+ <Highlight
106
+ data={{ testid: 'highlight-multi' }}
107
+ highlightedText={['cat', 'dog']}
108
+ text="cat and dog and cat"
109
+ />
110
+ )
111
+
112
+ const kit = screen.getByTestId('highlight-multi')
113
+ const marks = Array.from(kit.querySelectorAll('mark')).map((mark) => mark.textContent)
114
+
115
+ expect(marks).toEqual(['cat', 'dog', 'cat'])
116
+ })
117
+
118
+ test('merges overlapping or adjacent matches into one mark', () => {
119
+ render(
120
+ <Highlight
121
+ data={{ testid: 'highlight-merge' }}
122
+ highlightedText={['foo', 'bar']}
123
+ text="foobar"
124
+ />
125
+ )
126
+
127
+ const kit = screen.getByTestId('highlight-merge')
128
+ const marks = kit.querySelectorAll('mark')
129
+
130
+ expect(marks).toHaveLength(1)
131
+ expect(marks[0]).toHaveTextContent('foobar')
132
+ })
133
+
134
+ test('renders without marks when there are no matches', () => {
135
+ render(
136
+ <Highlight
137
+ data={{ testid: 'highlight-none' }}
138
+ highlightedText={['missing']}
139
+ text="nothing to see"
140
+ />
141
+ )
142
+
143
+ const kit = screen.getByTestId('highlight-none')
144
+
145
+ expect(kit.querySelectorAll('mark')).toHaveLength(0)
146
+ expect(kit).toHaveTextContent('nothing to see')
147
+ })
148
+
149
+ test('matches case-insensitively', () => {
150
+ render(
151
+ <Highlight
152
+ data={{ testid: 'highlight-case' }}
153
+ highlightedText={['highlight']}
154
+ text="HiGhLiGhT text"
155
+ />
156
+ )
157
+
158
+ const kit = screen.getByTestId('highlight-case')
159
+ const marks = kit.querySelectorAll('mark')
160
+
161
+ expect(marks).toHaveLength(1)
162
+ expect(marks[0]).toHaveTextContent('HiGhLiGhT')
163
+ })
164
+
165
+ test('matches regex special characters literally', () => {
166
+ render(
167
+ <Highlight
168
+ data={{ testid: 'highlight-regex' }}
169
+ highlightedText={['a+b', '(x)', 'file.js']}
170
+ text="use a+b or (x) in file.js"
171
+ />
172
+ )
173
+
174
+ const kit = screen.getByTestId('highlight-regex')
175
+ const marks = Array.from(kit.querySelectorAll('mark')).map((mark) => mark.textContent)
176
+
177
+ expect(marks).toEqual(['a+b', '(x)', 'file.js'])
178
+ })
179
+
180
+ test('passes id and htmlOptions through to the wrapper', () => {
181
+ render(
182
+ <Highlight
183
+ data={{ testid: 'highlight-attrs' }}
184
+ htmlOptions={{ 'aria-label': 'highlighted content' }}
185
+ id="highlight-id"
186
+ text="highlight me"
187
+ />
188
+ )
189
+
190
+ const kit = screen.getByTestId('highlight-attrs')
191
+
192
+ expect(kit).toHaveAttribute('id', 'highlight-id')
193
+ expect(kit).toHaveAttribute('aria-label', 'highlighted content')
194
+ })
195
+
196
+ test('uses the default highlightedText of highlight when omitted', () => {
197
+ render(
198
+ <Highlight
199
+ data={{ testid: 'highlight-default' }}
200
+ text="default highlight behavior"
201
+ />
202
+ )
203
+
204
+ const kit = screen.getByTestId('highlight-default')
205
+ const marks = kit.querySelectorAll('mark')
206
+
207
+ expect(marks).toHaveLength(1)
208
+ expect(marks[0]).toHaveTextContent('highlight')
209
+ })
210
+ })
211
+
212
+ describe('Body with highlighting', () => {
213
+ test('renders Highlight for matching text via text prop', () => {
214
+ render(
215
+ <Body
216
+ data={{ testid: 'body-highlight' }}
217
+ highlightedText={['highlighted', 'Body Kit']}
218
+ highlighting
219
+ text="This is text highlighted in the Body Kit."
220
+ />
221
+ )
222
+
223
+ const kit = screen.getByTestId('body-highlight')
224
+ const marks = Array.from(kit.querySelectorAll('mark')).map((mark) => mark.textContent)
225
+
226
+ expect(kit).toHaveClass('pb_body_kit')
227
+ expect(marks).toEqual(['highlighted', 'Body Kit'])
228
+ expect(kit).toHaveTextContent('This is text highlighted in the Body Kit.')
229
+ })
230
+
231
+ test('renders Highlight for matching text via children', () => {
232
+ render(
233
+ <Body
234
+ data={{ testid: 'body-highlight-children' }}
235
+ highlightedText={['children']}
236
+ highlighting
237
+ >
238
+ Body with children highlight
239
+ </Body>
240
+ )
241
+
242
+ const kit = screen.getByTestId('body-highlight-children')
243
+ const marks = kit.querySelectorAll('mark')
244
+
245
+ expect(marks).toHaveLength(1)
246
+ expect(marks[0]).toHaveTextContent('children')
247
+ })
248
+
249
+ test('does not highlight when highlighting is false', () => {
250
+ render(
251
+ <Body
252
+ data={{ testid: 'body-no-highlight' }}
253
+ highlightedText={['highlighted']}
254
+ text="This is text highlighted in the Body Kit."
255
+ />
256
+ )
257
+
258
+ const kit = screen.getByTestId('body-no-highlight')
259
+
260
+ expect(kit.querySelectorAll('mark')).toHaveLength(0)
261
+ expect(kit).toHaveTextContent('This is text highlighted in the Body Kit.')
262
+ })
263
+ })
@@ -1,3 +1,3 @@
1
1
  <%= pb_content_tag do %>
2
- <%= helpers.will_paginate object.model, renderer: Playbook::Pagination::Rails %>
2
+ <%= will_paginate object.model, renderer: Playbook::Pagination::Rails %>
3
3
  <% end %>
@@ -158,5 +158,5 @@
158
158
 
159
159
  <%# Module script: rich_text_editor_rails.js %>
160
160
  <script type="module">
161
- <%= ::File.read(Playbook.kit_path("rich_text_editor", "rich_text_editor_rails.js")).html_safe %>
161
+ <%= File.read(Playbook.kit_path("rich_text_editor", "rich_text_editor_rails.js")).html_safe %>
162
162
  </script>
@@ -44,7 +44,8 @@
44
44
  color: $input_text_active;
45
45
  }
46
46
  }
47
- .text_input {
47
+ // React select control only — Rails <input class="text_input"> must keep max-height: 45px
48
+ div.text_input {
48
49
  max-height: none;
49
50
  }
50
51
  & > input:first-child {
@@ -142,6 +143,7 @@
142
143
  &__control {
143
144
  &--is-disabled {
144
145
  background-color: $input_background_disabled;
146
+ border-color: $input_border_disabled;
145
147
  }
146
148
  &--is-focused {
147
149
  @include pb_textarea_focus;
@@ -24,6 +24,7 @@
24
24
  required_indicator: object.required_indicator,
25
25
  id: object.input_options[:id],
26
26
  dark: object.dark,
27
+ disabled: object.disabled,
27
28
  }) %>
28
29
  <%= pb_rails("list", props: { ordered: false, borderless: true, xpadding: true, role: "status", aria: { live: "polite" }, data: { pb_typeahead_kit_results: true } }) do %>
29
30
  <% end %>
@@ -16,7 +16,11 @@ module Playbook
16
16
 
17
17
  def display_badge
18
18
  @display_badge ||= begin
19
- badge_path = Playbook.kit_path("user_badge", "badges", "#{badge}.svg")
19
+ badge_path = File.join(
20
+ File.dirname(self.class.source_location),
21
+ "badges",
22
+ "#{badge}.svg"
23
+ )
20
24
  File.read(badge_path).html_safe
21
25
  end
22
26
  end