commit a6887112071801c1356856a444ee92359fbfa90e
parent 5932e3061f68b1ec22385097531f909e3d894996
Author: brookjeynes <me@brookjeynes.dev>
Date: Sun, 31 May 2026 13:10:48 +1000
feat: add tabs
Signed-off-by: brookjeynes <me@brookjeynes.dev>
Diffstat:
5 files changed, 78 insertions(+), 106 deletions(-)
diff --git a/index.html b/index.html
@@ -12,6 +12,16 @@
<body>
<h1>숫자마스터</h1>
+ <div x-data role="tablist" aria-label="number mode tablist">
+ <button role="tab" :aria-selected="$store.app.tab === 'sino'" id="panel-sino"
+ @click="$store.app.changeTab('sino')">한자어</button>
+ <button role="tab" :aria-selected="$store.app.tab === 'native'" id="panel-native"
+ @click="$store.app.changeTab('native')">고유어</button>
+ <button role="tab" id="panel-time" disabled>시간</button>
+ <button role="tab" id="panel-date" disabled>날짜</button>
+ <button role="tab" id="panel-counter" disabled>단위</button>
+ </div>
+
<fieldset x-data>
<legend>모드</legend>
<label>
@@ -26,7 +36,7 @@
</label>
</fieldset>
- <fieldset x-data>
+ <fieldset x-data x-show="$store.app.tab === 'sino'">
<legend>범위</legend>
<label>
최소
@@ -84,7 +94,8 @@
</span>
</template>
<br>
- <input autocomplete="off" class="answer-input" id="input" type="text" placeholder="한글 입력" :class="{
+ <input autocomplete="off" class="answer-input" id="input" type="text"
+ :placeholder="$store.app.mode === 'number' ? '한글 입력' : '숫자 입력'" :class="{
'-correct': $store.app.questionState === 'correct',
'-incorrect': $store.app.questionState === 'incorrect'
}" />
diff --git a/minimal.css b/minimal.css
@@ -196,3 +196,14 @@ label {
margin: 8px 0;
}
}
+
+[role="tablist"] {
+ display: flex;
+ gap: 15px;
+
+ [role="tab"][aria-selected="true"] {
+ text-decoration: underline;
+ text-decoration-thickness: 1px;
+ text-underline-offset: 3px;
+ }
+}
diff --git a/src/native.js b/src/native.js
@@ -9,55 +9,9 @@ export function nativeStringFromNumber(num) {
const ONES = ["", "하나", "둘", "셋", "넷", "다섯", "여섯", "일곱", "여덟", "아홉"];
const TENS = ["", "열", "스물", "서른", "마흔", "쉰", "예순", "일흔", "여든", "아흔"];
- const groups = [];
- let temp = num;
- while (temp > 0) {
- groups.push(temp % 10);
- temp = Math.floor(temp / 10);
- }
+ const tens = Math.floor(num / 10);
+ const ones = num % 10;
- const ones = ONES[groups[0]];
-
- if (groups.length === 1) {
- return ones;
- }
-
- const tens = TENS[groups[1]];
- return tens + ones;
-}
-
-/**
- * @param {string} str
- * @returns {number}
- */
-export function numberFromNativeString(str) {
- if (str.length === 0) throw new Error("string cannot be of zero length");
- if (str.length > 4) throw new Error("string cannot be greater than 4 characters");
-
- const ONES = { "하나": 1, "둘": 2, "셋": 3, "넷": 4, "다섯": 5, "여섯": 6, "일곱": 7, "여덟": 8, "아홉": 9 };
- const TENS = { "열": 10, "스물": 20, "서른": 30, "마흔": 40, "쉰": 50, "예순": 60, "일흔": 70, "여든": 80, "아흔": 90 };
-
- /** @type{number} */
- let result = 0;
- /** @type{string} */
- let temp = str;
-
- for (const ten of Object.keys(TENS)) {
- if (temp.startsWith(ten)) {
- result += TENS[ten];
- temp = temp.slice(ten.length);
- break;
- }
- }
- for (const one of Object.keys(ONES)) {
- if (temp.startsWith(one)) {
- result += ONES[one];
- temp = temp.slice(one.length);
- break;
- }
- }
-
- if (temp.length !== 0) throw new Error("invalid input");
-
- return result;
+ if (tens === 0) return ONES[ones];
+ return TENS[tens] + ONES[ones];
}
diff --git a/src/sino.js b/src/sino.js
@@ -22,7 +22,7 @@ export function sinoStringFromNumber(num) {
temp = Math.floor(temp / 1_0000)
}
- /** @type {string} */
+ /** @type {string[]} */
let result = [];
for (let g = groups.length - 1; g >= 0; g--) {
/** @type {number} */
@@ -57,49 +57,3 @@ export function sinoStringFromNumber(num) {
return result.join(" ");
}
-
-/**
- * @param {string} str
- * @returns {number}
- */
-export function numberFromSinoString(str) {
- const UNITS = { "일": 1, "이": 2, "삼": 3, "사": 4, "오": 5, "육": 6, "칠": 7, "팔": 8, "구": 9 };
- const SMALL = { "십": 10, "백": 100, "천": 1000 };
- const LARGE = { "만": 10000, "억": 100000000, "조": 1000000000000 };
-
- if (str.length === 0) throw new Error("string cannot be of zero length");
- if (str === "영") return 0;
-
- /** @type {number} */
- let result = 0;
- /**
- * Accumulates value within current large-unit group.
- * @type {number}
- */
- let currentGroup = 0;
- /**
- * Defaults to 1 to handle omitted 일 (e.g. "십" = 10).
- * @type {number}
- */
- let currentDigit = 1;
-
- for (const char of str) {
- if (UNITS[char] !== undefined) {
- currentDigit = UNITS[char];
- } else if (SMALL[char] !== undefined) {
- currentGroup += currentDigit * SMALL[char];
- // reset for next digit (restores 일-omission default)
- currentDigit = 1;
- } else if (LARGE[char] !== undefined) {
- // flush any trailing 일-place digit into group, then apply large unit
- result += (currentGroup + currentDigit) * LARGE[char];
- currentGroup = 0;
- currentDigit = 1;
- }
- }
-
- // flush the final group (no large unit suffix)
- result += currentGroup + currentDigit;
-
- return result;
-}
diff --git a/src/store.js b/src/store.js
@@ -1,5 +1,5 @@
-import { nativeStringFromNumber, numberFromNativeString } from "./native.js";
-import { sinoStringFromNumber, numberFromSinoString } from "./sino.js";
+import { nativeStringFromNumber } from "./native.js";
+import { sinoStringFromNumber } from "./sino.js";
/**
* @typedef {
@@ -10,6 +10,16 @@ import { sinoStringFromNumber, numberFromSinoString } from "./sino.js";
/**
* @typedef {
+ * | "sino"
+ * | "native"
+ * | "time"
+ * | "date"
+ * | "counter"
+ * } Tab
+ */
+
+/**
+ * @typedef {
* | "correct"
* | "incorrect"
* | "undecided"
@@ -38,6 +48,8 @@ window.Alpine.store("app", {
max: DEFAULT_CONFIG.max,
/** @type {Mode} */
mode: DEFAULT_CONFIG.mode,
+ /** @type {Tab} */
+ tab: "sino",
/** @type {QuestionState} */
questionState: "undecided",
/** @type {string | null} */
@@ -62,6 +74,9 @@ window.Alpine.store("app", {
case "number":
this.mode = "number";
break;
+ default:
+ this.mode = DEFAULT_CONFIG.mode;
+ console.error("unknown mode selected");
}
this.min = isNaN(min) ? DEFAULT_CONFIG.min : min;
@@ -69,19 +84,26 @@ window.Alpine.store("app", {
}
this.onRangeChange()
- this.number = random(this.min, this.max * 10);
+ this.number = this.randomNumber;
},
/** @returns {string} */
get string() {
- return sinoStringFromNumber(this.number);
+ switch (this.tab) {
+ case "sino":
+ return sinoStringFromNumber(this.number);
+ case "native":
+ return nativeStringFromNumber(this.number);
+ default:
+ throw new Error("unknown tab selected");
+ }
},
/** @returns {string} */
get answer() {
switch (this.mode) {
case "number":
- return sinoStringFromNumber(this.number);
+ return this.string;
case "hangeul":
return this.number.toString();
}
@@ -96,7 +118,7 @@ window.Alpine.store("app", {
const sanitised = input.trim().split(" ").join("");
if (this.questionState !== "undecided") {
- this.number = random(this.min, this.max * 10);
+ this.number = this.randomNumber;
event.target.input.value = "";
this.questionState = "undecided";
return
@@ -126,7 +148,7 @@ window.Alpine.store("app", {
}
this.rangeError = null;
- this.number = random(this.min, this.max * 10);
+ this.number = this.randomNumber;
this.questionState = "undecided";
},
@@ -140,6 +162,26 @@ window.Alpine.store("app", {
this.configSaved = false;
}, 2000)
},
+
+ /**
+ * @param {Tab} tab
+ */
+ changeTab(tab) {
+ this.questionState = "undecided";
+ this.tab = tab;
+ this.number = this.randomNumber;
+ },
+
+ get randomNumber() {
+ switch (this.tab) {
+ case "sino":
+ return random(this.min, this.max * 10);
+ case "native":
+ return random(1, 100);
+ default:
+ throw new Error("unknown tab selected");
+ }
+ },
});
/**