From 0530da14cfec41e3260d0784796c5e573eb7a64e Mon Sep 17 00:00:00 2001 From: Axel Bocciarelli Date: Thu, 11 Mar 2021 10:12:22 +0100 Subject: [PATCH] Implement editing of domain bounds --- src/h5web/toolbar/Toolbar.module.css | 10 +-- .../DomainSlider/BoundEditor.module.css | 78 +++++++++++++++++ .../controls/DomainSlider/BoundEditor.tsx | 85 ++++++++++++++++++ .../DomainSlider/BoundErrorMessage.tsx | 2 +- .../DomainSlider/DomainSlider.module.css | 2 +- .../controls/DomainSlider/DomainSlider.tsx | 86 +++++++++++-------- .../DomainSlider/DomainTooltip.module.css | 46 +--------- .../controls/DomainSlider/DomainTooltip.tsx | 48 ++++++++--- .../controls/DomainSlider/ScaledSlider.tsx | 14 +-- .../core/heatmap/MappedHeatmapVis.tsx | 7 +- src/h5web/vis-packs/core/heatmap/hooks.ts | 3 +- src/h5web/vis-packs/core/heatmap/utils.ts | 14 +-- src/styles/utils.css | 6 ++ src/styles/vars.css | 2 +- 14 files changed, 285 insertions(+), 118 deletions(-) create mode 100644 src/h5web/toolbar/controls/DomainSlider/BoundEditor.module.css create mode 100644 src/h5web/toolbar/controls/DomainSlider/BoundEditor.tsx diff --git a/src/h5web/toolbar/Toolbar.module.css b/src/h5web/toolbar/Toolbar.module.css index 21eb380b3..29e2aea71 100644 --- a/src/h5web/toolbar/Toolbar.module.css +++ b/src/h5web/toolbar/Toolbar.module.css @@ -29,12 +29,6 @@ padding: 0 0.25rem; } -.btn:disabled, -.btn[aria-disabled='true'] { - opacity: 0.5; - pointer-events: none; -} - .btn[data-small] { padding: 0 0.125rem; } @@ -88,6 +82,10 @@ box-shadow: var(--btn-shadow-idle) var(--btn-shadow-color); } +.btn:active > .btnLike { + box-shadow: var(--btn-shadow-pressed) var(--btn-shadow-color); +} + .btn[data-raised]:hover > .btnLike { box-shadow: var(--btn-shadow-raised) var(--btn-shadow-color), var(--btn-shadow-idle) var(--btn-shadow-color); diff --git a/src/h5web/toolbar/controls/DomainSlider/BoundEditor.module.css b/src/h5web/toolbar/controls/DomainSlider/BoundEditor.module.css new file mode 100644 index 000000000..3ce8190b6 --- /dev/null +++ b/src/h5web/toolbar/controls/DomainSlider/BoundEditor.module.css @@ -0,0 +1,78 @@ +.boundEditor { + display: flex; + align-items: center; + margin-bottom: 0.75rem; + font-weight: bold; +} + +.label { + width: 2.5em; + margin: 0 1rem 0 0; + text-transform: uppercase; + font-size: inherit; + color: var(--near-black); +} + +.value { + flex: 1 1 0%; + width: 0; /* ensures this input shrinks before filling the available space */ + height: 1.875rem; /* maintain height regardless of font size */ + margin-right: 0.375rem; + padding: 0.25rem 0.375rem; + background-color: rgba(255, 255, 255, 0.5); + border: 1px solid transparent; + border-radius: 0.125rem; + box-shadow: 0 0 2px var(--dark-slate-gray); + text-align: right; + color: var(--near-black); + font-weight: inherit; + line-height: inherit; + transition: background-color 0.05s ease-in-out, box-shadow 0.05s ease-in-out; + cursor: text; +} + +.value:hover { + box-shadow: 1px 1px 2px 1px var(--dark-gray); +} + +.value:focus { + box-shadow: 1px 1px 2px 1px var(--secondary-dark); + outline: none; +} + +.boundEditor[data-error] .label, +.boundEditor[data-error] .value { + color: var(--warn); +} + +.boundEditor[data-editing='true'] .value { + background-color: var(--white); + border-color: var(--secondary-dark); + outline: none; + font-weight: 600; + font-size: 0.9375em; + line-height: calc(1.2 / 0.9375); +} + +.boundEditor[data-editing='true'] .value:hover { + box-shadow: 1px 1px 2px 1px var(--secondary-dark); +} + +.action { + composes: btn-clean from global; + display: flex; + align-items: center; + padding: 0.25rem; + font-size: 1.125em; + border-radius: 0.5rem; + transition: background-color 0.05s ease-in-out, box-shadow 0.05s ease-in-out; +} + +.action:hover { + background-color: var(--secondary-light); + box-shadow: var(--btn-shadow-idle) var(--btn-shadow-color); +} + +.action:active { + box-shadow: var(--btn-shadow-pressed) var(--btn-shadow-color); +} diff --git a/src/h5web/toolbar/controls/DomainSlider/BoundEditor.tsx b/src/h5web/toolbar/controls/DomainSlider/BoundEditor.tsx new file mode 100644 index 000000000..0e0b90ece --- /dev/null +++ b/src/h5web/toolbar/controls/DomainSlider/BoundEditor.tsx @@ -0,0 +1,85 @@ +import { ReactElement, useEffect, useRef, useState } from 'react'; +import { FiCheck, FiSlash } from 'react-icons/fi'; +import { formatValue } from '../../../utils'; +import styles from './BoundEditor.module.css'; + +interface Props { + label: string; + value: number; + isEditing: boolean; + hasError: boolean; + onEditToggle: (force: boolean) => void; + onChange: (val: number) => void; +} + +function BoundEditor(props: Props): ReactElement { + const { label, value, isEditing, hasError, onEditToggle, onChange } = props; + + const id = `${label}-bound`; + const inputRef = useRef(null); + + const [inputValue, setInputValue] = useState(formatValue(value)); + + useEffect(() => { + setInputValue(isEditing ? value.toString() : formatValue(value)); + }, [isEditing, value, setInputValue]); + + useEffect(() => { + if (!isEditing) { + // Remove focus from min field when editing is turned off + inputRef.current?.blur(); + } + + if (isEditing && label === 'Min') { + // Give focus to min field when opening tooltip in edit mode + inputRef.current?.focus(); + } + }, [isEditing, label]); + + return ( +
{ + evt.preventDefault(); + onChange(Number.parseFloat(inputValue)); + onEditToggle(false); + }} + > + + + setInputValue(evt.target.value)} + onFocus={() => { + if (!isEditing) { + onEditToggle(true); + } + }} + /> + + + +
+ ); +} + +export default BoundEditor; diff --git a/src/h5web/toolbar/controls/DomainSlider/BoundErrorMessage.tsx b/src/h5web/toolbar/controls/DomainSlider/BoundErrorMessage.tsx index 88d5544c6..7692088c8 100644 --- a/src/h5web/toolbar/controls/DomainSlider/BoundErrorMessage.tsx +++ b/src/h5web/toolbar/controls/DomainSlider/BoundErrorMessage.tsx @@ -1,7 +1,7 @@ import type { ReactElement } from 'react'; import { FiCornerDownRight } from 'react-icons/fi'; import { Bound, BoundError } from '../../../vis-packs/core/models'; -import styles from './DomainSlider.module.css'; +import styles from './DomainTooltip.module.css'; interface Props { bound: Bound; diff --git a/src/h5web/toolbar/controls/DomainSlider/DomainSlider.module.css b/src/h5web/toolbar/controls/DomainSlider/DomainSlider.module.css index efe536221..4665156ce 100644 --- a/src/h5web/toolbar/controls/DomainSlider/DomainSlider.module.css +++ b/src/h5web/toolbar/controls/DomainSlider/DomainSlider.module.css @@ -11,7 +11,7 @@ .slider { display: flex; width: 8rem; - margin-right: 0.25rem; + margin-right: -0.25rem; font-size: 0.75rem; cursor: pointer; } diff --git a/src/h5web/toolbar/controls/DomainSlider/DomainSlider.tsx b/src/h5web/toolbar/controls/DomainSlider/DomainSlider.tsx index c0693323f..77b1fe2ec 100644 --- a/src/h5web/toolbar/controls/DomainSlider/DomainSlider.tsx +++ b/src/h5web/toolbar/controls/DomainSlider/DomainSlider.tsx @@ -1,4 +1,4 @@ -import { ReactElement, useEffect, useState } from 'react'; +import { ReactElement, useEffect, useRef, useState } from 'react'; import styles from './DomainSlider.module.css'; import type { CustomDomain, @@ -6,26 +6,17 @@ import type { ScaleType, } from '../../../vis-packs/core/models'; import ToggleBtn from '../ToggleBtn'; -import { FiZap } from 'react-icons/fi'; -import { useKey, useToggle } from 'react-use'; +import { MdTune } from 'react-icons/md'; +import { useClickAway, useKey, useToggle } from 'react-use'; import DomainTooltip from './DomainTooltip'; import ScaledSlider from './ScaledSlider'; -import { useVisDomain } from '../../../vis-packs/core/heatmap/hooks'; +import { + useSafeDomain, + useVisDomain, +} from '../../../vis-packs/core/heatmap/hooks'; const TOOLTIP_ID = 'domain-tooltip'; -function getAutoscaleLabel(isAutoMin: boolean, isAutoMax: boolean): string { - if (isAutoMin && !isAutoMax) { - return 'Min'; - } - - if (isAutoMax && !isAutoMin) { - return 'Max'; - } - - return 'Auto'; -} - interface Props { dataDomain: Domain; customDomain: CustomDomain; @@ -38,38 +29,55 @@ function DomainSlider(props: Props): ReactElement { const { dataDomain, customDomain, scaleType, disabled } = props; const { onCustomDomainChange } = props; - const [visDomain, errors] = useVisDomain(dataDomain, customDomain, scaleType); - const [sliderDomain, setSliderDomain] = useState(visDomain); + const visDomain = useVisDomain(customDomain, dataDomain); + const [safeDomain, errors] = useSafeDomain(visDomain, dataDomain, scaleType); + const [sliderDomain, setSliderDomain] = useState(visDomain); useEffect(() => { setSliderDomain(visDomain); }, [visDomain, setSliderDomain]); const isAutoMin = customDomain[0] === undefined; const isAutoMax = customDomain[1] === undefined; - const isAutoscaling = isAutoMin || isAutoMax; - const [tooltipOpen, toggleTooltip] = useToggle(false); - useKey('Escape', () => toggleTooltip(false)); + const [hovered, toggleHovered] = useToggle(false); + const [isEditingMin, toggleEditingMin] = useToggle(false); + const [isEditingMax, toggleEditingMax] = useToggle(false); + const isEditing = isEditingMin || isEditingMax; + + function toggleAll(force: boolean) { + toggleEditingMin(force); + toggleEditingMax(force); + toggleHovered(force); + } + + const rootRef = useRef(null); + useClickAway(rootRef, () => toggleAll(false)); + useKey('Escape', () => toggleAll(false)); return (
toggleTooltip(true)} - onPointerLeave={() => toggleTooltip(false)} + onPointerEnter={() => toggleHovered(true)} + onPointerLeave={() => toggleHovered(false)} > { + setSliderDomain(newValue); + toggleEditingMin(false); + toggleEditingMax(false); + }} onAfterChange={(hasMinChanged, hasMaxChanged) => { onCustomDomainChange([ hasMinChanged ? sliderDomain[0] : customDomain[0], @@ -79,22 +87,18 @@ function DomainSlider(props: Props): ReactElement { /> { - onCustomDomainChange( - isAutoscaling ? dataDomain : [undefined, undefined] - ); - }} + onChange={() => toggleAll(!isEditing)} /> { const newMin = isAutoMin ? dataDomain[0] : undefined; onCustomDomainChange([newMin, customDomain[1]]); + toggleEditingMin(isAutoMin); }} onAutoMaxToggle={() => { const newMax = isAutoMax ? dataDomain[1] : undefined; onCustomDomainChange([customDomain[0], newMax]); + toggleEditingMax(isAutoMax); }} + isEditingMin={isEditingMin} + isEditingMax={isEditingMax} + onEditMin={toggleEditingMin} + onEditMax={toggleEditingMax} + onChangeMin={(val) => onCustomDomainChange([val, customDomain[1]])} + onChangeMax={(val) => onCustomDomainChange([customDomain[0], val])} />
); diff --git a/src/h5web/toolbar/controls/DomainSlider/DomainTooltip.module.css b/src/h5web/toolbar/controls/DomainSlider/DomainTooltip.module.css index 1671a0f9f..e85fbcc70 100644 --- a/src/h5web/toolbar/controls/DomainSlider/DomainTooltip.module.css +++ b/src/h5web/toolbar/controls/DomainSlider/DomainTooltip.module.css @@ -1,13 +1,13 @@ .tooltip { composes: popup from '../../Toolbar.module.css'; left: 50%; - min-width: 17em; + min-width: 18.25em; transform: translate(-50%, 100%); } .tooltipInner { composes: popupInner from '../../Toolbar.module.css'; - padding: 1rem 0.75rem; + padding: 1rem 0.375rem 1rem 0.75rem; } .tooltipInner > p { @@ -18,48 +18,8 @@ margin-bottom: 0; } -.minMax { - display: grid; - grid-template-columns: [label] auto [value] 1fr [actions] 2.5em; - grid-gap: 0.5rem; - margin-bottom: 1rem; - font-weight: bold; - color: var(--near-black); -} - -.minMax > h3, -.minMax > p { - margin-bottom: 0; -} - -.minMax > h3 { - grid-column: label; - text-transform: uppercase; - font-size: inherit; -} - -.value { - grid-column: value; - display: flex; - align-items: center; - text-align: right; -} - -.value::before { - flex: 1 1 0%; - content: ''; - min-width: 1em; - margin-right: 0.5rem; - margin-top: 0.5rem; - border-bottom: 2px dotted currentColor; - color: var(--black); -} - -.value[data-error] { - color: var(--warn); -} - .dataRange { + margin-top: 1rem; white-space: nowrap; } diff --git a/src/h5web/toolbar/controls/DomainSlider/DomainTooltip.tsx b/src/h5web/toolbar/controls/DomainSlider/DomainTooltip.tsx index 39c1f5012..1a2b83915 100644 --- a/src/h5web/toolbar/controls/DomainSlider/DomainTooltip.tsx +++ b/src/h5web/toolbar/controls/DomainSlider/DomainTooltip.tsx @@ -3,39 +3,61 @@ import type { Domain } from '../../../../packages/lib'; import { formatValue } from '../../../utils'; import type { DomainErrors } from '../../../vis-packs/core/models'; import ToggleBtn from '../ToggleBtn'; +import BoundEditor from './BoundEditor'; import BoundErrorMessage from './BoundErrorMessage'; import styles from './DomainTooltip.module.css'; interface Props { id: string; open: boolean; - domain: Domain; + sliderDomain: Domain; dataDomain: Domain; errors: DomainErrors; isAutoMin: boolean; isAutoMax: boolean; onAutoMinToggle: () => void; onAutoMaxToggle: () => void; + isEditingMin: boolean; + isEditingMax: boolean; + onEditMin: (force: boolean) => void; + onEditMax: (force: boolean) => void; + onChangeMin: (val: number) => void; + onChangeMax: (val: number) => void; } function DomainTooltip(props: Props): ReactElement { - const { id, open, domain, dataDomain, errors, isAutoMin, isAutoMax } = props; - const { onAutoMinToggle, onAutoMaxToggle } = props; + const { id, open, sliderDomain, dataDomain, errors } = props; + const { isAutoMin, isAutoMax, isEditingMin, isEditingMax } = props; + const { + onAutoMinToggle, + onAutoMaxToggle, + onEditMin, + onEditMax, + onChangeMin, + onChangeMax, + } = props; + const { minError, maxError } = errors; return (