summaryrefslogtreecommitdiffstats
path: root/devtools/client/inspector/fonts/components/FontPropertyValue.js
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--devtools/client/inspector/fonts/components/FontPropertyValue.js434
1 files changed, 434 insertions, 0 deletions
diff --git a/devtools/client/inspector/fonts/components/FontPropertyValue.js b/devtools/client/inspector/fonts/components/FontPropertyValue.js
new file mode 100644
index 0000000000..59920818c4
--- /dev/null
+++ b/devtools/client/inspector/fonts/components/FontPropertyValue.js
@@ -0,0 +1,434 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const {
+ createElement,
+ Fragment,
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+
+const {
+ toFixed,
+} = require("resource://devtools/client/inspector/fonts/utils/font-utils.js");
+
+class FontPropertyValue extends PureComponent {
+ static get propTypes() {
+ return {
+ // Whether to allow input values above the value defined by the `max` prop.
+ allowOverflow: PropTypes.bool,
+ // Whether to allow input values below the value defined by the `min` prop.
+ allowUnderflow: PropTypes.bool,
+ className: PropTypes.string,
+ defaultValue: PropTypes.number,
+ disabled: PropTypes.bool.isRequired,
+ label: PropTypes.string.isRequired,
+ min: PropTypes.number.isRequired,
+ // Whether to show the `min` prop value as a label.
+ minLabel: PropTypes.bool,
+ max: PropTypes.number.isRequired,
+ // Whether to show the `max` prop value as a label.
+ maxLabel: PropTypes.bool,
+ name: PropTypes.string.isRequired,
+ // Whether to show the `name` prop value as an extra label (used to show axis tags).
+ nameLabel: PropTypes.bool,
+ onChange: PropTypes.func.isRequired,
+ step: PropTypes.number,
+ // Whether to show the value input field.
+ showInput: PropTypes.bool,
+ // Whether to show the unit select dropdown.
+ showUnit: PropTypes.bool,
+ unit: PropTypes.string,
+ unitOptions: PropTypes.array,
+ value: PropTypes.number,
+ valueLabel: PropTypes.string,
+ };
+ }
+
+ static get defaultProps() {
+ return {
+ allowOverflow: false,
+ allowUnderflow: false,
+ className: "",
+ minLabel: false,
+ maxLabel: false,
+ nameLabel: false,
+ step: 1,
+ showInput: true,
+ showUnit: true,
+ unit: null,
+ unitOptions: [],
+ };
+ }
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ // Whether the user is dragging the slider thumb or pressing on the numeric stepper.
+ interactive: false,
+ // Snapshot of the value from props before the user starts editing the number input.
+ // Used to restore the value when the input is left invalid.
+ initialValue: this.props.value,
+ // Snapshot of the value from props. Reconciled with props on blur.
+ // Used while the user is interacting with the inputs.
+ value: this.props.value,
+ };
+
+ this.onBlur = this.onBlur.bind(this);
+ this.onChange = this.onChange.bind(this);
+ this.onFocus = this.onFocus.bind(this);
+ this.onMouseDown = this.onMouseDown.bind(this);
+ this.onMouseUp = this.onMouseUp.bind(this);
+ this.onUnitChange = this.onUnitChange.bind(this);
+ }
+
+ /**
+ * Given a `prop` key found on the component's props, check the matching `propLabel`.
+ * If `propLabel` is true, return the `prop` value; Otherwise, return null.
+ *
+ * @param {String} prop
+ * Key found on the component's props.
+ * @return {Number|null}
+ */
+ getPropLabel(prop) {
+ const label = this.props[`${prop}Label`];
+ // Decimal count used to limit numbers in labels.
+ const decimals = Math.abs(Math.log10(this.props.step));
+
+ return label ? toFixed(this.props[prop], decimals) : null;
+ }
+
+ /**
+ * Check if the given value is valid according to the constraints of this component.
+ * Ensure it is a number and that it does not go outside the min/max limits, unless
+ * allowed by the `allowOverflow` and `allowUnderflow` props.
+ *
+ * @param {Number} value
+ * Numeric value
+ * @return {Boolean}
+ * Whether the value conforms to the components contraints.
+ */
+ isValueValid(value) {
+ const { allowOverflow, allowUnderflow, min, max } = this.props;
+
+ if (typeof value !== "number" || isNaN(value)) {
+ return false;
+ }
+
+ // Ensure it does not go below minimum value, unless underflow is allowed.
+ if (min !== undefined && value < min && !allowUnderflow) {
+ return false;
+ }
+
+ // Ensure it does not go over maximum value, unless overflow is allowed.
+ if (max !== undefined && value > max && !allowOverflow) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Handler for "blur" events from the range and number input fields.
+ * Reconciles the value between internal state and props.
+ * Marks the input as non-interactive so it may update in response to changes in props.
+ */
+ onBlur() {
+ const isValid = this.isValueValid(this.state.value);
+ let value;
+
+ if (isValid) {
+ value = this.state.value;
+ } else if (this.state.value !== null) {
+ value = Math.max(
+ this.props.min,
+ Math.min(this.state.value, this.props.max)
+ );
+ } else {
+ value = this.state.initialValue;
+ }
+
+ // Avoid updating the value if a keyword value like "normal" is present
+ if (!this.props.valueLabel) {
+ this.updateValue(value);
+ }
+
+ this.toggleInteractiveState(false);
+ }
+
+ /**
+ * Handler for "change" events from the range and number input fields. Calls the change
+ * handler provided with props and updates internal state with the current value.
+ *
+ * Number inputs in Firefox can't be trusted to filter out non-digit characters,
+ * therefore we must implement our own validation.
+ * @see https://bugzilla.mozilla.org/show_bug.cgi?id=1398528
+ *
+ * @param {Event} e
+ * Change event.
+ */
+ onChange(e) {
+ // Regular expresion to check for floating point or integer numbers. Accept negative
+ // numbers only if the min value is negative. Otherwise, expect positive numbers.
+ // Whitespace and non-digit characters are invalid (aside from a single dot).
+ const regex =
+ this.props.min && this.props.min < 0
+ ? /^-?[0-9]+(.[0-9]+)?$/
+ : /^[0-9]+(.[0-9]+)?$/;
+ let string = e.target.value.trim();
+
+ if (e.target.validity.badInput) {
+ return;
+ }
+
+ // Prefix with zero if the string starts with a dot: .5 => 0.5
+ if (string.charAt(0) === "." && string.length > 1) {
+ string = "0" + string;
+ e.target.value = string;
+ }
+
+ // Accept empty strings to allow the input value to be completely erased while typing.
+ // A null value will be handled on blur. @see this.onBlur()
+ if (string === "") {
+ this.setState(prevState => {
+ return {
+ ...prevState,
+ value: null,
+ };
+ });
+
+ return;
+ }
+
+ if (!regex.test(string)) {
+ return;
+ }
+
+ const value = parseFloat(string);
+ this.updateValue(value);
+ }
+
+ onFocus(e) {
+ if (e.target.type === "number") {
+ e.target.select();
+ }
+
+ this.setState(prevState => {
+ return {
+ ...prevState,
+ interactive: true,
+ initialValue: this.props.value,
+ };
+ });
+ }
+
+ onUnitChange(e) {
+ this.props.onChange(
+ this.props.name,
+ this.props.value,
+ this.props.unit,
+ e.target.value
+ );
+ // Reset internal state value and wait for converted value from props.
+ this.setState(prevState => {
+ return {
+ ...prevState,
+ value: null,
+ };
+ });
+ }
+
+ onMouseDown() {
+ this.toggleInteractiveState(true);
+ }
+
+ onMouseUp() {
+ this.toggleInteractiveState(false);
+ }
+
+ /**
+ * Toggle the "interactive" state which causes render() to use `value` fom internal
+ * state instead of from props to prevent jittering during continous dragging of the
+ * range input thumb or incrementing from the number input.
+ *
+ * @param {Boolean} isInteractive
+ * Whether to mark the interactive state on or off.
+ */
+ toggleInteractiveState(isInteractive) {
+ this.setState(prevState => {
+ return {
+ ...prevState,
+ interactive: isInteractive,
+ };
+ });
+ }
+
+ /**
+ * Calls the given `onChange` callback with the current property name, value and unit
+ * if the value is valid according to the constraints of this component (min, max).
+ * Updates the internal state with the current value. This will be used to render the
+ * UI while the input is interactive and the user may be typing a value that's not yet
+ * valid.
+ *
+ * @see this.onBlur() for logic reconciling the internal state with props.
+ *
+ * @param {Number} value
+ * Numeric property value.
+ */
+ updateValue(value) {
+ if (this.isValueValid(value)) {
+ this.props.onChange(this.props.name, value, this.props.unit);
+ }
+
+ this.setState(prevState => {
+ return {
+ ...prevState,
+ value,
+ };
+ });
+ }
+
+ renderUnitSelect() {
+ if (!this.props.unitOptions.length) {
+ return null;
+ }
+
+ // Ensure the select element has the current unit type even if we don't recognize it.
+ // The unit conversion function will use a 1-to-1 scale for unrecognized units.
+ const options = this.props.unitOptions.includes(this.props.unit)
+ ? this.props.unitOptions
+ : this.props.unitOptions.concat([this.props.unit]);
+
+ return dom.select(
+ {
+ className: "font-value-select",
+ disabled: this.props.disabled,
+ onChange: this.onUnitChange,
+ value: this.props.unit,
+ },
+ options.map(unit => {
+ return dom.option(
+ {
+ key: unit,
+ value: unit,
+ },
+ unit
+ );
+ })
+ );
+ }
+
+ renderLabelContent() {
+ const { label, name, nameLabel } = this.props;
+
+ const labelEl = dom.span(
+ {
+ className: "font-control-label-text",
+ "aria-describedby": nameLabel ? `detail-${name}` : null,
+ },
+ label
+ );
+
+ // Show the `name` prop value as an additional label if the `nameLabel` prop is true.
+ const detailEl = nameLabel
+ ? dom.span(
+ {
+ className: "font-control-label-detail",
+ id: `detail-${name}`,
+ },
+ this.getPropLabel("name")
+ )
+ : null;
+
+ return createElement(Fragment, null, labelEl, detailEl);
+ }
+
+ renderValueLabel() {
+ if (!this.props.valueLabel) {
+ return null;
+ }
+
+ return dom.div({ className: "font-value-label" }, this.props.valueLabel);
+ }
+
+ render() {
+ // Guard against bad axis data.
+ if (this.props.min === this.props.max) {
+ return null;
+ }
+
+ const propsValue =
+ this.props.value !== null ? this.props.value : this.props.defaultValue;
+
+ const defaults = {
+ min: this.props.min,
+ max: this.props.max,
+ onBlur: this.onBlur,
+ onChange: this.onChange,
+ onFocus: this.onFocus,
+ step: this.props.step,
+ // While interacting with the range and number inputs, prevent updating value from
+ // outside props which is debounced and causes jitter on successive renders.
+ value: this.state.interactive ? this.state.value : propsValue,
+ };
+
+ const range = dom.input({
+ ...defaults,
+ onMouseDown: this.onMouseDown,
+ onMouseUp: this.onMouseUp,
+ className: "font-value-slider",
+ disabled: this.props.disabled,
+ name: this.props.name,
+ title: this.props.label,
+ type: "range",
+ });
+
+ const input = dom.input({
+ ...defaults,
+ // Remove lower limit from number input if it is allowed to underflow.
+ min: this.props.allowUnderflow ? null : this.props.min,
+ // Remove upper limit from number input if it is allowed to overflow.
+ max: this.props.allowOverflow ? null : this.props.max,
+ name: this.props.name,
+ className: "font-value-input",
+ disabled: this.props.disabled,
+ type: "number",
+ });
+
+ return dom.label(
+ {
+ className: `font-control ${this.props.className}`,
+ disabled: this.props.disabled,
+ },
+ dom.div(
+ {
+ className: "font-control-label",
+ title: this.props.label,
+ },
+ this.renderLabelContent()
+ ),
+ dom.div(
+ {
+ className: "font-control-input",
+ },
+ dom.div(
+ {
+ className: "font-value-slider-container",
+ "data-min": this.getPropLabel("min"),
+ "data-max": this.getPropLabel("max"),
+ },
+ range
+ ),
+ this.renderValueLabel(),
+ this.props.showInput && input,
+ this.props.showUnit && this.renderUnitSelect()
+ )
+ );
+ }
+}
+
+module.exports = FontPropertyValue;