Skip to content

Commit

Permalink
Add new field for capturing timezones
Browse files Browse the repository at this point in the history
  • Loading branch information
jb3 committed Jul 3, 2024
1 parent c537e21 commit 0b697ff
Show file tree
Hide file tree
Showing 4 changed files with 330 additions and 1 deletion.
3 changes: 2 additions & 1 deletion src/api/question.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ export enum QuestionType {
Select = "select",
ShortText = "short_text",
Range = "range",
Section = "section"
Section = "section",
TimeZone = "timezone",
}

export interface Question {
Expand Down
321 changes: 321 additions & 0 deletions src/components/InputTypes/TimeZone.tsx
Original file line number Diff line number Diff line change
@@ -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",

Check failure on line 11 in src/components/InputTypes/TimeZone.tsx

View workflow job for this annotation

GitHub Actions / lint

[ESLint] indent: Expected indentation of 4 spaces but found 2.
"-11:00",

Check failure on line 12 in src/components/InputTypes/TimeZone.tsx

View workflow job for this annotation

GitHub Actions / lint

[ESLint] indent: Expected indentation of 4 spaces but found 2.
"-10:00",

Check failure on line 13 in src/components/InputTypes/TimeZone.tsx

View workflow job for this annotation

GitHub Actions / lint

[ESLint] indent: Expected indentation of 4 spaces but found 2.
"-09:30",

Check failure on line 14 in src/components/InputTypes/TimeZone.tsx

View workflow job for this annotation

GitHub Actions / lint

[ESLint] indent: Expected indentation of 4 spaces but found 2.
"-09:00",

Check failure on line 15 in src/components/InputTypes/TimeZone.tsx

View workflow job for this annotation

GitHub Actions / lint

[ESLint] indent: Expected indentation of 4 spaces but found 2.
"-08:00",

Check failure on line 16 in src/components/InputTypes/TimeZone.tsx

View workflow job for this annotation

GitHub Actions / lint

[ESLint] indent: Expected indentation of 4 spaces but found 2.
"-07:00",

Check failure on line 17 in src/components/InputTypes/TimeZone.tsx

View workflow job for this annotation

GitHub Actions / lint

[ESLint] indent: Expected indentation of 4 spaces but found 2.
"-06:00",

Check failure on line 18 in src/components/InputTypes/TimeZone.tsx

View workflow job for this annotation

GitHub Actions / lint

[ESLint] indent: Expected indentation of 4 spaces but found 2.
"-05:00",

Check failure on line 19 in src/components/InputTypes/TimeZone.tsx

View workflow job for this annotation

GitHub Actions / lint

[ESLint] indent: Expected indentation of 4 spaces but found 2.
"-04:00",

Check failure on line 20 in src/components/InputTypes/TimeZone.tsx

View workflow job for this annotation

GitHub Actions / lint

[ESLint] indent: Expected indentation of 4 spaces but found 2.
"-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;
5 changes: 5 additions & 0 deletions src/components/InputTypes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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}/>;
}
Expand Down
2 changes: 2 additions & 0 deletions src/components/Question.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const skip_normal_state: Array<QuestionType> = [
QuestionType.Radio,
QuestionType.Checkbox,
QuestionType.Select,
QuestionType.TimeZone,
QuestionType.Section,
QuestionType.Range
];
Expand Down Expand Up @@ -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;
Expand Down

0 comments on commit 0b697ff

Please sign in to comment.