From b22c70c88305f404a6703f14fa3556a337a8047a Mon Sep 17 00:00:00 2001 From: Alexandra Goff Date: Wed, 2 Aug 2023 09:55:54 -0700 Subject: [PATCH] [C] make widgets controlled --- packages/epo-widget-lib/package.json | 4 +- .../src/atomic/Blinker/Blinker.tsx | 41 ++-- .../src/atomic/Blinker/Image/Image.tsx | 4 +- .../src/atomic/Blinker/Image/styles.ts | 14 +- .../src/atomic/Blinker/Images/Images.tsx | 19 +- .../src/atomic/Blinker/Images/styles.ts | 14 +- .../src/atomic/Blinker/styles.ts | 36 ++- .../src/widgets/CameraFilter/CameraFilter.tsx | 4 +- .../widgets/ColorTool/ColorTool.stories.tsx | 62 ++++-- .../src/widgets/ColorTool/ColorTool.tsx | 206 +++++++++--------- .../src/widgets/ColorTool/styles.ts | 38 ++-- .../SourceSelector/SourceSelector.stories.tsx | 51 +++-- .../widgets/SourceSelector/SourceSelector.tsx | 64 +++--- .../src/widgets/SourceSelector/utilities.ts | 21 +- 14 files changed, 287 insertions(+), 291 deletions(-) diff --git a/packages/epo-widget-lib/package.json b/packages/epo-widget-lib/package.json index c42e4a09..1d65e463 100644 --- a/packages/epo-widget-lib/package.json +++ b/packages/epo-widget-lib/package.json @@ -1,6 +1,6 @@ { "name": "@rubin-epo/epo-widget-lib", - "version": "0.4.3", + "version": "0.5.0", "description": "Rubin Observatory Education & Public Outreach team React scientific and educational widgets.", "author": "Rubin EPO", "license": "MIT", @@ -86,7 +86,7 @@ "react-i18next": "^12.2.0" }, "dependencies": { - "@rubin-epo/epo-react-lib": "^2.0.0", + "@rubin-epo/epo-react-lib": "^2.0.1", "lodash": "^4.17.21", "styled-components": "^6.0.4", "use-resize-observer": "^9.1.0" diff --git a/packages/epo-widget-lib/src/atomic/Blinker/Blinker.tsx b/packages/epo-widget-lib/src/atomic/Blinker/Blinker.tsx index 234a62ab..55d9a0cd 100644 --- a/packages/epo-widget-lib/src/atomic/Blinker/Blinker.tsx +++ b/packages/epo-widget-lib/src/atomic/Blinker/Blinker.tsx @@ -1,14 +1,12 @@ -import { FunctionComponent, useEffect, useState } from "react"; +import { FunctionComponent, PropsWithChildren, useState } from "react"; import { ImageShape } from "@rubin-epo/epo-react-lib"; -import { tokens } from "@rubin-epo/epo-react-lib/styles"; import useInterval from "@/hooks/useInterval"; -import useResizeObserver from "use-resize-observer"; import * as Styled from "./styles"; import { getClampedArrayIndex } from "@/lib/utils"; export interface BlinkerProps { images: ImageShape[]; - activeIndex?: number; + activeIndex: number; autoplay?: boolean; loop?: boolean; interval?: number; @@ -17,33 +15,20 @@ export interface BlinkerProps { className?: string; } -const Blinker: FunctionComponent = ({ +const Blinker: FunctionComponent> = ({ images = [], - activeIndex: presetIndex = 0, + activeIndex = 0, autoplay = true, loop = true, interval = 200, blinkCallback, loadedCallback, className, + children, }) => { - const { ref, width = 1 } = useResizeObserver(); const [playing, setPlaying] = useState(autoplay); - const [activeIndex, setActiveIndex] = useState(presetIndex); const [loaded, setLoaded] = useState(false); - const { BREAK_MOBILE } = tokens; const canBlink = images.length > 1; - const isCondensed = width < parseInt(BREAK_MOBILE); - - useEffect(() => { - blinkCallback && blinkCallback(activeIndex); - }, [activeIndex]); - - useEffect(() => { - if (loaded) { - loadedCallback && loadedCallback(); - } - }, [loaded]); const getBlink = (direction = 0) => { const lastIndex = images.length - 1; @@ -58,8 +43,7 @@ const Blinker: FunctionComponent = ({ if (loop === false && nextIndex === images.length - 1) { stopBlink(); } - - setActiveIndex(nextIndex); + blinkCallback && blinkCallback(nextIndex); } }; @@ -76,17 +60,21 @@ const Blinker: FunctionComponent = ({ }; const handlePrevious = () => { stopBlink(); - setActiveIndex(getBlink(-1)); + blinkCallback && blinkCallback(getBlink(-1)); }; useInterval(nextBlink, canBlink && loaded && playing ? interval : null); return ( - + setLoaded(true)} - {...{ images, activeIndex, $isCondensed: isCondensed }} + loadedCallback={() => { + setLoaded(true); + loadedCallback && loadedCallback(); + }} + {...{ images, activeIndex }} /> + {children} {canBlink && ( = ({ handleStartStop, handleNext, handlePrevious, - $isCondensed: isCondensed, }} /> )} diff --git a/packages/epo-widget-lib/src/atomic/Blinker/Image/Image.tsx b/packages/epo-widget-lib/src/atomic/Blinker/Image/Image.tsx index 255d2a75..c5669fc9 100644 --- a/packages/epo-widget-lib/src/atomic/Blinker/Image/Image.tsx +++ b/packages/epo-widget-lib/src/atomic/Blinker/Image/Image.tsx @@ -19,8 +19,8 @@ const BlinkerImage: FunctionComponent = ({ loadCallback && loadCallback()} + style={{ "--image-visibility": active ? "visible" : "hidden" }} /> ); }; diff --git a/packages/epo-widget-lib/src/atomic/Blinker/Image/styles.ts b/packages/epo-widget-lib/src/atomic/Blinker/Image/styles.ts index bb1105ed..dc880c00 100644 --- a/packages/epo-widget-lib/src/atomic/Blinker/Image/styles.ts +++ b/packages/epo-widget-lib/src/atomic/Blinker/Image/styles.ts @@ -1,6 +1,6 @@ -import styled, { css } from "styled-components"; +import styled from "styled-components"; -export const BlinkerImage = styled.img<{ $active: boolean }>` +export const BlinkerImage = styled.img` position: absolute; top: 0; right: 0; @@ -15,13 +15,5 @@ export const BlinkerImage = styled.img<{ $active: boolean }>` -o-user-drag: none; -ms-user-drag: none; user-drag: none; - - ${({ $active }) => - $active - ? css` - visibility: visible; - ` - : css` - visibility: hidden; - `} + visibility: var(--image-visibility, hidden); `; diff --git a/packages/epo-widget-lib/src/atomic/Blinker/Images/Images.tsx b/packages/epo-widget-lib/src/atomic/Blinker/Images/Images.tsx index b3b10f2b..4e51aead 100644 --- a/packages/epo-widget-lib/src/atomic/Blinker/Images/Images.tsx +++ b/packages/epo-widget-lib/src/atomic/Blinker/Images/Images.tsx @@ -1,4 +1,4 @@ -import { FunctionComponent, useEffect, useState } from "react"; +import { FunctionComponent, useCallback, useEffect, useState } from "react"; import CircularLoader from "@rubin-epo/epo-react-lib/CircularLoader"; import * as Styled from "./styles"; import BlinkerImage from "../Image/Image"; @@ -17,24 +17,25 @@ const Images: FunctionComponent = ({ loadedCallback, }) => { const [imagesLoaded, setImagesLoaded] = useState(0); - const [isLoading, setIsLoading] = useState(true); + const isLoading = imagesLoaded !== images.length; - const loadCallback = () => { - setImagesLoaded((count) => count + 1); - }; + const loadCallback = useCallback( + () => setImagesLoaded((count) => count + 1), + [] + ); useEffect(() => { - setIsLoading(imagesLoaded !== images.length); - if (!isLoading) { loadedCallback && loadedCallback(); } - }, [imagesLoaded, isLoading]); + }, [isLoading]); return ( {!imagesLoaded && } - + {images.map((image, i) => { const { url } = image; const active = activeIndex === i; diff --git a/packages/epo-widget-lib/src/atomic/Blinker/Images/styles.ts b/packages/epo-widget-lib/src/atomic/Blinker/Images/styles.ts index cf75c292..8eb3b86c 100644 --- a/packages/epo-widget-lib/src/atomic/Blinker/Images/styles.ts +++ b/packages/epo-widget-lib/src/atomic/Blinker/Images/styles.ts @@ -1,4 +1,4 @@ -import styled, { css } from "styled-components"; +import styled from "styled-components"; export const BlinkContainer = styled.div` background-color: var(--neutral95, #1f2121); @@ -8,15 +8,7 @@ export const BlinkContainer = styled.div` position: relative; `; -export const LoadingContainer = styled.div<{ $isLoading: boolean }>` +export const LoadingContainer = styled.div` + opacity: var(--loading-opacity, 0); transition: opacity ease var(--DURATION_SLOW, 0.4s); - - ${({ $isLoading }) => - $isLoading - ? css` - opacity: 0; - ` - : css` - opacity: 1; - `} `; diff --git a/packages/epo-widget-lib/src/atomic/Blinker/styles.ts b/packages/epo-widget-lib/src/atomic/Blinker/styles.ts index 433a2e86..f7d84e27 100644 --- a/packages/epo-widget-lib/src/atomic/Blinker/styles.ts +++ b/packages/epo-widget-lib/src/atomic/Blinker/styles.ts @@ -1,8 +1,10 @@ -import styled, { css } from "styled-components"; +import styled from "styled-components"; +import { token } from "@rubin-epo/epo-react-lib/styles"; import Controls from "./Controls/Controls"; import Images from "./Images/Images"; export const BlinkerContainer = styled.div` + container: blinker / inline-size; display: grid; grid-template-columns: 1fr; grid-template-rows: 1fr min-content; @@ -11,30 +13,24 @@ export const BlinkerContainer = styled.div` height: 100%; `; -export const BlinkerControls = styled(Controls)<{ $isCondensed: boolean }>` +const breakSize: string = token("BREAK_MOBILE") as string; + +export const BlinkerControls = styled(Controls)` grid-row: 2; + margin-block-start: var(--PADDING_SMALL, 20px); - ${({ $isCondensed }) => - $isCondensed - ? css` - margin-block-start: var(--PADDING_SMALL, 20px); - ` - : css` - margin-block-end: var(--PADDING_SMALL, 20px); - `} + @container blinker (min-width: ${breakSize}) { + margin-block-end: var(--PADDING_SMALL, 20px); + } `; -export const BlinkerImages = styled(Images)<{ $isCondensed: boolean }>` +export const BlinkerImages = styled(Images)` aspect-ratio: 1; + grid-row: 1; width: 100%; - ${({ $isCondensed }) => - $isCondensed - ? css` - grid-row: 1; - ` - : css` - position: absolute; - grid-row: span 2; - `}; + @container blinker (min-width: ${breakSize}) { + position: absolute; + grid-row: span 2; + } `; diff --git a/packages/epo-widget-lib/src/widgets/CameraFilter/CameraFilter.tsx b/packages/epo-widget-lib/src/widgets/CameraFilter/CameraFilter.tsx index df86d94e..2a497a44 100644 --- a/packages/epo-widget-lib/src/widgets/CameraFilter/CameraFilter.tsx +++ b/packages/epo-widget-lib/src/widgets/CameraFilter/CameraFilter.tsx @@ -65,10 +65,10 @@ const CameraFilter: FunctionComponent = () => { - {filters.map(({ band }) => ( + {filters.map(({ band }, i) => ( = { +const meta: Meta = { argTypes: { isDisplayOnly: { control: "boolean", @@ -144,12 +146,28 @@ const meta: ComponentMeta = { }; export default meta; -export const Primary: ComponentStoryObj = { - args: { - data: singleData, - selectedData: singleData[0].objects[0], - colorOptions, - }, +const Template: StoryFn = (args) => { + const [selectedData, setSelectedData] = useState( + prepareData(args.selectedData) + ); + + return ( + { + setSelectedData(selected); + args.selectionCallback && args.selectionCallback(selected); + }} + /> + ); +}; + +export const Primary: StoryFn = Template.bind({}); +Primary.args = { + data: singleData, + selectedData: singleData[0].objects[0], + colorOptions, }; const objectOptions: Option[] = []; @@ -164,21 +182,19 @@ multiData.forEach((category) => { }); }); -export const MultipleImages: ComponentStoryObj = { - args: { - data: multiData, - objectOptions, - selectedData: multiData[0].objects[0], - colorOptions: multiSpectralOptions, - }, +export const MultipleImages: StoryFn = Template.bind({}); +MultipleImages.args = { + data: multiData, + objectOptions, + selectedData: multiData[0].objects[0], + colorOptions: multiSpectralOptions, }; -export const DisplayOnly: ComponentStoryObj = { - args: { - data: readOnlyData, - objectOptions, - selectedData: readOnlyData[0].objects[0], - colorOptions: multiSpectralOptions, - isDisplayOnly: true, - }, +export const DisplayOnly: StoryFn = Template.bind({}); +DisplayOnly.args = { + data: readOnlyData, + objectOptions, + selectedData: readOnlyData[0].objects[0], + colorOptions: multiSpectralOptions, + isDisplayOnly: true, }; diff --git a/packages/epo-widget-lib/src/widgets/ColorTool/ColorTool.tsx b/packages/epo-widget-lib/src/widgets/ColorTool/ColorTool.tsx index 6d09b575..af953e3b 100644 --- a/packages/epo-widget-lib/src/widgets/ColorTool/ColorTool.tsx +++ b/packages/epo-widget-lib/src/widgets/ColorTool/ColorTool.tsx @@ -1,11 +1,9 @@ -import { FormEvent, FunctionComponent, useState, useEffect } from "react"; -import useResizeObserver from "use-resize-observer"; +import { FormEvent, FunctionComponent } from "react"; import { useTranslation } from "react-i18next"; import { getCategoryName, getDataFiltersByName, isResetButtonActive, - prepareData, resetFilters, } from "./utilities"; import * as Styled from "./styles"; @@ -53,7 +51,7 @@ interface ColorToolProps { const ColorTool: FunctionComponent = ({ data, objectOptions = [], - selectedData: preSelectedData, + selectedData, colorOptions = [], selectionCallback, isDisabled = false, @@ -61,15 +59,6 @@ const ColorTool: FunctionComponent = ({ hideImage = false, hideSubtitle = false, }) => { - const { ref, width = 1 } = useResizeObserver(); - const [selectedData, setSelectedData] = useState( - prepareData(preSelectedData) - ); - - useEffect(() => { - selectionCallback && selectionCallback(selectedData); - }, [selectedData]); - const handleFilterChange = (updatedFilter: ImageFilter) => { const { label } = updatedFilter; const { filters } = selectedData; @@ -77,23 +66,30 @@ const ColorTool: FunctionComponent = ({ f.label === label ? updatedFilter : f ); - return setSelectedData({ - ...selectedData, - filters: updatedFilters, - }); + return ( + selectionCallback && + selectionCallback({ + ...selectedData, + filters: updatedFilters, + }) + ); }; const handleCategorySelection = (event: FormEvent) => { const { value } = event.target as HTMLSelectElement; - return setSelectedData({ - name: value, - filters: getDataFiltersByName(data, value), - }); + return ( + selectionCallback && + selectionCallback({ + name: value, + filters: getDataFiltersByName(data, value), + }) + ); }; const handleReset = () => - setSelectedData({ + selectionCallback && + selectionCallback({ ...selectedData, filters: resetFilters(selectedData.filters), }); @@ -105,89 +101,91 @@ const ColorTool: FunctionComponent = ({ const selectedCategoryName = getCategoryName(data, selectedObjectName); return ( - - {selectedObjectName && (isDisplayOnly || hasMultipleDatasets) && ( - - {hasMultipleDatasets && ( - <> -
{t("colorTool.labels.object_type")}
-
{selectedCategoryName}
- - )} - {!hideSubtitle && ( - <> -
- {t("colorTool.labels.object", { - context: hasMultipleDatasets ? "selected" : false, - })} -
-
{selectedObjectName}
- - )} - {} -
- )} - {!isDisplayOnly && ( - - {hasMultipleDatasets && ( - - - ); - })} - - )} - {!hideImage && } - {selectedObjectName && !isDisplayOnly && ( - - {t("colorTool.actions.reset")} - - )} - {caption && {caption}} + + )} + {filters && ( + <> + + {t("colorTool.labels.filter")} + + + {t("colorTool.labels.color")} + + + {t("colorTool.labels.color_intensity")} + + + )} + {filters && + filters.map((imageFilter) => { + const { label, isDisabled: isFilterDisabled } = imageFilter; + + return ( + + ); + })} + + )} + {!hideImage && } + {selectedObjectName && !isDisplayOnly && ( + + {t("colorTool.actions.reset")} + + )} + {caption && {caption}} +
); }; diff --git a/packages/epo-widget-lib/src/widgets/ColorTool/styles.ts b/packages/epo-widget-lib/src/widgets/ColorTool/styles.ts index 0e2e2192..4e78cef9 100644 --- a/packages/epo-widget-lib/src/widgets/ColorTool/styles.ts +++ b/packages/epo-widget-lib/src/widgets/ColorTool/styles.ts @@ -1,34 +1,26 @@ import styled, { css } from "styled-components"; import Button from "@rubin-epo/epo-react-lib/Button"; import HorizontalSlider from "@rubin-epo/epo-react-lib/HorizontalSlider"; +import { token } from "@rubin-epo/epo-react-lib/styles"; import FilterImage from "./FilterImage"; -export const WidgetContainer = styled.section<{ - $isCondensed: boolean; - $hideControls: boolean; -}>` +export const WidgetContainer = styled.section` + container: colorTool / inline-size; +`; + +const breakSize = token("BREAK_LARGE_TABLET_MIN") as string; + +export const WidgetLayout = styled.div` + --widget-areas: "title" "subtitle" "image" "caption" "controls" "reset"; + --controls-row: "controls image"; display: grid; gap: var(--PADDING_SMALL, 20px); + grid-template-areas: var(--widget-areas); - ${({ $isCondensed, $hideControls }) => - $isCondensed - ? css` - grid-template-areas: - "title" - "subtitle" - "image" - "caption" - "controls" - "reset"; - ` - : css` - grid-template-areas: - "title title" - "subtitle subtitle" - ${$hideControls ? "'image image'" : "'controls image'"} - "reset reset" - "caption caption"; - `} + @container colorTool (min-width: ${breakSize}) { + --widget-areas: "title title" "subtitle subtitle" var(--controls-row) + "reset reset" "caption caption"; + } `; export const Title = styled.h2` diff --git a/packages/epo-widget-lib/src/widgets/SourceSelector/SourceSelector.stories.tsx b/packages/epo-widget-lib/src/widgets/SourceSelector/SourceSelector.stories.tsx index 7a780786..61f463f6 100644 --- a/packages/epo-widget-lib/src/widgets/SourceSelector/SourceSelector.stories.tsx +++ b/packages/epo-widget-lib/src/widgets/SourceSelector/SourceSelector.stories.tsx @@ -1,9 +1,10 @@ -import { ComponentMeta, ComponentStoryObj } from "@storybook/react"; +import { ComponentStoryObj, Meta, StoryFn } from "@storybook/react"; import { data, images, biggerData } from "./mocks"; import SourceSelector from "."; +import { useState } from "react"; -const meta: ComponentMeta = { +const meta: Meta = { argTypes: { width: { description: "Static width for the widget in pixels", @@ -137,24 +138,38 @@ const meta: ComponentMeta = { }; export default meta; -export const Primary: ComponentStoryObj = { - args: { sources: data.sources, images }, +const Template: StoryFn = (args) => { + const [selectedSource, setSelectedSource] = useState( + args.selectedSource || [] + ); + + return ( + { + setSelectedSource(sources); + args.selectionCallback && args.selectionCallback(sources); + }} + /> + ); }; -export const Alerts: ComponentStoryObj = { - args: { - sources: biggerData.sources, - images: biggerData.alerts?.map((a) => a.image), - alerts: biggerData.alerts, - }, +export const Primary: StoryFn = Template.bind({}); +Primary.args = { sources: data.sources, images }; + +export const Alerts: StoryFn = Template.bind({}); +Alerts.args = { + sources: biggerData.sources, + images: biggerData.alerts?.map((a) => a.image), + alerts: biggerData.alerts, }; -export const DisplayOnly: ComponentStoryObj = { - args: { - sources: biggerData.sources, - selectedSource: [biggerData.sources[0]], - images: biggerData.alerts?.map((a) => a.image), - alerts: biggerData.alerts, - isDisplayOnly: true, - }, +export const DisplayOnly: StoryFn = Template.bind({}); +DisplayOnly.args = { + sources: biggerData.sources, + selectedSource: [biggerData.sources[0]], + images: biggerData.alerts?.map((a) => a.image), + alerts: biggerData.alerts, + isDisplayOnly: true, }; diff --git a/packages/epo-widget-lib/src/widgets/SourceSelector/SourceSelector.tsx b/packages/epo-widget-lib/src/widgets/SourceSelector/SourceSelector.tsx index f728c2f9..f8590b19 100644 --- a/packages/epo-widget-lib/src/widgets/SourceSelector/SourceSelector.tsx +++ b/packages/epo-widget-lib/src/widgets/SourceSelector/SourceSelector.tsx @@ -1,10 +1,4 @@ -import { - FunctionComponent, - MouseEvent, - ReactNode, - useEffect, - useState, -} from "react"; +import { FunctionComponent, MouseEvent, ReactNode, useState } from "react"; import { Alert, Source } from "@/types/astro"; import { useTranslation } from "react-i18next"; import { ImageShape } from "@rubin-epo/epo-react-lib"; @@ -41,7 +35,7 @@ interface SourceSelectorProps { const SourceSelector: FunctionComponent = ({ width = 600, height = 600, - selectedSource: preSelectedSource = [], + selectedSource = [], sources, alerts, images, @@ -50,19 +44,14 @@ const SourceSelector: FunctionComponent = ({ color, isDisplayOnly = false, }) => { - const [selectedSource, setSelectedSource] = - useState(preSelectedSource); const [isLoaded, setLoaded] = useState(false); const [message, setMessage] = useState(); const [isMessageVisible, setMessageVisible] = useState(false); const [elapsed, setElapsed] = useState(null); + const [imageIndex, setIndex] = useState(0); const { t } = useTranslation(); const svgId = "sourceSelectorWidget"; - useEffect(() => { - selectionCallback && selectionCallback(selectedSource); - }, [selectedSource]); - const findData = (data: Source[], id: string, type: string) => data.filter((d) => d.id === id && d.type === type); @@ -76,7 +65,8 @@ const SourceSelector: FunctionComponent = ({ if (!isAlreadySelected) { const newSelect = findData(sources, id, type); - setSelectedSource((value) => value.concat(newSelect)); + selectionCallback && + selectionCallback(selectedSource.concat(newSelect)); setMessage( <> @@ -97,6 +87,7 @@ const SourceSelector: FunctionComponent = ({ }; const handleBlinkChange = (index: number) => { + setIndex(index); if (alerts && alerts.length > 1) { const currentAlert = alerts[index]; const diff = currentAlert.date - alerts[0].date; @@ -121,28 +112,33 @@ const SourceSelector: FunctionComponent = ({ )} setLoaded(true)} + loadedCallback={() => { + console.log("loaded"); + setLoaded(true); + }} {...blinkConfig} - /> - {elapsed && } - - - + {elapsed && } + + + + ); }; diff --git a/packages/epo-widget-lib/src/widgets/SourceSelector/utilities.ts b/packages/epo-widget-lib/src/widgets/SourceSelector/utilities.ts index 2c11b8d2..c680784c 100644 --- a/packages/epo-widget-lib/src/widgets/SourceSelector/utilities.ts +++ b/packages/epo-widget-lib/src/widgets/SourceSelector/utilities.ts @@ -1,9 +1,20 @@ export const getLinearScale = ( domain: number[], - range: number[] -): ((value: number) => number) => { - const [dMin, dMax] = domain; - const [rMin, rMax] = range; + range: number[], + clamp = false +) => { + return (val: number) => { + const sub = domain[1] - domain[0]; - return (value) => (value / (dMax - dMin)) * (rMax - rMin) + rMin; + if (sub === 0) { + return (range[0] + range[1]) / 2; + } + let t = (val - domain[0]) / sub; + + if (clamp) { + t = Math.min(Math.max(t, 0), 1); + } + + return t * (range[1] - range[0]) + range[0]; + }; };