playbook_ui 16.1.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 (104) 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_dialog/_dialog.scss +8 -6
  5. data/app/pb_kits/playbook/pb_dropdown/_dropdown.scss +6 -0
  6. data/app/pb_kits/playbook/pb_dropdown/_dropdown.tsx +37 -2
  7. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_blank_selection_rails.md +3 -0
  8. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_blank_selection_react.md +3 -0
  9. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_with_clearable.html.erb +52 -0
  10. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_with_clearable.jsx +72 -0
  11. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_with_clearable.md +5 -0
  12. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_with_constrain_height.jsx +33 -0
  13. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_with_constrain_height_rails.html.erb +20 -0
  14. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_with_constrain_height_rails.md +8 -0
  15. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_with_constrain_height_react.md +8 -0
  16. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_with_placeholder.html.erb +9 -0
  17. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_with_placeholder.jsx +33 -0
  18. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_with_placeholder.md +3 -0
  19. data/app/pb_kits/playbook/pb_dropdown/docs/example.yml +6 -0
  20. data/app/pb_kits/playbook/pb_dropdown/docs/index.js +4 -1
  21. data/app/pb_kits/playbook/pb_dropdown/dropdown.html.erb +2 -2
  22. data/app/pb_kits/playbook/pb_dropdown/dropdown.rb +6 -0
  23. data/app/pb_kits/playbook/pb_dropdown/dropdown.test.jsx +94 -0
  24. data/app/pb_kits/playbook/pb_dropdown/dropdown_container.rb +5 -1
  25. data/app/pb_kits/playbook/pb_dropdown/index.js +59 -4
  26. data/app/pb_kits/playbook/pb_dropdown/subcomponents/DropdownContainer.tsx +3 -0
  27. data/app/pb_kits/playbook/pb_dropdown/subcomponents/DropdownTrigger.tsx +2 -1
  28. data/app/pb_kits/playbook/pb_filter/Filter/SortMenu.tsx +1 -1
  29. data/app/pb_kits/playbook/pb_filter/docs/_filter_default.html.erb +2 -2
  30. data/app/pb_kits/playbook/pb_filter/docs/_filter_default.jsx +16 -9
  31. data/app/pb_kits/playbook/pb_filter/filter.rb +2 -2
  32. data/app/pb_kits/playbook/pb_form/docs/_form_with_required_indicator.html.erb +2 -0
  33. data/app/pb_kits/playbook/pb_form/pb_form_validation.js +9 -2
  34. data/app/pb_kits/playbook/pb_form_pill/docs/_form_pill_truncated_text.html.erb +5 -5
  35. data/app/pb_kits/playbook/pb_form_pill/docs/_form_pill_truncated_text.jsx +4 -4
  36. data/app/pb_kits/playbook/pb_form_pill/form_pill.rb +4 -0
  37. data/app/pb_kits/playbook/pb_passphrase/_passphrase.tsx +20 -5
  38. data/app/pb_kits/playbook/pb_passphrase/docs/_passphrase_meter_settings.jsx +1 -0
  39. data/app/pb_kits/playbook/pb_passphrase/docs/_passphrase_required_indicator.html.erb +7 -0
  40. data/app/pb_kits/playbook/pb_passphrase/docs/_passphrase_required_indicator.jsx +24 -0
  41. data/app/pb_kits/playbook/pb_passphrase/docs/_passphrase_required_indicator.md +3 -0
  42. data/app/pb_kits/playbook/pb_passphrase/docs/example.yml +2 -0
  43. data/app/pb_kits/playbook/pb_passphrase/docs/index.js +1 -0
  44. data/app/pb_kits/playbook/pb_passphrase/passphrase.rb +2 -0
  45. data/app/pb_kits/playbook/pb_passphrase/passphrase.test.jsx +30 -1
  46. data/app/pb_kits/playbook/pb_phone_number_input/_phone_number_input.tsx +3 -0
  47. data/app/pb_kits/playbook/pb_phone_number_input/docs/_phone_number_input_required_indicator.html.erb +5 -0
  48. data/app/pb_kits/playbook/pb_phone_number_input/docs/_phone_number_input_required_indicator.jsx +14 -0
  49. data/app/pb_kits/playbook/pb_phone_number_input/docs/_phone_number_input_required_indicator.md +3 -0
  50. data/app/pb_kits/playbook/pb_phone_number_input/docs/example.yml +2 -0
  51. data/app/pb_kits/playbook/pb_phone_number_input/docs/index.js +1 -0
  52. data/app/pb_kits/playbook/pb_phone_number_input/phone_number_input.rb +3 -0
  53. data/app/pb_kits/playbook/pb_phone_number_input/phone_number_input.test.js +34 -3
  54. data/app/pb_kits/playbook/pb_textarea/_textarea.tsx +10 -0
  55. data/app/pb_kits/playbook/pb_textarea/docs/_textarea_default.html.erb +3 -3
  56. data/app/pb_kits/playbook/pb_textarea/docs/_textarea_default.jsx +3 -0
  57. data/app/pb_kits/playbook/pb_textarea/docs/_textarea_default.md +1 -0
  58. data/app/pb_kits/playbook/pb_textarea/textarea.html.erb +25 -9
  59. data/app/pb_kits/playbook/pb_textarea/textarea.rb +7 -1
  60. data/app/pb_kits/playbook/pb_time_picker/_time_picker.tsx +97 -11
  61. data/app/pb_kits/playbook/pb_time_picker/docs/_time_picker_on_handler.jsx +5 -2
  62. data/app/pb_kits/playbook/pb_time_picker/docs/_time_picker_required_indicator.html.erb +6 -0
  63. data/app/pb_kits/playbook/pb_time_picker/docs/_time_picker_required_indicator.jsx +16 -0
  64. data/app/pb_kits/playbook/pb_time_picker/docs/_time_picker_required_indicator.md +3 -0
  65. data/app/pb_kits/playbook/pb_time_picker/docs/example.yml +2 -0
  66. data/app/pb_kits/playbook/pb_time_picker/docs/index.js +1 -0
  67. data/app/pb_kits/playbook/pb_time_picker/time_picker.rb +3 -0
  68. data/app/pb_kits/playbook/pb_time_picker/time_picker.test.jsx +47 -1
  69. data/app/pb_kits/playbook/pb_typeahead/_typeahead.test.jsx +24 -1
  70. data/app/pb_kits/playbook/pb_typeahead/_typeahead.tsx +2 -1
  71. data/app/pb_kits/playbook/pb_typeahead/components/MultiValue.tsx +4 -1
  72. data/app/pb_kits/playbook/pb_typeahead/docs/_typeahead_truncated_text.html.erb +1 -1
  73. data/app/pb_kits/playbook/pb_typeahead/docs/_typeahead_truncated_text.jsx +1 -1
  74. data/app/pb_kits/playbook/pb_typeahead/typeahead.rb +4 -0
  75. data/dist/chunks/{_pb_line_graph-ERhTGrxH.js → _pb_line_graph-BgKF_zz1.js} +1 -1
  76. data/dist/chunks/_typeahead-CWA5wlah.js +1 -0
  77. data/dist/chunks/{globalProps-C5qTX7aJ.js → globalProps-BhVYCqRf.js} +1 -1
  78. data/dist/chunks/{lib-B7ivt23s.js → lib-DD34ZrWL.js} +2 -2
  79. data/dist/chunks/vendor.js +4 -4
  80. data/dist/menu.yml +1 -1
  81. data/dist/playbook-rails-react-bindings.js +1 -1
  82. data/dist/playbook-rails.js +1 -1
  83. data/dist/playbook.css +1 -1
  84. data/lib/playbook/align_content.rb +13 -3
  85. data/lib/playbook/align_items.rb +13 -3
  86. data/lib/playbook/align_self.rb +13 -3
  87. data/lib/playbook/display.rb +5 -0
  88. data/lib/playbook/flex.rb +13 -3
  89. data/lib/playbook/flex_direction.rb +13 -3
  90. data/lib/playbook/flex_grow.rb +13 -3
  91. data/lib/playbook/flex_shrink.rb +13 -3
  92. data/lib/playbook/flex_wrap.rb +13 -3
  93. data/lib/playbook/forms/builder/phone_number_field.rb +9 -0
  94. data/lib/playbook/justify_content.rb +13 -3
  95. data/lib/playbook/justify_self.rb +13 -3
  96. data/lib/playbook/order.rb +13 -3
  97. data/lib/playbook/spacing.rb +39 -9
  98. data/lib/playbook/text_align.rb +13 -3
  99. data/lib/playbook/truncate.rb +1 -1
  100. data/lib/playbook/version.rb +2 -2
  101. data/lib/playbook/vertical_align.rb +13 -3
  102. data/lib/playbook/z_index.rb +5 -0
  103. metadata +29 -6
  104. data/dist/chunks/_typeahead-DmWq2Utd.js +0 -1
@@ -36,6 +36,7 @@ type PhoneNumberInputProps = {
36
36
  excludeCountries: string[],
37
37
  preferredCountries?: string[],
38
38
  required?: boolean,
39
+ requiredIndicator?: boolean,
39
40
  value?: string,
40
41
  formatAsYouType?: boolean,
41
42
  strictMode?: boolean,
@@ -91,6 +92,7 @@ const PhoneNumberInput = (props: PhoneNumberInputProps, ref?: React.Ref<unknown>
91
92
  onlyCountries = [],
92
93
  excludeCountries = [],
93
94
  required = false,
95
+ requiredIndicator = false,
94
96
  preferredCountries = [],
95
97
  value = "",
96
98
  formatAsYouType = false,
@@ -533,6 +535,7 @@ const PhoneNumberInput = (props: PhoneNumberInputProps, ref?: React.Ref<unknown>
533
535
  validateErrors()
534
536
  },
535
537
  onChange: formatAsYouType ? undefined : handleOnChange,
538
+ requiredIndicator,
536
539
  value: inputValue
537
540
  }
538
541
 
@@ -0,0 +1,5 @@
1
+ <%= pb_rails("phone_number_input", props: {
2
+ id: "phone_number_input_required_indicator",
3
+ label: "Required Phone Number",
4
+ required_indicator: true,
5
+ }) %>
@@ -0,0 +1,14 @@
1
+ import React from 'react'
2
+ import PhoneNumberInput from '../../pb_phone_number_input/_phone_number_input'
3
+
4
+ const PhoneNumberInputRequiredIndicator = (props) => (
5
+ <>
6
+ <PhoneNumberInput
7
+ id='phone_number_input_required_indicator'
8
+ label='Phone Number'
9
+ requiredIndicator
10
+ {...props} />
11
+ </>
12
+ )
13
+
14
+ export default PhoneNumberInputRequiredIndicator
@@ -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.
@@ -12,6 +12,7 @@ examples:
12
12
  - phone_number_input_format: Format as You Type
13
13
  - phone_number_input_strict_mode: Strict Mode
14
14
  - phone_number_input_country_search: Country Search
15
+ - phone_number_input_required_indicator: Required Indicator
15
16
 
16
17
  rails:
17
18
  - phone_number_input_default: Default
@@ -24,3 +25,4 @@ examples:
24
25
  - phone_number_input_strict_mode: Strict Mode
25
26
  - phone_number_input_hidden_inputs: Hidden Inputs
26
27
  - phone_number_input_country_search: Country Search
28
+ - phone_number_input_required_indicator: Required Indicator
@@ -9,3 +9,4 @@ export { default as PhoneNumberInputAccessInputElement } from './_phone_number_i
9
9
  export { default as PhoneNumberInputFormat } from './_phone_number_input_format'
10
10
  export { default as PhoneNumberInputStrictMode } from './_phone_number_input_strict_mode'
11
11
  export { default as PhoneNumberInputCountrySearch } from './_phone_number_input_country_search'
12
+ export { default as PhoneNumberInputRequiredIndicator } from './_phone_number_input_required_indicator.jsx'
@@ -7,6 +7,8 @@ module Playbook
7
7
  default: false
8
8
  prop :required, type: Playbook::Props::Boolean,
9
9
  default: false
10
+ prop :required_indicator, type: Playbook::Props::Boolean,
11
+ default: false
10
12
  prop :initial_country, type: Playbook::Props::String,
11
13
  default: ""
12
14
  prop :label, type: Playbook::Props::String,
@@ -52,6 +54,7 @@ module Playbook
52
54
  excludeCountries: exclude_countries,
53
55
  preferredCountries: preferred_countries,
54
56
  required: required,
57
+ requiredIndicator: required_indicator,
55
58
  value: value,
56
59
  countrySearch: country_search,
57
60
  }
@@ -1,5 +1,5 @@
1
1
  import React from "react";
2
- import { render, screen, act } from "../utilities/test-utils";
2
+ import { render, screen, act, within } from "../utilities/test-utils";
3
3
  import PhoneNumberInput from "./_phone_number_input";
4
4
 
5
5
  const testId = "phoneNumberInput";
@@ -129,7 +129,7 @@ test("should format phone number as '555-555-5555' with formatAsYouType and 'us'
129
129
  };
130
130
 
131
131
  render(<PhoneNumberInput {...props} />);
132
-
132
+
133
133
  const input = screen.getByRole("textbox");
134
134
 
135
135
  act(() => {
@@ -154,7 +154,38 @@ test("should pass countrySearch prop to component", () => {
154
154
  };
155
155
 
156
156
  render(<PhoneNumberInput {...props} />);
157
-
157
+
158
158
  const wrapper = screen.getByTestId('phone-input-with-search');
159
159
  expect(wrapper).toBeInTheDocument();
160
160
  });
161
+
162
+ test("renders required indicator asterisk when requiredIndicator is true", () => {
163
+ const props = {
164
+ data: { testid: testId },
165
+ id: testId,
166
+ label: "Required Phone Number",
167
+ requiredIndicator: true,
168
+ };
169
+
170
+ render(<PhoneNumberInput {...props} />);
171
+
172
+ const kit = screen.getByTestId(testId);
173
+ const label = within(kit).getByText(/Required Phone Number/);
174
+ expect(label).toBeInTheDocument();
175
+ expect(kit).toHaveTextContent("*");
176
+ });
177
+
178
+ test("does not render required indicator asterisk when requiredIndicator is false", () => {
179
+ const props = {
180
+ data: { testid: testId },
181
+ id: testId,
182
+ label: "Phone Number",
183
+ };
184
+
185
+ render(<PhoneNumberInput {...props} />);
186
+
187
+ const kit = screen.getByTestId(testId);
188
+ const label = within(kit).getByText(/Phone Number/);
189
+ expect(label).toBeInTheDocument();
190
+ expect(kit).not.toHaveTextContent("*");
191
+ });
@@ -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
  })