/* 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;