diff options
author | 2024-07-03 17:04:21 +0100 | |
---|---|---|
committer | 2024-07-03 17:14:15 +0100 | |
commit | 2f24f22eac95056fe274c6086c422db2fdb069cf (patch) | |
tree | bffb9d7cd228fe89de4eaf3adb987d05b8fae385 | |
parent | Merge pull request #506 from python-discord/dependabot/npm_and_yarn/codemirro... (diff) |
Add new field for capturing timezones
-rw-r--r-- | src/api/question.ts | 3 | ||||
-rw-r--r-- | src/components/InputTypes/TimeZone.tsx | 321 | ||||
-rw-r--r-- | src/components/InputTypes/index.tsx | 5 | ||||
-rw-r--r-- | src/components/Question.tsx | 2 |
4 files changed, 330 insertions, 1 deletions
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<RenderedQuestion> + 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<TimeZoneProps> { + selected_option: React.RefObject<HTMLDivElement> | null = null; + + handler(selected_option: React.RefObject<HTMLDivElement>, event: React.ChangeEvent<HTMLInputElement>): 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<HTMLDivElement>, + selected_option: React.RefObject<HTMLDivElement>, + event: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement> + ): 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<HTMLDivElement> = React.createRef(); + const selected_option_ref: React.RefObject<HTMLDivElement> = React.createRef(); + + this.selected_option = selected_option_ref; + + const handle_click = (event: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>) => 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 ( + <> + <div css={copyStyles}> + {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. + </div> + <div css={[containerStyles, arrowStyles, optionContainerStyles, invalidStyles]} onFocus={this.focusOption.bind(this)} ref={container_ref} onBlur={this.props.onBlurHandler}> + <div css={mainWindowStyles} className={!this.props.valid ? "invalid-box selected_container" : "selected_container"}> + <span className="arrow" /> + <div tabIndex={0} className="selected_option" ref={selected_option_ref} onMouseDown={handle_click} onKeyDown={handle_click}>{tz ? tz : "..."}</div> + </div> + + <div className="option_container" tabIndex={-1}> + <div className="scrollbar-container"> + {TIMEZONE_OFFSETS.map((option, index) => ( + <div key={index} css={optionStyles}> + <input type="checkbox" css={[hiddenInput, inputStyles]} onChange={event => this.handler.call(this, selected_option_ref, event)} /> + <div>{option}</div> + </div> + ))} + </div> + </div> + </div> + </> + ); + } +} + +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 = <Range question_id={question.id} options={options} handler={handler} required={question.required} onBlurHandler={onBlurHandler}/>; break; + case QuestionType.TimeZone: + result = <TimeZone question={renderedQuestionProps.selfRef} valid={valid} onBlurHandler={onBlurHandler}/>; + break; + default: result = <TextArea handler={handler} valid={valid} onBlurHandler={onBlurHandler} focus_ref={focus_ref}/>; } diff --git a/src/components/Question.tsx b/src/components/Question.tsx index a5a71c5..fb5c419 100644 --- a/src/components/Question.tsx +++ b/src/components/Question.tsx @@ -11,6 +11,7 @@ const skip_normal_state: Array<QuestionType> = [ QuestionType.Radio, QuestionType.Checkbox, QuestionType.Select, + QuestionType.TimeZone, QuestionType.Section, QuestionType.Range ]; @@ -192,6 +193,7 @@ class RenderedQuestion extends React.Component<QuestionProp> { case QuestionType.Select: case QuestionType.Range: + case QuestionType.TimeZone: case QuestionType.Radio: if (!this.realState.value) { valid = false; |