@instructure/ui-color-picker 10.26.2 → 10.26.3

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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,17 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [10.26.3](https://github.com/instructure/instructure-ui/compare/v10.26.2...v10.26.3) (2025-11-19)
7
+
8
+
9
+ ### Bug Fixes
10
+
11
+ * **ui-color-picker:** fix popover scrolling and button alignment issues ([b7fbf0b](https://github.com/instructure/instructure-ui/commit/b7fbf0b807ddf480ab0f5fab35bf9a38666f8dd0))
12
+
13
+
14
+
15
+
16
+
6
17
  ## [10.26.2](https://github.com/instructure/instructure-ui/compare/v10.26.1...v10.26.2) (2025-10-13)
7
18
 
8
19
  **Note:** Version bump only for package @instructure/ui-color-picker
@@ -64,7 +64,16 @@ let ColorPicker = (_dec = withStyle(generateStyle, generateComponentTheme), _dec
64
64
  this.inputContainerRef = null;
65
65
  this.handleInputContainerRef = el => {
66
66
  this.inputContainerRef = el;
67
- this.setLabelHeight();
67
+ if (el) {
68
+ // Defer measurement until after layout is complete and CSS-in-JS styles are applied
69
+ requestAnimationFrame(() => {
70
+ this.setLabelHeight();
71
+ });
72
+ }
73
+ };
74
+ this.popoverContentRef = null;
75
+ this.handlePopoverContentRef = el => {
76
+ this.popoverContentRef = el;
68
77
  };
69
78
  this.setLabelHeight = () => {
70
79
  if (this.inputContainerRef) {
@@ -74,6 +83,56 @@ let ColorPicker = (_dec = withStyle(generateStyle, generateComponentTheme), _dec
74
83
  });
75
84
  }
76
85
  };
86
+ // Calculate the maximum height the popover can have without extending beyond
87
+ // the viewport. This enables scrolling when the ColorPicker's content (all
88
+ // color mixing controls, presets, and contrast checker) would otherwise exceed
89
+ // the available viewport space. Without this calculation, the popover would
90
+ // render off-screen on smaller viewports.
91
+ this.handlePopoverPositioned = position => {
92
+ if (this.popoverContentRef) {
93
+ // Double requestAnimationFrame ensures measurements happen after all child components
94
+ // (ColorMixer, ColorPreset, ColorContrast) complete their mount lifecycle and Emotion
95
+ // finishes injecting CSS-in-JS styles. A single rAF was insufficient as styles are
96
+ // injected dynamically in componentDidMount(). This timing issue only manifested when
97
+ // StrictMode was disabled, since StrictMode's double-rendering provided an accidental
98
+ // second measurement pass.
99
+ requestAnimationFrame(() => {
100
+ // First frame: DOM structure is laid out
101
+ requestAnimationFrame(() => {
102
+ // Second frame: styles injected, child components mounted, dimensions stable
103
+ if (!this.popoverContentRef) return;
104
+ const rect = this.popoverContentRef.getBoundingClientRect();
105
+ const viewportHeight = window.innerHeight;
106
+
107
+ // Detect if popover is positioned above (top) or below (bottom) the trigger.
108
+ // The Position component provides placement strings like "top center" or "bottom center".
109
+ const placement = (position === null || position === void 0 ? void 0 : position.placement) || '';
110
+ const isPositionedAbove = placement.startsWith('top');
111
+ let availableHeight;
112
+ if (isPositionedAbove) {
113
+ // When opening upward: available space is from viewport top to popover bottom.
114
+ // This is the space where the popover can expand within the viewport.
115
+ availableHeight = rect.top + rect.height - 16;
116
+ } else {
117
+ // When opening downward: available space is from popover top to viewport bottom.
118
+ // Subtract a small buffer (16px) for padding/margin.
119
+ availableHeight = viewportHeight - rect.top - 16;
120
+ }
121
+ const propMaxHeight = this.props.popoverMaxHeight;
122
+ let calculatedMaxHeight = `${Math.max(100, availableHeight)}px`;
123
+
124
+ // If prop specifies a maxHeight, respect it as an additional constraint
125
+ if (propMaxHeight && propMaxHeight !== '100vh') {
126
+ calculatedMaxHeight = propMaxHeight;
127
+ }
128
+ this.setState({
129
+ calculatedPopoverMaxHeight: calculatedMaxHeight,
130
+ isHeightCalculated: true
131
+ });
132
+ });
133
+ });
134
+ }
135
+ };
77
136
  this.checkSettings = () => {
78
137
  if (this.props.children && this.props.colorMixerSettings) {
79
138
  warn(false, 'You should either use children, colorMixerSettings or neither, not both. In this case, the colorMixerSettings will be ignored.', '');
@@ -96,7 +155,9 @@ let ColorPicker = (_dec = withStyle(generateStyle, generateComponentTheme), _dec
96
155
  onShowContent: () => {
97
156
  this.setState({
98
157
  openColorPicker: true,
99
- mixedColor: this.state.hexCode
158
+ mixedColor: this.state.hexCode,
159
+ calculatedPopoverMaxHeight: void 0,
160
+ isHeightCalculated: false
100
161
  });
101
162
  },
102
163
  onHideContent: () => {
@@ -110,8 +171,11 @@ let ColorPicker = (_dec = withStyle(generateStyle, generateComponentTheme), _dec
110
171
  shouldReturnFocus: true,
111
172
  shouldCloseOnDocumentClick: true,
112
173
  offsetY: "10rem",
174
+ onPositioned: this.handlePopoverPositioned,
175
+ onPositionChanged: this.handlePopoverPositioned,
113
176
  children: _jsx("div", {
114
177
  css: (_this$props$styles = this.props.styles) === null || _this$props$styles === void 0 ? void 0 : _this$props$styles.popoverContentContainer,
178
+ ref: this.handlePopoverContentRef,
115
179
  children: this.isDefaultPopover ? this.renderDefaultPopoverContent() : this.renderCustomPopoverContent()
116
180
  })
117
181
  });
@@ -133,7 +197,9 @@ let ColorPicker = (_dec = withStyle(generateStyle, generateComponentTheme), _dec
133
197
  onChange === null || onChange === void 0 ? void 0 : onChange(`#${this.mixedColorWithStrippedAlpha}`);
134
198
  }, () => this.setState({
135
199
  openColorPicker: false,
136
- mixedColor: this.state.hexCode
200
+ mixedColor: this.state.hexCode,
201
+ calculatedPopoverMaxHeight: void 0,
202
+ isHeightCalculated: false
137
203
  }))
138
204
  });
139
205
  };
@@ -186,7 +252,9 @@ let ColorPicker = (_dec = withStyle(generateStyle, generateComponentTheme), _dec
186
252
  children: [_jsx(Button, {
187
253
  onClick: () => this.setState({
188
254
  openColorPicker: false,
189
- mixedColor: this.state.hexCode
255
+ mixedColor: this.state.hexCode,
256
+ calculatedPopoverMaxHeight: void 0,
257
+ isHeightCalculated: false
190
258
  }),
191
259
  color: "secondary",
192
260
  margin: "xx-small",
@@ -217,7 +285,9 @@ let ColorPicker = (_dec = withStyle(generateStyle, generateComponentTheme), _dec
217
285
  showHelperErrorMessages: false,
218
286
  openColorPicker: false,
219
287
  mixedColor: '',
220
- labelHeight: 0
288
+ labelHeight: 0,
289
+ calculatedPopoverMaxHeight: void 0,
290
+ isHeightCalculated: false
221
291
  };
222
292
  }
223
293
  componentDidMount() {
@@ -467,6 +537,7 @@ let ColorPicker = (_dec = withStyle(generateStyle, generateComponentTheme), _dec
467
537
  }), !this.isSimple && _jsx("div", {
468
538
  css: (_this$props$styles7 = this.props.styles) === null || _this$props$styles7 === void 0 ? void 0 : _this$props$styles7.colorMixerButtonContainer,
469
539
  style: {
540
+ alignSelf: this.state.labelHeight > 0 ? 'flex-start' : 'flex-end',
470
541
  paddingTop: this.state.labelHeight
471
542
  },
472
543
  children: _jsx("div", {
@@ -42,7 +42,8 @@ const generateStyle = (componentTheme, props, state) => {
42
42
  const checkContrast = props.checkContrast,
43
43
  popoverMaxHeight = props.popoverMaxHeight,
44
44
  margin = props.margin;
45
- const isSimple = state.isSimple;
45
+ const isSimple = state.isSimple,
46
+ calculatedPopoverMaxHeight = state.calculatedPopoverMaxHeight;
46
47
  const cssMargin = mapSpacingToShorthand(margin, spacing);
47
48
  return {
48
49
  colorPicker: {
@@ -112,8 +113,14 @@ const generateStyle = (componentTheme, props, state) => {
112
113
  },
113
114
  popoverContentContainer: {
114
115
  label: 'colorPicker__popoverContentContainer',
115
- maxHeight: popoverMaxHeight || '100vh',
116
- overflow: 'auto'
116
+ maxHeight: calculatedPopoverMaxHeight || popoverMaxHeight || '100vh',
117
+ overflowY: 'auto',
118
+ overflowX: 'hidden',
119
+ scrollbarGutter: 'stable',
120
+ display: 'flex',
121
+ flexDirection: 'column',
122
+ opacity: state.isHeightCalculated ? 1 : 0,
123
+ transition: 'opacity 150ms ease-in'
117
124
  },
118
125
  colorMixerButtonWrapper: {
119
126
  label: 'colorPicker__colorMixerButtonWrapper',
@@ -75,7 +75,16 @@ let ColorPicker = exports.ColorPicker = (_dec = (0, _emotion.withStyle)(_styles.
75
75
  this.inputContainerRef = null;
76
76
  this.handleInputContainerRef = el => {
77
77
  this.inputContainerRef = el;
78
- this.setLabelHeight();
78
+ if (el) {
79
+ // Defer measurement until after layout is complete and CSS-in-JS styles are applied
80
+ requestAnimationFrame(() => {
81
+ this.setLabelHeight();
82
+ });
83
+ }
84
+ };
85
+ this.popoverContentRef = null;
86
+ this.handlePopoverContentRef = el => {
87
+ this.popoverContentRef = el;
79
88
  };
80
89
  this.setLabelHeight = () => {
81
90
  if (this.inputContainerRef) {
@@ -85,6 +94,56 @@ let ColorPicker = exports.ColorPicker = (_dec = (0, _emotion.withStyle)(_styles.
85
94
  });
86
95
  }
87
96
  };
97
+ // Calculate the maximum height the popover can have without extending beyond
98
+ // the viewport. This enables scrolling when the ColorPicker's content (all
99
+ // color mixing controls, presets, and contrast checker) would otherwise exceed
100
+ // the available viewport space. Without this calculation, the popover would
101
+ // render off-screen on smaller viewports.
102
+ this.handlePopoverPositioned = position => {
103
+ if (this.popoverContentRef) {
104
+ // Double requestAnimationFrame ensures measurements happen after all child components
105
+ // (ColorMixer, ColorPreset, ColorContrast) complete their mount lifecycle and Emotion
106
+ // finishes injecting CSS-in-JS styles. A single rAF was insufficient as styles are
107
+ // injected dynamically in componentDidMount(). This timing issue only manifested when
108
+ // StrictMode was disabled, since StrictMode's double-rendering provided an accidental
109
+ // second measurement pass.
110
+ requestAnimationFrame(() => {
111
+ // First frame: DOM structure is laid out
112
+ requestAnimationFrame(() => {
113
+ // Second frame: styles injected, child components mounted, dimensions stable
114
+ if (!this.popoverContentRef) return;
115
+ const rect = this.popoverContentRef.getBoundingClientRect();
116
+ const viewportHeight = window.innerHeight;
117
+
118
+ // Detect if popover is positioned above (top) or below (bottom) the trigger.
119
+ // The Position component provides placement strings like "top center" or "bottom center".
120
+ const placement = (position === null || position === void 0 ? void 0 : position.placement) || '';
121
+ const isPositionedAbove = placement.startsWith('top');
122
+ let availableHeight;
123
+ if (isPositionedAbove) {
124
+ // When opening upward: available space is from viewport top to popover bottom.
125
+ // This is the space where the popover can expand within the viewport.
126
+ availableHeight = rect.top + rect.height - 16;
127
+ } else {
128
+ // When opening downward: available space is from popover top to viewport bottom.
129
+ // Subtract a small buffer (16px) for padding/margin.
130
+ availableHeight = viewportHeight - rect.top - 16;
131
+ }
132
+ const propMaxHeight = this.props.popoverMaxHeight;
133
+ let calculatedMaxHeight = `${Math.max(100, availableHeight)}px`;
134
+
135
+ // If prop specifies a maxHeight, respect it as an additional constraint
136
+ if (propMaxHeight && propMaxHeight !== '100vh') {
137
+ calculatedMaxHeight = propMaxHeight;
138
+ }
139
+ this.setState({
140
+ calculatedPopoverMaxHeight: calculatedMaxHeight,
141
+ isHeightCalculated: true
142
+ });
143
+ });
144
+ });
145
+ }
146
+ };
88
147
  this.checkSettings = () => {
89
148
  if (this.props.children && this.props.colorMixerSettings) {
90
149
  (0, _console.warn)(false, 'You should either use children, colorMixerSettings or neither, not both. In this case, the colorMixerSettings will be ignored.', '');
@@ -107,7 +166,9 @@ let ColorPicker = exports.ColorPicker = (_dec = (0, _emotion.withStyle)(_styles.
107
166
  onShowContent: () => {
108
167
  this.setState({
109
168
  openColorPicker: true,
110
- mixedColor: this.state.hexCode
169
+ mixedColor: this.state.hexCode,
170
+ calculatedPopoverMaxHeight: void 0,
171
+ isHeightCalculated: false
111
172
  });
112
173
  },
113
174
  onHideContent: () => {
@@ -121,8 +182,11 @@ let ColorPicker = exports.ColorPicker = (_dec = (0, _emotion.withStyle)(_styles.
121
182
  shouldReturnFocus: true,
122
183
  shouldCloseOnDocumentClick: true,
123
184
  offsetY: "10rem",
185
+ onPositioned: this.handlePopoverPositioned,
186
+ onPositionChanged: this.handlePopoverPositioned,
124
187
  children: (0, _jsxRuntime.jsx)("div", {
125
188
  css: (_this$props$styles = this.props.styles) === null || _this$props$styles === void 0 ? void 0 : _this$props$styles.popoverContentContainer,
189
+ ref: this.handlePopoverContentRef,
126
190
  children: this.isDefaultPopover ? this.renderDefaultPopoverContent() : this.renderCustomPopoverContent()
127
191
  })
128
192
  });
@@ -144,7 +208,9 @@ let ColorPicker = exports.ColorPicker = (_dec = (0, _emotion.withStyle)(_styles.
144
208
  onChange === null || onChange === void 0 ? void 0 : onChange(`#${this.mixedColorWithStrippedAlpha}`);
145
209
  }, () => this.setState({
146
210
  openColorPicker: false,
147
- mixedColor: this.state.hexCode
211
+ mixedColor: this.state.hexCode,
212
+ calculatedPopoverMaxHeight: void 0,
213
+ isHeightCalculated: false
148
214
  }))
149
215
  });
150
216
  };
@@ -197,7 +263,9 @@ let ColorPicker = exports.ColorPicker = (_dec = (0, _emotion.withStyle)(_styles.
197
263
  children: [(0, _jsxRuntime.jsx)(_Button.Button, {
198
264
  onClick: () => this.setState({
199
265
  openColorPicker: false,
200
- mixedColor: this.state.hexCode
266
+ mixedColor: this.state.hexCode,
267
+ calculatedPopoverMaxHeight: void 0,
268
+ isHeightCalculated: false
201
269
  }),
202
270
  color: "secondary",
203
271
  margin: "xx-small",
@@ -228,7 +296,9 @@ let ColorPicker = exports.ColorPicker = (_dec = (0, _emotion.withStyle)(_styles.
228
296
  showHelperErrorMessages: false,
229
297
  openColorPicker: false,
230
298
  mixedColor: '',
231
- labelHeight: 0
299
+ labelHeight: 0,
300
+ calculatedPopoverMaxHeight: void 0,
301
+ isHeightCalculated: false
232
302
  };
233
303
  }
234
304
  componentDidMount() {
@@ -478,6 +548,7 @@ let ColorPicker = exports.ColorPicker = (_dec = (0, _emotion.withStyle)(_styles.
478
548
  }), !this.isSimple && (0, _jsxRuntime.jsx)("div", {
479
549
  css: (_this$props$styles7 = this.props.styles) === null || _this$props$styles7 === void 0 ? void 0 : _this$props$styles7.colorMixerButtonContainer,
480
550
  style: {
551
+ alignSelf: this.state.labelHeight > 0 ? 'flex-start' : 'flex-end',
481
552
  paddingTop: this.state.labelHeight
482
553
  },
483
554
  children: (0, _jsxRuntime.jsx)("div", {
@@ -48,7 +48,8 @@ const generateStyle = (componentTheme, props, state) => {
48
48
  const checkContrast = props.checkContrast,
49
49
  popoverMaxHeight = props.popoverMaxHeight,
50
50
  margin = props.margin;
51
- const isSimple = state.isSimple;
51
+ const isSimple = state.isSimple,
52
+ calculatedPopoverMaxHeight = state.calculatedPopoverMaxHeight;
52
53
  const cssMargin = (0, _emotion.mapSpacingToShorthand)(margin, spacing);
53
54
  return {
54
55
  colorPicker: {
@@ -118,8 +119,14 @@ const generateStyle = (componentTheme, props, state) => {
118
119
  },
119
120
  popoverContentContainer: {
120
121
  label: 'colorPicker__popoverContentContainer',
121
- maxHeight: popoverMaxHeight || '100vh',
122
- overflow: 'auto'
122
+ maxHeight: calculatedPopoverMaxHeight || popoverMaxHeight || '100vh',
123
+ overflowY: 'auto',
124
+ overflowX: 'hidden',
125
+ scrollbarGutter: 'stable',
126
+ display: 'flex',
127
+ flexDirection: 'column',
128
+ opacity: state.isHeightCalculated ? 1 : 0,
129
+ transition: 'opacity 150ms ease-in'
123
130
  },
124
131
  colorMixerButtonWrapper: {
125
132
  label: 'colorPicker__colorMixerButtonWrapper',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@instructure/ui-color-picker",
3
- "version": "10.26.2",
3
+ "version": "10.26.3",
4
4
  "description": "A UI component library made by Instructure Inc.",
5
5
  "author": "Instructure, Inc. Engineering and Product Design",
6
6
  "module": "./es/index.js",
@@ -24,32 +24,32 @@
24
24
  "license": "MIT",
25
25
  "dependencies": {
26
26
  "@babel/runtime": "^7.27.6",
27
- "@instructure/console": "10.26.2",
28
- "@instructure/emotion": "10.26.2",
29
- "@instructure/shared-types": "10.26.2",
30
- "@instructure/ui-a11y-content": "10.26.2",
31
- "@instructure/ui-buttons": "10.26.2",
32
- "@instructure/ui-color-utils": "10.26.2",
33
- "@instructure/ui-dom-utils": "10.26.2",
34
- "@instructure/ui-drilldown": "10.26.2",
35
- "@instructure/ui-form-field": "10.26.2",
36
- "@instructure/ui-icons": "10.26.2",
37
- "@instructure/ui-pill": "10.26.2",
38
- "@instructure/ui-popover": "10.26.2",
39
- "@instructure/ui-react-utils": "10.26.2",
40
- "@instructure/ui-testable": "10.26.2",
41
- "@instructure/ui-text": "10.26.2",
42
- "@instructure/ui-text-input": "10.26.2",
43
- "@instructure/ui-themes": "10.26.2",
44
- "@instructure/ui-tooltip": "10.26.2",
45
- "@instructure/ui-utils": "10.26.2",
46
- "@instructure/ui-view": "10.26.2",
27
+ "@instructure/console": "10.26.3",
28
+ "@instructure/emotion": "10.26.3",
29
+ "@instructure/shared-types": "10.26.3",
30
+ "@instructure/ui-a11y-content": "10.26.3",
31
+ "@instructure/ui-buttons": "10.26.3",
32
+ "@instructure/ui-color-utils": "10.26.3",
33
+ "@instructure/ui-dom-utils": "10.26.3",
34
+ "@instructure/ui-drilldown": "10.26.3",
35
+ "@instructure/ui-form-field": "10.26.3",
36
+ "@instructure/ui-icons": "10.26.3",
37
+ "@instructure/ui-pill": "10.26.3",
38
+ "@instructure/ui-popover": "10.26.3",
39
+ "@instructure/ui-react-utils": "10.26.3",
40
+ "@instructure/ui-testable": "10.26.3",
41
+ "@instructure/ui-text": "10.26.3",
42
+ "@instructure/ui-text-input": "10.26.3",
43
+ "@instructure/ui-themes": "10.26.3",
44
+ "@instructure/ui-tooltip": "10.26.3",
45
+ "@instructure/ui-utils": "10.26.3",
46
+ "@instructure/ui-view": "10.26.3",
47
47
  "prop-types": "^15.8.1"
48
48
  },
49
49
  "devDependencies": {
50
- "@instructure/ui-axe-check": "10.26.2",
51
- "@instructure/ui-babel-preset": "10.26.2",
52
- "@instructure/ui-scripts": "10.26.2",
50
+ "@instructure/ui-axe-check": "10.26.3",
51
+ "@instructure/ui-babel-preset": "10.26.3",
52
+ "@instructure/ui-scripts": "10.26.3",
53
53
  "@testing-library/jest-dom": "^6.6.3",
54
54
  "@testing-library/react": "^16.0.1",
55
55
  "@testing-library/user-event": "^14.6.1",
@@ -110,7 +110,9 @@ class ColorPicker extends Component<ColorPickerProps, ColorPickerState> {
110
110
  showHelperErrorMessages: false,
111
111
  openColorPicker: false,
112
112
  mixedColor: '',
113
- labelHeight: 0
113
+ labelHeight: 0,
114
+ calculatedPopoverMaxHeight: undefined,
115
+ isHeightCalculated: false
114
116
  }
115
117
  }
116
118
 
@@ -130,7 +132,19 @@ class ColorPicker extends Component<ColorPickerProps, ColorPickerState> {
130
132
 
131
133
  handleInputContainerRef = (el: Element | null) => {
132
134
  this.inputContainerRef = el
133
- this.setLabelHeight()
135
+
136
+ if (el) {
137
+ // Defer measurement until after layout is complete and CSS-in-JS styles are applied
138
+ requestAnimationFrame(() => {
139
+ this.setLabelHeight()
140
+ })
141
+ }
142
+ }
143
+
144
+ popoverContentRef: HTMLDivElement | null = null
145
+
146
+ handlePopoverContentRef = (el: HTMLDivElement | null) => {
147
+ this.popoverContentRef = el
134
148
  }
135
149
 
136
150
  setLabelHeight = () => {
@@ -143,6 +157,62 @@ class ColorPicker extends Component<ColorPickerProps, ColorPickerState> {
143
157
  }
144
158
  }
145
159
 
160
+ // Calculate the maximum height the popover can have without extending beyond
161
+ // the viewport. This enables scrolling when the ColorPicker's content (all
162
+ // color mixing controls, presets, and contrast checker) would otherwise exceed
163
+ // the available viewport space. Without this calculation, the popover would
164
+ // render off-screen on smaller viewports.
165
+ handlePopoverPositioned = (position?: { placement?: string }) => {
166
+ if (this.popoverContentRef) {
167
+ // Double requestAnimationFrame ensures measurements happen after all child components
168
+ // (ColorMixer, ColorPreset, ColorContrast) complete their mount lifecycle and Emotion
169
+ // finishes injecting CSS-in-JS styles. A single rAF was insufficient as styles are
170
+ // injected dynamically in componentDidMount(). This timing issue only manifested when
171
+ // StrictMode was disabled, since StrictMode's double-rendering provided an accidental
172
+ // second measurement pass.
173
+ requestAnimationFrame(() => {
174
+ // First frame: DOM structure is laid out
175
+ requestAnimationFrame(() => {
176
+ // Second frame: styles injected, child components mounted, dimensions stable
177
+ if (!this.popoverContentRef) return
178
+
179
+ const rect = this.popoverContentRef.getBoundingClientRect()
180
+ const viewportHeight = window.innerHeight
181
+
182
+ // Detect if popover is positioned above (top) or below (bottom) the trigger.
183
+ // The Position component provides placement strings like "top center" or "bottom center".
184
+ const placement = position?.placement || ''
185
+ const isPositionedAbove = placement.startsWith('top')
186
+
187
+ let availableHeight: number
188
+
189
+ if (isPositionedAbove) {
190
+ // When opening upward: available space is from viewport top to popover bottom.
191
+ // This is the space where the popover can expand within the viewport.
192
+ availableHeight = rect.top + rect.height - 16
193
+ } else {
194
+ // When opening downward: available space is from popover top to viewport bottom.
195
+ // Subtract a small buffer (16px) for padding/margin.
196
+ availableHeight = viewportHeight - rect.top - 16
197
+ }
198
+
199
+ const propMaxHeight = this.props.popoverMaxHeight
200
+ let calculatedMaxHeight = `${Math.max(100, availableHeight)}px`
201
+
202
+ // If prop specifies a maxHeight, respect it as an additional constraint
203
+ if (propMaxHeight && propMaxHeight !== '100vh') {
204
+ calculatedMaxHeight = propMaxHeight
205
+ }
206
+
207
+ this.setState({
208
+ calculatedPopoverMaxHeight: calculatedMaxHeight,
209
+ isHeightCalculated: true
210
+ })
211
+ })
212
+ })
213
+ }
214
+ }
215
+
146
216
  componentDidMount() {
147
217
  this.props.makeStyles?.({ ...this.state, isSimple: this.isSimple })
148
218
  this.checkSettings()
@@ -427,7 +497,12 @@ class ColorPicker extends Component<ColorPickerProps, ColorPickerState> {
427
497
  }
428
498
  isShowingContent={this.state.openColorPicker}
429
499
  onShowContent={() => {
430
- this.setState({ openColorPicker: true, mixedColor: this.state.hexCode })
500
+ this.setState({
501
+ openColorPicker: true,
502
+ mixedColor: this.state.hexCode,
503
+ calculatedPopoverMaxHeight: undefined,
504
+ isHeightCalculated: false
505
+ })
431
506
  }}
432
507
  onHideContent={() => {
433
508
  this.setState({ openColorPicker: false })
@@ -438,8 +513,13 @@ class ColorPicker extends Component<ColorPickerProps, ColorPickerState> {
438
513
  shouldReturnFocus
439
514
  shouldCloseOnDocumentClick
440
515
  offsetY="10rem"
516
+ onPositioned={this.handlePopoverPositioned}
517
+ onPositionChanged={this.handlePopoverPositioned}
441
518
  >
442
- <div css={this.props.styles?.popoverContentContainer}>
519
+ <div
520
+ css={this.props.styles?.popoverContentContainer}
521
+ ref={this.handlePopoverContentRef}
522
+ >
443
523
  {this.isDefaultPopover
444
524
  ? this.renderDefaultPopoverContent()
445
525
  : this.renderCustomPopoverContent()}
@@ -470,7 +550,9 @@ class ColorPicker extends Component<ColorPickerProps, ColorPickerState> {
470
550
  () =>
471
551
  this.setState({
472
552
  openColorPicker: false,
473
- mixedColor: this.state.hexCode
553
+ mixedColor: this.state.hexCode,
554
+ calculatedPopoverMaxHeight: undefined,
555
+ isHeightCalculated: false
474
556
  })
475
557
  )}
476
558
  </div>
@@ -576,7 +658,9 @@ class ColorPicker extends Component<ColorPickerProps, ColorPickerState> {
576
658
  onClick={() =>
577
659
  this.setState({
578
660
  openColorPicker: false,
579
- mixedColor: this.state.hexCode
661
+ mixedColor: this.state.hexCode,
662
+ calculatedPopoverMaxHeight: undefined,
663
+ isHeightCalculated: false
580
664
  })
581
665
  }
582
666
  color="secondary"
@@ -639,7 +723,10 @@ class ColorPicker extends Component<ColorPickerProps, ColorPickerState> {
639
723
  {!this.isSimple && (
640
724
  <div
641
725
  css={this.props.styles?.colorMixerButtonContainer}
642
- style={{ paddingTop: this.state.labelHeight }}
726
+ style={{
727
+ alignSelf: this.state.labelHeight > 0 ? 'flex-start' : 'flex-end',
728
+ paddingTop: this.state.labelHeight
729
+ }}
643
730
  >
644
731
  <div css={this.props.styles?.colorMixerButtonWrapper}>
645
732
  {this.renderPopover()}
@@ -247,6 +247,8 @@ type ColorPickerState = {
247
247
  openColorPicker: boolean
248
248
  mixedColor: string
249
249
  labelHeight: number
250
+ calculatedPopoverMaxHeight: string | undefined
251
+ isHeightCalculated: boolean
250
252
  }
251
253
 
252
254
  type PropKeys = keyof ColorPickerOwnProps
@@ -54,7 +54,7 @@ const generateStyle = (
54
54
  spacing
55
55
  } = componentTheme
56
56
  const { checkContrast, popoverMaxHeight, margin } = props
57
- const { isSimple } = state
57
+ const { isSimple, calculatedPopoverMaxHeight } = state
58
58
 
59
59
  const cssMargin = mapSpacingToShorthand(margin, spacing)
60
60
  return {
@@ -127,8 +127,14 @@ const generateStyle = (
127
127
  },
128
128
  popoverContentContainer: {
129
129
  label: 'colorPicker__popoverContentContainer',
130
- maxHeight: popoverMaxHeight || '100vh',
131
- overflow: 'auto'
130
+ maxHeight: calculatedPopoverMaxHeight || popoverMaxHeight || '100vh',
131
+ overflowY: 'auto',
132
+ overflowX: 'hidden',
133
+ scrollbarGutter: 'stable',
134
+ display: 'flex',
135
+ flexDirection: 'column',
136
+ opacity: state.isHeightCalculated ? 1 : 0,
137
+ transition: 'opacity 150ms ease-in'
132
138
  },
133
139
  colorMixerButtonWrapper: {
134
140
  label: 'colorPicker__colorMixerButtonWrapper',