From 2f24f22eac95056fe274c6086c422db2fdb069cf Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Wed, 3 Jul 2024 17:04:21 +0100 Subject: Add new field for capturing timezones --- src/api/question.ts | 3 +- src/components/InputTypes/TimeZone.tsx | 321 +++++++++++++++++++++++++++++++++ src/components/InputTypes/index.tsx | 5 + src/components/Question.tsx | 2 + 4 files changed, 330 insertions(+), 1 deletion(-) create mode 100644 src/components/InputTypes/TimeZone.tsx diff --git a/src/api/question.ts b/src/api/question.ts index a9a4d4a..56706e2 100644 --- a/src/api/question.ts +++ b/src/api/question.ts @@ -6,7 +6,8 @@ export enum QuestionType { Select = "select", ShortText = "short_text", Range = "range", - Section = "section" + Section = "section", + TimeZone = "timezone", } export interface Question { diff --git a/src/components/InputTypes/TimeZone.tsx b/src/components/InputTypes/TimeZone.tsx new file mode 100644 index 0000000..17b61d9 --- /dev/null +++ b/src/components/InputTypes/TimeZone.tsx @@ -0,0 +1,321 @@ +/** @jsx jsx */ +/** @jsxFrag React.Fragment */ +import { jsx, css } from "@emotion/react"; +import React from "react"; + +import { hiddenInput, invalidStyles } from "../../commonStyles"; +import RenderedQuestion from "../Question"; + +// This should be mostly exhaustive, but it's not guaranteed to be. +const TIMEZONE_OFFSETS = [ + "-12:00", + "-11:00", + "-10:00", + "-09:30", + "-09:00", + "-08:00", + "-07:00", + "-06:00", + "-05:00", + "-04:00", + "-03:30", + "-03:00", + "-02:00", + "-01:00", + "+00:00", + "+01:00", + "+02:00", + "+03:00", + "+03:30", + "+04:00", + "+04:30", + "+05:00", + "+05:30", + "+05:45", + "+06:00", + "+06:30", + "+07:00", + "+08:00", + "+08:45", + "+09:00", + "+09:30", + "+10:00", + "+10:30", + "+11:00", + "+12:00", + "+12:45", + "+13:00", + "+14:00" +]; + +const offsetToText = (offset: number) => { + const hours = Math.floor(offset); + const minutes = (offset - hours) * 60; + + return `${hours < 0 ? "-" : "+"}${String(Math.abs(hours)).padStart(2, "0")}:${String(Math.abs(minutes)).padStart(2, "0")}`; +}; + +interface TimeZoneProps { + valid: boolean, + question: React.RefObject + onBlurHandler: () => void +} + +const containerStyles = css` + display: flex; + position: relative; + width: min(20rem, 90%); + + flex-direction: column; + border-bottom: 0; + + color: black; + cursor: pointer; + + :focus-within .selected_container { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + + border-bottom-color: transparent; + } +`; + +const copyStyles = css` + color: white; + margin-bottom: 15px; +`; + +const mainWindowStyles = css` + display: inline-block; + position: relative; + background: whitesmoke; + + width: 100%; + height: 100%; + min-height: 2.5rem; + + margin-bottom: 0; + + overflow: hidden; + z-index: 1; + + :hover, :focus-within { + background-color: lightgray; + } + + .selected_option { + position: absolute; + height: 100%; + width: 100%; + + outline: none; + padding-left: 0.75rem; + line-height: 250%; + } + + border: 0.1rem solid black; + border-radius: 8px; + + transition: border-radius 200ms; +`; + +const arrowStyles = css` + .arrow { + display: inline-block; + height: 0.5rem; + width: 0.5rem; + + position: relative; + float: right; + right: 1em; + top: 0.7rem; + + border: solid black; + border-width: 0 0.2rem 0.2rem 0; + + transform: rotate(45deg); + transition: transform 200ms; + } + + :focus-within .arrow { + transform: translateY(40%) rotate(225deg); + } +`; + +const optionContainerStyles = css` + .option_container { + width: 100%; + height: 0; + + top: 2.3rem; + padding-top: 0.2rem; + + visibility: hidden; + opacity: 0; + + overflow: hidden; + background: whitesmoke; + + border: 0.1rem solid black; + border-radius: 0 0 8px 8px; + border-top: none; + outline: none; + + transition: opacity 200ms, visibility 200ms; + + * { + cursor: pointer; + } + + .scrollbar-container { + height: 150px; + overflow-y: scroll; + } + } + + :focus-within .option_container { + height: 100%; + visibility: visible; + opacity: 1; + } + + .option_container .hidden { + display: none; + } +`; + +const inputStyles = css` + position: absolute; + width: 100%; + height: 100%; + + z-index: 2; + + margin: 0; + border: none; + outline: none; +`; + +const optionStyles = css` + position: relative; + + :hover, :focus-within { + background-color: lightgray; + } + + div { + padding: 0.75rem; + } +`; + +const getTZ = () => { + const distanceFromUTC = -(new Date().getTimezoneOffset() / 60); + const guessedTimeZoneOffset = offsetToText(distanceFromUTC); + const recognisedZone = TIMEZONE_OFFSETS.indexOf(guessedTimeZoneOffset) !== -1; + + return recognisedZone ? guessedTimeZoneOffset : false; +}; + +class TimeZone extends React.Component { + selected_option: React.RefObject | null = null; + + handler(selected_option: React.RefObject, event: React.ChangeEvent): void { + const option_container = event.target.parentElement; + if (!option_container || !option_container.parentElement || !selected_option.current) { + return; + } + + if (!this.props.question?.current) { + throw new Error("Missing ref for select question."); + } + + // Update stored value + this.props.question.current.setState({ value: option_container.textContent }); + + // Close the menu + selected_option.current.focus(); + selected_option.current.blur(); + selected_option.current.textContent = option_container.textContent; + } + + handle_click( + container: React.RefObject, + selected_option: React.RefObject, + event: React.MouseEvent | React.KeyboardEvent + ): void { + if (!container.current || !selected_option.current || (event.type === "keydown" && (event as React.KeyboardEvent).code !== "Space")) { + return; + } + + // Check if menu is open + if (container.current.contains(document.activeElement)) { + // Close menu + selected_option.current.focus(); + selected_option.current.blur(); + event.preventDefault(); + } + } + + focusOption(): void { + if (!this.props.question?.current) { + throw new Error("Missing ref for select question."); + } + + if (!this.props.question.current.realState.value) { + this.props.question.current.setState({ value: "temporary" }); + this.props.onBlurHandler(); + this.props.question.current.setState({ value: null }); + } + } + + componentDidMount() { + const tz = getTZ(); + + if (tz) { + this.props.question.current?.setState({ value: tz }); + } + } + + render(): JSX.Element { + const container_ref: React.RefObject = React.createRef(); + const selected_option_ref: React.RefObject = React.createRef(); + + this.selected_option = selected_option_ref; + + const handle_click = (event: React.MouseEvent | React.KeyboardEvent) => this.handle_click(container_ref, selected_option_ref, event); + + const tz = getTZ(); + + const FOUND_COPY = "We have tried to guess your timezone based on your system settings. If this is incorrect, please select the correct timezone from the list below."; + const NOT_FOUND_COPY = "We could not automatically detect your timezone. Please select it from the list below."; + + return ( + <> +
+ {tz ? FOUND_COPY : NOT_FOUND_COPY} + + Timezones are displayed as offsets from UTC. For example, UTC+1 is one hour ahead of UTC, and UTC-5 is five hours behind UTC. +
+
+
+ +
{tz ? tz : "..."}
+
+ +
+
+ {TIMEZONE_OFFSETS.map((option, index) => ( +
+ this.handler.call(this, selected_option_ref, event)} /> +
{option}
+
+ ))} +
+
+
+ + ); + } +} + +export default TimeZone; diff --git a/src/components/InputTypes/index.tsx b/src/components/InputTypes/index.tsx index 0973383..85634cc 100644 --- a/src/components/InputTypes/index.tsx +++ b/src/components/InputTypes/index.tsx @@ -10,6 +10,7 @@ import React, {ChangeEvent} from "react"; import {QuestionType} from "../../api/question"; import RenderedQuestion from "../Question"; import Code from "./Code"; +import TimeZone from "./TimeZone"; export default function create_input( {props: renderedQuestionProps, realState}: RenderedQuestion, @@ -59,6 +60,10 @@ export default function create_input( result = ; break; + case QuestionType.TimeZone: + result = ; + break; + default: result =