UX audit ทั้งแอป + typography audit 2026-09-16: toast แจ้ง error การบันทึก (H1 — เดิมรายการหายเงียบ + ReplyBox เก็บ draft ตอน fail), คำเตือน redaction แสดงทุกขนาดจอ (H2), toolbar แสดงชื่อ tool active + aria-pressed บนจอสัมผัส (H3/M9), ปุ่ม popover จิ้มง่าย 36px + focus ring เติมครบ (H4) + M1-M9 (ปิด sidebar ตอน navigate, bookmark หัวแถวจอเล็ก, hint โหมดมาร์กบนจอสัมผัส, การ์ดหน้ารายการคลิกได้ทั้งใบ, เช็ค 100MB ฝั่ง client, feedback ✓/pending ตอนบันทึก bookmark, dropdown วางที่ sort ตามหน้า+เลขหน้า, slate-500 แทน slate-400) + webfont Sarabun ผ่าน next/font self-host 400/600 (Option B — แสดงผลตรงกันทุกอุปกรณ์ รวม TEXTBOX ที่ผูก stack นี้) + NumberCombo preset เป็น chips แทน dropdown ล้นเมนู (L2) + leading-relaxed/13px ข้อความไทยที่ wrap + tabular-nums + prefers-reduced-motion (L7) + L1/L4/L5 + แถว bookmark แสดงชื่อผู้บันทึกเหมือนผลการค้นหา

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-09-16 14:28:53 +07:00
parent a020e96bda
commit 21b9958600
10 changed files with 351 additions and 158 deletions

View file

@ -1,13 +1,19 @@
"use client";
import { useEffect, useRef, useState, type ComponentType, type ReactNode } from "react";
import { Check, ChevronDown, ChevronUp, Highlighter, Strikethrough, Underline } from "lucide-react";
import { useEffect, useState, type ComponentType, type ReactNode } from "react";
import { ChevronDown, ChevronUp, Highlighter, Strikethrough, Underline } from "lucide-react";
import type { HighlightStyle } from "@/db/schema";
// ตัวควบคุมที่ใช้ร่วมระหว่าง dropdown บนปุ่ม tool (PDFViewer) กับแผงเครื่องมือ
// บนกรอบ selection (AnnotationSelectionLayer) — หน้าตา/พฤติกรรมเดียวกันทั้งสองที่
/** UX audit 2026-09-16: focus ring คีย์บอร์ด — ค่าเดียวกับ FOCUS_RING_CLS ของ
* PDFViewer/AnnotationSidebar (ประกาศแยกที่นี่เพราะ PDFViewer import ไฟล์นี้
* อยู่แล้ว ห้ามวนกลับ) */
const FOCUS_RING_CLS =
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1";
/** สี preset ให้เลือก (ทุก tool ที่ปรับสีได้: draw/shape/highlight/textbox/stamp)
* สีอื่นนอกรายการเลือกได้ผ่านปุ่มสีรุ้ง (input type="color") ท้ายแถว */
export const INK_COLORS = [
@ -43,7 +49,7 @@ export function ColorSwatches({ value, onChange }: { value: string; onChange: (c
type="button"
aria-label={`เลือกสี ${c}`}
title={c}
className={`h-4 w-4 rounded-full border border-slate-300 transition ${
className={`h-4 w-4 rounded-full border border-slate-300 transition ${FOCUS_RING_CLS} ${
value === c ? "ring-2 ring-primary ring-offset-1" : "hover:scale-110"
}`}
style={{ backgroundColor: c }}
@ -52,7 +58,7 @@ export function ColorSwatches({ value, onChange }: { value: string; onChange: (c
))}
<label
title="เลือกสีอื่น"
className={`relative h-4 w-4 cursor-pointer rounded-full border border-slate-300 transition ${
className={`relative h-4 w-4 cursor-pointer rounded-full border border-slate-300 transition has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary has-[:focus-visible]:ring-offset-1 ${
INK_COLORS.includes(value) ? "hover:scale-110" : "ring-2 ring-primary ring-offset-1"
}`}
style={{
@ -96,11 +102,9 @@ export function NumberCombo({
suffix?: string;
onChange: (v: number) => void;
}) {
const [open, setOpen] = useState(false);
// FIX-3: ถือ draft ระหว่างพิมพ์ — commit ครั้งเดียวตอน blur/Enter
// ไม่ยิง server action + version snapshot ต่อ keystroke; sync กลับเมื่อค่าจากภายนอกเปลี่ยน
const [draft, setDraft] = useState(String(value));
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => setDraft(String(value)), [value]);
@ -120,15 +124,6 @@ export function NumberCombo({
// eslint-disable-next-line react-hooks/exhaustive-deps -- draft/value/onChange ใช้ค่าล่าสุดตอน timer ยิง
}, [draft]);
useEffect(() => {
if (!open) return;
const onMouseDown = (e: MouseEvent) => {
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onMouseDown);
return () => document.removeEventListener("mousedown", onMouseDown);
}, [open]);
/** ยืนยันค่าที่พิมพ์หนึ่งครั้ง — clamp เข้า min–max; พิมพ์ไม่เป็นตัวเลข = คืนค่าเดิม
* ค่าเท่าเดิมไม่ยิง onChange — focus/blur เฉย ๆ ต้องไม่เกิด server action + snapshot ขยะ */
function commitDraft() {
@ -153,17 +148,7 @@ export function NumberCombo({
}
return (
<div
ref={rootRef}
className="number-combo relative"
onKeyDown={(e) => {
// Esc ปิดแค่ dropdown นี้ก่อน (stopPropagation ไม่ให้ปิดทั้งเมนู/กรอบ)
if (e.key === "Escape" && open) {
e.stopPropagation();
setOpen(false);
}
}}
>
<div className="number-combo">
<div className="flex items-stretch overflow-hidden rounded-md border border-slate-300 bg-white">
<input
type="number"
@ -173,10 +158,7 @@ export function NumberCombo({
step={step}
onChange={(e) => setDraft(e.target.value)}
onBlur={commitDraft}
onFocus={(e) => {
setOpen(true); // กดที่ช่อง = เปิดรายการให้เลือกเลย (ไม่ต้องมีปุ่มเปิดแยก)
e.target.select(); // พิมพ์ทับค่าเดิมได้ทันที
}}
onFocus={(e) => e.target.select()} // พิมพ์ทับค่าเดิมได้ทันที
onKeyDown={(e) => {
if (e.key !== "Escape") e.stopPropagation(); // พิมพ์ในช่องต้องไม่ชนคีย์ลัด F3 (Esc ปล่อยไหลไปปิดเมนู)
if (e.key === "Enter") commitDraft(); // ยืนยันครั้งเดียวตรงนี้ ไม่ต้องรอ blur
@ -190,7 +172,7 @@ export function NumberCombo({
type="button"
aria-label="เพิ่มค่า"
onMouseDown={(e) => e.preventDefault()} // ไม่แย่ง focus จาก input (dropdown ไม่กระพริบ)
className="combo-step flex flex-1 items-center justify-center px-1.5 text-slate-500 hover:bg-slate-100"
className={`combo-step flex flex-1 items-center justify-center rounded-sm px-1.5 text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
onClick={() => stepValue(1)}
>
<ChevronUp className="h-3 w-3" />
@ -199,42 +181,41 @@ export function NumberCombo({
type="button"
aria-label="ลดค่า"
onMouseDown={(e) => e.preventDefault()}
className="combo-step flex flex-1 items-center justify-center border-t border-slate-300 px-1.5 text-slate-500 hover:bg-slate-100"
className={`combo-step flex flex-1 items-center justify-center rounded-sm border-t border-slate-300 px-1.5 text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
onClick={() => stepValue(-1)}
>
<ChevronDown className="h-3 w-3" />
</button>
</div>
</div>
{open && (
<div
role="listbox"
className="absolute right-0 top-full z-[60] mt-1 w-16 overflow-hidden rounded-md border border-slate-200 bg-white py-0.5 shadow-lg"
>
{options.map((opt) => (
{/* L2 (UX audit 2026-09-16): preset เดิมเป็น dropdown เปิดลงจากช่อง — แถวท้าย
ของเมนู tool (ความทึบ/ความหนา) ล้นเขต scroll ของเมนู ต้องเลื่อนเมนูจึงเห็น
ครบ เปลี่ยนเป็น chips แถวเดียวใต้ช่อง: เห็นค่าที่เลือกได้ทันที ไม่มี popup
ซ้อนในเมนูอีก (จอสัมผัสได้ min 36px จาก rule [data-tool-menu] โดยอัตโนมัติ) */}
<div className="mt-1 flex flex-wrap gap-1">
{options.map((opt) => {
const active = value === opt;
return (
<button
key={opt}
type="button"
role="option"
aria-selected={value === opt}
onMouseDown={(e) => e.preventDefault()} // ไม่แย่ง focus จาก input ก่อนคลิก
className={`flex w-full items-center justify-end gap-1 px-2 py-1 text-right text-[11px] font-medium transition ${
value === opt
? "bg-slate-100 text-slate-900"
: "text-slate-600 hover:bg-slate-100"
aria-pressed={active}
className={`rounded border px-1.5 py-0.5 text-[11px] transition ${FOCUS_RING_CLS} ${
active
? "border-primary bg-primary/10 font-medium text-primary"
: "border-slate-300 text-slate-500 hover:bg-slate-100"
}`}
onClick={() => {
setDraft(String(opt));
onChange(opt);
setOpen(false);
}}
>
{opt}
{suffix}
{value === opt && <Check className="h-3 w-3 shrink-0" />}
</button>
))}
</div>
)}
);
})}
</div>
</div>
);
}