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 39801cf
Show file tree
Hide file tree
Showing 4 changed files with 327 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
318 changes: 318 additions & 0 deletions src/components/InputTypes/TimeZone.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,318 @@
/** @jsx jsx */
/** @jsxFrag React.Fragment */
import { jsx, css } from "@emotion/react";
import React, { useEffect } from "react";

Check warning on line 4 in src/components/InputTypes/TimeZone.tsx

View workflow job for this annotation

GitHub Actions / lint

[ESLint] @typescript-eslint/no-unused-vars: 'useEffect' is defined but never used.

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();

return (
<>
<div css={copyStyles}>
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.

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 39801cf

Please sign in to comment.