@instructure/ui-color-picker 11.2.0 → 11.2.1-snapshot-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,18 @@
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
+ ## [11.2.1-snapshot-3](https://github.com/instructure/instructure-ui/compare/v11.2.0...v11.2.1-snapshot-3) (2025-11-19)
7
+
8
+
9
+ ### Bug Fixes
10
+
11
+ * **ui-color-picker:** fix mixer button alignment and visual jump ([68c3e60](https://github.com/instructure/instructure-ui/commit/68c3e60acc5e1f410ed79a623a35fa3eaf7107f5))
12
+ * **ui-color-picker:** fix popover scrolling when content exceeds viewport ([66f2b18](https://github.com/instructure/instructure-ui/commit/66f2b18af0dead1f62ee61629262c39c6273dad0))
13
+
14
+
15
+
16
+
17
+
6
18
  # [11.2.0](https://github.com/instructure/instructure-ui/compare/v11.0.1...v11.2.0) (2025-11-06)
7
19
 
8
20
 
@@ -63,7 +63,16 @@ let ColorPicker = (_dec = withStyle(generateStyle, generateComponentTheme), _dec
63
63
  this.inputContainerRef = null;
64
64
  this.handleInputContainerRef = el => {
65
65
  this.inputContainerRef = el;
66
- this.setLabelHeight();
66
+ if (el) {
67
+ // Defer measurement until after layout is complete and CSS-in-JS styles are applied
68
+ requestAnimationFrame(() => {
69
+ this.setLabelHeight();
70
+ });
71
+ }
72
+ };
73
+ this.popoverContentRef = null;
74
+ this.handlePopoverContentRef = el => {
75
+ this.popoverContentRef = el;
67
76
  };
68
77
  this.setLabelHeight = () => {
69
78
  if (this.inputContainerRef) {
@@ -73,6 +82,56 @@ let ColorPicker = (_dec = withStyle(generateStyle, generateComponentTheme), _dec
73
82
  });
74
83
  }
75
84
  };
85
+ // Calculate the maximum height the popover can have without extending beyond
86
+ // the viewport. This enables scrolling when the ColorPicker's content (all
87
+ // color mixing controls, presets, and contrast checker) would otherwise exceed
88
+ // the available viewport space. Without this calculation, the popover would
89
+ // render off-screen on smaller viewports.
90
+ this.handlePopoverPositioned = position => {
91
+ if (this.popoverContentRef) {
92
+ // Double requestAnimationFrame ensures measurements happen after all child components
93
+ // (ColorMixer, ColorPreset, ColorContrast) complete their mount lifecycle and Emotion
94
+ // finishes injecting CSS-in-JS styles. A single rAF was insufficient as styles are
95
+ // injected dynamically in componentDidMount(). This timing issue only manifested when
96
+ // StrictMode was disabled, since StrictMode's double-rendering provided an accidental
97
+ // second measurement pass.
98
+ requestAnimationFrame(() => {
99
+ // First frame: DOM structure is laid out
100
+ requestAnimationFrame(() => {
101
+ // Second frame: styles injected, child components mounted, dimensions stable
102
+ if (!this.popoverContentRef) return;
103
+ const rect = this.popoverContentRef.getBoundingClientRect();
104
+ const viewportHeight = window.innerHeight;
105
+
106
+ // Detect if popover is positioned above (top) or below (bottom) the trigger.
107
+ // The Position component provides placement strings like "top center" or "bottom center".
108
+ const placement = (position === null || position === void 0 ? void 0 : position.placement) || '';
109
+ const isPositionedAbove = placement.startsWith('top');
110
+ let availableHeight;
111
+ if (isPositionedAbove) {
112
+ // When opening upward: available space is from viewport top to popover bottom.
113
+ // This is the space where the popover can expand within the viewport.
114
+ availableHeight = rect.top + rect.height - 16;
115
+ } else {
116
+ // When opening downward: available space is from popover top to viewport bottom.
117
+ // Subtract a small buffer (16px) for padding/margin.
118
+ availableHeight = viewportHeight - rect.top - 16;
119
+ }
120
+ const propMaxHeight = this.props.popoverMaxHeight;
121
+ let calculatedMaxHeight = `${Math.max(100, availableHeight)}px`;
122
+
123
+ // If prop specifies a maxHeight, respect it as an additional constraint
124
+ if (propMaxHeight && propMaxHeight !== '100vh') {
125
+ calculatedMaxHeight = propMaxHeight;
126
+ }
127
+ this.setState({
128
+ calculatedPopoverMaxHeight: calculatedMaxHeight,
129
+ isHeightCalculated: true
130
+ });
131
+ });
132
+ });
133
+ }
134
+ };
76
135
  this.checkSettings = () => {
77
136
  if (this.props.children && this.props.colorMixerSettings) {
78
137
  warn(false, 'You should either use children, colorMixerSettings or neither, not both. In this case, the colorMixerSettings will be ignored.', '');
@@ -95,7 +154,9 @@ let ColorPicker = (_dec = withStyle(generateStyle, generateComponentTheme), _dec
95
154
  onShowContent: () => {
96
155
  this.setState({
97
156
  openColorPicker: true,
98
- mixedColor: this.state.hexCode
157
+ mixedColor: this.state.hexCode,
158
+ calculatedPopoverMaxHeight: void 0,
159
+ isHeightCalculated: false
99
160
  });
100
161
  },
101
162
  onHideContent: () => {
@@ -109,8 +170,11 @@ let ColorPicker = (_dec = withStyle(generateStyle, generateComponentTheme), _dec
109
170
  shouldReturnFocus: true,
110
171
  shouldCloseOnDocumentClick: true,
111
172
  offsetY: "10rem",
173
+ onPositioned: this.handlePopoverPositioned,
174
+ onPositionChanged: this.handlePopoverPositioned,
112
175
  children: _jsx("div", {
113
176
  css: (_this$props$styles = this.props.styles) === null || _this$props$styles === void 0 ? void 0 : _this$props$styles.popoverContentContainer,
177
+ ref: this.handlePopoverContentRef,
114
178
  children: this.isDefaultPopover ? this.renderDefaultPopoverContent() : this.renderCustomPopoverContent()
115
179
  })
116
180
  });
@@ -132,7 +196,9 @@ let ColorPicker = (_dec = withStyle(generateStyle, generateComponentTheme), _dec
132
196
  onChange === null || onChange === void 0 ? void 0 : onChange(`#${this.mixedColorWithStrippedAlpha}`);
133
197
  }, () => this.setState({
134
198
  openColorPicker: false,
135
- mixedColor: this.state.hexCode
199
+ mixedColor: this.state.hexCode,
200
+ calculatedPopoverMaxHeight: void 0,
201
+ isHeightCalculated: false
136
202
  }))
137
203
  });
138
204
  };
@@ -185,7 +251,9 @@ let ColorPicker = (_dec = withStyle(generateStyle, generateComponentTheme), _dec
185
251
  children: [_jsx(Button, {
186
252
  onClick: () => this.setState({
187
253
  openColorPicker: false,
188
- mixedColor: this.state.hexCode
254
+ mixedColor: this.state.hexCode,
255
+ calculatedPopoverMaxHeight: void 0,
256
+ isHeightCalculated: false
189
257
  }),
190
258
  color: "secondary",
191
259
  margin: "xx-small",
@@ -216,7 +284,9 @@ let ColorPicker = (_dec = withStyle(generateStyle, generateComponentTheme), _dec
216
284
  showHelperErrorMessages: false,
217
285
  openColorPicker: false,
218
286
  mixedColor: '',
219
- labelHeight: 0
287
+ labelHeight: 0,
288
+ calculatedPopoverMaxHeight: void 0,
289
+ isHeightCalculated: false
220
290
  };
221
291
  }
222
292
  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: {
@@ -107,13 +108,18 @@ const generateStyle = (componentTheme, props, state) => {
107
108
  },
108
109
  colorMixerButtonContainer: {
109
110
  label: 'colorPicker__colorMixerButtonContainer',
110
- alignSelf: 'flex-start',
111
111
  marginInlineStart: componentTheme.colorMixerButtonContainerLeftMargin
112
112
  },
113
113
  popoverContentContainer: {
114
114
  label: 'colorPicker__popoverContentContainer',
115
- maxHeight: popoverMaxHeight || '100vh',
116
- overflow: 'auto'
115
+ maxHeight: calculatedPopoverMaxHeight || popoverMaxHeight || '100vh',
116
+ overflowY: 'auto',
117
+ overflowX: 'hidden',
118
+ scrollbarGutter: 'stable',
119
+ display: 'flex',
120
+ flexDirection: 'column',
121
+ opacity: state.isHeightCalculated ? 1 : 0,
122
+ transition: 'opacity 150ms ease-in'
117
123
  },
118
124
  colorMixerButtonWrapper: {
119
125
  label: 'colorPicker__colorMixerButtonWrapper',
@@ -74,7 +74,16 @@ let ColorPicker = exports.ColorPicker = (_dec = (0, _emotion.withStyle)(_styles.
74
74
  this.inputContainerRef = null;
75
75
  this.handleInputContainerRef = el => {
76
76
  this.inputContainerRef = el;
77
- this.setLabelHeight();
77
+ if (el) {
78
+ // Defer measurement until after layout is complete and CSS-in-JS styles are applied
79
+ requestAnimationFrame(() => {
80
+ this.setLabelHeight();
81
+ });
82
+ }
83
+ };
84
+ this.popoverContentRef = null;
85
+ this.handlePopoverContentRef = el => {
86
+ this.popoverContentRef = el;
78
87
  };
79
88
  this.setLabelHeight = () => {
80
89
  if (this.inputContainerRef) {
@@ -84,6 +93,56 @@ let ColorPicker = exports.ColorPicker = (_dec = (0, _emotion.withStyle)(_styles.
84
93
  });
85
94
  }
86
95
  };
96
+ // Calculate the maximum height the popover can have without extending beyond
97
+ // the viewport. This enables scrolling when the ColorPicker's content (all
98
+ // color mixing controls, presets, and contrast checker) would otherwise exceed
99
+ // the available viewport space. Without this calculation, the popover would
100
+ // render off-screen on smaller viewports.
101
+ this.handlePopoverPositioned = position => {
102
+ if (this.popoverContentRef) {
103
+ // Double requestAnimationFrame ensures measurements happen after all child components
104
+ // (ColorMixer, ColorPreset, ColorContrast) complete their mount lifecycle and Emotion
105
+ // finishes injecting CSS-in-JS styles. A single rAF was insufficient as styles are
106
+ // injected dynamically in componentDidMount(). This timing issue only manifested when
107
+ // StrictMode was disabled, since StrictMode's double-rendering provided an accidental
108
+ // second measurement pass.
109
+ requestAnimationFrame(() => {
110
+ // First frame: DOM structure is laid out
111
+ requestAnimationFrame(() => {
112
+ // Second frame: styles injected, child components mounted, dimensions stable
113
+ if (!this.popoverContentRef) return;
114
+ const rect = this.popoverContentRef.getBoundingClientRect();
115
+ const viewportHeight = window.innerHeight;
116
+
117
+ // Detect if popover is positioned above (top) or below (bottom) the trigger.
118
+ // The Position component provides placement strings like "top center" or "bottom center".
119
+ const placement = (position === null || position === void 0 ? void 0 : position.placement) || '';
120
+ const isPositionedAbove = placement.startsWith('top');
121
+ let availableHeight;
122
+ if (isPositionedAbove) {
123
+ // When opening upward: available space is from viewport top to popover bottom.
124
+ // This is the space where the popover can expand within the viewport.
125
+ availableHeight = rect.top + rect.height - 16;
126
+ } else {
127
+ // When opening downward: available space is from popover top to viewport bottom.
128
+ // Subtract a small buffer (16px) for padding/margin.
129
+ availableHeight = viewportHeight - rect.top - 16;
130
+ }
131
+ const propMaxHeight = this.props.popoverMaxHeight;
132
+ let calculatedMaxHeight = `${Math.max(100, availableHeight)}px`;
133
+
134
+ // If prop specifies a maxHeight, respect it as an additional constraint
135
+ if (propMaxHeight && propMaxHeight !== '100vh') {
136
+ calculatedMaxHeight = propMaxHeight;
137
+ }
138
+ this.setState({
139
+ calculatedPopoverMaxHeight: calculatedMaxHeight,
140
+ isHeightCalculated: true
141
+ });
142
+ });
143
+ });
144
+ }
145
+ };
87
146
  this.checkSettings = () => {
88
147
  if (this.props.children && this.props.colorMixerSettings) {
89
148
  (0, _console.warn)(false, 'You should either use children, colorMixerSettings or neither, not both. In this case, the colorMixerSettings will be ignored.', '');
@@ -106,7 +165,9 @@ let ColorPicker = exports.ColorPicker = (_dec = (0, _emotion.withStyle)(_styles.
106
165
  onShowContent: () => {
107
166
  this.setState({
108
167
  openColorPicker: true,
109
- mixedColor: this.state.hexCode
168
+ mixedColor: this.state.hexCode,
169
+ calculatedPopoverMaxHeight: void 0,
170
+ isHeightCalculated: false
110
171
  });
111
172
  },
112
173
  onHideContent: () => {
@@ -120,8 +181,11 @@ let ColorPicker = exports.ColorPicker = (_dec = (0, _emotion.withStyle)(_styles.
120
181
  shouldReturnFocus: true,
121
182
  shouldCloseOnDocumentClick: true,
122
183
  offsetY: "10rem",
184
+ onPositioned: this.handlePopoverPositioned,
185
+ onPositionChanged: this.handlePopoverPositioned,
123
186
  children: (0, _jsxRuntime.jsx)("div", {
124
187
  css: (_this$props$styles = this.props.styles) === null || _this$props$styles === void 0 ? void 0 : _this$props$styles.popoverContentContainer,
188
+ ref: this.handlePopoverContentRef,
125
189
  children: this.isDefaultPopover ? this.renderDefaultPopoverContent() : this.renderCustomPopoverContent()
126
190
  })
127
191
  });
@@ -143,7 +207,9 @@ let ColorPicker = exports.ColorPicker = (_dec = (0, _emotion.withStyle)(_styles.
143
207
  onChange === null || onChange === void 0 ? void 0 : onChange(`#${this.mixedColorWithStrippedAlpha}`);
144
208
  }, () => this.setState({
145
209
  openColorPicker: false,
146
- mixedColor: this.state.hexCode
210
+ mixedColor: this.state.hexCode,
211
+ calculatedPopoverMaxHeight: void 0,
212
+ isHeightCalculated: false
147
213
  }))
148
214
  });
149
215
  };
@@ -196,7 +262,9 @@ let ColorPicker = exports.ColorPicker = (_dec = (0, _emotion.withStyle)(_styles.
196
262
  children: [(0, _jsxRuntime.jsx)(_Button.Button, {
197
263
  onClick: () => this.setState({
198
264
  openColorPicker: false,
199
- mixedColor: this.state.hexCode
265
+ mixedColor: this.state.hexCode,
266
+ calculatedPopoverMaxHeight: void 0,
267
+ isHeightCalculated: false
200
268
  }),
201
269
  color: "secondary",
202
270
  margin: "xx-small",
@@ -227,7 +295,9 @@ let ColorPicker = exports.ColorPicker = (_dec = (0, _emotion.withStyle)(_styles.
227
295
  showHelperErrorMessages: false,
228
296
  openColorPicker: false,
229
297
  mixedColor: '',
230
- labelHeight: 0
298
+ labelHeight: 0,
299
+ calculatedPopoverMaxHeight: void 0,
300
+ isHeightCalculated: false
231
301
  };
232
302
  }
233
303
  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: {
@@ -113,13 +114,18 @@ const generateStyle = (componentTheme, props, state) => {
113
114
  },
114
115
  colorMixerButtonContainer: {
115
116
  label: 'colorPicker__colorMixerButtonContainer',
116
- alignSelf: 'flex-start',
117
117
  marginInlineStart: componentTheme.colorMixerButtonContainerLeftMargin
118
118
  },
119
119
  popoverContentContainer: {
120
120
  label: 'colorPicker__popoverContentContainer',
121
- maxHeight: popoverMaxHeight || '100vh',
122
- overflow: 'auto'
121
+ maxHeight: calculatedPopoverMaxHeight || popoverMaxHeight || '100vh',
122
+ overflowY: 'auto',
123
+ overflowX: 'hidden',
124
+ scrollbarGutter: 'stable',
125
+ display: 'flex',
126
+ flexDirection: 'column',
127
+ opacity: state.isHeightCalculated ? 1 : 0,
128
+ transition: 'opacity 150ms ease-in'
123
129
  },
124
130
  colorMixerButtonWrapper: {
125
131
  label: 'colorPicker__colorMixerButtonWrapper',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@instructure/ui-color-picker",
3
- "version": "11.2.0",
3
+ "version": "11.2.1-snapshot-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",
@@ -15,34 +15,34 @@
15
15
  "license": "MIT",
16
16
  "dependencies": {
17
17
  "@babel/runtime": "^7.27.6",
18
- "@instructure/emotion": "11.2.0",
19
- "@instructure/console": "11.2.0",
20
- "@instructure/ui-a11y-content": "11.2.0",
21
- "@instructure/ui-buttons": "11.2.0",
22
- "@instructure/shared-types": "11.2.0",
23
- "@instructure/ui-color-utils": "11.2.0",
24
- "@instructure/ui-dom-utils": "11.2.0",
25
- "@instructure/ui-drilldown": "11.2.0",
26
- "@instructure/ui-form-field": "11.2.0",
27
- "@instructure/ui-icons": "11.2.0",
28
- "@instructure/ui-popover": "11.2.0",
29
- "@instructure/ui-pill": "11.2.0",
30
- "@instructure/ui-react-utils": "11.2.0",
31
- "@instructure/ui-text": "11.2.0",
32
- "@instructure/ui-text-input": "11.2.0",
33
- "@instructure/ui-themes": "11.2.0",
34
- "@instructure/ui-utils": "11.2.0",
35
- "@instructure/ui-tooltip": "11.2.0",
36
- "@instructure/ui-view": "11.2.0"
18
+ "@instructure/console": "11.2.1-snapshot-3",
19
+ "@instructure/shared-types": "11.2.1-snapshot-3",
20
+ "@instructure/ui-a11y-content": "11.2.1-snapshot-3",
21
+ "@instructure/ui-buttons": "11.2.1-snapshot-3",
22
+ "@instructure/emotion": "11.2.1-snapshot-3",
23
+ "@instructure/ui-color-utils": "11.2.1-snapshot-3",
24
+ "@instructure/ui-dom-utils": "11.2.1-snapshot-3",
25
+ "@instructure/ui-drilldown": "11.2.1-snapshot-3",
26
+ "@instructure/ui-icons": "11.2.1-snapshot-3",
27
+ "@instructure/ui-pill": "11.2.1-snapshot-3",
28
+ "@instructure/ui-form-field": "11.2.1-snapshot-3",
29
+ "@instructure/ui-react-utils": "11.2.1-snapshot-3",
30
+ "@instructure/ui-popover": "11.2.1-snapshot-3",
31
+ "@instructure/ui-text": "11.2.1-snapshot-3",
32
+ "@instructure/ui-tooltip": "11.2.1-snapshot-3",
33
+ "@instructure/ui-text-input": "11.2.1-snapshot-3",
34
+ "@instructure/ui-utils": "11.2.1-snapshot-3",
35
+ "@instructure/ui-themes": "11.2.1-snapshot-3",
36
+ "@instructure/ui-view": "11.2.1-snapshot-3"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@testing-library/jest-dom": "^6.6.3",
40
40
  "@testing-library/react": "15.0.7",
41
41
  "@testing-library/user-event": "^14.6.1",
42
42
  "vitest": "^3.2.2",
43
- "@instructure/ui-scripts": "11.2.0",
44
- "@instructure/ui-babel-preset": "11.2.0",
45
- "@instructure/ui-axe-check": "11.2.0"
43
+ "@instructure/ui-axe-check": "11.2.1-snapshot-3",
44
+ "@instructure/ui-babel-preset": "11.2.1-snapshot-3",
45
+ "@instructure/ui-scripts": "11.2.1-snapshot-3"
46
46
  },
47
47
  "peerDependencies": {
48
48
  "react": ">=18 <=19"
@@ -351,3 +351,60 @@ type: example
351
351
  </div>
352
352
 
353
353
  ```
354
+
355
+ ### Scrollable Popover Content
356
+
357
+ When the ColorPicker popover contains tall content (e.g., ColorMixer + ColorPreset + ColorContrast), the component automatically calculates the available viewport space and makes the popover scrollable.
358
+
359
+ The `popoverMaxHeight` prop can be used to set a custom maximum height for the popover content. By default, it's set to `'100vh'`, but the component dynamically adjusts this based on the available space to ensure the popover fits within the viewport and remains scrollable.
360
+
361
+ ```js
362
+ ---
363
+ type: example
364
+ ---
365
+ <ColorPicker
366
+ label="Color"
367
+ placeholderText="Enter HEX"
368
+ popoverButtonScreenReaderLabel="Open color mixer popover"
369
+ popoverMaxHeight="500px"
370
+ colorMixerSettings={{
371
+ popoverAddButtonLabel: "Add",
372
+ popoverCloseButtonLabel: "Cancel",
373
+ colorMixer: {
374
+ withAlpha: true,
375
+ rgbRedInputScreenReaderLabel:'Input field for red',
376
+ rgbGreenInputScreenReaderLabel:'Input field for green',
377
+ rgbBlueInputScreenReaderLabel:'Input field for blue',
378
+ rgbAlphaInputScreenReaderLabel:'Input field for alpha',
379
+ colorSliderNavigationExplanationScreenReaderLabel:`You are on a color slider. To navigate the slider left or right, use the 'A' and 'D' buttons respectively`,
380
+ alphaSliderNavigationExplanationScreenReaderLabel:`You are on an alpha slider. To navigate the slider left or right, use the 'A' and 'D' buttons respectively`,
381
+ colorPaletteNavigationExplanationScreenReaderLabel:`You are on a color palette. To navigate on the palette up, left, down or right, use the 'W', 'A', 'S' and 'D' buttons respectively`
382
+ },
383
+ colorPreset: {
384
+ label: "Preset Colors",
385
+ colors: [
386
+ "#FF0000",
387
+ "#00FF00",
388
+ "#0000FF",
389
+ "#FFFF00",
390
+ "#FF00FF",
391
+ "#00FFFF",
392
+ "#000000",
393
+ "#FFFFFF",
394
+ "#808080"
395
+ ]
396
+ },
397
+ colorContrast: {
398
+ firstColor: "#FFFFFF",
399
+ label: "Color Contrast Ratio",
400
+ successLabel: "PASS",
401
+ failureLabel: "FAIL",
402
+ normalTextLabel: "Normal text",
403
+ largeTextLabel: "Large text",
404
+ graphicsTextLabel: "Graphics text",
405
+ firstColorLabel: "Background",
406
+ secondColorLabel: "Foreground"
407
+ }
408
+ }}
409
+ />
410
+ ```
@@ -107,7 +107,9 @@ class ColorPicker extends Component<ColorPickerProps, ColorPickerState> {
107
107
  showHelperErrorMessages: false,
108
108
  openColorPicker: false,
109
109
  mixedColor: '',
110
- labelHeight: 0
110
+ labelHeight: 0,
111
+ calculatedPopoverMaxHeight: undefined,
112
+ isHeightCalculated: false
111
113
  }
112
114
  }
113
115
 
@@ -127,7 +129,19 @@ class ColorPicker extends Component<ColorPickerProps, ColorPickerState> {
127
129
 
128
130
  handleInputContainerRef = (el: Element | null) => {
129
131
  this.inputContainerRef = el
130
- this.setLabelHeight()
132
+
133
+ if (el) {
134
+ // Defer measurement until after layout is complete and CSS-in-JS styles are applied
135
+ requestAnimationFrame(() => {
136
+ this.setLabelHeight()
137
+ })
138
+ }
139
+ }
140
+
141
+ popoverContentRef: HTMLDivElement | null = null
142
+
143
+ handlePopoverContentRef = (el: HTMLDivElement | null) => {
144
+ this.popoverContentRef = el
131
145
  }
132
146
 
133
147
  setLabelHeight = () => {
@@ -140,6 +154,62 @@ class ColorPicker extends Component<ColorPickerProps, ColorPickerState> {
140
154
  }
141
155
  }
142
156
 
157
+ // Calculate the maximum height the popover can have without extending beyond
158
+ // the viewport. This enables scrolling when the ColorPicker's content (all
159
+ // color mixing controls, presets, and contrast checker) would otherwise exceed
160
+ // the available viewport space. Without this calculation, the popover would
161
+ // render off-screen on smaller viewports.
162
+ handlePopoverPositioned = (position?: { placement?: string }) => {
163
+ if (this.popoverContentRef) {
164
+ // Double requestAnimationFrame ensures measurements happen after all child components
165
+ // (ColorMixer, ColorPreset, ColorContrast) complete their mount lifecycle and Emotion
166
+ // finishes injecting CSS-in-JS styles. A single rAF was insufficient as styles are
167
+ // injected dynamically in componentDidMount(). This timing issue only manifested when
168
+ // StrictMode was disabled, since StrictMode's double-rendering provided an accidental
169
+ // second measurement pass.
170
+ requestAnimationFrame(() => {
171
+ // First frame: DOM structure is laid out
172
+ requestAnimationFrame(() => {
173
+ // Second frame: styles injected, child components mounted, dimensions stable
174
+ if (!this.popoverContentRef) return
175
+
176
+ const rect = this.popoverContentRef.getBoundingClientRect()
177
+ const viewportHeight = window.innerHeight
178
+
179
+ // Detect if popover is positioned above (top) or below (bottom) the trigger.
180
+ // The Position component provides placement strings like "top center" or "bottom center".
181
+ const placement = position?.placement || ''
182
+ const isPositionedAbove = placement.startsWith('top')
183
+
184
+ let availableHeight: number
185
+
186
+ if (isPositionedAbove) {
187
+ // When opening upward: available space is from viewport top to popover bottom.
188
+ // This is the space where the popover can expand within the viewport.
189
+ availableHeight = rect.top + rect.height - 16
190
+ } else {
191
+ // When opening downward: available space is from popover top to viewport bottom.
192
+ // Subtract a small buffer (16px) for padding/margin.
193
+ availableHeight = viewportHeight - rect.top - 16
194
+ }
195
+
196
+ const propMaxHeight = this.props.popoverMaxHeight
197
+ let calculatedMaxHeight = `${Math.max(100, availableHeight)}px`
198
+
199
+ // If prop specifies a maxHeight, respect it as an additional constraint
200
+ if (propMaxHeight && propMaxHeight !== '100vh') {
201
+ calculatedMaxHeight = propMaxHeight
202
+ }
203
+
204
+ this.setState({
205
+ calculatedPopoverMaxHeight: calculatedMaxHeight,
206
+ isHeightCalculated: true
207
+ })
208
+ })
209
+ })
210
+ }
211
+ }
212
+
143
213
  componentDidMount() {
144
214
  this.props.makeStyles?.({ ...this.state, isSimple: this.isSimple })
145
215
  this.checkSettings()
@@ -424,7 +494,12 @@ class ColorPicker extends Component<ColorPickerProps, ColorPickerState> {
424
494
  }
425
495
  isShowingContent={this.state.openColorPicker}
426
496
  onShowContent={() => {
427
- this.setState({ openColorPicker: true, mixedColor: this.state.hexCode })
497
+ this.setState({
498
+ openColorPicker: true,
499
+ mixedColor: this.state.hexCode,
500
+ calculatedPopoverMaxHeight: undefined,
501
+ isHeightCalculated: false
502
+ })
428
503
  }}
429
504
  onHideContent={() => {
430
505
  this.setState({ openColorPicker: false })
@@ -435,8 +510,13 @@ class ColorPicker extends Component<ColorPickerProps, ColorPickerState> {
435
510
  shouldReturnFocus
436
511
  shouldCloseOnDocumentClick
437
512
  offsetY="10rem"
513
+ onPositioned={this.handlePopoverPositioned}
514
+ onPositionChanged={this.handlePopoverPositioned}
438
515
  >
439
- <div css={this.props.styles?.popoverContentContainer}>
516
+ <div
517
+ css={this.props.styles?.popoverContentContainer}
518
+ ref={this.handlePopoverContentRef}
519
+ >
440
520
  {this.isDefaultPopover
441
521
  ? this.renderDefaultPopoverContent()
442
522
  : this.renderCustomPopoverContent()}
@@ -467,7 +547,9 @@ class ColorPicker extends Component<ColorPickerProps, ColorPickerState> {
467
547
  () =>
468
548
  this.setState({
469
549
  openColorPicker: false,
470
- mixedColor: this.state.hexCode
550
+ mixedColor: this.state.hexCode,
551
+ calculatedPopoverMaxHeight: undefined,
552
+ isHeightCalculated: false
471
553
  })
472
554
  )}
473
555
  </div>
@@ -573,7 +655,9 @@ class ColorPicker extends Component<ColorPickerProps, ColorPickerState> {
573
655
  onClick={() =>
574
656
  this.setState({
575
657
  openColorPicker: false,
576
- mixedColor: this.state.hexCode
658
+ mixedColor: this.state.hexCode,
659
+ calculatedPopoverMaxHeight: undefined,
660
+ isHeightCalculated: false
577
661
  })
578
662
  }
579
663
  color="secondary"
@@ -637,7 +721,10 @@ class ColorPicker extends Component<ColorPickerProps, ColorPickerState> {
637
721
  {!this.isSimple && (
638
722
  <div
639
723
  css={this.props.styles?.colorMixerButtonContainer}
640
- style={{ paddingTop: this.state.labelHeight }}
724
+ style={{
725
+ alignSelf: this.state.labelHeight > 0 ? 'flex-start' : 'flex-end',
726
+ paddingTop: this.state.labelHeight
727
+ }}
641
728
  >
642
729
  <div css={this.props.styles?.colorMixerButtonWrapper}>
643
730
  {this.renderPopover()}
@@ -244,6 +244,8 @@ type ColorPickerState = {
244
244
  openColorPicker: boolean
245
245
  mixedColor: string
246
246
  labelHeight: number
247
+ calculatedPopoverMaxHeight: string | undefined
248
+ isHeightCalculated: boolean
247
249
  }
248
250
 
249
251
  type PropKeys = keyof ColorPickerOwnProps