playbook_ui 16.2.0.pre.rc.0 → 16.2.0.pre.rc.1

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.
Files changed (27) hide show
  1. checksums.yaml +4 -4
  2. data/app/pb_kits/playbook/pb_date_picker/_date_picker.tsx +14 -5
  3. data/app/pb_kits/playbook/pb_date_picker/docs/_date_picker_default.md +1 -0
  4. data/app/pb_kits/playbook/pb_form/docs/_form_with_required_indicator.html.erb +1 -0
  5. data/app/pb_kits/playbook/pb_form/pb_form_validation.js +9 -2
  6. data/app/pb_kits/playbook/pb_textarea/_textarea.tsx +10 -0
  7. data/app/pb_kits/playbook/pb_textarea/docs/_textarea_default.html.erb +3 -3
  8. data/app/pb_kits/playbook/pb_textarea/docs/_textarea_default.jsx +3 -0
  9. data/app/pb_kits/playbook/pb_textarea/docs/_textarea_default.md +1 -0
  10. data/app/pb_kits/playbook/pb_textarea/textarea.html.erb +25 -9
  11. data/app/pb_kits/playbook/pb_textarea/textarea.rb +7 -1
  12. data/app/pb_kits/playbook/pb_time_picker/_time_picker.tsx +97 -11
  13. data/app/pb_kits/playbook/pb_time_picker/docs/_time_picker_on_handler.jsx +5 -2
  14. data/app/pb_kits/playbook/pb_time_picker/docs/_time_picker_required_indicator.html.erb +6 -0
  15. data/app/pb_kits/playbook/pb_time_picker/docs/_time_picker_required_indicator.jsx +16 -0
  16. data/app/pb_kits/playbook/pb_time_picker/docs/_time_picker_required_indicator.md +3 -0
  17. data/app/pb_kits/playbook/pb_time_picker/docs/example.yml +2 -0
  18. data/app/pb_kits/playbook/pb_time_picker/docs/index.js +1 -0
  19. data/app/pb_kits/playbook/pb_time_picker/time_picker.rb +3 -0
  20. data/app/pb_kits/playbook/pb_time_picker/time_picker.test.jsx +47 -1
  21. data/dist/chunks/{_typeahead-Bfy-4mll.js → _typeahead-CWA5wlah.js} +1 -1
  22. data/dist/chunks/vendor.js +3 -3
  23. data/dist/menu.yml +1 -1
  24. data/dist/playbook-rails-react-bindings.js +1 -1
  25. data/dist/playbook-rails.js +1 -1
  26. data/lib/playbook/version.rb +1 -1
  27. metadata +8 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c23c9c569be92144973f36beb52b16a31324a557d8a3fce6b9662a65c3fe9495
4
- data.tar.gz: 5af45dd28792072ef8c0c4ef0a237b4036d09cf324e91fa87b348df9f406d834
3
+ metadata.gz: 4716e526924fd62bb0c877a51c1b135e4d881bb50c2995a67a51aa3c667bf320
4
+ data.tar.gz: d8e539ed648f227860fee870d7c8b80ea9a6ba2cf9e0420c59cd192bf58dae50
5
5
  SHA512:
6
- metadata.gz: a7dc0f4a93cc629eaac57de2a013b831479c928c4ff459af2926f38221b201e42dda67d06f20112d64ccc1c7732b47f37c2efc6301c3f7c216f11c915aa9a564
7
- data.tar.gz: ca66a6db50a94ea148c6afbb58fccbb0d302f2c2ddae2aa1d6b530687809c39e3bd01849e051c56b31e4ac1301fb9fffc0987d8460f63c5e349fc05dde321f73
6
+ metadata.gz: 8d2b0e61996919c9d3234518bf9ce2708f3be86de41039abf8a820029d22063a2ebdceb41a183852f5ab88315a44c6bd20daeba2bb1a95ec3dcb2aa59cf74d1a
7
+ data.tar.gz: b5b94f8ead24d755a3d0c1a10fe6e2175c45eb874fe413705a99d5740b82a25be3b44abb98a1c22c41da6562f61cd1815eea65622ad97686e7a9f07069d573cc
@@ -40,7 +40,7 @@ type DatePickerProps = {
40
40
  maxDate: string,
41
41
  minDate: string,
42
42
  name: string,
43
- pickerId?: string,
43
+ pickerId: string,
44
44
  placeholder?: string,
45
45
  positionElement?: HTMLElement | null,
46
46
  scrollContainer?: string,
@@ -196,6 +196,8 @@ const DatePicker = (props: DatePickerProps): React.ReactElement => {
196
196
 
197
197
  const angleDown = getAllIcons()["angleDown"].icon as unknown as { [key: string]: SVGElement }
198
198
 
199
+ const errorId = error ? `${pickerId}-error` : undefined
200
+
199
201
  return (
200
202
  <div
201
203
  {...ariaProps}
@@ -211,14 +213,18 @@ const DatePicker = (props: DatePickerProps): React.ReactElement => {
211
213
  >
212
214
 
213
215
  {!hideLabel && (
214
- <Caption
215
- className="pb_date_picker_kit_label"
216
- text={label}
217
- />
216
+ <label htmlFor={pickerId}>
217
+ <Caption
218
+ className="pb_date_picker_kit_label"
219
+ text={label}
220
+ />
221
+ </label>
218
222
  )}
219
223
  <>
220
224
  <div className="date_picker_input_wrapper">
221
225
  <input
226
+ aria-describedby={errorId}
227
+ aria-invalid={!!error}
222
228
  autoComplete="off"
223
229
  className="date_picker_input"
224
230
  disabled={disableInput}
@@ -232,6 +238,9 @@ const DatePicker = (props: DatePickerProps): React.ReactElement => {
232
238
 
233
239
  {error &&
234
240
  <Body
241
+ aria={{ atomic: "true", live: "polite" }}
242
+ htmlOptions={{ role: "alert" }}
243
+ id={errorId}
235
244
  status="negative"
236
245
  text={error}
237
246
  variant={null}
@@ -0,0 +1 @@
1
+ `pickerId`/`picker_id` is a **required prop** to instantiate the Date Picker. The presence of `pickerId`/`picker_id` in your Date Picker also associates the label with the input, providing the ability to focus the Date Picker by clicking the label.
@@ -9,6 +9,7 @@
9
9
  <%= form.password_field :example_password_field, props: { label: true, required: true, required_indicator: true } %>
10
10
  <%= form.url_field :example_url_field, props: { label: true, required: true, required_indicator: true } %>
11
11
  <%= form.phone_number_field :example_phone_number_field, props: { label: true, required: true, required_indicator: true } %>
12
+ <%= form.time_picker :example_time_picker_required_indicator, props: { label: true, required: true, required_indicator: true } %>
12
13
 
13
14
  <%= form.actions do |action| %>
14
15
  <%= action.submit %>
@@ -26,6 +26,10 @@ class PbFormValidation extends PbEnhancedElement {
26
26
  const isPhoneNumberInput = field.closest('.pb_phone_number_input')
27
27
  if (isPhoneNumberInput) return
28
28
 
29
+ // Skip TimePicker inputs - they handle their own validation
30
+ const isTimePickerInput = field.closest('.pb_time_picker')
31
+ if (isTimePickerInput) return
32
+
29
33
  FIELD_EVENTS.forEach((e) => {
30
34
  field.addEventListener(e, debounce((event) => {
31
35
  this.validateFormField(event)
@@ -67,13 +71,16 @@ class PbFormValidation extends PbEnhancedElement {
67
71
 
68
72
  // Check if this is a phone number input
69
73
  const isPhoneNumberInput = kitElement.classList.contains('pb_phone_number_input')
74
+
75
+ // Check if this is a TimePicker input
76
+ const isTimePickerInput = kitElement.classList.contains('pb_time_picker')
70
77
 
71
78
  // ensure clean error message state
72
79
  this.clearError(target)
73
80
  kitElement.classList.add('error')
74
81
 
75
- // Only add error message if it's NOT a phone number input
76
- if (!isPhoneNumberInput) {
82
+ // Only add error message if it's NOT a phone number input or TimePicker input
83
+ if (!isPhoneNumberInput && !isTimePickerInput) {
77
84
  // set the error message element
78
85
  const errorMessageContainer = this.errorMessageContainer
79
86
 
@@ -120,6 +120,7 @@ const Textarea = ({
120
120
  const characterCounter = () => {
121
121
  return maxCharacters && characterCount ? `${checkIfZero(characterCount)} / ${maxCharacters}` : `${checkIfZero(characterCount)}`
122
122
  }
123
+ const errorId = error ? `${id}-error` : undefined
123
124
 
124
125
  return (
125
126
  <div
@@ -145,7 +146,10 @@ const Textarea = ({
145
146
  )}
146
147
  {children || (
147
148
  <textarea
149
+ aria-describedby={errorId}
150
+ aria-invalid={!!error}
148
151
  disabled={disabled}
152
+ id={id}
149
153
  name={name}
150
154
  onChange={emojiMask ? handleChange : onChange}
151
155
  onPaste={emojiMask ? handlePaste : undefined}
@@ -167,6 +171,9 @@ const Textarea = ({
167
171
  >
168
172
  <FlexItem>
169
173
  <Body
174
+ aria={{ atomic: "true", live: "polite" }}
175
+ htmlOptions={{ role: "alert" }}
176
+ id={errorId}
170
177
  margin="none"
171
178
  status="negative"
172
179
  text={error}
@@ -182,6 +189,9 @@ const Textarea = ({
182
189
  </Flex>
183
190
  ) : (
184
191
  <Body
192
+ aria={{ atomic: "true", live: "polite" }}
193
+ htmlOptions={{ role: "alert" }}
194
+ id={errorId}
185
195
  status="negative"
186
196
  text={error}
187
197
  />
@@ -1,9 +1,9 @@
1
- <%= pb_rails("textarea", props: { label: "Label", rows: 4}) %>
1
+ <%= pb_rails("textarea", props: { label: "Label", rows: 4, id: "default-example-1" }) %>
2
2
 
3
3
  <br/>
4
4
 
5
- <%= pb_rails("textarea", props: { label: "Label", placeholder: "Placeholder text" }) %>
5
+ <%= pb_rails("textarea", props: { label: "Label", placeholder: "Placeholder text", id: "default-example-2" }) %>
6
6
 
7
7
  <br/>
8
8
 
9
- <%= pb_rails("textarea", props: { label: "Label", name: "comment", value: "Default value text" }) %>
9
+ <%= pb_rails("textarea", props: { label: "Label", name: "comment", value: "Default value text", id: "default-example-3" }) %>
@@ -13,6 +13,7 @@ const TextareaDefault = (props) => {
13
13
  label="Label"
14
14
  rows={4}
15
15
  {...props}
16
+ id="default-example-1"
16
17
  />
17
18
 
18
19
  <br />
@@ -21,6 +22,7 @@ const TextareaDefault = (props) => {
21
22
  label="Label"
22
23
  placeholder="Placeholder text"
23
24
  {...props}
25
+ id="default-example-2"
24
26
  />
25
27
 
26
28
  <br />
@@ -32,6 +34,7 @@ const TextareaDefault = (props) => {
32
34
  placeholder="Placeholder text"
33
35
  value={value}
34
36
  {...props}
37
+ id="default-example-3"
35
38
  />
36
39
 
37
40
  </div>
@@ -0,0 +1 @@
1
+ Add an `id` to your Textarea so that clicking the label will move focus directly to the input.
@@ -1,12 +1,14 @@
1
- <%= pb_content_tag do %>
1
+ <%= pb_content_tag(:div, id: nil) do %>
2
2
  <% if object.label.present? %>
3
- <% if object.required_indicator %>
4
- <%= pb_rails("caption", props: { text: object.label, dark: object.dark }) do %>
5
- <%= object.label %><span style="color: #DA0014;"> *</span>
3
+ <label for="<%= object.input_options[:id] || object.id %>" >
4
+ <% if object.required_indicator %>
5
+ <%= pb_rails("caption", props: { text: object.label, dark: object.dark }) do %>
6
+ <%= object.label %><span style="color: #DA0014;"> *</span>
7
+ <% end %>
8
+ <% else %>
9
+ <%= pb_rails("caption", props: {text: object.label, dark: object.dark}) %>
6
10
  <% end %>
7
- <% else %>
8
- <%= pb_rails("caption", props: {text: object.label, dark: object.dark}) %>
9
- <% end %>
11
+ </label>
10
12
  <% end %>
11
13
  <% if content.present? %>
12
14
  <%= content %>
@@ -22,14 +24,28 @@
22
24
  <% if object.character_count %>
23
25
  <%= pb_rails("flex", props: { spacing: "between", vertical: "center" }) do %>
24
26
  <%= pb_rails("flex/flex_item") do %>
25
- <%= pb_rails("body", props: { dark: object.dark, status: "negative", text: object.error }) %>
27
+ <%= pb_rails("body", props: {
28
+ dark: object.dark,
29
+ status: "negative",
30
+ text: object.error,
31
+ id: object.error_id,
32
+ aria: { atomic: "true", live: "polite" },
33
+ html_options: { role: "alert" },
34
+ }) %>
26
35
  <% end %>
27
36
  <%= pb_rails("flex/flex_item") do %>
28
37
  <%= pb_rails("caption", props: { margin: "none", size: "xs", text: object.character_counter }) %>
29
38
  <% end %>
30
39
  <% end %>
31
40
  <% else %>
32
- <%= pb_rails("body", props: { dark: object.dark, status: "negative", text: object.error }) %>
41
+ <%= pb_rails("body", props: {
42
+ dark: object.dark,
43
+ status: "negative",
44
+ text: object.error,
45
+ id: object.error_id,
46
+ aria: { atomic: "true", live: "polite" },
47
+ html_options: { role: "alert" },
48
+ }) %>
33
49
  <% end %>
34
50
  <% else %>
35
51
  <%= pb_rails("caption", props: { margin: "none", size: "xs", text: object.character_counter }) %>
@@ -47,7 +47,9 @@ module Playbook
47
47
  merged_data = data_attrs.merge(input_data)
48
48
 
49
49
  base_attributes = {
50
- id: input_options[:id] || "object_method",
50
+ 'aria-describedby': error.present? ? error_id : nil,
51
+ 'aria-invalid': error.present?,
52
+ id: input_options[:id] || id || "object_method",
51
53
  max_characters: max_characters,
52
54
  name: name,
53
55
  onkeyup: onkeyup,
@@ -66,6 +68,10 @@ module Playbook
66
68
  result
67
69
  end
68
70
 
71
+ def error_id
72
+ "#{id}-error" if error.present?
73
+ end
74
+
69
75
  private
70
76
 
71
77
  def error_class
@@ -5,12 +5,12 @@ import { globalProps, GlobalProps } from '../utilities/globalProps'
5
5
  import Caption from '../pb_caption/_caption'
6
6
  import SelectableCard from '../pb_selectable_card/_selectable_card'
7
7
  import TextInput from '../pb_text_input/_text_input'
8
+ import colors from '../tokens/exports/_colors.module.scss'
8
9
 
9
10
  import {
10
11
  parseTime,
11
12
  parseTimeToMinutes,
12
13
  isTimeInRange as isTimeInRangeHelper,
13
- isHourDisabled as isHourDisabledHelper,
14
14
  isAnyAMTimeValid as isAnyAMTimeValidHelper,
15
15
  isAnyPMTimeValid as isAnyPMTimeValidHelper,
16
16
  getDisplayTime,
@@ -48,6 +48,7 @@ type TimePickerProps = {
48
48
  onChange?: (time: string) => void,
49
49
  onClose?: (time: string) => void,
50
50
  required?: boolean,
51
+ requiredIndicator?: boolean,
51
52
  showTimezone?: boolean,
52
53
  timeFormat?: TimeFormat,
53
54
  validationMessage?: string,
@@ -72,6 +73,7 @@ const TimePicker = (props: TimePickerProps): JSX.Element => {
72
73
  onChange,
73
74
  onClose,
74
75
  required = false,
76
+ requiredIndicator = false,
75
77
  showTimezone = false,
76
78
  timeFormat = 'AMPM',
77
79
  validationMessage,
@@ -114,10 +116,6 @@ const TimePicker = (props: TimePickerProps): JSX.Element => {
114
116
  return isTimeInRangeHelper(h, m, mer, timeFormat, minTimeMinutes, maxTimeMinutes)
115
117
  }
116
118
 
117
- const isHourDisabled = (h: number, mer?: 'AM' | 'PM'): boolean => {
118
- return isHourDisabledHelper(h, mer, timeFormat, minTimeMinutes, maxTimeMinutes)
119
- }
120
-
121
119
  const isCurrentTimeValid = (h: number, m: number, mer: 'AM' | 'PM'): boolean => {
122
120
  return isTimeInRange(h, m, mer)
123
121
  }
@@ -187,6 +185,28 @@ const TimePicker = (props: TimePickerProps): JSX.Element => {
187
185
  const [showHourDropdown, setShowHourDropdown] = useState(false)
188
186
  const [showMinuteDropdown, setShowMinuteDropdown] = useState(false)
189
187
 
188
+ // Clicking the clock add-on opens the dropdown
189
+ useEffect(() => {
190
+ if (disabled) return
191
+
192
+ const addOnCard = document.querySelector(`#${uniqueId}-input`)?.closest('.text_input_wrapper_add_on')?.querySelector('.add-on-card') as HTMLElement
193
+
194
+ if (addOnCard) {
195
+ const handleAddOnClick = (e: Event) => {
196
+ e.preventDefault()
197
+ e.stopPropagation()
198
+ setShowDropdown(true)
199
+ }
200
+
201
+ addOnCard.addEventListener('click', handleAddOnClick)
202
+ addOnCard.style.cursor = 'pointer'
203
+
204
+ return () => {
205
+ addOnCard.removeEventListener('click', handleAddOnClick)
206
+ }
207
+ }
208
+ }, [uniqueId, disabled, setShowDropdown])
209
+
190
210
  // Input dropdown scrolling
191
211
  const scrollDropdownToSelected = (dropdownRef: React.RefObject<HTMLDivElement>) => {
192
212
  if (dropdownRef.current) {
@@ -369,6 +389,10 @@ const TimePicker = (props: TimePickerProps): JSX.Element => {
369
389
  }
370
390
  }
371
391
 
392
+ const handleHourFocus = (e: React.FocusEvent<HTMLInputElement>) => {
393
+ e.target.select()
394
+ }
395
+
372
396
  const handleHourBlur = () => {
373
397
  const result = normalizeHourOnBlur(hourInputValue, hour, timeFormat)
374
398
  setHour(result.hour)
@@ -393,6 +417,10 @@ const TimePicker = (props: TimePickerProps): JSX.Element => {
393
417
  }
394
418
  }
395
419
 
420
+ const handleMinuteFocus = (e: React.FocusEvent<HTMLInputElement>) => {
421
+ e.target.select()
422
+ }
423
+
396
424
  const handleMinuteBlur = () => {
397
425
  const result = normalizeMinuteOnBlur(minuteInputValue, minute)
398
426
  setMinute(result.minute)
@@ -482,6 +510,30 @@ const TimePicker = (props: TimePickerProps): JSX.Element => {
482
510
  e.preventDefault()
483
511
  setShowHourDropdown(false)
484
512
  closeDropdown()
513
+ } else if (e.key === 'ArrowDown') {
514
+ // ArrowDown increases value (like scrolling down a list)
515
+ e.preventDefault()
516
+ const { maxHour, minHour } = getHourConstraints(timeFormat)
517
+ const newHour = hour >= maxHour ? minHour : hour + 1
518
+ setHour(newHour)
519
+ setHourInputValue(timeFormat === '24hour' ? newHour.toString().padStart(2, '0') : newHour.toString())
520
+ setHasSelectedTime(true)
521
+ const timeString = get24HourTime(newHour, minute, meridiem, timeFormat)
522
+ if (onChange) {
523
+ onChange(timeString)
524
+ }
525
+ } else if (e.key === 'ArrowUp') {
526
+ // ArrowUp decreases value (like scrolling up a list)
527
+ e.preventDefault()
528
+ const { maxHour, minHour } = getHourConstraints(timeFormat)
529
+ const newHour = hour <= minHour ? maxHour : hour - 1
530
+ setHour(newHour)
531
+ setHourInputValue(timeFormat === '24hour' ? newHour.toString().padStart(2, '0') : newHour.toString())
532
+ setHasSelectedTime(true)
533
+ const timeString = get24HourTime(newHour, minute, meridiem, timeFormat)
534
+ if (onChange) {
535
+ onChange(timeString)
536
+ }
485
537
  }
486
538
  }
487
539
 
@@ -513,6 +565,28 @@ const TimePicker = (props: TimePickerProps): JSX.Element => {
513
565
  e.preventDefault()
514
566
  setShowMinuteDropdown(false)
515
567
  closeDropdown()
568
+ } else if (e.key === 'ArrowDown') {
569
+ // ArrowDown increases value (like scrolling down a list)
570
+ e.preventDefault()
571
+ const newMinute = minute >= 59 ? 0 : minute + 1
572
+ setMinute(newMinute)
573
+ setMinuteInputValue(newMinute.toString().padStart(2, '0'))
574
+ setHasSelectedTime(true)
575
+ const timeString = get24HourTime(hour, newMinute, meridiem, timeFormat)
576
+ if (onChange) {
577
+ onChange(timeString)
578
+ }
579
+ } else if (e.key === 'ArrowUp') {
580
+ // ArrowUp decreases value (like scrolling up a list)
581
+ e.preventDefault()
582
+ const newMinute = minute <= 0 ? 59 : minute - 1
583
+ setMinute(newMinute)
584
+ setMinuteInputValue(newMinute.toString().padStart(2, '0'))
585
+ setHasSelectedTime(true)
586
+ const timeString = get24HourTime(hour, newMinute, meridiem, timeFormat)
587
+ if (onChange) {
588
+ onChange(timeString)
589
+ }
516
590
  }
517
591
  }
518
592
 
@@ -625,12 +699,22 @@ const TimePicker = (props: TimePickerProps): JSX.Element => {
625
699
  >
626
700
  {label && (
627
701
  <label htmlFor={`${uniqueId}-input`}>
628
- <Caption
629
- className="pb_time_picker_kit_label"
630
- marginBottom="xs"
631
- size="md"
632
- text={label}
633
- />
702
+ {requiredIndicator ? (
703
+ <Caption
704
+ className="pb_time_picker_kit_label"
705
+ marginBottom="xs"
706
+ size="md"
707
+ >
708
+ {label} <span style={{ color: `${colors.error}` }}>{'*'}</span>
709
+ </Caption>
710
+ ) : (
711
+ <Caption
712
+ className="pb_time_picker_kit_label"
713
+ marginBottom="xs"
714
+ size="md"
715
+ text={label}
716
+ />
717
+ )}
634
718
  </label>
635
719
  )}
636
720
  <div className="time_picker_wrapper">
@@ -689,6 +773,7 @@ const TimePicker = (props: TimePickerProps): JSX.Element => {
689
773
  onBlur={handleHourBlur}
690
774
  onChange={handleHourChange}
691
775
  onClick={() => { setShowHourDropdown(!showHourDropdown); setShowMinuteDropdown(false) }}
776
+ onFocus={handleHourFocus}
692
777
  onKeyDown={handleHourKeyDown}
693
778
  pattern="[0-9]*"
694
779
  ref={hourInputRef}
@@ -734,6 +819,7 @@ const TimePicker = (props: TimePickerProps): JSX.Element => {
734
819
  onBlur={handleMinuteBlur}
735
820
  onChange={handleMinuteChange}
736
821
  onClick={() => { setShowMinuteDropdown(!showMinuteDropdown); setShowHourDropdown(false) }}
822
+ onFocus={handleMinuteFocus}
737
823
  onKeyDown={handleMinuteKeyDown}
738
824
  pattern="[0-9]*"
739
825
  ref={minuteInputRef}
@@ -1,6 +1,7 @@
1
1
  import React, { useState } from 'react'
2
2
  import TimePicker from '../../pb_time_picker/_time_picker'
3
3
  import Body from '../../pb_body/_body'
4
+ import Flex from '../../pb_flex/_flex'
4
5
 
5
6
  const TimePickerOnHandler = (props) => {
6
7
  const [selectedTime, setSelectedTime] = useState('')
@@ -17,7 +18,9 @@ const TimePickerOnHandler = (props) => {
17
18
  return (
18
19
  <div>
19
20
  {(selectedTime || closedTime) && (
20
- <div style={{ marginBottom: '16px' }}>
21
+ <Flex marginBottom="sm"
22
+ orientation="column"
23
+ >
21
24
  {selectedTime && (
22
25
  <Body
23
26
  text={`onChange: ${selectedTime}`}
@@ -29,7 +32,7 @@ const TimePickerOnHandler = (props) => {
29
32
  text={`onClose: ${closedTime}`}
30
33
  />
31
34
  )}
32
- </div>
35
+ </Flex>
33
36
  )}
34
37
  <TimePicker
35
38
  id="time-picker-on-handler"
@@ -0,0 +1,6 @@
1
+ <%= pb_rails("time_picker", props: {
2
+ id: "time-picker-required-indicator",
3
+ label: "Select Time",
4
+ required_indicator: true,
5
+ }) %>
6
+
@@ -0,0 +1,16 @@
1
+ import React from 'react'
2
+ import TimePicker from '../_time_picker'
3
+
4
+ const TimePickerRequiredIndicator = (props) => (
5
+ <div>
6
+ <TimePicker
7
+ id="time-picker-required-indicator"
8
+ label="Select Time"
9
+ requiredIndicator
10
+ {...props}
11
+ />
12
+ </div>
13
+ )
14
+
15
+ export default TimePickerRequiredIndicator
16
+
@@ -0,0 +1,3 @@
1
+ The `requiredIndicator`/`required_indicator` prop displays a red asterisk (*) next to the label, visually indicating that the field is required. This is purely visual and does not enforce validation.
2
+
3
+ You can use `requiredIndicator`/`required_indicator` with any validation approach: HTML5 validation via the `required` prop, client-side validation, or backend validation. For this reason, it works independently and doesn't need to be paired with the `required` prop.
@@ -9,6 +9,7 @@ examples:
9
9
  - time_picker_min_max_time: Min & Max Time
10
10
  - time_picker_error: Error
11
11
  - time_picker_disabled: Disabled
12
+ - time_picker_required_indicator: Required Indicator
12
13
  - time_picker_input_options: Input Options
13
14
 
14
15
 
@@ -21,4 +22,5 @@ examples:
21
22
  - time_picker_min_max_time: Min & Max Time
22
23
  - time_picker_error: Error
23
24
  - time_picker_disabled: Disabled
25
+ - time_picker_required_indicator: Required Indicator
24
26
  - time_picker_on_handler: onChange & onClose Handlers
@@ -7,3 +7,4 @@ export { default as TimePickerOnHandler } from './_time_picker_on_handler.jsx'
7
7
  export { default as TimePickerMinMaxTime } from './_time_picker_min_max_time.jsx'
8
8
  export { default as TimePickerError } from './_time_picker_error.jsx'
9
9
  export { default as TimePickerDisabled } from './_time_picker_disabled.jsx'
10
+ export { default as TimePickerRequiredIndicator } from './_time_picker_required_indicator.jsx'
@@ -16,6 +16,8 @@ module Playbook
16
16
  prop :name, type: Playbook::Props::String
17
17
  prop :required, type: Playbook::Props::Boolean,
18
18
  default: false
19
+ prop :required_indicator, type: Playbook::Props::Boolean,
20
+ default: false
19
21
  prop :show_timezone, type: Playbook::Props::Boolean,
20
22
  default: false
21
23
  prop :time_format, type: Playbook::Props::Enum,
@@ -68,6 +70,7 @@ module Playbook
68
70
  minTime: min_time,
69
71
  name: name,
70
72
  required: required,
73
+ requiredIndicator: required_indicator,
71
74
  showTimezone: show_timezone,
72
75
  timeFormat: time_format,
73
76
  validationMessage: validation_message,
@@ -1,5 +1,5 @@
1
1
  import React from 'react'
2
- import { render, screen, fireEvent } from '../utilities/test-utils'
2
+ import { render, screen, fireEvent, within } from '../utilities/test-utils'
3
3
 
4
4
  import TimePicker from './_time_picker'
5
5
 
@@ -111,4 +111,50 @@ describe('TimePicker', () => {
111
111
  expect(screen.getByText('AM')).toBeInTheDocument()
112
112
  expect(screen.getByText('PM')).toBeInTheDocument()
113
113
  })
114
+
115
+ test('renders required indicator asterisk when requiredIndicator is true', () => {
116
+ render(
117
+ <TimePicker
118
+ data={{ testid: 'required-indicator-picker' }}
119
+ label="Select Time"
120
+ requiredIndicator
121
+ />
122
+ )
123
+ const kit = screen.getByTestId('required-indicator-picker')
124
+ const label = within(kit).getByText(/Select Time/)
125
+
126
+ expect(label).toBeInTheDocument()
127
+ expect(kit).toHaveTextContent('*')
128
+ })
129
+
130
+ test('requiredIndicator works independently of required prop', () => {
131
+ render(
132
+ <TimePicker
133
+ data={{ testid: 'indicator-without-required' }}
134
+ label="Select Time"
135
+ requiredIndicator
136
+ />
137
+ )
138
+ const kit = screen.getByTestId('indicator-without-required')
139
+
140
+ expect(kit).toHaveTextContent('*')
141
+ const input = screen.getByPlaceholderText('Select Time')
142
+ expect(input).not.toHaveAttribute('required')
143
+ })
144
+
145
+ test('requiredIndicator and required can be used together', () => {
146
+ render(
147
+ <TimePicker
148
+ data={{ testid: 'both-props-picker' }}
149
+ label="Select Time"
150
+ required
151
+ requiredIndicator
152
+ />
153
+ )
154
+ const kit = screen.getByTestId('both-props-picker')
155
+ const input = screen.getByPlaceholderText('Select Time')
156
+
157
+ expect(kit).toHaveTextContent('*')
158
+ expect(input).toHaveAttribute('required')
159
+ })
114
160
  })