@gitlab/ui 137.2.0 → 137.2.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.
@@ -2,6 +2,8 @@ import * as echarts from 'echarts';
2
2
  import { merge } from 'lodash-es';
3
3
  import { validRenderers, toolboxHeight, defaultWidth, defaultHeight } from '../../../utils/charts/config';
4
4
  import { themeName, createTheme } from '../../../utils/charts/theme';
5
+ import { resolveCssValues } from '../../../utils/charts/css_values';
6
+ import { onColorModeChange } from '../../../utils/charts/color_mode';
5
7
  import { GlResizeObserverDirective } from '../../../directives/resize_observer/resize_observer';
6
8
  import { debounceByAnimationFrame } from '../../../utils/utils';
7
9
  import __vue_normalize__ from 'vue-runtime-helpers/dist/normalize-component.js';
@@ -126,32 +128,91 @@ var script = {
126
128
  },
127
129
  async mounted() {
128
130
  await this.$nextTick();
129
- const chart = echarts.init(this.$refs.chart, themeName, {
130
- renderer: this.renderer,
131
- width: defaultWidth,
132
- height: defaultHeight
133
- });
134
- // FIXME: temporary workaround to ensure compatibility with @vue/compat
135
- // eslint-disable-next-line no-underscore-dangle
136
- chart.__v_skip = true;
137
- this.chart = chart;
138
- if (this.groupId.length) {
139
- this.chart.group = this.groupId;
140
- echarts.connect(this.groupId);
141
- }
142
- this.chart.on('click', this.handleClick);
143
- /**
144
- * Emitted after calling `echarts.init`
145
- */
146
- this.$emit('created', this.chart);
147
- this.draw();
148
- this.setChartSize();
131
+ this.initChart();
132
+ this.observeColorMode();
149
133
  },
150
134
  beforeDestroy() {
135
+ var _this$unsubscribeColo;
151
136
  if (!this.chart) return;
152
137
  this.chart.off('click', this.handleClick);
138
+ (_this$unsubscribeColo = this.unsubscribeColorMode) === null || _this$unsubscribeColo === void 0 ? void 0 : _this$unsubscribeColo.call(this);
153
139
  },
154
140
  methods: {
141
+ initChart() {
142
+ const chart = echarts.init(this.$refs.chart, this.theme(), {
143
+ renderer: this.renderer,
144
+ width: defaultWidth,
145
+ height: defaultHeight
146
+ });
147
+ // FIXME: temporary workaround to ensure compatibility with @vue/compat
148
+ // eslint-disable-next-line no-underscore-dangle
149
+ chart.__v_skip = true;
150
+ this.chart = chart;
151
+ if (this.groupId.length) {
152
+ this.chart.group = this.groupId;
153
+ echarts.connect(this.groupId);
154
+ }
155
+ this.chart.on('click', this.handleClick);
156
+ /**
157
+ * Emitted after calling `echarts.init`
158
+ */
159
+ this.$emit('created', this.chart);
160
+ this.draw();
161
+ this.setChartSize();
162
+ },
163
+ /**
164
+ * The SVG renderer consumes the registered theme as-is, leaving its `var(--token)` colours for
165
+ * the browser to resolve. Canvas cannot, so it gets a copy resolved against this element.
166
+ */
167
+ theme() {
168
+ if (this.renderer !== 'canvas') {
169
+ return themeName;
170
+ }
171
+ return resolveCssValues(createTheme(this.options), this.$refs.chart);
172
+ },
173
+ /**
174
+ * Rebuilding the chart discards its interaction state, so the legend selection and zoom range
175
+ * are carried over. Only these stateful fields are copied: the full option from getOption()
176
+ * holds colours resolved against the old colour mode.
177
+ */
178
+ preservedState() {
179
+ const _this$chart$getOption = this.chart.getOption(),
180
+ _this$chart$getOption2 = _this$chart$getOption.legend,
181
+ legend = _this$chart$getOption2 === void 0 ? [] : _this$chart$getOption2,
182
+ _this$chart$getOption3 = _this$chart$getOption.dataZoom,
183
+ dataZoom = _this$chart$getOption3 === void 0 ? [] : _this$chart$getOption3;
184
+ return {
185
+ legend: legend.map(_ref => {
186
+ let selected = _ref.selected;
187
+ return {
188
+ selected
189
+ };
190
+ }),
191
+ dataZoom: dataZoom.map(_ref2 => {
192
+ let start = _ref2.start,
193
+ end = _ref2.end;
194
+ return {
195
+ start,
196
+ end
197
+ };
198
+ })
199
+ };
200
+ },
201
+ /**
202
+ * Colours resolved for canvas are frozen at init, so the chart has to be rebuilt when the
203
+ * colour mode changes. SVG re-resolves on its own and needs no subscription.
204
+ */
205
+ observeColorMode() {
206
+ if (this.renderer !== 'canvas') {
207
+ return;
208
+ }
209
+ this.unsubscribeColorMode = onColorModeChange(this.$refs.chart, () => {
210
+ const state = this.preservedState();
211
+ this.chart.dispose();
212
+ this.initChart();
213
+ this.chart.setOption(state);
214
+ });
215
+ },
155
216
  draw() {
156
217
  this.chart.setOption(this.modifiedOptions);
157
218
  /**
@@ -1,13 +1,9 @@
1
1
  import { merge, uniq, sortBy } from 'lodash-es';
2
- import { gaugeSafeHues, gaugeWarningHue, gaugeNeutralHues } from '../../../utils/charts/theme';
2
+ import { gaugeSafeHues, gaugeWarningHue } from '../../../utils/charts/theme';
3
3
  import Chart from '../chart/chart';
4
4
  import __vue_normalize__ from 'vue-runtime-helpers/dist/normalize-component.js';
5
5
 
6
- const AXIS_LABEL_FONT_SIZE_PX = 14;
7
6
  const ARC_RADIUS = '80%';
8
- const DETAIL_FONT_SIZE_PX = 30;
9
- const DETAIL_FONT_FAMILY = 'sans-serif';
10
- const DETAIL_FONT_WEIGHT = 'bold';
11
7
  const POINTER_LENGTH = '65%';
12
8
  const POINTER_WIDTH_PX = 5;
13
9
  const gaugeChartSeries = _ref => {
@@ -24,17 +20,11 @@ const gaugeChartSeries = _ref => {
24
20
  formatter: () => {
25
21
  const currentValue = Number.isFinite(value) ? value : null;
26
22
  return text || (currentValue !== null && currentValue !== void 0 ? currentValue : '--');
27
- },
28
- color: `${gaugeNeutralHues[0]}`,
29
- fontSize: DETAIL_FONT_SIZE_PX,
30
- fontFamily: DETAIL_FONT_FAMILY,
31
- fontWeight: DETAIL_FONT_WEIGHT
23
+ }
32
24
  },
33
25
  axisLabel: {
34
26
  show: true,
35
- fontSize: AXIS_LABEL_FONT_SIZE_PX,
36
- formatter: theValue => Number.isFinite(theValue) ? theValue : '--',
37
- color: `${gaugeNeutralHues[1]}`
27
+ formatter: theValue => Number.isFinite(theValue) ? theValue : '--'
38
28
  },
39
29
  axisLine: {
40
30
  lineStyle: {
@@ -1,8 +1,8 @@
1
1
  import { merge } from 'lodash-es';
2
- import { GL_COLOR_NEUTRAL_100, GL_COLOR_NEUTRAL_0 } from '../../../tokens/build/js/tokens';
3
2
  import { getTooltipTitle, getTooltipContent } from '../../../utils/charts/config';
4
3
  import { HEIGHT_AUTO_CLASSES } from '../../../utils/charts/constants';
5
- import { heatmapHues } from '../../../utils/charts/theme';
4
+ import { heatmapHuesDark, heatmapHues, heatmapSurfaceDark, heatmapSurface } from '../../../utils/charts/theme';
5
+ import { isDarkMode, onColorModeChange } from '../../../utils/charts/color_mode';
6
6
  import { engineeringNotation } from '../../../utils/number_utils';
7
7
  import TooltipDefaultFormat from '../shared/tooltip/tooltip_default_format/tooltip_default_format';
8
8
  import Chart from '../chart/chart';
@@ -61,10 +61,7 @@ function _unsupportedIterableToArray(r, a) {
61
61
 
62
62
  const defaultOptions = {
63
63
  visualMap: {
64
- show: false,
65
- inRange: {
66
- color: heatmapHues
67
- }
64
+ show: false
68
65
  },
69
66
  series: {
70
67
  type: 'heatmap'
@@ -201,10 +198,20 @@ var script = {
201
198
  left: '0',
202
199
  top: '0'
203
200
  },
204
- selectedFormatTooltipText: this.formatTooltipText || this.defaultFormatTooltipText
201
+ selectedFormatTooltipText: this.formatTooltipText || this.defaultFormatTooltipText,
202
+ // ECharts interpolates the visualMap stops numerically, so these colours cannot be CSS
203
+ // custom properties and have to be picked for the current colour mode instead. Resolved on
204
+ // mount, because the mode depends on where this chart sits in the document.
205
+ isDark: false
205
206
  };
206
207
  },
207
208
  computed: {
209
+ hues() {
210
+ return this.isDark ? heatmapHuesDark : heatmapHues;
211
+ },
212
+ surface() {
213
+ return this.isDark ? heatmapSurfaceDark : heatmapSurface;
214
+ },
208
215
  computedOptions() {
209
216
  const _getRange = getRange(this.dataSeries),
210
217
  min = _getRange.min,
@@ -219,11 +226,14 @@ var script = {
219
226
  right: '32px',
220
227
  show: true,
221
228
  borderWidth: 0,
222
- backgroundColor: GL_COLOR_NEUTRAL_100
229
+ backgroundColor: this.surface.plate
223
230
  },
224
231
  visualMap: {
225
232
  min,
226
- max
233
+ max,
234
+ inRange: {
235
+ color: this.hues
236
+ }
227
237
  },
228
238
  xAxis: {
229
239
  data: this.xAxisLabels,
@@ -243,7 +253,7 @@ var script = {
243
253
  show: true,
244
254
  interval: 0,
245
255
  lineStyle: {
246
- color: GL_COLOR_NEUTRAL_0,
256
+ color: this.surface.separator,
247
257
  width: 2
248
258
  }
249
259
  },
@@ -270,7 +280,7 @@ var script = {
270
280
  show: true,
271
281
  interval: 0,
272
282
  lineStyle: {
273
- color: GL_COLOR_NEUTRAL_0,
283
+ color: this.surface.separator,
274
284
  width: 2
275
285
  }
276
286
  }
@@ -290,8 +300,8 @@ var script = {
290
300
  const _getRange2 = getRange(this.dataSeries),
291
301
  min = _getRange2.min,
292
302
  max = _getRange2.max;
293
- const step = (max - min) / heatmapHues.length;
294
- return heatmapHues.map((color, index) => {
303
+ const step = (max - min) / this.hues.length;
304
+ return this.hues.map((color, index) => {
295
305
  const lowerBound = engineeringNotation(min + step * index);
296
306
  const upperBound = engineeringNotation(min + step * (index + 1));
297
307
  return {
@@ -305,6 +315,16 @@ var script = {
305
315
  return this.height === 'auto';
306
316
  }
307
317
  },
318
+ mounted() {
319
+ this.isDark = isDarkMode(this.$el);
320
+ this.unsubscribeColorMode = onColorModeChange(this.$el, isDark => {
321
+ this.isDark = isDark;
322
+ });
323
+ },
324
+ beforeDestroy() {
325
+ var _this$unsubscribeColo;
326
+ (_this$unsubscribeColo = this.unsubscribeColorMode) === null || _this$unsubscribeColo === void 0 ? void 0 : _this$unsubscribeColo.call(this);
327
+ },
308
328
  methods: {
309
329
  defaultFormatTooltipText(params) {
310
330
  this.tooltip.title = getTooltipTitle(params, this.computedOptions.xAxis.name);
@@ -1,5 +1,6 @@
1
1
  import * as echarts from 'echarts';
2
2
  import { defaultFontSize } from '../../../utils/charts/config';
3
+ import { defaultFontFamily } from '../../../utils/charts/theme';
3
4
  import { LEGEND_AVERAGE_TEXT, LEGEND_CURRENT_TEXT, LEGEND_MIN_TEXT, LEGEND_MAX_TEXT, LEGEND_LAYOUT_INLINE, LEGEND_LAYOUT_TABLE } from '../../../utils/charts/constants';
4
5
  import { engineeringNotation, average } from '../../../utils/number_utils';
5
6
  import GlTruncate from '../../utilities/truncate/truncate';
@@ -144,7 +145,7 @@ var script = {
144
145
  computed: {
145
146
  fontStyle() {
146
147
  return {
147
- fontFamily: this.textStyle.fontFamily || 'sans-serif',
148
+ fontFamily: this.textStyle.fontFamily || defaultFontFamily,
148
149
  fontSize: `${this.textStyle.fontSize || defaultFontSize}px`
149
150
  };
150
151
  },
@@ -152,6 +153,17 @@ var script = {
152
153
  return this.seriesInfo.length === 1;
153
154
  }
154
155
  },
156
+ watch: {
157
+ /**
158
+ * When the canvas renderer rebuilds the chart on a colour-mode change it disposes the old
159
+ * ECharts instance and creates a new one. The parent emits `created` with the new instance and
160
+ * updates this prop, so we must rebind our persistent listener to the new instance.
161
+ */
162
+ chart(newChart, oldChart) {
163
+ oldChart.off('legendselectchanged', this.suppressLastActiveSeriesLabelToggle);
164
+ newChart.on('legendselectchanged', this.suppressLastActiveSeriesLabelToggle);
165
+ }
166
+ },
155
167
  created() {
156
168
  this.chart.on('legendselectchanged', this.suppressLastActiveSeriesLabelToggle);
157
169
  },
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The classes that decide which set of token values applies to an element.
3
+ *
4
+ * Dark mode is applied either to the whole document, by putting `gl-dark` on the root, or to a
5
+ * subtree, by putting `gl-dark-scope` on any ancestor. `gl-light-scope` flips a subtree back.
6
+ *
7
+ * These are the single source of truth: `bin/build_tokens.mjs` builds the CSS selectors from them,
8
+ * so the stylesheets and any JavaScript that has to reason about the colour mode cannot drift
9
+ * apart. Changing a name here changes both.
10
+ */
11
+ const DARK_ROOT_CLASS = 'gl-dark';
12
+ const DARK_SCOPE_CLASS = 'gl-dark-scope';
13
+ const LIGHT_SCOPE_CLASS = 'gl-light-scope';
14
+ const LIGHT_SELECTOR = `:root, .${LIGHT_SCOPE_CLASS}`;
15
+ const DARK_SELECTOR = `:root.${DARK_ROOT_CLASS}, .${DARK_SCOPE_CLASS}`;
16
+
17
+ export { DARK_ROOT_CLASS, DARK_SCOPE_CLASS, DARK_SELECTOR, LIGHT_SCOPE_CLASS, LIGHT_SELECTOR };
@@ -0,0 +1,95 @@
1
+ import { LIGHT_SCOPE_CLASS, DARK_SCOPE_CLASS, DARK_ROOT_CLASS } from '../../tokens/color_mode';
2
+
3
+ const SCOPE_CLASSES = [DARK_SCOPE_CLASS, LIGHT_SCOPE_CLASS, DARK_ROOT_CLASS];
4
+ const SCOPE_SELECTOR = SCOPE_CLASSES.map(scopeClass => `.${scopeClass}`).join(', ');
5
+
6
+ /**
7
+ * Whether the given element sits in dark mode.
8
+ *
9
+ * Colour mode is not a document-wide fact. Tokens are declared for the root dark class and for a
10
+ * dark scope class, so any subtree can be flipped independently, and a light scope can flip one
11
+ * back inside a dark document. The nearest scope wins, which is what `closest` gives us.
12
+ *
13
+ * This lives under `charts` rather than in the general utilities because charts are the only thing
14
+ * in the package that needs it. Everywhere else, colour follows tokens and CSS re-resolves them on
15
+ * its own. Charts cannot always do that: ECharts parses colours numerically for its visual maps,
16
+ * and canvas cannot read custom properties at all, so those values have to be chosen in JavaScript
17
+ * and re-chosen when the mode changes.
18
+ */
19
+ const isDarkMode = element => {
20
+ var _element$closest;
21
+ const scope = element === null || element === void 0 ? void 0 : (_element$closest = element.closest) === null || _element$closest === void 0 ? void 0 : _element$closest.call(element, SCOPE_SELECTOR);
22
+ return Boolean(scope) && !scope.classList.contains(LIGHT_SCOPE_CLASS);
23
+ };
24
+
25
+ // Keyed by subscription so that each subscribed element's last known mode is tracked separately.
26
+ const subscriptions = new Map();
27
+ let observer = null;
28
+
29
+ // Most class mutations (hover states, dropdowns opening and closing) have nothing to do with the
30
+ // colour mode. Only mutations that added or removed a scope class can change it, so anything else
31
+ // can be dismissed with this cheap check instead of re-checking every subscriber.
32
+ const touchesColorMode = _ref => {
33
+ let target = _ref.target,
34
+ oldValue = _ref.oldValue;
35
+ return SCOPE_CLASSES.some(scopeClass => {
36
+ var _target$classList;
37
+ return ((_target$classList = target.classList) === null || _target$classList === void 0 ? void 0 : _target$classList.contains(scopeClass)) || (oldValue === null || oldValue === void 0 ? void 0 : oldValue.includes(scopeClass));
38
+ });
39
+ };
40
+ const notify = () => {
41
+ subscriptions.forEach((_ref2, key) => {
42
+ let element = _ref2.element,
43
+ subscriber = _ref2.subscriber,
44
+ mode = _ref2.mode;
45
+ const current = isDarkMode(element);
46
+ if (current !== mode) {
47
+ subscriptions.set(key, {
48
+ element,
49
+ subscriber,
50
+ mode: current
51
+ });
52
+ subscriber(current);
53
+ }
54
+ });
55
+ };
56
+
57
+ /**
58
+ * Calls back when the colour mode of `element` changes, and returns a function that unsubscribes.
59
+ *
60
+ * Most colour can be left to CSS, which re-resolves custom properties on its own. This exists for
61
+ * the places that cannot: colours ECharts parses numerically, such as the interpolated stops of a
62
+ * continuous `visualMap`, and colours handed to a canvas context. Both need a concrete value picked
63
+ * in JavaScript, so both need telling when the mode flips.
64
+ *
65
+ * Because a scope class can live on any ancestor, this watches the whole document for class
66
+ * changes, then re-checks each subscribed element and only calls back when its own mode changed.
67
+ */
68
+ const onColorModeChange = (element, subscriber) => {
69
+ if (!subscriptions.size && typeof MutationObserver !== 'undefined') {
70
+ observer = new MutationObserver(mutations => {
71
+ if (mutations.some(mutation => touchesColorMode(mutation))) notify();
72
+ });
73
+ observer.observe(document.documentElement, {
74
+ attributeFilter: ['class'],
75
+ attributeOldValue: true,
76
+ subtree: true
77
+ });
78
+ }
79
+ const key = Symbol('colorModeSubscription');
80
+ subscriptions.set(key, {
81
+ element,
82
+ subscriber,
83
+ mode: isDarkMode(element)
84
+ });
85
+ return () => {
86
+ subscriptions.delete(key);
87
+ if (!subscriptions.size) {
88
+ var _observer;
89
+ (_observer = observer) === null || _observer === void 0 ? void 0 : _observer.disconnect();
90
+ observer = null;
91
+ }
92
+ };
93
+ };
94
+
95
+ export { isDarkMode, onColorModeChange };
@@ -0,0 +1,105 @@
1
+ import { mapValues } from 'lodash-es';
2
+
3
+ function _arrayLikeToArray(r, a) {
4
+ (null == a || a > r.length) && (a = r.length);
5
+ for (var e = 0, n = Array(a); e < a; e++) n[e] = r[e];
6
+ return n;
7
+ }
8
+ function _arrayWithHoles(r) {
9
+ if (Array.isArray(r)) return r;
10
+ }
11
+ function _iterableToArrayLimit(r, l) {
12
+ var t = null == r ? null : "undefined" != typeof Symbol && r[Symbol.iterator] || r["@@iterator"];
13
+ if (null != t) {
14
+ var e,
15
+ n,
16
+ i,
17
+ u,
18
+ a = [],
19
+ f = !0,
20
+ o = !1;
21
+ try {
22
+ if (i = (t = t.call(r)).next, 0 === l) {
23
+ if (Object(t) !== t) return;
24
+ f = !1;
25
+ } else for (; !(f = (e = i.call(t)).done) && (a.push(e.value), a.length !== l); f = !0);
26
+ } catch (r) {
27
+ o = !0, n = r;
28
+ } finally {
29
+ try {
30
+ if (!f && null != t.return && (u = t.return(), Object(u) !== u)) return;
31
+ } finally {
32
+ if (o) throw n;
33
+ }
34
+ }
35
+ return a;
36
+ }
37
+ }
38
+ function _nonIterableRest() {
39
+ throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.");
40
+ }
41
+ function _slicedToArray(r, e) {
42
+ return _arrayWithHoles(r) || _iterableToArrayLimit(r, e) || _unsupportedIterableToArray(r, e) || _nonIterableRest();
43
+ }
44
+ function _unsupportedIterableToArray(r, a) {
45
+ if (r) {
46
+ if ("string" == typeof r) return _arrayLikeToArray(r, a);
47
+ var t = {}.toString.call(r).slice(8, -1);
48
+ return "Object" === t && r.constructor && (t = r.constructor.name), "Map" === t || "Set" === t ? Array.from(r) : "Arguments" === t || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t) ? _arrayLikeToArray(r, a) : void 0;
49
+ }
50
+ }
51
+
52
+ const CSS_VAR = /^var\(\s*(--[\w-]+)\s*(?:,\s*([\s\S]*?)\s*)?\)$/;
53
+ const resolveString = (value, styles) => {
54
+ if (value.toLowerCase() === 'currentcolor') {
55
+ return styles.color;
56
+ }
57
+ const match = CSS_VAR.exec(value);
58
+ if (!match) {
59
+ return value;
60
+ }
61
+ const _match = _slicedToArray(match, 3),
62
+ name = _match[1],
63
+ fallback = _match[2];
64
+ const resolved = styles.getPropertyValue(name).trim();
65
+ if (resolved) {
66
+ return resolved;
67
+ }
68
+ return fallback ? resolveString(fallback, styles) : value;
69
+ };
70
+
71
+ /**
72
+ * Deeply replaces `var(--token)` and `currentcolor` strings with the values they resolve to for the
73
+ * given element.
74
+ *
75
+ * The SVG renderer needs none of this: it emits both forms into the DOM, where the browser resolves
76
+ * them and keeps them live across a colour-mode change. Canvas has no such context. `fillStyle` is
77
+ * a plain string setter, so an unparseable `var(--token)` is dropped on the floor and leaves the
78
+ * previous fill in place, while `currentcolor` has no element to inherit from and is defined to
79
+ * resolve to opaque black. Either way a canvas-rendered chart silently loses its themed colours.
80
+ *
81
+ * Resolution is relative to `element` rather than the document root so that `.gl-dark-scope`
82
+ * ancestors are honoured.
83
+ *
84
+ * Values are resolved once, so callers are responsible for resolving again when the colour mode
85
+ * changes.
86
+ */
87
+ const resolveCssValues = (input, element) => {
88
+ const styles = window.getComputedStyle(element);
89
+ const walk = value => {
90
+ if (typeof value === 'string') {
91
+ return resolveString(value, styles);
92
+ }
93
+ if (Array.isArray(value)) {
94
+ return value.map(item => walk(item));
95
+ }
96
+ // Themes carry functions (formatters) which must pass through untouched.
97
+ if (value !== null && typeof value === 'object') {
98
+ return mapValues(value, walk);
99
+ }
100
+ return value;
101
+ };
102
+ return walk(input);
103
+ };
104
+
105
+ export { resolveCssValues };
@@ -1,10 +1,35 @@
1
- import { GL_COLOR_NEUTRAL_100, GL_COLOR_DATA_BLUE_200, GL_COLOR_DATA_BLUE_400, GL_COLOR_DATA_BLUE_600, GL_COLOR_DATA_BLUE_800, GL_COLOR_NEUTRAL_900, GL_COLOR_NEUTRAL_500, GL_COLOR_DATA_BLUE_500, GL_COLOR_DATA_BLUE_900, GL_COLOR_DATA_ORANGE_500, GL_COLOR_DATA_ORANGE_600, GL_COLOR_DATA_AQUA_500, GL_COLOR_DATA_GREEN_600, GL_COLOR_DATA_MAGENTA_500, GL_COLOR_DATA_BLUE_700, GL_COLOR_DATA_ORANGE_800, GL_COLOR_DATA_AQUA_700, GL_COLOR_DATA_GREEN_800, GL_COLOR_DATA_MAGENTA_700, GL_COLOR_DATA_ORANGE_950, GL_COLOR_DATA_AQUA_900, GL_COLOR_DATA_GREEN_950, GL_COLOR_DATA_MAGENTA_900, GL_COLOR_DATA_ORANGE_700, GL_COLOR_DATA_AQUA_600, GL_COLOR_DATA_GREEN_700, GL_COLOR_DATA_MAGENTA_600, GL_COLOR_DATA_ORANGE_900, GL_COLOR_DATA_AQUA_800, GL_COLOR_DATA_GREEN_900, GL_COLOR_DATA_MAGENTA_800, GL_COLOR_DATA_BLUE_950, GL_COLOR_DATA_AQUA_950, GL_COLOR_DATA_GREEN_500, GL_COLOR_DATA_MAGENTA_950, GL_COLOR_DATA_ORANGE_400, GL_COLOR_DATA_GREEN_400, GL_COLOR_DATA_BLUE_300, GL_COLOR_DATA_ORANGE_200, GL_COLOR_DATA_AQUA_300, GL_COLOR_DATA_GREEN_200, GL_COLOR_DATA_MAGENTA_300, GL_COLOR_DATA_BLUE_100, GL_COLOR_DATA_ORANGE_50, GL_COLOR_DATA_AQUA_100, GL_COLOR_DATA_GREEN_50, GL_COLOR_DATA_MAGENTA_100, GL_COLOR_DATA_ORANGE_300, GL_COLOR_DATA_AQUA_400, GL_COLOR_DATA_GREEN_300, GL_COLOR_DATA_MAGENTA_400, GL_COLOR_DATA_ORANGE_100, GL_COLOR_DATA_AQUA_200, GL_COLOR_DATA_GREEN_100, GL_COLOR_DATA_MAGENTA_200, GL_COLOR_DATA_BLUE_50, GL_COLOR_DATA_AQUA_50, GL_COLOR_DATA_MAGENTA_50, GL_COLOR_NEUTRAL_50 } from '../../tokens/build/js/tokens';
1
+ import { GL_FONT_FAMILY_REGULAR, GL_COLOR_NEUTRAL_100, GL_COLOR_DATA_BLUE_500, GL_COLOR_DATA_BLUE_600, GL_COLOR_DATA_BLUE_800, GL_COLOR_DATA_BLUE_950, GL_COLOR_NEUTRAL_800, GL_COLOR_DATA_BLUE_400, GL_COLOR_DATA_BLUE_200, GL_COLOR_NEUTRAL_0, GL_COLOR_NEUTRAL_950, GL_COLOR_DATA_BLUE_900, GL_COLOR_DATA_ORANGE_500, GL_COLOR_DATA_ORANGE_600, GL_COLOR_DATA_AQUA_500, GL_COLOR_DATA_GREEN_600, GL_COLOR_DATA_MAGENTA_500, GL_COLOR_DATA_BLUE_700, GL_COLOR_DATA_ORANGE_800, GL_COLOR_DATA_AQUA_700, GL_COLOR_DATA_GREEN_800, GL_COLOR_DATA_MAGENTA_700, GL_COLOR_DATA_ORANGE_950, GL_COLOR_DATA_AQUA_900, GL_COLOR_DATA_GREEN_950, GL_COLOR_DATA_MAGENTA_900, GL_COLOR_DATA_ORANGE_700, GL_COLOR_DATA_AQUA_600, GL_COLOR_DATA_GREEN_700, GL_COLOR_DATA_MAGENTA_600, GL_COLOR_DATA_ORANGE_900, GL_COLOR_DATA_AQUA_800, GL_COLOR_DATA_GREEN_900, GL_COLOR_DATA_MAGENTA_800, GL_COLOR_DATA_AQUA_950, GL_COLOR_DATA_GREEN_500, GL_COLOR_DATA_MAGENTA_950, GL_COLOR_DATA_ORANGE_400, GL_COLOR_DATA_GREEN_400, GL_COLOR_DATA_BLUE_300, GL_COLOR_DATA_ORANGE_200, GL_COLOR_DATA_AQUA_300, GL_COLOR_DATA_GREEN_200, GL_COLOR_DATA_MAGENTA_300, GL_COLOR_DATA_BLUE_100, GL_COLOR_DATA_ORANGE_50, GL_COLOR_DATA_AQUA_100, GL_COLOR_DATA_GREEN_50, GL_COLOR_DATA_MAGENTA_100, GL_COLOR_DATA_ORANGE_300, GL_COLOR_DATA_AQUA_400, GL_COLOR_DATA_GREEN_300, GL_COLOR_DATA_MAGENTA_400, GL_COLOR_DATA_ORANGE_100, GL_COLOR_DATA_AQUA_200, GL_COLOR_DATA_GREEN_100, GL_COLOR_DATA_MAGENTA_200, GL_COLOR_DATA_BLUE_50, GL_COLOR_DATA_AQUA_50, GL_COLOR_DATA_MAGENTA_50, GL_COLOR_NEUTRAL_50 } from '../../tokens/build/js/tokens';
2
2
  import { scrollHandleSvgPath, marqueeSelectionSvgPath, redoSvgPath, clearAllSvgPath, downloadSvgPath } from '../svgs/svg_paths';
3
+ import { defaultFontSize } from './config';
3
4
 
4
5
  const GL_BORDER_RADIUS_BASE = '0.25rem';
5
6
  const themeName = 'gitlab';
6
- const heatmapHues = [GL_COLOR_NEUTRAL_100, GL_COLOR_DATA_BLUE_200, GL_COLOR_DATA_BLUE_400, GL_COLOR_DATA_BLUE_600, GL_COLOR_DATA_BLUE_800];
7
- const gaugeNeutralHues = [GL_COLOR_NEUTRAL_900, GL_COLOR_NEUTRAL_500];
7
+
8
+ /**
9
+ * Sequential scales step away from the surface so that higher values gain contrast against it:
10
+ * darker on a light surface, lighter on a dark one. The lowest step is neutral rather than blue, so
11
+ * that "no data" reads as absence instead of as the bottom of the data range.
12
+ *
13
+ * https://design.gitlab.com/data-visualization/color#sequential-data
14
+ */
15
+ const heatmapHues = [GL_COLOR_NEUTRAL_100, GL_COLOR_DATA_BLUE_500, GL_COLOR_DATA_BLUE_600, GL_COLOR_DATA_BLUE_800, GL_COLOR_DATA_BLUE_950];
16
+ const heatmapHuesDark = [GL_COLOR_NEUTRAL_800, GL_COLOR_DATA_BLUE_600, GL_COLOR_DATA_BLUE_500, GL_COLOR_DATA_BLUE_400, GL_COLOR_DATA_BLUE_200];
17
+
18
+ /**
19
+ * The plate behind the cells, and the separators between them. Separators match the surface so that
20
+ * neighbouring cells, which are only guaranteed to contrast with the surface and not with each
21
+ * other, stay distinguishable.
22
+ *
23
+ * https://design.gitlab.com/data-visualization/color#visual-separators
24
+ */
25
+ const heatmapSurface = {
26
+ plate: GL_COLOR_NEUTRAL_100,
27
+ separator: GL_COLOR_NEUTRAL_0
28
+ };
29
+ const heatmapSurfaceDark = {
30
+ plate: GL_COLOR_NEUTRAL_800,
31
+ separator: GL_COLOR_NEUTRAL_950
32
+ };
8
33
  const gaugeSafeHues = [GL_COLOR_DATA_BLUE_500, GL_COLOR_DATA_BLUE_900];
9
34
  const gaugeWarningHue = GL_COLOR_DATA_ORANGE_500;
10
35
 
@@ -18,12 +43,32 @@ const colorPaletteDefault = [GL_COLOR_DATA_BLUE_500, GL_COLOR_DATA_ORANGE_600, G
18
43
  const colorFromDefaultPalette = index => colorPaletteDefault[index % colorPaletteDefault.length];
19
44
  const colorPaletteDark = [GL_COLOR_DATA_BLUE_500, GL_COLOR_DATA_ORANGE_400, GL_COLOR_DATA_AQUA_500, GL_COLOR_DATA_GREEN_400, GL_COLOR_DATA_MAGENTA_500, GL_COLOR_DATA_BLUE_300, GL_COLOR_DATA_ORANGE_200, GL_COLOR_DATA_AQUA_300, GL_COLOR_DATA_GREEN_200, GL_COLOR_DATA_MAGENTA_300, GL_COLOR_DATA_BLUE_100, GL_COLOR_DATA_ORANGE_50, GL_COLOR_DATA_AQUA_100, GL_COLOR_DATA_GREEN_50, GL_COLOR_DATA_MAGENTA_100, GL_COLOR_DATA_BLUE_400, GL_COLOR_DATA_ORANGE_300, GL_COLOR_DATA_AQUA_400, GL_COLOR_DATA_GREEN_300, GL_COLOR_DATA_MAGENTA_400, GL_COLOR_DATA_BLUE_200, GL_COLOR_DATA_ORANGE_100, GL_COLOR_DATA_AQUA_200, GL_COLOR_DATA_GREEN_100, GL_COLOR_DATA_MAGENTA_200, GL_COLOR_DATA_BLUE_50, GL_COLOR_DATA_ORANGE_500, GL_COLOR_DATA_AQUA_50, GL_COLOR_DATA_GREEN_500, GL_COLOR_DATA_MAGENTA_50];
20
45
  const colorFromDarkPalette = index => colorPaletteDark[index % colorPaletteDark.length];
46
+
47
+ // ECharts lays out text by assigning a CSS font shorthand to a canvas context and calling
48
+ // `measureText`, even under the SVG renderer. Canvas does not substitute CSS custom properties,
49
+ // and an unparseable shorthand is rejected wholesale, leaving text measured at the canvas default
50
+ // of 10px sans-serif. So the chart font stack must be concrete: drop the leading
51
+ // `var(--default-regular-font, 'GitLab Sans')` consumer override, whose own fallback is the
52
+ // 'GitLab Sans' that follows it.
53
+ const defaultFontFamily = GL_FONT_FAMILY_REGULAR.filter(family => !family.startsWith('var(')).join(', ');
54
+
55
+ /**
56
+ * Chart font sizes are concrete pixel numbers rather than the rem-based font.size tokens they
57
+ * mirror. zrender measures text on a detached canvas, which always resolves 1rem as 16px however
58
+ * the document root is set, so rem sizes render scaled while being laid out at the wrong width.
59
+ *
60
+ * `defaultFontSize` (12, font.size.100) is set per component in the theme below, because ECharts
61
+ * pins fontSize in its own component defaults (axis labels at 12, title at 18, gauge detail at 30)
62
+ * and those shadow `textStyle`.
63
+ */
64
+
21
65
  const axes = {
22
66
  axisLabel: {
23
67
  margin: 8,
24
68
  show: true,
25
69
  color: 'var(--gl-chart-axis-text-color)',
26
- hideOverlap: true
70
+ hideOverlap: true,
71
+ fontSize: defaultFontSize
27
72
  },
28
73
  axisLine: {
29
74
  show: false
@@ -46,7 +91,8 @@ const axes = {
46
91
  },
47
92
  nameGap: 30,
48
93
  nameTextStyle: {
49
- fontWeight: 'bold'
94
+ fontWeight: 'bold',
95
+ fontSize: defaultFontSize
50
96
  },
51
97
  splitLine: {
52
98
  lineStyle: {
@@ -64,7 +110,26 @@ const createTheme = function () {
64
110
  color: colorPaletteDefault,
65
111
  backgroundColor: 'transparent',
66
112
  textStyle: {
67
- color: 'var(--gl-text-color-default)'
113
+ color: 'var(--gl-text-color-default)',
114
+ fontFamily: defaultFontFamily,
115
+ fontSize: defaultFontSize
116
+ },
117
+ title: {
118
+ textStyle: {
119
+ fontSize: 18 // font.size.500-fixed, 1.125rem
120
+ }
121
+ },
122
+ gauge: {
123
+ axisLabel: {
124
+ fontSize: 14,
125
+ // font.size.300, 0.875rem, the default body size
126
+ color: 'var(--gl-chart-axis-text-color)'
127
+ },
128
+ detail: {
129
+ fontSize: 30,
130
+ fontWeight: 'bold',
131
+ color: 'var(--gl-text-color-default)'
132
+ }
68
133
  },
69
134
  markLine: {
70
135
  silent: true,
@@ -197,4 +262,4 @@ const createTheme = function () {
197
262
  };
198
263
  };
199
264
 
200
- export { colorFromDarkPalette, colorFromDefaultPalette, colorPaletteDark, colorPaletteDefault, createTheme, gaugeNeutralHues, gaugeSafeHues, gaugeWarningHue, heatmapHues, themeName };
265
+ export { colorFromDarkPalette, colorFromDefaultPalette, colorPaletteDark, colorPaletteDefault, createTheme, defaultFontFamily, gaugeSafeHues, gaugeWarningHue, heatmapHues, heatmapHuesDark, heatmapSurface, heatmapSurfaceDark, themeName };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gitlab/ui",
3
- "version": "137.2.0",
3
+ "version": "137.2.1",
4
4
  "description": "GitLab UI Components",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -109,7 +109,7 @@
109
109
  "@gitlab/fonts": "^1.3.1",
110
110
  "@gitlab/hybrid-vue": "npm:@vue/compat@3.5.34",
111
111
  "@gitlab/svgs": "*",
112
- "@jest/test-sequencer": "30.4.1",
112
+ "@jest/test-sequencer": "30.5.1",
113
113
  "@rollup/plugin-commonjs": "^28.0.9",
114
114
  "@rollup/plugin-node-resolve": "^16.0.3",
115
115
  "@rollup/plugin-replace": "^6.0.3",
@@ -175,7 +175,7 @@
175
175
  "start-server-and-test": "^2.1.5",
176
176
  "storybook": "^7.6.24",
177
177
  "storybook-dark-mode": "4.0.2",
178
- "style-dictionary": "^5.5.2",
178
+ "style-dictionary": "^5.5.3",
179
179
  "style-loader": "^4",
180
180
  "tailwindcss": "3.4.19",
181
181
  "vue": "2.7.16",
@@ -8,6 +8,8 @@ import {
8
8
  toolboxHeight,
9
9
  } from '../../../utils/charts/config';
10
10
  import { createTheme, themeName } from '../../../utils/charts/theme';
11
+ import { resolveCssValues } from '../../../utils/charts/css_values';
12
+ import { onColorModeChange } from '../../../utils/charts/color_mode';
11
13
  import { GlResizeObserverDirective } from '../../../directives/resize_observer/resize_observer';
12
14
  import { debounceByAnimationFrame } from '../../../utils/utils';
13
15
 
@@ -122,35 +124,79 @@ export default {
122
124
  async mounted() {
123
125
  await this.$nextTick();
124
126
 
125
- const chart = echarts.init(this.$refs.chart, themeName, {
126
- renderer: this.renderer,
127
- width: defaultWidth,
128
- height: defaultHeight,
129
- });
130
- // FIXME: temporary workaround to ensure compatibility with @vue/compat
131
- // eslint-disable-next-line no-underscore-dangle
132
- chart.__v_skip = true;
133
- this.chart = chart;
134
-
135
- if (this.groupId.length) {
136
- this.chart.group = this.groupId;
137
- echarts.connect(this.groupId);
138
- }
139
-
140
- this.chart.on('click', this.handleClick);
141
- /**
142
- * Emitted after calling `echarts.init`
143
- */
144
- this.$emit('created', this.chart);
145
- this.draw();
146
- this.setChartSize();
127
+ this.initChart();
128
+ this.observeColorMode();
147
129
  },
148
130
  beforeDestroy() {
149
131
  if (!this.chart) return;
150
132
 
151
133
  this.chart.off('click', this.handleClick);
134
+ this.unsubscribeColorMode?.();
152
135
  },
153
136
  methods: {
137
+ initChart() {
138
+ const chart = echarts.init(this.$refs.chart, this.theme(), {
139
+ renderer: this.renderer,
140
+ width: defaultWidth,
141
+ height: defaultHeight,
142
+ });
143
+ // FIXME: temporary workaround to ensure compatibility with @vue/compat
144
+ // eslint-disable-next-line no-underscore-dangle
145
+ chart.__v_skip = true;
146
+ this.chart = chart;
147
+
148
+ if (this.groupId.length) {
149
+ this.chart.group = this.groupId;
150
+ echarts.connect(this.groupId);
151
+ }
152
+
153
+ this.chart.on('click', this.handleClick);
154
+ /**
155
+ * Emitted after calling `echarts.init`
156
+ */
157
+ this.$emit('created', this.chart);
158
+ this.draw();
159
+ this.setChartSize();
160
+ },
161
+ /**
162
+ * The SVG renderer consumes the registered theme as-is, leaving its `var(--token)` colours for
163
+ * the browser to resolve. Canvas cannot, so it gets a copy resolved against this element.
164
+ */
165
+ theme() {
166
+ if (this.renderer !== 'canvas') {
167
+ return themeName;
168
+ }
169
+
170
+ return resolveCssValues(createTheme(this.options), this.$refs.chart);
171
+ },
172
+ /**
173
+ * Rebuilding the chart discards its interaction state, so the legend selection and zoom range
174
+ * are carried over. Only these stateful fields are copied: the full option from getOption()
175
+ * holds colours resolved against the old colour mode.
176
+ */
177
+ preservedState() {
178
+ const { legend = [], dataZoom = [] } = this.chart.getOption();
179
+ return {
180
+ legend: legend.map(({ selected }) => ({ selected })),
181
+ dataZoom: dataZoom.map(({ start, end }) => ({ start, end })),
182
+ };
183
+ },
184
+ /**
185
+ * Colours resolved for canvas are frozen at init, so the chart has to be rebuilt when the
186
+ * colour mode changes. SVG re-resolves on its own and needs no subscription.
187
+ */
188
+ observeColorMode() {
189
+ if (this.renderer !== 'canvas') {
190
+ return;
191
+ }
192
+
193
+ this.unsubscribeColorMode = onColorModeChange(this.$refs.chart, () => {
194
+ const state = this.preservedState();
195
+ this.chart.dispose();
196
+ this.initChart();
197
+ this.chart.setOption(state);
198
+ });
199
+ },
154
200
  draw() {
155
201
  this.chart.setOption(this.modifiedOptions);
156
202
  /**
@@ -1,14 +1,9 @@
1
1
  <script>
2
2
  import { merge, uniq, sortBy } from 'lodash-es';
3
- import { gaugeNeutralHues, gaugeSafeHues, gaugeWarningHue } from '../../../utils/charts/theme';
3
+ import { gaugeSafeHues, gaugeWarningHue } from '../../../utils/charts/theme';
4
4
  import Chart from '../chart/chart.vue';
5
5
 
6
- const AXIS_LABEL_FONT_SIZE_PX = 14;
7
-
8
6
  const ARC_RADIUS = '80%';
9
- const DETAIL_FONT_SIZE_PX = 30;
10
- const DETAIL_FONT_FAMILY = 'sans-serif';
11
- const DETAIL_FONT_WEIGHT = 'bold';
12
7
  const POINTER_LENGTH = '65%';
13
8
  const POINTER_WIDTH_PX = 5;
14
9
 
@@ -22,16 +17,10 @@ const gaugeChartSeries = ({ value, text, min, max, splitNumber, axisColor }) =>
22
17
 
23
18
  return text || (currentValue ?? '--');
24
19
  },
25
- color: `${gaugeNeutralHues[0]}`,
26
- fontSize: DETAIL_FONT_SIZE_PX,
27
- fontFamily: DETAIL_FONT_FAMILY,
28
- fontWeight: DETAIL_FONT_WEIGHT,
29
20
  },
30
21
  axisLabel: {
31
22
  show: true,
32
- fontSize: AXIS_LABEL_FONT_SIZE_PX,
33
23
  formatter: (theValue) => (Number.isFinite(theValue) ? theValue : '--'),
34
- color: `${gaugeNeutralHues[1]}`,
35
24
  },
36
25
  axisLine: {
37
26
  lineStyle: {
@@ -1,9 +1,14 @@
1
1
  <script>
2
2
  import { merge } from 'lodash-es';
3
- import { GL_COLOR_NEUTRAL_0, GL_COLOR_NEUTRAL_100 } from '../../../tokens/build/js/tokens';
4
3
  import { getTooltipTitle, getTooltipContent } from '../../../utils/charts/config';
5
4
  import { HEIGHT_AUTO_CLASSES } from '../../../utils/charts/constants';
6
- import { heatmapHues } from '../../../utils/charts/theme';
5
+ import {
6
+ heatmapHues,
7
+ heatmapHuesDark,
8
+ heatmapSurface,
9
+ heatmapSurfaceDark,
10
+ } from '../../../utils/charts/theme';
11
+ import { isDarkMode, onColorModeChange } from '../../../utils/charts/color_mode';
7
12
  import { engineeringNotation } from '../../../utils/number_utils';
8
13
  import TooltipDefaultFormat from '../shared/tooltip/tooltip_default_format/tooltip_default_format.vue';
9
14
  import Chart from '../chart/chart.vue';
@@ -13,9 +18,6 @@ import ChartTooltip from '../shared/tooltip/tooltip.vue';
13
18
  const defaultOptions = {
14
19
  visualMap: {
15
20
  show: false,
16
- inRange: {
17
- color: heatmapHues,
18
- },
19
21
  },
20
22
  series: {
21
23
  type: 'heatmap',
@@ -154,9 +156,19 @@ export default {
154
156
  top: '0',
155
157
  },
156
158
  selectedFormatTooltipText: this.formatTooltipText || this.defaultFormatTooltipText,
159
+ // ECharts interpolates the visualMap stops numerically, so these colours cannot be CSS
160
+ // custom properties and have to be picked for the current colour mode instead. Resolved on
161
+ // mount, because the mode depends on where this chart sits in the document.
162
+ isDark: false,
157
163
  };
158
164
  },
159
165
  computed: {
166
+ hues() {
167
+ return this.isDark ? heatmapHuesDark : heatmapHues;
168
+ },
169
+ surface() {
170
+ return this.isDark ? heatmapSurfaceDark : heatmapSurface;
171
+ },
160
172
  computedOptions() {
161
173
  const { min, max } = getRange(this.dataSeries);
162
174
  return merge(
@@ -172,11 +184,14 @@ export default {
172
184
  right: '32px',
173
185
  show: true,
174
186
  borderWidth: 0,
175
- backgroundColor: GL_COLOR_NEUTRAL_100,
187
+ backgroundColor: this.surface.plate,
176
188
  },
177
189
  visualMap: {
178
190
  min,
179
191
  max,
192
+ inRange: {
193
+ color: this.hues,
194
+ },
180
195
  },
181
196
  xAxis: {
182
197
  data: this.xAxisLabels,
@@ -196,7 +211,7 @@ export default {
196
211
  show: true,
197
212
  interval: 0,
198
213
  lineStyle: {
199
- color: GL_COLOR_NEUTRAL_0,
214
+ color: this.surface.separator,
200
215
  width: 2,
201
216
  },
202
217
  },
@@ -223,7 +238,7 @@ export default {
223
238
  show: true,
224
239
  interval: 0,
225
240
  lineStyle: {
226
- color: GL_COLOR_NEUTRAL_0,
241
+ color: this.surface.separator,
227
242
  width: 2,
228
243
  },
229
244
  },
@@ -240,9 +255,9 @@ export default {
240
255
  },
241
256
  seriesInfo() {
242
257
  const { min, max } = getRange(this.dataSeries);
243
- const step = (max - min) / heatmapHues.length;
258
+ const step = (max - min) / this.hues.length;
244
259
 
245
- return heatmapHues.map((color, index) => {
260
+ return this.hues.map((color, index) => {
246
261
  const lowerBound = engineeringNotation(min + step * index);
247
262
  const upperBound = engineeringNotation(min + step * (index + 1));
248
263
 
@@ -257,6 +272,15 @@ export default {
257
272
  return this.height === 'auto';
258
273
  },
259
274
  },
275
+ mounted() {
276
+ this.isDark = isDarkMode(this.$el);
277
+ this.unsubscribeColorMode = onColorModeChange(this.$el, (isDark) => {
278
+ this.isDark = isDark;
279
+ });
280
+ },
281
+ beforeDestroy() {
282
+ this.unsubscribeColorMode?.();
283
+ },
260
284
  methods: {
261
285
  defaultFormatTooltipText(params) {
262
286
  this.tooltip.title = getTooltipTitle(params, this.computedOptions.xAxis.name);
@@ -1,6 +1,7 @@
1
1
  <script>
2
2
  import * as echarts from 'echarts';
3
3
  import { defaultFontSize } from '../../../utils/charts/config';
4
+ import { defaultFontFamily } from '../../../utils/charts/theme';
4
5
  import {
5
6
  LEGEND_LAYOUT_INLINE,
6
7
  LEGEND_LAYOUT_TABLE,
@@ -102,7 +103,7 @@ export default {
102
103
  computed: {
103
104
  fontStyle() {
104
105
  return {
105
- fontFamily: this.textStyle.fontFamily || 'sans-serif',
106
+ fontFamily: this.textStyle.fontFamily || defaultFontFamily,
106
107
  fontSize: `${this.textStyle.fontSize || defaultFontSize}px`,
107
108
  };
108
109
  },
@@ -110,6 +111,17 @@ export default {
110
111
  return this.seriesInfo.length === 1;
111
112
  },
112
113
  },
114
+ watch: {
115
+ /**
116
+ * When the canvas renderer rebuilds the chart on a colour-mode change it disposes the old
117
+ * ECharts instance and creates a new one. The parent emits `created` with the new instance and
118
+ * updates this prop, so we must rebind our persistent listener to the new instance.
119
+ */
120
+ chart(newChart, oldChart) {
121
+ oldChart.off('legendselectchanged', this.suppressLastActiveSeriesLabelToggle);
122
+ newChart.on('legendselectchanged', this.suppressLastActiveSeriesLabelToggle);
123
+ },
124
+ },
113
125
  created() {
114
126
  this.chart.on('legendselectchanged', this.suppressLastActiveSeriesLabelToggle);
115
127
  },
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The classes that decide which set of token values applies to an element.
3
+ *
4
+ * Dark mode is applied either to the whole document, by putting `gl-dark` on the root, or to a
5
+ * subtree, by putting `gl-dark-scope` on any ancestor. `gl-light-scope` flips a subtree back.
6
+ *
7
+ * These are the single source of truth: `bin/build_tokens.mjs` builds the CSS selectors from them,
8
+ * so the stylesheets and any JavaScript that has to reason about the colour mode cannot drift
9
+ * apart. Changing a name here changes both.
10
+ */
11
+ export const DARK_ROOT_CLASS = 'gl-dark';
12
+ export const DARK_SCOPE_CLASS = 'gl-dark-scope';
13
+ export const LIGHT_SCOPE_CLASS = 'gl-light-scope';
14
+
15
+ export const LIGHT_SELECTOR = `:root, .${LIGHT_SCOPE_CLASS}`;
16
+ export const DARK_SELECTOR = `:root.${DARK_ROOT_CLASS}, .${DARK_SCOPE_CLASS}`;
@@ -0,0 +1,83 @@
1
+ import { DARK_ROOT_CLASS, DARK_SCOPE_CLASS, LIGHT_SCOPE_CLASS } from '../../tokens/color_mode';
2
+
3
+ const SCOPE_CLASSES = [DARK_SCOPE_CLASS, LIGHT_SCOPE_CLASS, DARK_ROOT_CLASS];
4
+
5
+ const SCOPE_SELECTOR = SCOPE_CLASSES.map((scopeClass) => `.${scopeClass}`).join(', ');
6
+
7
+ /**
8
+ * Whether the given element sits in dark mode.
9
+ *
10
+ * Colour mode is not a document-wide fact. Tokens are declared for the root dark class and for a
11
+ * dark scope class, so any subtree can be flipped independently, and a light scope can flip one
12
+ * back inside a dark document. The nearest scope wins, which is what `closest` gives us.
13
+ *
14
+ * This lives under `charts` rather than in the general utilities because charts are the only thing
15
+ * in the package that needs it. Everywhere else, colour follows tokens and CSS re-resolves them on
16
+ * its own. Charts cannot always do that: ECharts parses colours numerically for its visual maps,
17
+ * and canvas cannot read custom properties at all, so those values have to be chosen in JavaScript
18
+ * and re-chosen when the mode changes.
19
+ */
20
+ export const isDarkMode = (element) => {
21
+ const scope = element?.closest?.(SCOPE_SELECTOR);
22
+
23
+ return Boolean(scope) && !scope.classList.contains(LIGHT_SCOPE_CLASS);
24
+ };
25
+
26
+ // Keyed by subscription so that each subscribed element's last known mode is tracked separately.
27
+ const subscriptions = new Map();
28
+ let observer = null;
29
+
30
+ // Most class mutations (hover states, dropdowns opening and closing) have nothing to do with the
31
+ // colour mode. Only mutations that added or removed a scope class can change it, so anything else
32
+ // can be dismissed with this cheap check instead of re-checking every subscriber.
33
+ const touchesColorMode = ({ target, oldValue }) =>
34
+ SCOPE_CLASSES.some(
35
+ (scopeClass) => target.classList?.contains(scopeClass) || oldValue?.includes(scopeClass),
36
+ );
37
+
38
+ const notify = () => {
39
+ subscriptions.forEach(({ element, subscriber, mode }, key) => {
40
+ const current = isDarkMode(element);
41
+
42
+ if (current !== mode) {
43
+ subscriptions.set(key, { element, subscriber, mode: current });
44
+ subscriber(current);
45
+ }
46
+ });
47
+ };
48
+
49
+ /**
50
+ * Calls back when the colour mode of `element` changes, and returns a function that unsubscribes.
51
+ *
52
+ * Most colour can be left to CSS, which re-resolves custom properties on its own. This exists for
53
+ * the places that cannot: colours ECharts parses numerically, such as the interpolated stops of a
54
+ * continuous `visualMap`, and colours handed to a canvas context. Both need a concrete value picked
55
+ * in JavaScript, so both need telling when the mode flips.
56
+ *
57
+ * Because a scope class can live on any ancestor, this watches the whole document for class
58
+ * changes, then re-checks each subscribed element and only calls back when its own mode changed.
59
+ */
60
+ export const onColorModeChange = (element, subscriber) => {
61
+ if (!subscriptions.size && typeof MutationObserver !== 'undefined') {
62
+ observer = new MutationObserver((mutations) => {
63
+ if (mutations.some((mutation) => touchesColorMode(mutation))) notify();
64
+ });
65
+ observer.observe(document.documentElement, {
66
+ attributeFilter: ['class'],
67
+ attributeOldValue: true,
68
+ subtree: true,
69
+ });
70
+ }
71
+
72
+ const key = Symbol('colorModeSubscription');
73
+ subscriptions.set(key, { element, subscriber, mode: isDarkMode(element) });
74
+
75
+ return () => {
76
+ subscriptions.delete(key);
77
+
78
+ if (!subscriptions.size) {
79
+ observer?.disconnect();
80
+ observer = null;
81
+ }
82
+ };
83
+ };
@@ -0,0 +1,59 @@
1
+ import { mapValues } from 'lodash-es';
2
+
3
+ const CSS_VAR = /^var\(\s*(--[\w-]+)\s*(?:,\s*([\s\S]*?)\s*)?\)$/;
4
+
5
+ const resolveString = (value, styles) => {
6
+ if (value.toLowerCase() === 'currentcolor') {
7
+ return styles.color;
8
+ }
9
+
10
+ const match = CSS_VAR.exec(value);
11
+ if (!match) {
12
+ return value;
13
+ }
14
+
15
+ const [, name, fallback] = match;
16
+ const resolved = styles.getPropertyValue(name).trim();
17
+
18
+ if (resolved) {
19
+ return resolved;
20
+ }
21
+
22
+ return fallback ? resolveString(fallback, styles) : value;
23
+ };
24
+
25
+ /**
26
+ * Deeply replaces `var(--token)` and `currentcolor` strings with the values they resolve to for the
27
+ * given element.
28
+ *
29
+ * The SVG renderer needs none of this: it emits both forms into the DOM, where the browser resolves
30
+ * them and keeps them live across a colour-mode change. Canvas has no such context. `fillStyle` is
31
+ * a plain string setter, so an unparseable `var(--token)` is dropped on the floor and leaves the
32
+ * previous fill in place, while `currentcolor` has no element to inherit from and is defined to
33
+ * resolve to opaque black. Either way a canvas-rendered chart silently loses its themed colours.
34
+ *
35
+ * Resolution is relative to `element` rather than the document root so that `.gl-dark-scope`
36
+ * ancestors are honoured.
37
+ *
38
+ * Values are resolved once, so callers are responsible for resolving again when the colour mode
39
+ * changes.
40
+ */
41
+ export const resolveCssValues = (input, element) => {
42
+ const styles = window.getComputedStyle(element);
43
+
44
+ const walk = (value) => {
45
+ if (typeof value === 'string') {
46
+ return resolveString(value, styles);
47
+ }
48
+ if (Array.isArray(value)) {
49
+ return value.map((item) => walk(item));
50
+ }
51
+ // Themes carry functions (formatters) which must pass through untouched.
52
+ if (value !== null && typeof value === 'object') {
53
+ return mapValues(value, walk);
54
+ }
55
+ return value;
56
+ };
57
+
58
+ return walk(input);
59
+ };
@@ -1,8 +1,9 @@
1
1
  import {
2
+ GL_COLOR_NEUTRAL_0,
2
3
  GL_COLOR_NEUTRAL_50,
3
4
  GL_COLOR_NEUTRAL_100,
4
- GL_COLOR_NEUTRAL_500,
5
- GL_COLOR_NEUTRAL_900,
5
+ GL_COLOR_NEUTRAL_800,
6
+ GL_COLOR_NEUTRAL_950,
6
7
  GL_COLOR_DATA_AQUA_50,
7
8
  GL_COLOR_DATA_AQUA_100,
8
9
  GL_COLOR_DATA_AQUA_200,
@@ -58,6 +59,7 @@ import {
58
59
  GL_COLOR_DATA_ORANGE_800,
59
60
  GL_COLOR_DATA_ORANGE_950,
60
61
  GL_COLOR_DATA_ORANGE_900,
62
+ GL_FONT_FAMILY_REGULAR,
61
63
  } from '../../tokens/build/js/tokens';
62
64
  import {
63
65
  scrollHandleSvgPath,
@@ -66,20 +68,45 @@ import {
66
68
  clearAllSvgPath,
67
69
  downloadSvgPath,
68
70
  } from '../svgs/svg_paths';
71
+ import { defaultFontSize } from './config';
69
72
 
70
73
  const GL_BORDER_RADIUS_BASE = '0.25rem';
71
74
 
72
75
  export const themeName = 'gitlab';
73
76
 
77
+ /**
78
+ * Sequential scales step away from the surface so that higher values gain contrast against it:
79
+ * darker on a light surface, lighter on a dark one. The lowest step is neutral rather than blue, so
80
+ * that "no data" reads as absence instead of as the bottom of the data range.
81
+ *
82
+ * https://design.gitlab.com/data-visualization/color#sequential-data
83
+ */
74
84
  export const heatmapHues = [
75
85
  GL_COLOR_NEUTRAL_100,
76
- GL_COLOR_DATA_BLUE_200,
77
- GL_COLOR_DATA_BLUE_400,
86
+ GL_COLOR_DATA_BLUE_500,
78
87
  GL_COLOR_DATA_BLUE_600,
79
88
  GL_COLOR_DATA_BLUE_800,
89
+ GL_COLOR_DATA_BLUE_950,
80
90
  ];
81
91
 
82
- export const gaugeNeutralHues = [GL_COLOR_NEUTRAL_900, GL_COLOR_NEUTRAL_500];
92
+ export const heatmapHuesDark = [
93
+ GL_COLOR_NEUTRAL_800,
94
+ GL_COLOR_DATA_BLUE_600,
95
+ GL_COLOR_DATA_BLUE_500,
96
+ GL_COLOR_DATA_BLUE_400,
97
+ GL_COLOR_DATA_BLUE_200,
98
+ ];
99
+
100
+ /**
101
+ * The plate behind the cells, and the separators between them. Separators match the surface so that
102
+ * neighbouring cells, which are only guaranteed to contrast with the surface and not with each
103
+ * other, stay distinguishable.
104
+ *
105
+ * https://design.gitlab.com/data-visualization/color#visual-separators
106
+ */
107
+ export const heatmapSurface = { plate: GL_COLOR_NEUTRAL_100, separator: GL_COLOR_NEUTRAL_0 };
108
+ export const heatmapSurfaceDark = { plate: GL_COLOR_NEUTRAL_800, separator: GL_COLOR_NEUTRAL_950 };
109
+
83
110
  export const gaugeSafeHues = [GL_COLOR_DATA_BLUE_500, GL_COLOR_DATA_BLUE_900];
84
111
  export const gaugeWarningHue = GL_COLOR_DATA_ORANGE_500;
85
112
 
@@ -159,12 +186,33 @@ export const colorPaletteDark = [
159
186
 
160
187
  export const colorFromDarkPalette = (index) => colorPaletteDark[index % colorPaletteDark.length];
161
188
 
189
+ // ECharts lays out text by assigning a CSS font shorthand to a canvas context and calling
190
+ // `measureText`, even under the SVG renderer. Canvas does not substitute CSS custom properties,
191
+ // and an unparseable shorthand is rejected wholesale, leaving text measured at the canvas default
192
+ // of 10px sans-serif. So the chart font stack must be concrete: drop the leading
193
+ // `var(--default-regular-font, 'GitLab Sans')` consumer override, whose own fallback is the
194
+ // 'GitLab Sans' that follows it.
195
+ export const defaultFontFamily = GL_FONT_FAMILY_REGULAR.filter(
196
+ (family) => !family.startsWith('var('),
197
+ ).join(', ');
198
+
199
+ /**
200
+ * Chart font sizes are concrete pixel numbers rather than the rem-based font.size tokens they
201
+ * mirror. zrender measures text on a detached canvas, which always resolves 1rem as 16px however
202
+ * the document root is set, so rem sizes render scaled while being laid out at the wrong width.
203
+ *
204
+ * `defaultFontSize` (12, font.size.100) is set per component in the theme below, because ECharts
205
+ * pins fontSize in its own component defaults (axis labels at 12, title at 18, gauge detail at 30)
206
+ * and those shadow `textStyle`.
207
+ */
208
+
162
209
  const axes = {
163
210
  axisLabel: {
164
211
  margin: 8,
165
212
  show: true,
166
213
  color: 'var(--gl-chart-axis-text-color)',
167
214
  hideOverlap: true,
215
+ fontSize: defaultFontSize,
168
216
  },
169
217
  axisLine: {
170
218
  show: false,
@@ -188,6 +236,7 @@ const axes = {
188
236
  nameGap: 30,
189
237
  nameTextStyle: {
190
238
  fontWeight: 'bold',
239
+ fontSize: defaultFontSize,
191
240
  },
192
241
  splitLine: {
193
242
  lineStyle: {
@@ -208,6 +257,24 @@ export const createTheme = (options = {}) => ({
208
257
  backgroundColor: 'transparent',
209
258
  textStyle: {
210
259
  color: 'var(--gl-text-color-default)',
260
+ fontFamily: defaultFontFamily,
261
+ fontSize: defaultFontSize,
262
+ },
263
+ title: {
264
+ textStyle: {
265
+ fontSize: 18, // font.size.500-fixed, 1.125rem
266
+ },
267
+ },
268
+ gauge: {
269
+ axisLabel: {
270
+ fontSize: 14, // font.size.300, 0.875rem, the default body size
271
+ color: 'var(--gl-chart-axis-text-color)',
272
+ },
273
+ detail: {
274
+ fontSize: 30,
275
+ fontWeight: 'bold',
276
+ color: 'var(--gl-text-color-default)',
277
+ },
211
278
  },
212
279
  markLine: {
213
280
  silent: true,