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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a988bf8c7d3f7dc24128ab71ff825edfaf0425e2c82854ee02b12f5876789207
4
- data.tar.gz: 0627220e056eb624154ac9f6e2dd151d4009f35189bf25be5cc3ab7678e7fdc0
3
+ metadata.gz: 4fdf310b5d41f5febe313a89aee1640f92a2cba62520f40b70cf32633f4afbab
4
+ data.tar.gz: f1353fd541ac9ca745a651e048fa48561d66d7f070803a56de669727d657f096
5
5
  SHA512:
6
- metadata.gz: fcd7d378e0be4204abdd7a5e5bc4842425c4cff7aa451109b802c254cf32de63b3972f5302af62142b62fe8b43711edb4fe0882fccac8313febe31ed23d662aa
7
- data.tar.gz: b1df4f6521de4682521e52468a4a891f4e64fdb64abb09e7d495869c24dc64d946c9ff46732eddabe4decf9946c3707327e1be46c3bb6d5082203fb1a8a73815
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
- const SELECTORS = {
5
- kit: '[class^="pb_"][class*="_kit"]',
6
- errorMessage: '.pb_body_kit_negative',
7
-
8
- form: 'form[data-pb-form-validation="true"]',
9
- requiredFields: 'input[required],textarea[required],select[required]',
10
- phoneValidationError: '[data-pb-phone-validation-error="true"]',
11
-
12
- errorParents: [
13
- '.text_input_wrapper',
14
- '.pb_select_kit_wrapper',
15
- '.dropdown_wrapper',
16
- '.input_wrapper',
17
- '.pb_typeahead_wrapper',
18
- ],
19
- controlWrappers: [
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 SELECTORS.form
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 (shouldSkipField(field)) return
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 isValid = this.validateField(field)
224
- if (!isValid) foundInvalid = true
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 (shouldSkipField(field)) return
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.validateField(target)
266
- }
132
+ if (this.isReactTypeaheadField(target)) return
267
133
 
268
- validateField(target) {
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
- if (target.validity.valid) {
138
+ const isValid = target.validity.valid
139
+
140
+ if (isValid) {
275
141
  this.clearError(target)
276
- return true
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
- const errorParent = getErrorParent(target, kitElement, parentElement)
297
- if (!errorParent) return
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
- const message = getValidationMessage(target, kitElement) || target.validationMessage
300
- const errorMessageElement = findOrCreateErrorMessageElement(errorParent)
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
- if (!errorParent) return
315
- clearOurErrorMessage(errorParent)
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(SELECTORS.phoneValidationError)
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(SELECTORS.requiredFields)
297
+ return this.element.querySelectorAll(REQUIRED_FIELDS_SELECTOR)
324
298
  }
325
299
  }
326
300
 
@@ -55,6 +55,7 @@ sections:
55
55
  - table_with_clickable_rows
56
56
  - table_with_selectable_rows
57
57
  - table_with_filter_variant
58
+ - table_with_filter_variant_external_filter_rails
58
59
  - table_with_filter_variant_with_pagination
59
60
  - table_disable_hover
60
61
 
@@ -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 demonstrating in our Table Filter Card Building Block as seen [here](https://playbook.powerapp.cloud/building_blocks/table_filter_card/rails).
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
- <% if object.filter_content.present? %>
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 object.filter_content.present? %>
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
  }