playbook_ui 16.4.0.pre.alpha.play2838formcustomvalidationsconsistency15233 → 16.4.0.pre.alpha.play2838formcustomvalidationsconsistency15237
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 +4 -4
- data/app/pb_kits/playbook/pb_form/pb_form_validation.js +179 -205
- data/app/pb_kits/playbook/pb_table/docs/_sections.yml +1 -0
- data/app/pb_kits/playbook/pb_table/docs/_table_with_filter_variant_external_filter_rails.html.erb +45 -0
- data/app/pb_kits/playbook/pb_table/docs/_table_with_filter_variant_external_filter_rails.md +39 -0
- data/app/pb_kits/playbook/pb_table/docs/_table_with_filter_variant_rails.md +2 -1
- data/app/pb_kits/playbook/pb_table/docs/example.yml +1 -0
- data/app/pb_kits/playbook/pb_table/table.html.erb +5 -2
- data/app/pb_kits/playbook/pb_table/table.rb +4 -0
- data/app/pb_kits/playbook/utilities/_hover.scss +6 -3
- data/dist/playbook-rails.js +1 -1
- data/dist/playbook.css +1 -1
- data/lib/playbook/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4fdf310b5d41f5febe313a89aee1640f92a2cba62520f40b70cf32633f4afbab
|
|
4
|
+
data.tar.gz: f1353fd541ac9ca745a651e048fa48561d66d7f070803a56de669727d657f096
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 33aec662586e2c66cb52ff826c245ad99993412ace4fef583bf10f9b028ec482737b345969d29a7148f9dc49f0ac29b6b7f39b0eb67e254b349d72da85caedc6
|
|
7
|
+
data.tar.gz: '09a023c64e2ea3cc00611965cc29382e6d27647ee96a26b7a743831f36f6cdc6d04204f4cc13ca1fb331951172691c361964aae54961a02000ffb0f298f89b71'
|
|
@@ -1,181 +1,25 @@
|
|
|
1
1
|
import PbEnhancedElement from '../pb_enhanced_element'
|
|
2
2
|
import { debounce } from '../utilities/object'
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
'.dropdown_wrapper',
|
|
21
|
-
'.pb_select_kit_wrapper',
|
|
22
|
-
'.text_input_wrapper',
|
|
23
|
-
],
|
|
24
|
-
kitFallbacks: [
|
|
25
|
-
'[data-pb-select]',
|
|
26
|
-
'[data-pb-date-picker]',
|
|
27
|
-
'[data-pb-typeahead-kit]',
|
|
28
|
-
],
|
|
29
|
-
messageWrappers: [
|
|
30
|
-
'[data-validation-message]',
|
|
31
|
-
'[data-pb-select]',
|
|
32
|
-
'.dropdown_wrapper',
|
|
33
|
-
'.pb_select_kit_wrapper',
|
|
34
|
-
],
|
|
35
|
-
reactTypeaheadRoots: [
|
|
36
|
-
'[data-pb-react-component="Typeahead"]',
|
|
37
|
-
'.pb_typeahead_kit.react-select',
|
|
38
|
-
],
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const ATTRS = {
|
|
42
|
-
message: 'data-pb-form-validation-message',
|
|
43
|
-
reused: 'data-pb-form-validation-message-reused',
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const FIELD_EVENTS = ['change', 'valid', 'invalid']
|
|
47
|
-
|
|
48
|
-
const closestAny = (el, selectors) => {
|
|
49
|
-
if (!el || !selectors) return null
|
|
50
|
-
for (const selector of selectors) {
|
|
51
|
-
const found = el.closest?.(selector)
|
|
52
|
-
if (found) return found
|
|
53
|
-
}
|
|
54
|
-
return null
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const isReactTypeaheadField = (el) => {
|
|
58
|
-
return !!closestAny(el, SELECTORS.reactTypeaheadRoots)
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const shouldSkipField = (field) => {
|
|
62
|
-
return (
|
|
63
|
-
isReactTypeaheadField(field) ||
|
|
64
|
-
!!field?.closest?.('.pb_phone_number_input, .pb_time_picker')
|
|
65
|
-
)
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const getKitElement = (target) => {
|
|
69
|
-
return (
|
|
70
|
-
target.closest?.(SELECTORS.kit) ||
|
|
71
|
-
target.parentElement?.closest?.(SELECTORS.kit) ||
|
|
72
|
-
closestAny(target, SELECTORS.kitFallbacks) ||
|
|
73
|
-
null
|
|
74
|
-
)
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
const getControlWrapper = (target, kitElement) => {
|
|
78
|
-
const fromTarget = closestAny(target, SELECTORS.controlWrappers)
|
|
79
|
-
if (fromTarget) return fromTarget
|
|
80
|
-
|
|
81
|
-
if (!kitElement) return null
|
|
82
|
-
for (const selector of SELECTORS.controlWrappers) {
|
|
83
|
-
const found = kitElement.querySelector?.(selector)
|
|
84
|
-
if (found) return found
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
return null
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const getErrorParent = (target, kitElement, parentElement) => {
|
|
91
|
-
const candidate =
|
|
92
|
-
closestAny(target, SELECTORS.errorParents) ||
|
|
93
|
-
kitElement ||
|
|
94
|
-
parentElement
|
|
95
|
-
|
|
96
|
-
if (!candidate) return null
|
|
97
|
-
|
|
98
|
-
if (
|
|
99
|
-
kitElement &&
|
|
100
|
-
candidate &&
|
|
101
|
-
candidate !== kitElement &&
|
|
102
|
-
!kitElement.contains(candidate)
|
|
103
|
-
) {
|
|
104
|
-
return kitElement
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
return candidate
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
const getValidationMessage = (target, kitElement) => {
|
|
111
|
-
const fromTarget = target.dataset?.message || target.dataset?.validationMessage
|
|
112
|
-
|
|
113
|
-
const wrapperWithMessage = closestAny(target, SELECTORS.messageWrappers)
|
|
114
|
-
const fromWrapper = wrapperWithMessage?.dataset?.validationMessage
|
|
115
|
-
|
|
116
|
-
const fromKit = kitElement?.dataset?.validationMessage
|
|
117
|
-
|
|
118
|
-
return fromTarget || fromWrapper || fromKit || ''
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
const hasNewRequiredFields = (mutations) => {
|
|
122
|
-
return mutations.some((mutation) => {
|
|
123
|
-
return Array.from(mutation.addedNodes || []).some((node) => {
|
|
124
|
-
if (!(node instanceof Element)) return false
|
|
125
|
-
if (node.matches && node.matches(SELECTORS.requiredFields)) return true
|
|
126
|
-
return !!node.querySelector?.(SELECTORS.requiredFields)
|
|
127
|
-
})
|
|
128
|
-
})
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
const createErrorMessageContainer = (doc = document) => {
|
|
132
|
-
const errorContainer = doc.createElement('div')
|
|
133
|
-
const kitClassName = SELECTORS.errorMessage.replace(/\./, '')
|
|
134
|
-
errorContainer.classList.add(kitClassName)
|
|
135
|
-
errorContainer.setAttribute(ATTRS.message, 'true')
|
|
136
|
-
errorContainer.setAttribute('role', 'alert')
|
|
137
|
-
errorContainer.setAttribute('aria-live', 'polite')
|
|
138
|
-
return errorContainer
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const findOrCreateErrorMessageElement = (errorParent) => {
|
|
142
|
-
if (!errorParent) return null
|
|
143
|
-
let errorMessageElement = errorParent.querySelector(`[${ATTRS.message}="true"]`)
|
|
144
|
-
if (errorMessageElement) return errorMessageElement
|
|
145
|
-
|
|
146
|
-
const existingEmpty = Array.from(errorParent.querySelectorAll(SELECTORS.errorMessage)).find((el) => {
|
|
147
|
-
return (el.textContent || '').trim() === ''
|
|
148
|
-
})
|
|
149
|
-
|
|
150
|
-
if (existingEmpty) {
|
|
151
|
-
existingEmpty.setAttribute(ATTRS.message, 'true')
|
|
152
|
-
existingEmpty.setAttribute(ATTRS.reused, 'true')
|
|
153
|
-
return existingEmpty
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
errorMessageElement = createErrorMessageContainer(errorParent.ownerDocument || document)
|
|
157
|
-
errorParent.appendChild(errorMessageElement)
|
|
158
|
-
return errorMessageElement
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
const clearOurErrorMessage = (errorParent) => {
|
|
162
|
-
if (!errorParent) return
|
|
163
|
-
const messageEl = errorParent.querySelector(`[${ATTRS.message}="true"]`)
|
|
164
|
-
if (!messageEl) return
|
|
165
|
-
|
|
166
|
-
const reused = messageEl.getAttribute(ATTRS.reused) === 'true'
|
|
167
|
-
if (reused) {
|
|
168
|
-
messageEl.textContent = ''
|
|
169
|
-
messageEl.removeAttribute(ATTRS.message)
|
|
170
|
-
messageEl.removeAttribute(ATTRS.reused)
|
|
171
|
-
} else {
|
|
172
|
-
messageEl.remove()
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
|
|
4
|
+
// Kit selectors
|
|
5
|
+
const KIT_SELECTOR = '[class^="pb_"][class*="_kit"]'
|
|
6
|
+
const ERROR_MESSAGE_SELECTOR = '.pb_body_kit_negative'
|
|
7
|
+
|
|
8
|
+
// Validation selectors
|
|
9
|
+
const FORM_SELECTOR = 'form[data-pb-form-validation="true"]'
|
|
10
|
+
const REQUIRED_FIELDS_SELECTOR = 'input[required],textarea[required],select[required]'
|
|
11
|
+
const PHONE_NUMBER_VALIDATION_ERROR_SELECTOR = '[data-pb-phone-validation-error="true"]'
|
|
12
|
+
const FORM_VALIDATION_MESSAGE_ATTR = 'data-pb-form-validation-message'
|
|
13
|
+
const FORM_VALIDATION_MESSAGE_REUSED_ATTR = 'data-pb-form-validation-message-reused'
|
|
14
|
+
|
|
15
|
+
const FIELD_EVENTS = [
|
|
16
|
+
'change',
|
|
17
|
+
'valid',
|
|
18
|
+
'invalid',
|
|
19
|
+
]
|
|
176
20
|
class PbFormValidation extends PbEnhancedElement {
|
|
177
21
|
static get selector() {
|
|
178
|
-
return
|
|
22
|
+
return FORM_SELECTOR
|
|
179
23
|
}
|
|
180
24
|
|
|
181
25
|
static start() {
|
|
@@ -193,7 +37,7 @@ class PbFormValidation extends PbEnhancedElement {
|
|
|
193
37
|
}, 100)
|
|
194
38
|
|
|
195
39
|
this.mutationObserver = new MutationObserver((mutations) => {
|
|
196
|
-
if (!hasNewRequiredFields(mutations)) return
|
|
40
|
+
if (!this.hasNewRequiredFields(mutations)) return
|
|
197
41
|
this.debouncedBindValidationListeners()
|
|
198
42
|
})
|
|
199
43
|
this.mutationObserver.observe(this.element, { childList: true, subtree: true })
|
|
@@ -218,10 +62,27 @@ class PbFormValidation extends PbEnhancedElement {
|
|
|
218
62
|
let foundInvalid = false
|
|
219
63
|
|
|
220
64
|
this.formValidationFields.forEach((field) => {
|
|
221
|
-
if (
|
|
65
|
+
if (this.isReactTypeaheadField(field)) return
|
|
66
|
+
|
|
67
|
+
const isPhoneNumberInput = field.closest('.pb_phone_number_input')
|
|
68
|
+
if (isPhoneNumberInput) return
|
|
69
|
+
|
|
70
|
+
const isTimePickerInput = field.closest('.pb_time_picker')
|
|
71
|
+
if (isTimePickerInput) return
|
|
72
|
+
|
|
73
|
+
field.setCustomValidity('')
|
|
74
|
+
const kitElement = this.getKitElement(field)
|
|
75
|
+
|
|
76
|
+
if (field.validity.valid) {
|
|
77
|
+
this.clearError(field)
|
|
78
|
+
return
|
|
79
|
+
}
|
|
222
80
|
|
|
223
|
-
const
|
|
224
|
-
if (
|
|
81
|
+
const message = this.getValidationMessage(field, kitElement)
|
|
82
|
+
if (message) field.setCustomValidity(message)
|
|
83
|
+
|
|
84
|
+
foundInvalid = true
|
|
85
|
+
this.showValidationMessage(field)
|
|
225
86
|
})
|
|
226
87
|
|
|
227
88
|
return foundInvalid
|
|
@@ -235,7 +96,13 @@ class PbFormValidation extends PbEnhancedElement {
|
|
|
235
96
|
this.formValidationFields.forEach((field) => {
|
|
236
97
|
if (this.boundFields?.has(field)) return
|
|
237
98
|
|
|
238
|
-
if (
|
|
99
|
+
if (this.isReactTypeaheadField(field)) return
|
|
100
|
+
|
|
101
|
+
const isPhoneNumberInput = field.closest('.pb_phone_number_input')
|
|
102
|
+
if (isPhoneNumberInput) return
|
|
103
|
+
|
|
104
|
+
const isTimePickerInput = field.closest('.pb_time_picker')
|
|
105
|
+
if (isTimePickerInput) return
|
|
239
106
|
|
|
240
107
|
const debouncedHandler = debounce((event) => {
|
|
241
108
|
this.validateFormField(event)
|
|
@@ -262,65 +129,172 @@ class PbFormValidation extends PbEnhancedElement {
|
|
|
262
129
|
|
|
263
130
|
const { target } = event
|
|
264
131
|
|
|
265
|
-
this.
|
|
266
|
-
}
|
|
132
|
+
if (this.isReactTypeaheadField(target)) return
|
|
267
133
|
|
|
268
|
-
|
|
269
|
-
if (shouldSkipField(target)) return true
|
|
134
|
+
const kitElement = this.getKitElement(target)
|
|
270
135
|
|
|
271
|
-
const kitElement = getKitElement(target)
|
|
272
136
|
target.setCustomValidity('')
|
|
273
137
|
|
|
274
|
-
|
|
138
|
+
const isValid = target.validity.valid
|
|
139
|
+
|
|
140
|
+
if (isValid) {
|
|
275
141
|
this.clearError(target)
|
|
276
|
-
|
|
142
|
+
} else {
|
|
143
|
+
const message = this.getValidationMessage(target, kitElement)
|
|
144
|
+
if (message) target.setCustomValidity(message)
|
|
145
|
+
this.showValidationMessage(target)
|
|
277
146
|
}
|
|
278
|
-
|
|
279
|
-
const message = getValidationMessage(target, kitElement)
|
|
280
|
-
if (message) target.setCustomValidity(message)
|
|
281
|
-
this.showValidationMessage(target)
|
|
282
|
-
return false
|
|
283
147
|
}
|
|
284
148
|
|
|
285
149
|
showValidationMessage(target) {
|
|
286
150
|
const { parentElement } = target
|
|
287
|
-
const kitElement = getKitElement(target)
|
|
151
|
+
const kitElement = this.getKitElement(target)
|
|
152
|
+
|
|
153
|
+
const isPhoneNumberInput = kitElement?.classList?.contains('pb_phone_number_input') || false
|
|
154
|
+
const isTimePickerInput = kitElement?.classList?.contains('pb_time_picker') || false
|
|
288
155
|
|
|
289
156
|
// ensure clean error message state
|
|
290
157
|
this.clearError(target)
|
|
291
158
|
if (kitElement) kitElement.classList.add('error')
|
|
292
159
|
|
|
293
|
-
const controlWrapper = getControlWrapper(target, kitElement)
|
|
160
|
+
const controlWrapper = this.getControlWrapper(target, kitElement)
|
|
294
161
|
if (controlWrapper) controlWrapper.classList.add('error')
|
|
295
162
|
|
|
296
|
-
|
|
297
|
-
|
|
163
|
+
if (!isPhoneNumberInput && !isTimePickerInput) {
|
|
164
|
+
const errorParent = this.getErrorParent(target, kitElement, parentElement)
|
|
165
|
+
const message = this.getValidationMessage(target, kitElement) || target.validationMessage
|
|
166
|
+
|
|
167
|
+
let errorMessageElement = errorParent.querySelector(`[${FORM_VALIDATION_MESSAGE_ATTR}="true"]`)
|
|
168
|
+
if (!errorMessageElement) {
|
|
169
|
+
const existingEmpty = Array.from(errorParent.querySelectorAll(ERROR_MESSAGE_SELECTOR)).find((el) => {
|
|
170
|
+
return (el.textContent || '').trim() === ''
|
|
171
|
+
})
|
|
172
|
+
if (existingEmpty) {
|
|
173
|
+
errorMessageElement = existingEmpty
|
|
174
|
+
errorMessageElement.setAttribute(FORM_VALIDATION_MESSAGE_ATTR, 'true')
|
|
175
|
+
errorMessageElement.setAttribute(FORM_VALIDATION_MESSAGE_REUSED_ATTR, 'true')
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (!errorMessageElement) {
|
|
180
|
+
errorMessageElement = this.errorMessageContainer
|
|
181
|
+
errorParent.appendChild(errorMessageElement)
|
|
182
|
+
}
|
|
298
183
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
if (!errorMessageElement) return
|
|
302
|
-
errorMessageElement.textContent = message
|
|
184
|
+
errorMessageElement.textContent = message
|
|
185
|
+
}
|
|
303
186
|
}
|
|
304
187
|
|
|
305
188
|
clearError(target) {
|
|
306
189
|
const { parentElement } = target
|
|
307
|
-
const kitElement = getKitElement(target)
|
|
190
|
+
const kitElement = this.getKitElement(target)
|
|
308
191
|
if (kitElement) kitElement.classList.remove('error')
|
|
309
192
|
|
|
310
|
-
const controlWrapper = getControlWrapper(target, kitElement)
|
|
193
|
+
const controlWrapper = this.getControlWrapper(target, kitElement)
|
|
311
194
|
if (controlWrapper) controlWrapper.classList.remove('error')
|
|
312
195
|
|
|
313
|
-
const errorParent = getErrorParent(target, kitElement, parentElement)
|
|
314
|
-
|
|
315
|
-
|
|
196
|
+
const errorParent = this.getErrorParent(target, kitElement, parentElement)
|
|
197
|
+
const messageEl = errorParent.querySelector(`[${FORM_VALIDATION_MESSAGE_ATTR}="true"]`)
|
|
198
|
+
if (messageEl) {
|
|
199
|
+
const reused = messageEl.getAttribute(FORM_VALIDATION_MESSAGE_REUSED_ATTR) === 'true'
|
|
200
|
+
if (reused) {
|
|
201
|
+
messageEl.textContent = ''
|
|
202
|
+
messageEl.removeAttribute(FORM_VALIDATION_MESSAGE_ATTR)
|
|
203
|
+
messageEl.removeAttribute(FORM_VALIDATION_MESSAGE_REUSED_ATTR)
|
|
204
|
+
} else {
|
|
205
|
+
messageEl.remove()
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
getErrorParent(target, kitElement, parentElement) {
|
|
211
|
+
const candidate =
|
|
212
|
+
target.closest('.text_input_wrapper') ||
|
|
213
|
+
target.closest('.pb_select_kit_wrapper') ||
|
|
214
|
+
target.closest('.dropdown_wrapper') ||
|
|
215
|
+
target.closest('.input_wrapper') ||
|
|
216
|
+
target.closest('.pb_typeahead_wrapper') ||
|
|
217
|
+
kitElement ||
|
|
218
|
+
parentElement
|
|
219
|
+
|
|
220
|
+
if (kitElement && candidate && candidate !== kitElement && !kitElement.contains(candidate)) {
|
|
221
|
+
return kitElement
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return candidate
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
getControlWrapper(target, kitElement) {
|
|
228
|
+
return (
|
|
229
|
+
target.closest('.dropdown_wrapper') ||
|
|
230
|
+
target.closest('.pb_select_kit_wrapper') ||
|
|
231
|
+
target.closest('.text_input_wrapper') ||
|
|
232
|
+
kitElement?.querySelector?.('.dropdown_wrapper') ||
|
|
233
|
+
kitElement?.querySelector?.('.pb_select_kit_wrapper') ||
|
|
234
|
+
null
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
isReactTypeaheadField(el) {
|
|
239
|
+
return !!(
|
|
240
|
+
el?.closest?.('[data-pb-react-component="Typeahead"]') ||
|
|
241
|
+
el?.closest?.('.pb_typeahead_kit.react-select')
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
hasNewRequiredFields(mutations) {
|
|
246
|
+
return mutations.some((mutation) => {
|
|
247
|
+
return Array.from(mutation.addedNodes || []).some((node) => {
|
|
248
|
+
if (!(node instanceof Element)) return false
|
|
249
|
+
if (node.matches && node.matches(REQUIRED_FIELDS_SELECTOR)) return true
|
|
250
|
+
return !!node.querySelector?.(REQUIRED_FIELDS_SELECTOR)
|
|
251
|
+
})
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
getKitElement(target) {
|
|
256
|
+
return (
|
|
257
|
+
target.closest(KIT_SELECTOR) ||
|
|
258
|
+
target.parentElement?.closest(KIT_SELECTOR) ||
|
|
259
|
+
// Some kits don't expose a *_kit class but do expose data hooks.
|
|
260
|
+
target.closest('[data-pb-select]') ||
|
|
261
|
+
target.closest('[data-pb-date-picker]') ||
|
|
262
|
+
target.closest('[data-pb-typeahead-kit]') ||
|
|
263
|
+
null
|
|
264
|
+
)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
getValidationMessage(target, kitElement) {
|
|
268
|
+
const fromTarget = target.dataset?.message || target.dataset?.validationMessage
|
|
269
|
+
|
|
270
|
+
const wrapperWithMessage =
|
|
271
|
+
target.closest?.('[data-validation-message]') ||
|
|
272
|
+
target.closest?.('[data-pb-select]') ||
|
|
273
|
+
target.closest?.('.dropdown_wrapper') ||
|
|
274
|
+
target.closest?.('.pb_select_kit_wrapper')
|
|
275
|
+
const fromWrapper = wrapperWithMessage?.dataset?.validationMessage
|
|
276
|
+
|
|
277
|
+
const fromKit = kitElement?.dataset?.validationMessage
|
|
278
|
+
|
|
279
|
+
return fromTarget || fromWrapper || fromKit || ''
|
|
316
280
|
}
|
|
317
281
|
|
|
318
282
|
hasPhoneNumberValidationErrors() {
|
|
319
|
-
const phoneNumberErrors = this.element.querySelectorAll(
|
|
283
|
+
const phoneNumberErrors = this.element.querySelectorAll(PHONE_NUMBER_VALIDATION_ERROR_SELECTOR)
|
|
320
284
|
return phoneNumberErrors.length > 0
|
|
321
285
|
}
|
|
286
|
+
|
|
287
|
+
get errorMessageContainer() {
|
|
288
|
+
const errorContainer = document.createElement('div')
|
|
289
|
+
const kitClassName = ERROR_MESSAGE_SELECTOR.replace(/\./, '')
|
|
290
|
+
errorContainer.classList.add(kitClassName)
|
|
291
|
+
errorContainer.setAttribute(FORM_VALIDATION_MESSAGE_ATTR, 'true')
|
|
292
|
+
errorContainer.setAttribute('role', 'alert')
|
|
293
|
+
errorContainer.setAttribute('aria-live', 'polite')
|
|
294
|
+
return errorContainer
|
|
295
|
+
}
|
|
322
296
|
get formValidationFields() {
|
|
323
|
-
return this.element.querySelectorAll(
|
|
297
|
+
return this.element.querySelectorAll(REQUIRED_FIELDS_SELECTOR)
|
|
324
298
|
}
|
|
325
299
|
}
|
|
326
300
|
|
data/app/pb_kits/playbook/pb_table/docs/_table_with_filter_variant_external_filter_rails.html.erb
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<%# External filter: capture any filter markup and pass it via the filter prop.
|
|
2
|
+
Use your own helper (e.g. a search/filter form) or pb_rails("filter") as shown here. %>
|
|
3
|
+
<% users = [
|
|
4
|
+
{ name: "Alex", role: "Engineer" },
|
|
5
|
+
{ name: "Sam", role: "Designer" },
|
|
6
|
+
{ name: "Jordan", role: "Manager" },
|
|
7
|
+
] %>
|
|
8
|
+
|
|
9
|
+
<% filter_output = capture do %>
|
|
10
|
+
<%= pb_rails("filter", props: {
|
|
11
|
+
id: "external-filter-demo",
|
|
12
|
+
template: "single",
|
|
13
|
+
results: 3,
|
|
14
|
+
background: false,
|
|
15
|
+
sort_menu: [
|
|
16
|
+
{ item: "Name", link: "#", active: true, direction: "asc" },
|
|
17
|
+
{ item: "Role", link: "#", active: false },
|
|
18
|
+
],
|
|
19
|
+
}) do %>
|
|
20
|
+
<%= pb_rails("text_input", props: { label: "Name", placeholder: "Search by name" }) %>
|
|
21
|
+
<%= pb_rails("text_input", props: { label: "Role", placeholder: "e.g. Engineer, Designer" }) %>
|
|
22
|
+
<%= pb_rails("button", props: { text: "Apply" }) %>
|
|
23
|
+
<% end %>
|
|
24
|
+
<% end %>
|
|
25
|
+
|
|
26
|
+
<%= pb_rails("table", props: {
|
|
27
|
+
variant: "with_filter",
|
|
28
|
+
title: "Table with External Filter",
|
|
29
|
+
filter: filter_output,
|
|
30
|
+
}) do %>
|
|
31
|
+
<%= pb_rails("table/table_head") do %>
|
|
32
|
+
<%= pb_rails("table/table_row") do %>
|
|
33
|
+
<%= pb_rails("table/table_header", props: { text: "Name" }) %>
|
|
34
|
+
<%= pb_rails("table/table_header", props: { text: "Role" }) %>
|
|
35
|
+
<% end %>
|
|
36
|
+
<% end %>
|
|
37
|
+
<%= pb_rails("table/table_body") do %>
|
|
38
|
+
<% users.each do |user| %>
|
|
39
|
+
<%= pb_rails("table/table_row") do %>
|
|
40
|
+
<%= pb_rails("table/table_cell") { user[:name] } %>
|
|
41
|
+
<%= pb_rails("table/table_cell") { user[:role] } %>
|
|
42
|
+
<% end %>
|
|
43
|
+
<% end %>
|
|
44
|
+
<% end %>
|
|
45
|
+
<% end %>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
Use the **"with_filter"** variant with an **external filter** (Option B): pass pre-rendered filter markup via the `filter` prop. Same layout as Variant with Filter (card, title, separator, flex); only the filter slot is supplied by you. Use this when you need:
|
|
2
|
+
|
|
3
|
+
- **Manual filter submission** – Apply / Filter button instead of automatic application
|
|
4
|
+
- **Full control** – Over filter props, template, sort menu, and submission
|
|
5
|
+
- **Custom or app-specific filter helpers** – Any helper that returns filter markup (e.g. search/filter forms)
|
|
6
|
+
|
|
7
|
+
#### Required props
|
|
8
|
+
|
|
9
|
+
- `variant: "with_filter"`
|
|
10
|
+
- `filter` – Pre-rendered filter HTML (e.g. from `capture { ... }`)
|
|
11
|
+
|
|
12
|
+
When `filter` is present, `filter_content` and `filter_props` are ignored.
|
|
13
|
+
|
|
14
|
+
#### How to do it
|
|
15
|
+
|
|
16
|
+
1. **Render your filter** (e.g. `pb_rails("filter", ...)` or any helper that returns filter markup).
|
|
17
|
+
2. **Capture the output** with `capture do ... end`.
|
|
18
|
+
3. **Pass it to the Table** as the `filter` prop.
|
|
19
|
+
|
|
20
|
+
**Example (generic pattern):**
|
|
21
|
+
|
|
22
|
+
```erb
|
|
23
|
+
<% filter_output = capture do %>
|
|
24
|
+
<%= pb_rails("filter", props: { template: "single", results: 10, background: false }) do %>
|
|
25
|
+
<%= pb_rails("text_input", props: { label: "Name", placeholder: "Search by name" }) %>
|
|
26
|
+
<%= pb_rails("button", props: { text: "Apply" }) %>
|
|
27
|
+
<% end %>
|
|
28
|
+
<% end %>
|
|
29
|
+
|
|
30
|
+
<%= pb_rails("table", props: {
|
|
31
|
+
variant: "with_filter",
|
|
32
|
+
title: "My Table",
|
|
33
|
+
filter: filter_output,
|
|
34
|
+
}) do %>
|
|
35
|
+
<%# table_head / table_body ... %>
|
|
36
|
+
<% end %>
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
For Nitro apps that use a shared search/filter pattern, reference the example on Alpha for implementation details.
|
|
@@ -26,8 +26,9 @@ The Table kit automatically sets these Filter defaults (which you can override v
|
|
|
26
26
|
- `min_width: "xs"`
|
|
27
27
|
- `popover_props: { width: "350px" }`
|
|
28
28
|
|
|
29
|
+
Alternatively, you can pass pre-rendered filter markup via the `filter` prop (e.g. for manual submission or custom filter helpers)—scroll down for that approach.
|
|
29
30
|
|
|
30
31
|
**IMPORTANT NOTE**:
|
|
31
32
|
The purpose of this variant is to provide an easy way to set up a Table with a Filter with Design standards applied by default.
|
|
32
33
|
|
|
33
|
-
If you are looking for more customization than this embedded variant provides, you may be better served by using the individual kits as
|
|
34
|
+
If you are looking for more customization than this embedded variant provides, you may be better served by using the individual kits as demonstrated in our Table Filter Card Building Block as seen [here](https://playbook.powerapp.cloud/building_blocks/table_filter_card/rails).
|
|
@@ -41,6 +41,7 @@ examples:
|
|
|
41
41
|
- table_with_header_style_borderless: Header Style Borderless
|
|
42
42
|
- table_with_header_style_floating: Header Style Floating
|
|
43
43
|
- table_with_filter_variant_rails: Variant with Filter
|
|
44
|
+
- table_with_filter_variant_external_filter_rails: Variant with Filter (External Filter)
|
|
44
45
|
- table_with_filter_variant_with_pagination_rails: Variant with Filter and Pagination
|
|
45
46
|
- table_with_filter_with_card_title_props_rails: Variant with Filter (with Card and Title Props)
|
|
46
47
|
|
|
@@ -16,7 +16,10 @@
|
|
|
16
16
|
flex_direction: "column",
|
|
17
17
|
gap: "none"
|
|
18
18
|
}) do %>
|
|
19
|
-
<%
|
|
19
|
+
<% has_filter = object.filter.present? || object.filter_content.present? %>
|
|
20
|
+
<% if object.filter.present? %>
|
|
21
|
+
<%= object.filter %>
|
|
22
|
+
<% elsif object.filter_content.present? %>
|
|
20
23
|
<%
|
|
21
24
|
default_filter_props = {
|
|
22
25
|
background: false,
|
|
@@ -31,7 +34,7 @@
|
|
|
31
34
|
<%= object.filter_content %>
|
|
32
35
|
<% end %>
|
|
33
36
|
<% end %>
|
|
34
|
-
<%= pb_rails("section_separator") if
|
|
37
|
+
<%= pb_rails("section_separator") if has_filter %>
|
|
35
38
|
<% if object.pagination.present? %>
|
|
36
39
|
<%= object.pagination %>
|
|
37
40
|
<%= pb_rails("section_separator") %>
|
|
@@ -46,6 +46,10 @@ module Playbook
|
|
|
46
46
|
prop :filter_props, type: Playbook::Props::HashProp,
|
|
47
47
|
default: {}
|
|
48
48
|
prop :filter_content
|
|
49
|
+
# Pre-rendered filter slot (e.g. output of capture { your_filter_helper }).
|
|
50
|
+
# When present, this is rendered as-is; filter_content and filter_props are ignored.
|
|
51
|
+
# Use this for manual filter submission or app-specific filter helpers.
|
|
52
|
+
prop :filter
|
|
49
53
|
prop :pagination
|
|
50
54
|
prop :title, type: Playbook::Props::String,
|
|
51
55
|
default: nil
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
@mixin hover-underline {
|
|
24
|
-
.hover_underline:hover
|
|
24
|
+
.hover_underline:hover,
|
|
25
|
+
.group_hover:hover .group_hover.hover_underline {
|
|
25
26
|
text-decoration: underline;
|
|
26
27
|
transition: text-decoration $transition-speed ease;
|
|
27
28
|
}
|
|
@@ -29,11 +30,13 @@
|
|
|
29
30
|
|
|
30
31
|
@mixin hover-color-classes($colors-list) {
|
|
31
32
|
@each $name, $color in $colors-list {
|
|
32
|
-
.hover_background-#{"" + $name}:hover
|
|
33
|
+
.hover_background-#{"" + $name}:hover,
|
|
34
|
+
.group_hover:hover .group_hover.hover_background-#{"" + $name} {
|
|
33
35
|
background-color: $color !important;
|
|
34
36
|
transition: background-color $transition-speed ease;
|
|
35
37
|
}
|
|
36
|
-
.hover_color-#{"" + $name}:hover
|
|
38
|
+
.hover_color-#{"" + $name}:hover,
|
|
39
|
+
.group_hover:hover .group_hover.hover_color-#{"" + $name} {
|
|
37
40
|
color: $color !important;
|
|
38
41
|
transition: color $transition-speed ease;
|
|
39
42
|
}
|