playbook_ui 16.8.0.pre.alpha.PLAY2945selectformblankselection16424 → 16.8.0.pre.alpha.PLAY2965datepickerconsoleerrors16591

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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/app/pb_kits/playbook/pb_advanced_table/Components/RegularTableView.tsx +2 -0
  3. data/app/pb_kits/playbook/pb_advanced_table/Components/TableHeaderCell.tsx +2 -0
  4. data/app/pb_kits/playbook/pb_advanced_table/Components/VirtualizedTableView.tsx +5 -1
  5. data/app/pb_kits/playbook/pb_advanced_table/Hooks/useTableState.ts +24 -0
  6. data/app/pb_kits/playbook/pb_advanced_table/Utilities/ColumnLayoutHelper.ts +138 -0
  7. data/app/pb_kits/playbook/pb_advanced_table/advanced_table.test.jsx +144 -0
  8. data/app/pb_kits/playbook/pb_advanced_table/docs/_advanced_table_column_styling.jsx +1 -0
  9. data/app/pb_kits/playbook/pb_advanced_table/docs/_advanced_table_column_styling.md +6 -0
  10. data/app/pb_kits/playbook/pb_advanced_table/docs/_advanced_table_column_styling_width.jsx +57 -0
  11. data/app/pb_kits/playbook/pb_advanced_table/docs/_advanced_table_column_styling_width.md +66 -0
  12. data/app/pb_kits/playbook/pb_advanced_table/docs/_playground.json +5 -2
  13. data/app/pb_kits/playbook/pb_advanced_table/docs/_playground.overrides.json +1 -1
  14. data/app/pb_kits/playbook/pb_advanced_table/docs/advanced_table_column_definitions_styling.json +4 -1
  15. data/app/pb_kits/playbook/pb_advanced_table/docs/example.yml +1 -0
  16. data/app/pb_kits/playbook/pb_advanced_table/docs/index.js +1 -0
  17. data/app/pb_kits/playbook/pb_card/_card.tsx +1 -1
  18. data/app/pb_kits/playbook/pb_card/card.html.erb +1 -1
  19. data/app/pb_kits/playbook/pb_currency/_currency.tsx +9 -6
  20. data/app/pb_kits/playbook/pb_currency/currency.rb +5 -10
  21. data/app/pb_kits/playbook/pb_currency/currency.test.js +44 -1
  22. data/app/pb_kits/playbook/pb_date_picker/date_picker.html.erb +36 -21
  23. data/app/pb_kits/playbook/pb_date_picker/docs/_date_picker_dialog_submission.jsx +2 -2
  24. data/app/pb_kits/playbook/pb_draggable/_draggable.scss +19 -0
  25. data/app/pb_kits/playbook/pb_draggable/docs/_draggable_with_cards_rails.md +2 -0
  26. data/app/pb_kits/playbook/pb_draggable/docs/_draggable_with_cards_react.md +1 -0
  27. data/app/pb_kits/playbook/pb_draggable/docs/_draggable_with_list_rails.md +2 -0
  28. data/app/pb_kits/playbook/pb_draggable/docs/_draggable_with_list_react.md +3 -1
  29. data/app/pb_kits/playbook/pb_draggable/docs/_draggable_with_selectable_list_rails.md +3 -1
  30. data/app/pb_kits/playbook/pb_draggable/docs/_draggable_with_selectable_list_react.md +3 -1
  31. data/app/pb_kits/playbook/pb_draggable/draggable.test.jsx +16 -0
  32. data/app/pb_kits/playbook/pb_draggable/draggable_container.html.erb +3 -1
  33. data/app/pb_kits/playbook/pb_draggable/draggable_item.html.erb +1 -0
  34. data/app/pb_kits/playbook/pb_draggable/index.js +149 -7
  35. data/app/pb_kits/playbook/pb_draggable/subcomponents/DraggableContainer.tsx +1 -0
  36. data/app/pb_kits/playbook/pb_draggable/subcomponents/DraggableItem.tsx +67 -1
  37. data/app/pb_kits/playbook/pb_draggable/touchDrag.test.js +38 -0
  38. data/app/pb_kits/playbook/pb_draggable/utilities/touchDrag.ts +173 -0
  39. data/app/pb_kits/playbook/pb_form/docs/example.yml +0 -1
  40. data/app/pb_kits/playbook/pb_icon/docs/example.yml +0 -2
  41. data/app/pb_kits/playbook/pb_icon/docs/index.js +0 -1
  42. data/app/pb_kits/playbook/pb_list/_list_item.tsx +4 -1
  43. data/app/pb_kits/playbook/pb_list/item.html.erb +1 -1
  44. data/dist/chunks/{_typeahead-D2TWdJTn.js → _typeahead-DA__Kgp5.js} +1 -1
  45. data/dist/chunks/vendor.js +2 -2
  46. data/dist/menu.yml +67 -127
  47. data/dist/playbook-rails-react-bindings.js +1 -1
  48. data/dist/playbook-rails.js +1 -1
  49. data/dist/playbook.css +1 -1
  50. data/lib/playbook/forms/builder/collection_select_field.rb +1 -1
  51. data/lib/playbook/forms/builder/select_field.rb +1 -1
  52. data/lib/playbook/forms/builder/time_zone_select_field.rb +1 -1
  53. data/lib/playbook/version.rb +1 -1
  54. metadata +9 -8
  55. data/app/pb_kits/playbook/pb_form/docs/_form_form_with_select_examples.html.erb +0 -31
  56. data/app/pb_kits/playbook/pb_icon/docs/_icon_fa_kit.html.erb +0 -1
  57. data/app/pb_kits/playbook/pb_icon/docs/_icon_fa_kit.jsx +0 -21
  58. data/app/pb_kits/playbook/pb_icon/docs/_icon_fa_kit.md +0 -7
@@ -59,12 +59,13 @@ const Currency = (props: CurrencyProps): React.ReactElement => {
59
59
  commaSeparator = false,
60
60
  } = props
61
61
 
62
+ const isAmountEmpty = (value: string | number): boolean => (
63
+ value === '' || value == null || (typeof value === 'string' && value.trim() === '')
64
+ )
65
+
62
66
  // Convert numeric input to string format
63
67
  const convertAmount = (input: string | number): string => {
64
68
  if (typeof input === 'number') {
65
- if (input === 0 && !nullDisplay) {
66
- return ""
67
- }
68
69
  return input.toFixed(2)
69
70
  }
70
71
  return input
@@ -81,7 +82,9 @@ const Currency = (props: CurrencyProps): React.ReactElement => {
81
82
  variantClass = '_bold'
82
83
  }
83
84
 
84
- const [whole, decimal = '00'] = currencyAmount.split('.')
85
+ const [wholePart, decimalPart = '00'] = currencyAmount ? currencyAmount.split('.') : ['', '00']
86
+ const decimal = (decimalPart || '00').padEnd(2, '0').slice(0, 2)
87
+ const whole = currencyAmount === '' ? '' : (wholePart === '' ? '0' : wholePart)
85
88
  const ariaProps = buildAriaProps(aria)
86
89
  const dataProps = buildDataProps(data)
87
90
  const htmlProps = buildHtmlProps(htmlOptions)
@@ -135,7 +138,7 @@ const Currency = (props: CurrencyProps): React.ReactElement => {
135
138
  <Caption dark={dark}>{label}</Caption>
136
139
  <div className={`pb_currency_wrapper${variantClass || emphasizedClass}`}>
137
140
  {unstyled ? (
138
- nullDisplay && !amount ? (
141
+ nullDisplay && isAmountEmpty(amount) ? (
139
142
  <div>{nullDisplay}</div>
140
143
  ) : (
141
144
  <>
@@ -148,7 +151,7 @@ const Currency = (props: CurrencyProps): React.ReactElement => {
148
151
  </>
149
152
  )
150
153
  ) : (
151
- nullDisplay && !amount ? (
154
+ nullDisplay && isAmountEmpty(amount) ? (
152
155
  <Title
153
156
  className="pb_currency_value"
154
157
  dark={dark}
@@ -68,7 +68,7 @@ module Playbook
68
68
  end
69
69
 
70
70
  def title_text
71
- if null_display
71
+ if null_display && amount.blank?
72
72
  null_display
73
73
  elsif swap_negative
74
74
  absolute_amount(abbr_or_format_amount)
@@ -125,14 +125,7 @@ module Playbook
125
125
  # Convert numeric input to string format
126
126
  def convert_amount(input)
127
127
  if input.is_a?(Numeric)
128
- if input.zero? && null_display.nil?
129
- ""
130
- else
131
- format("%.2f", input)
132
- end
133
- # Handle string representations of zero
134
- elsif input.to_s.strip.match?(/^-?0+(\.0+)?$/) && null_display.nil?
135
- ""
128
+ Kernel.format("%.2f", input)
136
129
  else
137
130
  input.to_s
138
131
  end
@@ -142,6 +135,7 @@ module Playbook
142
135
  return "" if currency_amount.blank?
143
136
 
144
137
  value = currency_amount.split(".").first
138
+ value = "0" if value.blank?
145
139
  if comma_separator
146
140
  number_with_delimiter(value.gsub(",", ""))
147
141
  else
@@ -152,7 +146,8 @@ module Playbook
152
146
  def decimal_value
153
147
  return "00" if currency_amount.blank?
154
148
 
155
- currency_amount.split(".")[1] || "00"
149
+ fraction = currency_amount.split(".")[1] || "00"
150
+ fraction.ljust(2, "0")[0, 2]
156
151
  end
157
152
 
158
153
  def units_element
@@ -178,5 +178,48 @@ test('handles numeric amounts correctly', () => {
178
178
  expect(screen.getByTestId('test-numeric-no-symbol')).toHaveTextContent('400.50')
179
179
  expect(screen.getByTestId('test-numeric-medium-size')).toHaveTextContent('$500.55')
180
180
  expect(screen.getByTestId('test-numeric-negative')).toHaveTextContent('-$600.70')
181
- expect(screen.getByTestId('test-numeric-null')).toHaveTextContent('$.00')
181
+ expect(screen.getByTestId('test-numeric-null')).toHaveTextContent('$0.00')
182
+ })
183
+
184
+ test('renders zero values with leading digit', () => {
185
+ render(
186
+ <>
187
+ <Currency
188
+ amount="0"
189
+ data={{ testid: 'test-string-zero' }}
190
+ />
191
+ <Currency
192
+ amount={0}
193
+ data={{ testid: 'test-numeric-zero' }}
194
+ />
195
+ </>
196
+ )
197
+
198
+ expect(screen.getByTestId('test-string-zero')).toHaveTextContent('$0.00')
199
+ expect(screen.getByTestId('test-numeric-zero')).toHaveTextContent('$0.00')
200
+ })
201
+
202
+ test('normalizes string zero with single decimal digit to .00', () => {
203
+ render(
204
+ <Currency
205
+ amount="0.0"
206
+ commaSeparator
207
+ data={{ testid: 'test-string-zero-single-decimal' }}
208
+ />
209
+ )
210
+
211
+ expect(screen.getByTestId('test-string-zero-single-decimal')).toHaveTextContent('$0.00')
212
+ })
213
+
214
+ test('nullDisplay only applies when amount is empty', () => {
215
+ render(
216
+ <Currency
217
+ amount="-.53"
218
+ data={{ testid: 'test-null-display-negative' }}
219
+ nullDisplay="$0.00"
220
+ />
221
+ )
222
+
223
+ expect(screen.getByTestId('test-null-display-negative')).toHaveTextContent('-$')
224
+ expect(screen.getByTestId('test-null-display-negative')).not.toHaveTextContent('$0.00')
182
225
  })
@@ -79,42 +79,57 @@
79
79
 
80
80
  <%= javascript_tag do %>
81
81
  (function() {
82
+ const initQuickPickChangeListener = (input) => {
83
+ if (!<%= object.selection_type == "quickpick" %>) return
84
+ if (input.dataset.quickpickInitialized) return
85
+
86
+ input.dataset.quickpickInitialized = "true"
87
+ input.addEventListener("change", ({ target }) => {
88
+ const startDate = document.getElementById("<%= object.start_date_id %>")
89
+ const endDate = document.getElementById("<%= object.end_date_id %>")
90
+ const splittedValue = target.value.split(" to ")
91
+ startDate.value = splittedValue[0]
92
+ endDate.value = splittedValue[1] ? splittedValue[1] : splittedValue[0]
93
+ })
94
+ }
95
+
82
96
  const loadDatePicker = () => {
83
- const input = document.getElementById("<%= object.picker_id %>");
84
- if (input && !input._flatpickr) {
85
- datePickerHelper(<%= object.date_picker_config %>, "<%= object.scroll_container %>")
97
+ const input = document.getElementById("<%= object.picker_id %>")
98
+
99
+ if (!input || input._flatpickr) return true
100
+ if (typeof window.datePickerHelper !== "function") return false
86
101
 
87
- if (<%= object.selection_type == "quickpick" %>) {
88
- document.getElementById("<%= object.picker_id %>").addEventListener("change", ({ target }) => {
89
- const startDate = document.getElementById("<%= object.start_date_id %>")
90
- const endDate = document.getElementById("<%= object.end_date_id %>")
91
- const splittedValue = target.value.split(" to ")
92
- startDate.value = splittedValue[0]
93
- endDate.value = splittedValue[1] ? splittedValue[1] : splittedValue[0]
94
- })
95
- }
102
+ window.datePickerHelper(<%= object.date_picker_config %>, "<%= object.scroll_container %>")
103
+ initQuickPickChangeListener(input)
104
+
105
+ return true
106
+ }
107
+
108
+ let attempts = 0
109
+ const retryLoad = () => {
110
+ if (loadDatePicker()) return
111
+
112
+ if (attempts++ < 20) {
113
+ setTimeout(retryLoad, 100)
96
114
  }
97
115
  }
98
116
 
99
- // Try to initialize immediately if DOM is ready
100
117
  if (document.readyState === "loading") {
101
- window.addEventListener("DOMContentLoaded", loadDatePicker)
118
+ window.addEventListener("DOMContentLoaded", retryLoad)
102
119
  } else {
103
- loadDatePicker()
120
+ retryLoad()
104
121
  }
105
122
 
106
123
  // For dynamically added content (modals, etc.), check again after a brief delay
107
124
  setTimeout(() => {
108
- const input = document.getElementById("<%= object.picker_id %>");
125
+ const input = document.getElementById("<%= object.picker_id %>")
109
126
  if (input && !input._flatpickr) {
110
- loadDatePicker();
127
+ retryLoad()
111
128
  }
112
- }, 100);
129
+ }, 100)
113
130
 
114
131
  if (<%= !object.custom_event_type.empty? %>) {
115
- window.addEventListener("<%= object.custom_event_type %>", () => {
116
- loadDatePicker()
117
- })
132
+ window.addEventListener("<%= object.custom_event_type %>", retryLoad)
118
133
  }
119
134
  })()
120
135
  <% end %>
@@ -36,10 +36,10 @@ const DatePickerDialogSubmission = () => {
36
36
  <Dialog.Body>
37
37
  <DatePicker
38
38
  defaultDate={dateFixed || undefined}
39
- key={`fixed-${pickerInstance}`}
39
+ key={"fixed-" + pickerInstance}
40
40
  label="Date"
41
41
  onChange={(dateStr) => setDateFixed(dateStr || "")}
42
- pickerId={`datePickerFixed-${pickerInstance}`}
42
+ pickerId={"datePickerFixed-" + pickerInstance}
43
43
  staticPosition={false}
44
44
  />
45
45
  </Dialog.Body>
@@ -8,6 +8,25 @@
8
8
  cursor: grab;
9
9
  }
10
10
 
11
+ &.is_touch_active {
12
+ .pb_draggable_handle,
13
+ .card_draggable_handle {
14
+ color: $text_lt_default;
15
+ }
16
+ }
17
+
18
+ .pb_draggable_handle,
19
+ .card_draggable_handle {
20
+ touch-action: none;
21
+ -webkit-touch-callout: none;
22
+ user-select: none;
23
+ cursor: grab;
24
+
25
+ &:active {
26
+ color: $text_lt_default;
27
+ }
28
+ }
29
+
11
30
  &.is_dragging {
12
31
  position: relative;
13
32
 
@@ -5,3 +5,5 @@ Use the `draggable` kit and manage state as shown.
5
5
  `draggable/draggable_container` kit creates the container within which the cards can be dragged and dropped.
6
6
 
7
7
  The Card kit is optimized to work with the draggable kit. To enable drag, use the `draggable_item` and `drag_id` props on the Card kit as shown. An additional optional boolean prop (set to true by default) of `drag_handle` is also available to show the drag handle icon.
8
+
9
+ On touch devices, drag from the grip handle (no long press). Swiping the card body scrolls as usual. Keep `drag_handle` enabled on mobile. Desktop mouse drag is unchanged.
@@ -6,4 +6,5 @@ Use `DraggableProvider` and manage state as shown.
6
6
 
7
7
  The Card kit is optimized to work with the draggable kit. To enable drag, use the `draggableItem` and `dragId` props on the Card kit as shown. An additional optional boolean prop (set to true by default) of `dragHandle` is also available to show the drag handle icon.
8
8
 
9
+ On touch devices, drag from the grip handle (no long press). Swiping the card body scrolls as usual. Keep `dragHandle` enabled on mobile. Desktop mouse drag is unchanged.
9
10
 
@@ -3,3 +3,5 @@ For a simplified version of the Draggable API for the List kit, you can do the f
3
3
  The List kit is optimized to work with the draggable kit. To enable drag, use the `enable_drag` prop on List kit with an array of the included items AND `drag_id` prop on ListItems. You will also need to include the `items` prop containing your array of listed items for the Draggable API.
4
4
 
5
5
  An additional optional boolean prop (set to true by default) of `drag_handle` is also available on ListItem kit to show the drag handle icon.
6
+
7
+ On touch devices, drag from the grip handle (no long press). Swiping the rest of the row scrolls as usual. Keep `drag_handle` enabled on mobile. Desktop mouse drag is unchanged.
@@ -2,4 +2,6 @@ For a simplified version of the Draggable API for the List kit, you can do the f
2
2
 
3
3
  Use `DraggableProvider` and manage state as shown.
4
4
 
5
- The List kit is optimized to work with the draggable kit. To enable drag, use the `enableDrag` prop on List kit AND `dragId` prop on ListItem. An additional optional boolean prop (set to true by default) of `dragHandle` is also available on List kit to show the drag handle icon.
5
+ The List kit is optimized to work with the draggable kit. To enable drag, use the `enableDrag` prop on List kit AND `dragId` prop on ListItem. An additional optional boolean prop (set to true by default) of `dragHandle` is also available on ListItem to show the drag handle icon.
6
+
7
+ On touch devices, drag from the grip handle (no long press). Swiping the rest of the row scrolls as usual. Keep `dragHandle` enabled on mobile. Desktop mouse drag is unchanged.
@@ -1,3 +1,5 @@
1
1
  For a simplified version of the Draggable API for the SelectableList kit, you can do the following:
2
2
 
3
- The SelectableList kit is optimized to work with the draggable kit. To enable drag, use the `enable_drag` prop on SelectableList kit AND `drag_id` prop within the SelectableList kit prop. An additional optional boolean prop (set to true by default) of `drag_handle` is also available on SelectableList kit to show the drag handle icon.
3
+ The SelectableList kit is optimized to work with the draggable kit. To enable drag, use the `enable_drag` prop on SelectableList kit AND `drag_id` prop within the SelectableList kit prop. An additional optional boolean prop (set to true by default) of `drag_handle` is also available on SelectableList kit to show the drag handle icon.
4
+
5
+ On touch devices, drag from the grip handle (no long press). Swiping the rest of the row scrolls as usual. Keep `drag_handle` enabled on mobile. Desktop mouse drag is unchanged.
@@ -2,4 +2,6 @@ For a simplified version of the Draggable API for the SelectableList kit, you ca
2
2
 
3
3
  Use `DraggableProvider` and manage state as shown.
4
4
 
5
- The SelectableList kit is optimized to work with the draggable kit. To enable drag, use the `enableDrag` prop on SelectableList kit AND `dragId` prop on SelectableList.Item. An additional optional boolean prop (set to true by default) of `dragHandle` is also available on SelectableList kit to show the drag handle icon.
5
+ The SelectableList kit is optimized to work with the draggable kit. To enable drag, use the `enableDrag` prop on SelectableList kit AND `dragId` prop on SelectableList.Item. An additional optional boolean prop (set to true by default) of `dragHandle` is also available on SelectableList kit to show the drag handle icon.
6
+
7
+ On touch devices, drag from the grip handle (no long press). Swiping the rest of the row scrolls as usual. Keep `dragHandle` enabled on mobile. Desktop mouse drag is unchanged.
@@ -211,6 +211,14 @@ test("Attached draggable HTML attributes", () => {
211
211
  expect(item).toHaveAttribute("draggable");
212
212
  });
213
213
 
214
+ test("draggable items expose data-pb-drag-id for touch reordering", () => {
215
+ render(<DefaultDraggableKit />);
216
+ const kit = screen.getByTestId(testId);
217
+
218
+ const item = kit.querySelector(".pb_draggable_item");
219
+ expect(item).toHaveAttribute("data-pb-drag-id", "1");
220
+ });
221
+
214
222
  test("generated dragHandle with List", () => {
215
223
  render(<DraggableKitWithList />);
216
224
  const kit = screen.getByTestId(testId);
@@ -302,6 +310,14 @@ const DraggableMultipleContainers = () => {
302
310
  );
303
311
  };
304
312
 
313
+ test("draggable containers expose data-pb-drag-container when provided", () => {
314
+ render(<DraggableMultipleContainers />);
315
+ const kit = screen.getByTestId(testId);
316
+
317
+ const container = kit.querySelector('[data-testid="container-To Do"]');
318
+ expect(container).toHaveAttribute("data-pb-drag-container", "To Do");
319
+ });
320
+
305
321
  test("renders multiple containers with correct items", () => {
306
322
  render(<DraggableMultipleContainers />);
307
323
 
@@ -1,3 +1,5 @@
1
- <%= pb_content_tag(object.tag) do %>
1
+ <%= pb_content_tag(object.tag, {
2
+ "data-pb-drag-container": object.container.presence
3
+ }) do %>
2
4
  <%= content.presence %>
3
5
  <% end %>
@@ -1,5 +1,6 @@
1
1
  <%= pb_content_tag(object.tag, {
2
2
  id: "item_#{object.drag_id}",
3
+ "data-pb-drag-id": object.drag_id,
3
4
  draggable: true
4
5
  }) do %>
5
6
  <%= content.presence %>
@@ -2,7 +2,32 @@ import PbEnhancedElement from "../pb_enhanced_element";
2
2
 
3
3
  const DRAGGABLE_SELECTOR = "[data-pb-draggable]";
4
4
  const DRAGGABLE_CONTAINER = ".pb_draggable_container";
5
+ const DRAG_HANDLE_SELECTOR = ".pb_draggable_handle, .card_draggable_handle";
5
6
  const NEEDS_CLONE = ["shadow", "outline", "line"]; // clone only for these types
7
+ const DRAG_THRESHOLD_PX = 5;
8
+
9
+ const isTouchDragDevice = () => {
10
+ const hasTouch = "ontouchstart" in window;
11
+ const hasCoarsePointer =
12
+ typeof window.matchMedia === "function" &&
13
+ window.matchMedia("(hover: none) and (pointer: coarse)").matches;
14
+
15
+ return hasTouch || hasCoarsePointer;
16
+ };
17
+
18
+ const getDragIdFromElement = (element) => {
19
+ const item = element?.closest(".pb_draggable_item");
20
+ if (!item) return null;
21
+
22
+ return item.getAttribute("data-pb-drag-id") || item.id;
23
+ };
24
+
25
+ const getContainerFromElement = (element) => {
26
+ const container = element?.closest(DRAGGABLE_CONTAINER);
27
+ if (!container) return null;
28
+
29
+ return container.getAttribute("data-pb-drag-container") || container.id;
30
+ };
6
31
 
7
32
  export default class PbDraggable extends PbEnhancedElement {
8
33
  static get selector() { return DRAGGABLE_SELECTOR; }
@@ -21,6 +46,8 @@ export default class PbDraggable extends PbEnhancedElement {
21
46
  this.hasMultipleContainers = false;
22
47
  this.dragZoneType = "";
23
48
  this.dragZoneColor = "";
49
+ this.useTouchDrag = isTouchDragDevice();
50
+ this.touchCleanups = [];
24
51
 
25
52
  // If DOM is already loaded, bind immediately; otherwise wait for DOMContentLoaded
26
53
  if (document.readyState === "loading") {
@@ -54,9 +81,14 @@ export default class PbDraggable extends PbEnhancedElement {
54
81
 
55
82
  this.element.querySelectorAll(".pb_draggable_item")
56
83
  .forEach(item => {
57
- item.addEventListener("dragstart", this.handleDragStart.bind(this));
58
- item.addEventListener("dragend", this.handleDragEnd.bind(this));
59
- item.addEventListener("dragenter", this.handleDragEnter.bind(this));
84
+ if (this.useTouchDrag) {
85
+ item.setAttribute("draggable", "false");
86
+ this.touchCleanups.push(this.bindTouchDragForItem(item));
87
+ } else {
88
+ item.addEventListener("dragstart", this.handleDragStart.bind(this));
89
+ item.addEventListener("dragend", this.handleDragEnd.bind(this));
90
+ item.addEventListener("dragenter", this.handleDragEnter.bind(this));
91
+ }
60
92
  });
61
93
 
62
94
  containers.forEach(c => {
@@ -65,8 +97,118 @@ export default class PbDraggable extends PbEnhancedElement {
65
97
  });
66
98
  }
67
99
 
100
+ bindTouchDragForItem(item) {
101
+ const handle = item.querySelector(DRAG_HANDLE_SELECTOR);
102
+ const dragTarget = handle || item;
103
+ const state = {
104
+ active: false,
105
+ dragging: false,
106
+ startX: 0,
107
+ startY: 0,
108
+ lastTargetDragId: null,
109
+ };
110
+
111
+ const resetState = () => {
112
+ state.active = false;
113
+ state.dragging = false;
114
+ state.lastTargetDragId = null;
115
+ item.classList.remove("is_touch_active");
116
+ };
117
+
118
+ const pointerEventFromTouch = (touch, target) => ({
119
+ target,
120
+ clientX: touch.clientX,
121
+ clientY: touch.clientY,
122
+ preventDefault: () => undefined,
123
+ stopPropagation: () => undefined,
124
+ });
125
+
126
+ const onTouchStart = (event) => {
127
+ if (handle && !handle.contains(event.target)) return;
128
+
129
+ const touch = event.touches[0];
130
+ if (!touch) return;
131
+
132
+ state.active = true;
133
+ state.startX = touch.clientX;
134
+ state.startY = touch.clientY;
135
+ item.classList.add("is_touch_active");
136
+ };
137
+
138
+ const onTouchMove = (event) => {
139
+ if (!state.active) return;
140
+
141
+ const touch = event.touches[0];
142
+ if (!touch) return;
143
+
144
+ if (!state.dragging) {
145
+ const deltaX = touch.clientX - state.startX;
146
+ const deltaY = touch.clientY - state.startY;
147
+
148
+ if (Math.hypot(deltaX, deltaY) < DRAG_THRESHOLD_PX) return;
149
+
150
+ state.dragging = true;
151
+ this.handleDragStart(pointerEventFromTouch(touch, item));
152
+ }
153
+
154
+ event.preventDefault();
155
+
156
+ const elementBelow = document.elementFromPoint(touch.clientX, touch.clientY);
157
+ const targetItem = elementBelow?.closest(".pb_draggable_item");
158
+ const targetDragId = getDragIdFromElement(elementBelow);
159
+
160
+ if (targetItem && targetItem !== item && targetDragId !== state.lastTargetDragId) {
161
+ state.lastTargetDragId = targetDragId;
162
+ this.handleDragEnter(pointerEventFromTouch(touch, targetItem));
163
+ }
164
+
165
+ const targetContainer = getContainerFromElement(elementBelow);
166
+ if (targetContainer) {
167
+ const containerElement = elementBelow.closest(DRAGGABLE_CONTAINER);
168
+ if (containerElement) {
169
+ this.handleDragOver(pointerEventFromTouch(touch, containerElement));
170
+ }
171
+ }
172
+ };
173
+
174
+ const finishTouchDrag = (touch) => {
175
+ if (!state.active) return;
176
+
177
+ if (state.dragging && touch) {
178
+ const elementBelow = document.elementFromPoint(touch.clientX, touch.clientY);
179
+ const containerElement = elementBelow?.closest(DRAGGABLE_CONTAINER);
180
+
181
+ if (containerElement) {
182
+ this.handleDrop(pointerEventFromTouch(touch, containerElement));
183
+ }
184
+
185
+ this.handleDragEnd({ target: item });
186
+ }
187
+
188
+ resetState();
189
+ };
190
+
191
+ const onTouchEnd = (event) => finishTouchDrag(event.changedTouches[0]);
192
+ const onTouchCancel = (event) => finishTouchDrag(event.changedTouches[0]);
193
+
194
+ dragTarget.addEventListener("touchstart", onTouchStart, { passive: true });
195
+ dragTarget.addEventListener("touchmove", onTouchMove, { passive: false });
196
+ dragTarget.addEventListener("touchend", onTouchEnd, { passive: true });
197
+ dragTarget.addEventListener("touchcancel", onTouchCancel, { passive: true });
198
+
199
+ return () => {
200
+ dragTarget.removeEventListener("touchstart", onTouchStart);
201
+ dragTarget.removeEventListener("touchmove", onTouchMove);
202
+ dragTarget.removeEventListener("touchend", onTouchEnd);
203
+ dragTarget.removeEventListener("touchcancel", onTouchCancel);
204
+ };
205
+ }
206
+
68
207
  /* ---------------- DRAG START ---------------- */
69
208
  handleDragStart(event) {
209
+ const item = event.target.closest(".pb_draggable_item");
210
+ if (!item) return;
211
+
70
212
  // Needed to prevent images within draggable items from being independently draggable
71
213
  // Needed if using Image kit in draggable items
72
214
  if (event.target.tagName.toLowerCase() === "img") {
@@ -74,9 +216,9 @@ export default class PbDraggable extends PbEnhancedElement {
74
216
  return;
75
217
  }
76
218
 
77
- const container = event.target.closest(DRAGGABLE_CONTAINER);
78
- this.draggedItem = event.target;
79
- this.draggedItemId = event.target.id;
219
+ const container = item.closest(DRAGGABLE_CONTAINER);
220
+ this.draggedItem = item;
221
+ this.draggedItemId = getDragIdFromElement(item);
80
222
  this.dragZoneType = this.element.dataset.dropZoneType || "";
81
223
  this.dragZoneColor = this.element.dataset.dropZoneColor || "";
82
224
 
@@ -122,7 +264,7 @@ export default class PbDraggable extends PbEnhancedElement {
122
264
  }
123
265
 
124
266
  if (this.dragZoneType !== "line") {
125
- requestAnimationFrame(() => (event.target.style.opacity = "0.5"));
267
+ requestAnimationFrame(() => (item.style.opacity = "0.5"));
126
268
  }
127
269
  }
128
270
 
@@ -54,6 +54,7 @@ const DraggableContainer = (props: DraggableContainerProps) => {
54
54
  {...dataProps}
55
55
  {...htmlProps}
56
56
  className={classes}
57
+ data-pb-drag-container={container}
57
58
  id={id}
58
59
  key={container}
59
60
  onDragOver={(e: Event) => handleDragOver(e, container)}
@@ -9,6 +9,7 @@ import {
9
9
  import { globalProps } from "../../utilities/globalProps";
10
10
  import { DraggableContext } from "../context";
11
11
  import { noop } from '../../utilities/object'
12
+ import { bindTouchDrag, isTouchDragDevice } from "../utilities/touchDrag";
12
13
 
13
14
  type DraggableItemProps = {
14
15
  aria?: { [key: string]: string };
@@ -54,12 +55,76 @@ const DraggableItem = (props: DraggableItemProps) => {
54
55
  handleDragStart,
55
56
  handleDragEnter,
56
57
  handleDragEnd,
58
+ handleDrop,
59
+ handleDragOver,
57
60
  dropZone = 'ghost',
58
61
  dropZoneColor = 'neutral',
59
62
  direction = 'horizontal'
60
63
  } = DraggableContext();
61
64
 
62
65
  const itemRef = React.useRef<HTMLElement>(null);
66
+ const [useTouchDrag, setUseTouchDrag] = React.useState(false);
67
+ const handlersRef = React.useRef({
68
+ handleDragStart,
69
+ handleDragEnter,
70
+ handleDragEnd,
71
+ handleDrop,
72
+ handleDragOver,
73
+ onDragStart,
74
+ onDragEnter,
75
+ onDragOver,
76
+ onDrop,
77
+ onDragEnd,
78
+ });
79
+
80
+ handlersRef.current = {
81
+ handleDragStart,
82
+ handleDragEnter,
83
+ handleDragEnd,
84
+ handleDrop,
85
+ handleDragOver,
86
+ onDragStart,
87
+ onDragEnter,
88
+ onDragOver,
89
+ onDrop,
90
+ onDragEnd,
91
+ };
92
+
93
+ React.useEffect(() => {
94
+ setUseTouchDrag(isTouchDragDevice());
95
+ }, []);
96
+
97
+ React.useEffect(() => {
98
+ if (!useTouchDrag || !itemRef.current || !dragId) return;
99
+
100
+ return bindTouchDrag({
101
+ dragId,
102
+ container,
103
+ itemElement: itemRef.current,
104
+ handlers: {
105
+ onDragStart: (id, itemContainer) => {
106
+ handlersRef.current.handleDragStart(id, itemContainer);
107
+ handlersRef.current.onDragStart();
108
+ },
109
+ onDragEnter: (targetDragId, targetContainer) => {
110
+ handlersRef.current.handleDragEnter(targetDragId, targetContainer);
111
+ handlersRef.current.onDragEnter();
112
+ },
113
+ onDragOver: (event, targetContainer) => {
114
+ handlersRef.current.handleDragOver(event, targetContainer);
115
+ handlersRef.current.onDragOver();
116
+ },
117
+ onDrop: (dropContainer) => {
118
+ handlersRef.current.handleDrop(dropContainer);
119
+ handlersRef.current.onDrop();
120
+ },
121
+ onDragEnd: () => {
122
+ handlersRef.current.handleDragEnd();
123
+ handlersRef.current.onDragEnd();
124
+ },
125
+ },
126
+ });
127
+ }, [useTouchDrag, dragId, container]);
63
128
 
64
129
  const ariaProps = buildAriaProps(aria);
65
130
  const dataProps = buildDataProps(data);
@@ -122,7 +187,8 @@ const DraggableItem = (props: DraggableItemProps) => {
122
187
  {...dataProps}
123
188
  {...htmlProps}
124
189
  className={classes}
125
- draggable
190
+ data-pb-drag-id={dragId}
191
+ draggable={!useTouchDrag}
126
192
  id={id}
127
193
  key={dragId}
128
194
  onDrag={onDrag}