diff options
Diffstat (limited to 'src/components')
-rw-r--r-- | src/components/HeaderBar/index.tsx | 139 | ||||
-rw-r--r-- | src/components/HeaderBar/logo.svg | 3 | ||||
-rw-r--r-- | src/components/InputTypes/Checkbox.tsx | 65 | ||||
-rw-r--r-- | src/components/InputTypes/Code.tsx | 11 | ||||
-rw-r--r-- | src/components/InputTypes/Radio.tsx | 39 | ||||
-rw-r--r-- | src/components/InputTypes/Range.tsx | 117 | ||||
-rw-r--r-- | src/components/InputTypes/Select.tsx | 204 | ||||
-rw-r--r-- | src/components/InputTypes/ShortText.tsx | 12 | ||||
-rw-r--r-- | src/components/InputTypes/TextArea.tsx | 21 | ||||
-rw-r--r-- | src/components/InputTypes/index.tsx | 71 | ||||
-rw-r--r-- | src/components/Question.tsx | 166 | ||||
-rw-r--r-- | src/components/ScrollToTop.tsx | 93 |
12 files changed, 901 insertions, 40 deletions
diff --git a/src/components/HeaderBar/index.tsx b/src/components/HeaderBar/index.tsx index dfe3957..5c536ef 100644 --- a/src/components/HeaderBar/index.tsx +++ b/src/components/HeaderBar/index.tsx @@ -1,59 +1,118 @@ /** @jsx jsx */ -import { css, jsx } from "@emotion/react"; +import { jsx, css } from "@emotion/react"; import Header1 from "./header_1.svg"; import Header2 from "./header_2.svg"; +import Logo from "./logo.svg"; + +import { Link } from "react-router-dom"; interface HeaderBarProps { - title?: string + title?: string + description?: string } const headerImageStyles = css` -z-index: -1; -top: 0; -position: absolute; -width: 100%; -transition: height 1s; + * { + z-index: -1; + top: 0; + position: absolute; + width: 100%; + transition: height 1s; + } +`; + +const headerTextStyles = css` + transition: margin 1s; + font-family: "Uni Sans", "Hind", "Arial", sans-serif; + + margin: 0 2rem 10rem 2rem; + + .title { + font-size: 3vmax; + margin-bottom: 0; + } + + .full_size { + line-height: 200%; + } + + .description { + font-size: 1.5vmax; + } + + .title, .description { + transition: font-size 1s; + } + + @media (max-width: 480px) { + margin-top: 7rem; + text-align: center; + + .title { + font-size: 5vmax; + } + + .full_size { + line-height: 100%; + } + + .description { + font-size: 2vmax; + } + } +`; + +const homeButtonStyles = css` + svg { + transform: scale(0.25); + transition: top 300ms, transform 300ms; + + @media (max-width: 480px) { + transform: scale(0.15); + } + } + + * { + position: absolute; + top: -10rem; + right: 1rem; + + z-index: 0; + transform-origin: right; + + @media (max-width: 700px) { + top: -11.5rem; + } + + @media (max-width: 480px) { + top: -12.5rem; + } + } `; -function HeaderBar({ title }: HeaderBarProps): JSX.Element { +function HeaderBar({ title, description }: HeaderBarProps): JSX.Element { if (!title) { title = "Python Discord Forms"; } - - return <div> + + return ( <div> - <Header1 css={headerImageStyles}/> - <Header2 css={headerImageStyles}/> + <div css={headerImageStyles}> + <Header1/> + <Header2/> + </div> + + <div css={css`${headerTextStyles}; margin-bottom: 12.5%;`}> + <h1 className={description ? "title" : "title full_size"}>{title}</h1> + {description ? <h1 className="description">{description}</h1> : null} + </div> + + <Link to="/" css={homeButtonStyles}> + <Logo/> + </Link> </div> - <h1 css={css` - font-size: 4vw; - margin: 0; - margin-top: 30px; - margin-left: 30px; - margin-bottom: 200px; - transition-property: font-size, margin-bottom; - transition-duration: 1s; - font-family: "Uni Sans", "Hind", "Arial", sans-serif; - - @media (max-width: 1000px) { - margin-top: 15px; - font-size: 8vw; - } - - @media (max-width: 770px) { - margin-top: 15px; - font-size: 6vw; - margin-bottom: 100px; - } - @media (max-width: 450px) { - text-align: center; - margin-left: 0; - } - `}> - {title} - </h1> - </div>; + ); } export default HeaderBar; diff --git a/src/components/HeaderBar/logo.svg b/src/components/HeaderBar/logo.svg new file mode 100644 index 0000000..e7f43fc --- /dev/null +++ b/src/components/HeaderBar/logo.svg @@ -0,0 +1,3 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg width="463.86" height="463.86" version="1.1" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><metadata><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/><dc:title/></cc:Work></rdf:RDF></metadata> + <path d="m296.09 80.929-11.506 3.3086c-12.223-1.8051-24.757-2.597-36.895-2.5078-13.5 0.1-26.498 1.1992-37.898 3.1992-3.6998 0.65161-7.0474 1.386-10.107 2.1992h-35.893v18.801h4.5v7.6992h2.7832c-0.63936 3.7142-0.88476 7.7997-0.88476 12.301v4h-15.398l-2.2012 11 17.6 15.014v0.0859h79.201v10h-79.201-29.699c-23 0-43.2 13.8-49.5 40-7.3 30-7.6 48.801 0 80.201 0.49734 2.0782 1.0605 4.0985 1.6836 6.0625l-1.1836 10.438 13.346 11.549c7.032 7.5103 16.371 11.951 28.254 11.951h27.201v-36c0-26 22.6-49 49.5-49h79.199c22 0 39.6-18.102 39.6-40.102v-75.199c0-12.9-6.5819-23.831-16.516-31.273zm76.801 77.6-14.301 7.4004h-20.1v0.0996 10.301 24.699c0 27.2-23.1 50-49.5 50h-79.1c-21.7 0-39.6 18.5-39.6 40.1v75.102c0 21.4 18.7 34 39.6 40.1 25.1 7.3 49.1 8.7 79.1 0 19.9-5.7 39.6-17.3 39.6-40.1v-27.9-2.0996-0.10156h-0.11914l-11.721-10h11.84 39.6c23 0 31.602-16 39.602-40 8.3-24.7 7.9-48.499 0-80.199-3.6226-14.491-9.3525-26.71-18.947-33.699zm-123.4 167.6h57.5v10h-57.5z" fill-opacity=".20554"/><path class="st2" d="m229.99 66.629c-13.5 0.1-26.5 1.2-37.9 3.2-33.5 5.9-39.6 18.2-39.6 41v30.1h79.2v10h-79.2-29.7c-23 0-43.2 13.8-49.5 40-7.3 30-7.6 48.8 0 80.2 5.6 23.4 19.1 40 42.1 40h27.2v-36c0-26 22.6-49 49.5-49h79.1c22 0 39.6-18.1 39.6-40.1v-75.2c0-21.4-18.1-37.4-39.6-41-13.5-2.3-27.6-3.3-41.2-3.2zm-42.8 24.2c8.2 0 14.9 6.8 14.9 15.1 0 8.3-6.7 15-14.9 15s-14.9-6.7-14.9-15c0-8.4 6.7-15.1 14.9-15.1z" fill="#cbd6ff"/><path class="st3" d="m320.79 150.93v35c0 27.2-23.1 50-49.5 50h-79.1c-21.7 0-39.6 18.5-39.6 40.1v75.1c0 21.4 18.7 34 39.6 40.1 25.1 7.3 49.1 8.7 79.1 0 19.9-5.7 39.6-17.3 39.6-40.1v-30.1h-79.1v-10h79.1 39.6c23 0 31.6-16 39.6-40 8.3-24.7 7.9-48.5 0-80.2-5.7-22.8-16.6-40-39.6-40h-29.7zm-44.5 190.2c8.2 0 14.9 6.7 14.9 15s-6.7 15.1-14.9 15.1-14.9-6.8-14.9-15.1 6.7-15 14.9-15z" fill="#fff"/></svg> diff --git a/src/components/InputTypes/Checkbox.tsx b/src/components/InputTypes/Checkbox.tsx new file mode 100644 index 0000000..3093caf --- /dev/null +++ b/src/components/InputTypes/Checkbox.tsx @@ -0,0 +1,65 @@ +/** @jsx jsx */ +import { jsx, css } from "@emotion/react"; +import React, { ChangeEvent } from "react"; +import colors from "../../colors"; +import { multiSelectInput, hiddenInput } from "../../commonStyles"; + +interface CheckboxProps { + index: number, + option: string, + handler: (event: ChangeEvent<HTMLInputElement>) => void +} + +const generalStyles = css` + cursor: pointer; + + label { + width: 1em; + height: 1em; + top: 0.3rem; + + border-radius: 25%; + cursor: pointer; + } + + .unselected { + background-color: white; + } + + .unselected:focus-within, :hover .unselected { + background-color: lightgray; + } + + .checkmark { + position: absolute; + } +`; + +const activeStyles = css` + .selected { + background-color: ${colors.blurple}; + } + + .selected .checkmark { + width: 0.30rem; + height: 0.60rem; + left: 0.25em; + + border: solid white; + border-width: 0 0.2rem 0.2rem 0; + + transform: rotate(45deg); + } +`; + +export default function Checkbox(props: CheckboxProps): JSX.Element { + return ( + <label css={[generalStyles, activeStyles]}> + <label className="unselected" css={multiSelectInput}> + <input type="checkbox" value={props.option} css={hiddenInput} name={`${("000" + props.index).slice(-4)}. ${props.option}`} onChange={props.handler}/> + <span className="checkmark"/> + </label> + {props.option}<br/> + </label> + ); +} diff --git a/src/components/InputTypes/Code.tsx b/src/components/InputTypes/Code.tsx new file mode 100644 index 0000000..51ca98d --- /dev/null +++ b/src/components/InputTypes/Code.tsx @@ -0,0 +1,11 @@ +/** @jsx jsx */ +import { jsx } from "@emotion/react"; +import React, { ChangeEvent } from "react"; + +interface CodeProps { + handler: (event: ChangeEvent<HTMLInputElement>) => void +} + +export default function Code(props: CodeProps): JSX.Element { + return <input type="text" className="text" onChange={props.handler}/>; +} diff --git a/src/components/InputTypes/Radio.tsx b/src/components/InputTypes/Radio.tsx new file mode 100644 index 0000000..3bf13ed --- /dev/null +++ b/src/components/InputTypes/Radio.tsx @@ -0,0 +1,39 @@ +/** @jsx jsx */ +import { jsx, css } from "@emotion/react"; +import React, { ChangeEvent } from "react"; +import colors from "../../colors"; +import { multiSelectInput, hiddenInput } from "../../commonStyles"; + +interface RadioProps { + option: string, + question_id: string, + handler: (event: ChangeEvent<HTMLInputElement>) => void +} + +const styles = css` + div { + width: 0.7em; + height: 0.75em; + top: 0.18rem; + + border-radius: 50%; + } + + :hover div, :focus-within div { + background-color: lightgray; + } + + input:checked+div { + background-color: ${colors.blurple}; + } +`; + +export default function Radio(props: RadioProps): JSX.Element { + return ( + <label css={styles}> + <input type="radio" name={props.question_id} onChange={props.handler} css={hiddenInput}/> + <div css={multiSelectInput}/> + {props.option}<br/> + </label> + ); +} diff --git a/src/components/InputTypes/Range.tsx b/src/components/InputTypes/Range.tsx new file mode 100644 index 0000000..e2f89f4 --- /dev/null +++ b/src/components/InputTypes/Range.tsx @@ -0,0 +1,117 @@ +/** @jsx jsx */ +import { jsx, css } from "@emotion/react"; +import React, { ChangeEvent } from "react"; +import colors from "../../colors"; +import { hiddenInput, multiSelectInput } from "../../commonStyles"; + +interface RangeProps { + question_id: string, + options: Array<string>, + handler: (event: ChangeEvent<HTMLInputElement>) => void +} + +const containerStyles = css` + display: flex; + justify-content: space-between; + position: relative; + width: 100%; + + @media (max-width: 800px) { + width: 20%; + display: block; + margin: 0 auto; + + label span { + margin-left: 0; + transform: translateY(1.6rem) translateX(2rem); + } + } +`; + +const optionStyles = css` + display: inline-block; + transform: translateX(-50%); + margin: 0 50%; + + white-space: nowrap; + + transition: transform 300ms; +`; + +const selectorStyles = css` + cursor: pointer; + + div { + width: 1rem; + height: 1rem; + + background-color: whitesmoke; + + border-radius: 50%; + margin: 0 100% 0 0; + } + + :hover div, :focus-within div { + background-color: lightgray; + } + + input:checked+div { + background-color: ${colors.blurple}; + } +`; + +const sliderContainerStyles = css` + display: flex; + justify-content: center; + width: 100%; + + position: absolute; + z-index: -1; + + top: 2.1rem; + + transition: all 300ms; + + @media (max-width: 800px) { + width: 0.5rem; + height: 80%; + + left: 0.4rem; + + background: whitesmoke; + } +`; + +const sliderStyles = css` + width: 98%; /* Needs to be slightly smaller than container to work on all devices */ + height: 0.5rem; + background-color: whitesmoke; + + transition: transform 300ms; + + @media (max-width: 800px) { + display: none; + } +`; + +export default function Range(props: RangeProps): JSX.Element { + const range = props.options.map((option, index) => { + return ( + <label css={[selectorStyles, css`width: 1rem`]} key={index}> + <span css={optionStyles}>{option}</span> + <input type="radio" name={props.question_id} css={hiddenInput} onChange={props.handler}/> + <div css={multiSelectInput}/> + </label> + ); + }); + + return ( + <div css={containerStyles}> + { range } + + <div css={sliderContainerStyles}> + <div css={sliderStyles}/> + </div> + </div> + ); +} diff --git a/src/components/InputTypes/Select.tsx b/src/components/InputTypes/Select.tsx new file mode 100644 index 0000000..e753357 --- /dev/null +++ b/src/components/InputTypes/Select.tsx @@ -0,0 +1,204 @@ +/** @jsx jsx */ +import { jsx, css } from "@emotion/react"; +import React from "react"; +import { hiddenInput } from "../../commonStyles"; + +interface SelectProps { + options: Array<string>, + state_dict: Map<string, string | boolean | null> +} + +const containerStyles = css` + position: relative; + width: min(20rem, 90%); + + color: black; + cursor: pointer; + + :focus-within .selected_container { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + + border-bottom-color: transparent; + } +`; + +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 { + position: absolute; + 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; + + transition: opacity 200ms, visibility 200ms; + + * { + cursor: pointer; + } + } + + :focus-within .option_container { + height: auto; + 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; + } +`; + +class Select extends React.Component<SelectProps> { + 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; + } + + // Update stored value + this.props.state_dict.set("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(); + } + } + + render(): JSX.Element { + const container_ref: React.RefObject<HTMLDivElement> = React.createRef(); + const selected_option_ref: React.RefObject<HTMLDivElement> = React.createRef(); + + const handle_click = (event: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>) => this.handle_click(container_ref, selected_option_ref, event); + + return ( + <div css={[containerStyles, arrowStyles, optionContainerStyles]} ref={container_ref}> + <div className="selected_container" css={mainWindowStyles}> + <span className="arrow"/> + <div tabIndex={0} className="selected_option" ref={selected_option_ref} onMouseDown={handle_click} onKeyDown={handle_click}>...</div> + </div> + + <div className="option_container" tabIndex={-1} css={css`outline: none;`}> + { this.props.options.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> + ); + } +} + +export default Select; diff --git a/src/components/InputTypes/ShortText.tsx b/src/components/InputTypes/ShortText.tsx new file mode 100644 index 0000000..1e38bcd --- /dev/null +++ b/src/components/InputTypes/ShortText.tsx @@ -0,0 +1,12 @@ +/** @jsx jsx */ +import { jsx } from "@emotion/react"; +import React, { ChangeEvent } from "react"; +import { textInputs } from "../../commonStyles"; + +interface ShortTextProps { + handler: (event: ChangeEvent<HTMLInputElement>) => void +} + +export default function ShortText(props: ShortTextProps): JSX.Element { + return <input type="text" css={textInputs} placeholder="Enter Text..." onChange={props.handler}/>; +} diff --git a/src/components/InputTypes/TextArea.tsx b/src/components/InputTypes/TextArea.tsx new file mode 100644 index 0000000..6e46c27 --- /dev/null +++ b/src/components/InputTypes/TextArea.tsx @@ -0,0 +1,21 @@ +/** @jsx jsx */ +import { jsx, css } from "@emotion/react"; +import React, { ChangeEvent } from "react"; +import { textInputs } from "../../commonStyles"; + +interface TextAreaProps { + handler: (event: ChangeEvent<HTMLTextAreaElement>) => void +} + +const styles = css` + min-height: 20rem; + width: 100%; + box-sizing: border-box; + resize: vertical; + + padding: 1rem; +`; + +export default function TextArea(props: TextAreaProps): JSX.Element { + return <textarea css={[textInputs, styles]} placeholder="Enter Text..." onChange={props.handler}/>; +} diff --git a/src/components/InputTypes/index.tsx b/src/components/InputTypes/index.tsx new file mode 100644 index 0000000..f1e0b30 --- /dev/null +++ b/src/components/InputTypes/index.tsx @@ -0,0 +1,71 @@ +import Checkbox from "./Checkbox"; +import Code from "./Code"; +import Radio from "./Radio"; +import Range from "./Range"; +import Select from "./Select"; +import ShortText from "./ShortText"; +import TextArea from "./TextArea"; + +import React, { ChangeEvent } from "react"; + +import { QuestionType } from "../../api/question"; +import { QuestionProp } from "../Question"; + +const require_options: Array<QuestionType> = [ + QuestionType.Radio, + QuestionType.Checkbox, + QuestionType.Select, + QuestionType.Range +]; + +export default function create_input({ question, public_state }: QuestionProp, handler: (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void): JSX.Element | JSX.Element[] { + let result: JSX.Element | JSX.Element[]; + + // eslint-disable-next-line + // @ts-ignore + let options: string[] = question.data["options"]; + + // Catch input types that require options but don't have any + if ((options === undefined || typeof options !== "object") && require_options.includes(question.type)) { + // TODO: Implement some sort of warning here + options = []; + } + + /* eslint-disable react/react-in-jsx-scope */ + switch (question.type) { + case QuestionType.TextArea: + result = <TextArea handler={handler}/>; + break; + + case QuestionType.Checkbox: + result = options.map((option, index) => <Checkbox index={index} option={option} handler={handler} key={index}/>); + break; + + case QuestionType.Radio: + result = options.map((option, index) => <Radio option={option} question_id={question.id} handler={handler} key={index}/>); + break; + + case QuestionType.Select: + result = <Select options={options} state_dict={public_state}/>; + break; + + case QuestionType.ShortText: + result = <ShortText handler={handler}/>; + break; + + case QuestionType.Range: + result = <Range question_id={question.id} options={options} handler={handler}/>; + break; + + case QuestionType.Code: + // TODO: Implement + result = <Code handler={handler}/>; + break; + + default: + result = <TextArea handler={handler}/>; + } + /* eslint-enable react/react-in-jsx-scope */ + + return result; +} diff --git a/src/components/Question.tsx b/src/components/Question.tsx new file mode 100644 index 0000000..735d69b --- /dev/null +++ b/src/components/Question.tsx @@ -0,0 +1,166 @@ +/** @jsx jsx */ +import { jsx, css } from "@emotion/react"; +import React, { ChangeEvent } from "react"; + +import { Question, QuestionType } from "../api/question"; +import { selectable } from "../commonStyles"; +import create_input from "./InputTypes"; + +const skip_normal_state: Array<QuestionType> = [ + QuestionType.Radio, + QuestionType.Checkbox, + QuestionType.Select, + QuestionType.Section, + QuestionType.Range +]; + +export type QuestionProp = { + question: Question, + public_state: Map<string, string | boolean | null>, +} + +class RenderedQuestion extends React.Component<QuestionProp> { + constructor(props: QuestionProp) { + super(props); + if (props.question.type === QuestionType.TextArea) { + this.handler = this.text_area_handler.bind(this); + } else { + this.handler = this.normal_handler.bind(this); + } + + if (!skip_normal_state.includes(props.question.type)) { + this.setPublicState("value", ""); + } + } + + setPublicState(target: string, value: string | boolean | null, callback?:() => void): void { + this.setState({[target]: value}, callback); + this.props.public_state.set(target, value); + } + + // This is here to allow dynamic selection between the general handler, and the textarea handler. + handler(_: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>): void {} // eslint-disable-line + + normal_handler(event: ChangeEvent<HTMLInputElement>): void { + let target: string; + let value: string | boolean; + + switch (event.target.type) { + case "checkbox": + target = event.target.name; + value = event.target.checked; + break; + + case "radio": + // This handles radios and ranges, as they are both based on the same fundamental input type + target = "value"; + if (event.target.parentElement) { + value = event.target.parentElement.innerText.trimEnd(); + } else { + value = event.target.value; + } + break; + + default: + target = "value"; + value = event.target.value; + } + + this.setPublicState(target, value); + + // Toggle checkbox class + if (event.target.type == "checkbox" && event.target.parentElement !== null) { + event.target.parentElement.classList.toggle("unselected"); + event.target.parentElement.classList.toggle("selected"); + } + } + + text_area_handler(event: ChangeEvent<HTMLTextAreaElement>): void { + this.setPublicState("value", event.target.value); + } + + componentDidMount(): void { + // Initialize defaults for complex and nested fields + const options: string | string[] = this.props.question.data["options"]; + + if (this.props.public_state.size === 0) { + switch (this.props.question.type) { + case QuestionType.Checkbox: + if (typeof options === "string") { + return; + } + + options.forEach((option, index) => { + this.setPublicState(`${("000" + index).slice(-4)}. ${option}`, false); + }); + break; + + case QuestionType.Range: + case QuestionType.Radio: + case QuestionType.Select: + this.setPublicState("value", null); + break; + } + } + } + + render(): JSX.Element { + const question = this.props.question; + + if (question.type === QuestionType.Section) { + const styles = css` + h1 { + margin-bottom: 0; + } + + h3 { + margin-top: 0; + } + + h1, h3 { + text-align: center; + padding: 0 2rem; + } + + @media (max-width: 500px) { + h1, h3 { + padding: 0; + } + } + `; + + return <div css={styles}> + <h1 css={[selectable, css`line-height: 2.5rem;`]}>{question.name}</h1> + { question.data["text"] ? <h3 css={selectable}>{question.data["text"]}</h3> : "" } + <hr css={css`color: gray; margin: 3rem 0;`}/> + </div>; + + } else { + const requiredStarStyles = css` + span { + display: none; + } + + .required { + display: inline-block; + position: relative; + + color: red; + + top: -0.2rem; + margin-left: 0.2rem; + } + `; + + return <div> + <h2 css={[selectable, requiredStarStyles]}> + {question.name}<span className={question.required ? "required" : ""}>*</span> + </h2> + { create_input(this.props, this.handler) } + <hr css={css`color: gray; margin: 3rem 0;`}/> + </div>; + } + } +} + +export default RenderedQuestion; diff --git a/src/components/ScrollToTop.tsx b/src/components/ScrollToTop.tsx new file mode 100644 index 0000000..231c9e8 --- /dev/null +++ b/src/components/ScrollToTop.tsx @@ -0,0 +1,93 @@ +/** @jsx jsx */ +import { jsx, css } from "@emotion/react"; +import React from "react"; +import colors from "../colors"; + +import smoothscroll from "smoothscroll-polyfill"; +smoothscroll.polyfill(); + +const styles = css` + width: 2.5rem; + height: 2.5rem; + + position: fixed; + bottom: 3rem; + right: 3rem; + + background-color: ${colors.blurple}; + border-radius: 50%; + + opacity: 0; + transition: opacity 300ms; + + :after { + display: inline-block; + content: ""; + + position: fixed; + bottom: 3.5rem; + right: 3.65rem; + + border: solid whitesmoke; + border-width: 0.35rem 0.35rem 0 0; + padding: 0.4rem; + + transform: rotate(-45deg); + } + + @media (max-width: 800px) { + bottom: 1.5rem; + right: 1.5rem; + + :after { + bottom: 2rem; + right: 2.15rem; + } + } +`; + +let last_ref: React.RefObject<HTMLDivElement>; + +class ScrollToTop extends React.Component { + constructor(props: Record<string, never>) { + super(props); + last_ref = React.createRef(); + } + + handleScroll(): void { + if (!last_ref.current) return; + + if (window.pageYOffset > 250) { + last_ref.current.style.opacity = "1"; + last_ref.current.style.cursor = "pointer"; + } else { + last_ref.current.style.opacity = "0"; + last_ref.current.style.cursor = "default"; + } + } + + componentDidMount(): void { + // Register event handler + window.addEventListener("scroll", this.handleScroll, {passive: true}); + } + + componentDidUpdate(): void { + // Hide previous iterations, and register handler for current one + if (last_ref.current) { + last_ref.current.style.opacity = "0"; + } + + window.addEventListener("scroll", this.handleScroll, {passive: true}); + } + + componentWillUnmount(): void { + // Unregister handler + window.removeEventListener("scroll", this.handleScroll); + } + + render(): JSX.Element { + return <div css={styles} key={Date.now()} ref={last_ref} onClick={() => window.scrollTo({top: 0, behavior: "smooth"})}/>; + } +} + +export default ScrollToTop; |