From 3a569cab1744a01b17e89b46938489c96529a2b3 Mon Sep 17 00:00:00 2001 From: JKong05 Date: Thu, 30 Oct 2025 14:27:57 -0500 Subject: [PATCH 1/4] Add NumberInput component styling + functionality (#30) --- app/components/NumberInput.tsx | 292 ++++++++++++++++++++++++++++++++- app/layout.tsx | 6 + 2 files changed, 292 insertions(+), 6 deletions(-) diff --git a/app/components/NumberInput.tsx b/app/components/NumberInput.tsx index 0dfb0fd..7968b78 100644 --- a/app/components/NumberInput.tsx +++ b/app/components/NumberInput.tsx @@ -1,16 +1,296 @@ "use client"; +import { ChangeEvent, KeyboardEvent, useCallback, useState } from "react"; import styled from "styled-components"; -// TODO: Add props for NumberInput type NumberInputProps = { - propName: string; // replace string with actual prop type + defaultValue?: number; + onChange?: (value?: number) => void; + update?: number; + min?: number; + max?: number; + disabled?: boolean; + label?: string; }; +export default function NumberInput({ + defaultValue = 0, + onChange = () => {}, + update = 1, + min = 0, + max = 9, + disabled = false, + label, +}: NumberInputProps) { + const [val, setVal] = useState(() => + Math.max(min, Math.min(max, defaultValue)), + ); + const [cur, setCur] = useState(null); -// TODO: implement NumberInput component -export default function NumberInput({}: NumberInputProps) { - return ; + const format = (s: string) => s.replace(/^(-?)0+(?=\d)/, "$1"); + + // current existing value in input field + const base = () => { + if (cur === null || cur === "") return val; + let n = Number(format(cur)); + return !Number.isNaN(n) ? n : val; + }; + + // update value + const commit = useCallback( + (next: number) => { + if (next < min) next = min; + else if (next > max) next = max; + + setVal((prev) => { + if (prev !== next) onChange?.(next); + return next; + }); + }, + [min, max, onChange], + ); + + // value inc + dec handlers + const handleIncrement = () => { + commit(base() + update); + setCur(null); + }; + const handleDecrement = () => { + commit(base() - update); + setCur(null); + }; + + // keyboard handler + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key == "ArrowUp") { + event.preventDefault(); + handleIncrement(); + return; + } + if (event.key == "ArrowDown") { + event.preventDefault(); + handleDecrement(); + return; + } + if (event.key == "Enter") { + event.preventDefault(); + let n = Number(format(cur ?? String(val))); + + if (!Number.isNaN(n)) commit(n); + setCur(null); + event.currentTarget.blur(); + return; + } + if (event.key == "Escape") { + setCur(null); + } + }; + + const handleChange = (event: ChangeEvent) => { + let update = (event.target as HTMLInputElement).value; + if (update === "") { + setCur(update); + return; + } + update = format(update); + + setCur(update); + }; + + // element focus + const handleBlur = () => { + if (cur === null || cur === "") { + setCur(null); + return; + } + let update = Number(format(cur)); + if (!Number.isNaN(update)) commit(update); + + setCur(null); + }; + + return ( + + + + + + + + + = max} + /> + + + + + ); } +const NumberInputWrapper = styled.div` + --s: 1; + + @media (max-width: 768px) { + --s: 0.75; + } + + @media (max-width: 600px) { + --s: 0.5; + } + + @media (max-width: 360px) { + --s: 0.3; + } + + display: inline-block; + transform: scale(var(--s)); + transform-origin: top left; + + width: calc(52px / var(--s)); + height: calc(36px / var(--s)); +`; + const StyledNumberInput = styled.div` - /* TODO: Add styles for NumberInput */ + --border: #525252; + --bg: #ffffff; + --bg-hover: #f2f2f2; + --bg-focus: #e9e9e9; + --chevron: #525252; + + position: relative; + width: 52px; + height: 36px; + + display: grid; + grid-template-columns: 1fr 20px; + column-gap: 8px; + + border: 2px solid var(--border); + border-radius: 4px; + background: var(--bg); + box-sizing: border-box; + + padding-left: 8px; + padding-right: 2px; + overflow: hidden; + transition: + background 120ms ease, + border-color 120ms ease; + + &:focus-within { + background: var(--bg-focus); + } + + &[data-disabled="true"] { + --border: #cfcfcf; + --bg: #efefef; + --chevron: #cfcfcf; + } +`; + +const StyledInput = styled.input` + grid-column: 1 / 2; + grid-row: 1 / 2; + + width: 100%; + border: none; + outline: none; + background: transparent; + text-align: left; + + font-family: "Inter", sans-serif; + font-size: 14px; + font-weight: 400; + line-height: 20px; + color: var(--Black, #000); + + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } + -moz-appearance: textfield; + + &:disabled { + color: #9e9e9e; + } +`; + +const StyledNumberArrows = styled.div` + grid-column: 2 / 3; + grid-row: 1 / 2; + width: 20px; + height: 20px; + align-self: center; + position: relative; +`; + +const StyledVectorUp = styled.div` + position: absolute; + left: 6.42px; + top: 2.04px; + width: 0; + height: 0; + border-left: 3.585px solid transparent; + border-right: 3.585px solid transparent; + border-bottom: 3.92px solid var(--chevron); +`; + +const StyledVectorDown = styled.div` + position: absolute; + left: 6.42px; + bottom: 2.04px; + width: 0; + height: 0; + border-left: 3.585px solid transparent; + border-right: 3.585px solid transparent; + border-top: 3.92px solid var(--chevron); +`; + +const ArrowButton = styled.button` + position: absolute; + left: 0; + width: 100%; + border: none; + background: transparent; + cursor: pointer; + transition: opacity 100ms ease; + + &.number-input-up { + top: 0; + height: calc(50% - 4.04px); + } + &.number-input-down { + bottom: 0; + height: calc(50% - 4.04px); + } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } `; diff --git a/app/layout.tsx b/app/layout.tsx index 81e2a18..430cf5f 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -25,6 +25,12 @@ export default function RootLayout({ }>) { return ( + + + {children} From 7f2e7e426e2bf9f590afb48db1f3c023d1f67cb5 Mon Sep 17 00:00:00 2001 From: JKong05 Date: Sat, 1 Nov 2025 23:24:56 -0500 Subject: [PATCH 2/4] NumberInput linter fixes (#30) --- app/components/NumberInput.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/components/NumberInput.tsx b/app/components/NumberInput.tsx index 7968b78..97ffe56 100644 --- a/app/components/NumberInput.tsx +++ b/app/components/NumberInput.tsx @@ -25,13 +25,13 @@ export default function NumberInput({ ); const [cur, setCur] = useState(null); - const format = (s: string) => s.replace(/^(-?)0+(?=\d)/, "$1"); + const format = (value: string) => value.replace(/^(-?)0+(?=\d)/, "$1"); // current existing value in input field const base = () => { if (cur === null || cur === "") return val; - let n = Number(format(cur)); - return !Number.isNaN(n) ? n : val; + const num = Number(format(cur)); + return !Number.isNaN(num) ? num : val; }; // update value @@ -72,9 +72,9 @@ export default function NumberInput({ } if (event.key == "Enter") { event.preventDefault(); - let n = Number(format(cur ?? String(val))); + const num = Number(format(cur ?? String(val))); - if (!Number.isNaN(n)) commit(n); + if (!Number.isNaN(num)) commit(num); setCur(null); event.currentTarget.blur(); return; @@ -101,7 +101,7 @@ export default function NumberInput({ setCur(null); return; } - let update = Number(format(cur)); + const update = Number(format(cur)); if (!Number.isNaN(update)) commit(update); setCur(null); From 3675313bba82bf16c0c149f1de4de7008fff90a0 Mon Sep 17 00:00:00 2001 From: JKong05 Date: Sat, 15 Nov 2025 19:56:51 -0600 Subject: [PATCH 3/4] Revised NumberInput changes (#30) --- app/components/NumberInput.tsx | 180 +++++++++++---------------------- 1 file changed, 58 insertions(+), 122 deletions(-) diff --git a/app/components/NumberInput.tsx b/app/components/NumberInput.tsx index 97ffe56..40613f2 100644 --- a/app/components/NumberInput.tsx +++ b/app/components/NumberInput.tsx @@ -1,110 +1,50 @@ "use client"; -import { ChangeEvent, KeyboardEvent, useCallback, useState } from "react"; +import { ChangeEvent, KeyboardEvent, useState } from "react"; import styled from "styled-components"; type NumberInputProps = { defaultValue?: number; - onChange?: (value?: number) => void; - update?: number; - min?: number; - max?: number; disabled?: boolean; label?: string; }; + export default function NumberInput({ defaultValue = 0, - onChange = () => {}, - update = 1, - min = 0, - max = 9, disabled = false, label, }: NumberInputProps) { - const [val, setVal] = useState(() => - Math.max(min, Math.min(max, defaultValue)), - ); - const [cur, setCur] = useState(null); + const [val, setVal] = useState(defaultValue); - const format = (value: string) => value.replace(/^(-?)0+(?=\d)/, "$1"); + // can change these for future purposes + const step = 1; + const min = 0; + const max = 9; - // current existing value in input field - const base = () => { - if (cur === null || cur === "") return val; - const num = Number(format(cur)); - return !Number.isNaN(num) ? num : val; - }; - - // update value - const commit = useCallback( - (next: number) => { - if (next < min) next = min; - else if (next > max) next = max; - - setVal((prev) => { - if (prev !== next) onChange?.(next); - return next; - }); - }, - [min, max, onChange], - ); - - // value inc + dec handlers const handleIncrement = () => { - commit(base() + update); - setCur(null); + setVal(Math.min(max, val + step)); }; + const handleDecrement = () => { - commit(base() - update); - setCur(null); + setVal(Math.max(min, val - step)); }; - // keyboard handler const handleKeyDown = (event: KeyboardEvent) => { - if (event.key == "ArrowUp") { - event.preventDefault(); - handleIncrement(); - return; - } - if (event.key == "ArrowDown") { - event.preventDefault(); - handleDecrement(); - return; - } - if (event.key == "Enter") { - event.preventDefault(); - const num = Number(format(cur ?? String(val))); - - if (!Number.isNaN(num)) commit(num); - setCur(null); + if (event.key === "Enter") { event.currentTarget.blur(); return; } - if (event.key == "Escape") { - setCur(null); - } }; const handleChange = (event: ChangeEvent) => { - let update = (event.target as HTMLInputElement).value; - if (update === "") { - setCur(update); + const input = event.target as HTMLInputElement; + // if the field is empty + if (Number.isNaN(input.valueAsNumber)) { + setVal(min); return; } - update = format(update); - - setCur(update); - }; - - // element focus - const handleBlur = () => { - if (cur === null || cur === "") { - setCur(null); - return; - } - const update = Number(format(cur)); - if (!Number.isNaN(update)) commit(update); - - setCur(null); + const update = Math.max(min, Math.min(max, input.valueAsNumber)); + setVal(update); + input.value = String(update); }; return ( @@ -115,36 +55,34 @@ export default function NumberInput({ > - - - = max} - /> + disabled={disabled} + > + arrow_drop_up + + disabled={disabled} + > + arrow_drop_down + @@ -240,6 +178,19 @@ const StyledInput = styled.input` } `; +const MaterialIcon = styled.span` + font-family: "Material Symbols Outlined"; + font-weight: normal; + font-style: normal; + font-size: 16px; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--chevron); + user-select: none; +`; + const StyledNumberArrows = styled.div` grid-column: 2 / 3; grid-row: 1 / 2; @@ -249,45 +200,30 @@ const StyledNumberArrows = styled.div` position: relative; `; -const StyledVectorUp = styled.div` - position: absolute; - left: 6.42px; - top: 2.04px; - width: 0; - height: 0; - border-left: 3.585px solid transparent; - border-right: 3.585px solid transparent; - border-bottom: 3.92px solid var(--chevron); -`; - -const StyledVectorDown = styled.div` - position: absolute; - left: 6.42px; - bottom: 2.04px; - width: 0; - height: 0; - border-left: 3.585px solid transparent; - border-right: 3.585px solid transparent; - border-top: 3.92px solid var(--chevron); -`; - -const ArrowButton = styled.button` +const ArrowButton = styled.button<{ $dir: "up" | "down" }>` position: absolute; left: 0; width: 100%; border: none; background: transparent; cursor: pointer; + padding: 0; transition: opacity 100ms ease; - &.number-input-up { + display: flex; + align-items: center; + justify-content: center; + + ${({ $dir }) => + $dir === "up" + ? ` top: 0; - height: calc(50% - 4.04px); - } - &.number-input-down { + height: calc(50% - 2px); + ` + : ` bottom: 0; - height: calc(50% - 4.04px); - } + height: calc(50% - 2px); + `} &:disabled { opacity: 0.4; From 82d18b5f8a9905f602136a9d5d8eb3634a3c99c2 Mon Sep 17 00:00:00 2001 From: Rachel Koh Date: Sun, 30 Nov 2025 13:32:00 -0500 Subject: [PATCH 4/4] figma uses Material Symbols Rounded instead of Outlined --- app/components/NumberInput.tsx | 2 +- app/layout.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/components/NumberInput.tsx b/app/components/NumberInput.tsx index 40613f2..e0c34de 100644 --- a/app/components/NumberInput.tsx +++ b/app/components/NumberInput.tsx @@ -179,7 +179,7 @@ const StyledInput = styled.input` `; const MaterialIcon = styled.span` - font-family: "Material Symbols Outlined"; + font-family: "Material Symbols Rounded"; font-weight: normal; font-style: normal; font-size: 16px; diff --git a/app/layout.tsx b/app/layout.tsx index 430cf5f..c886b66 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -27,7 +27,7 @@ export default function RootLayout({