fix(ui) : simplify admin controls and artifact selection
This commit is contained in:
@@ -15,6 +15,7 @@ import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader,
|
||||
import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList } from "@/components/ui/combobox";
|
||||
import { Field, FieldDescription, FieldGroup, FieldLabel, FieldLegend, FieldSet } from "@/components/ui/field";
|
||||
import { ClearableInput as Input } from "@/components/admin/ui/clearable-input";
|
||||
import { Input as StandardInput } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
@@ -231,9 +232,9 @@ export function SortableTalentCard({
|
||||
<CardAction>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="plain"
|
||||
size="icon-sm"
|
||||
className="touch-none"
|
||||
className="touch-none cursor-grab active:cursor-grabbing"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
@@ -280,9 +281,9 @@ export function SortableArtifactRow({
|
||||
{children(
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="plain"
|
||||
size="icon-sm"
|
||||
className="touch-none"
|
||||
className="touch-none cursor-grab active:cursor-grabbing"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
@@ -612,26 +613,17 @@ export function ArtifactEditor({
|
||||
>
|
||||
{dragHandle}
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-3">
|
||||
<div className="grid gap-3 lg:grid-cols-[1fr_1fr_8rem_auto]">
|
||||
<div className="grid gap-3 lg:grid-cols-[1fr_8rem_auto]">
|
||||
<ArtifactSelect
|
||||
multiple
|
||||
presentation="dropdown"
|
||||
items={artifacts}
|
||||
value={row.firstArtifactKey}
|
||||
values={[row.firstArtifactKey, row.secondArtifactKey].filter((key): key is string => Boolean(key))}
|
||||
invalid={artifactIssue("groups", activeArtifactGroupIndex, "tiers", groupIndex, "rows", rowIndex, "firstArtifactKey")}
|
||||
placeholder="ค้นหาชุด Artifact แรก…"
|
||||
rarityBackground
|
||||
showSelectedImage
|
||||
onValueChange={(firstArtifactKey) =>
|
||||
setArtifactTierRows(groupIndex, (rows) => rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, firstArtifactKey } : candidate))
|
||||
}
|
||||
/>
|
||||
<ArtifactSelect
|
||||
items={artifacts}
|
||||
value={row.secondArtifactKey ?? ""}
|
||||
placeholder="ค้นหาชุดที่สอง (ถ้ามี)…"
|
||||
rarityBackground
|
||||
showSelectedImage
|
||||
onValueChange={(secondArtifactKey) =>
|
||||
setArtifactTierRows(groupIndex, (rows) => rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, secondArtifactKey: secondArtifactKey || null } : candidate))
|
||||
label="เลือกชุด Artifact (สูงสุด 2 ชุด)"
|
||||
placeholder="ค้นหาชุด Artifact…"
|
||||
onValuesChange={(keys) =>
|
||||
setArtifactTierRows(groupIndex, (rows) => rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, firstArtifactKey: keys[0] ?? "", secondArtifactKey: keys[1] ?? null } : candidate))
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
@@ -651,7 +643,7 @@ export function ArtifactEditor({
|
||||
trigger={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="plain"
|
||||
size="icon"
|
||||
/>
|
||||
}
|
||||
@@ -853,7 +845,7 @@ export function ArtifactEditor({
|
||||
<StatIcons values={[stat]} />
|
||||
{stat}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
<StandardInput
|
||||
value={target?.value ?? ""}
|
||||
placeholder="Value or text"
|
||||
aria-label={`${stat} value`}
|
||||
|
||||
@@ -79,9 +79,9 @@ export function SortableExtraItem({
|
||||
{children(
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="plain"
|
||||
size="icon-sm"
|
||||
className="touch-none"
|
||||
className="touch-none cursor-grab active:cursor-grabbing"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
@@ -163,7 +163,7 @@ export function ExtraCatalogItemsEditor({
|
||||
<p className="text-sm text-muted-foreground">{labels[reference.kind]}</p>
|
||||
</div>
|
||||
{dragHandle}
|
||||
<Button type="button" variant="ghost" size="icon-sm" onClick={() => onChange(value.filter((_, itemIndex) => itemIndex !== index))} aria-label={`ลบ ${label}`}>
|
||||
<Button type="button" variant="plain" size="icon-sm" onClick={() => onChange(value.filter((_, itemIndex) => itemIndex !== index))} aria-label={`ลบ ${label}`}>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</CardContent>
|
||||
@@ -352,7 +352,7 @@ export function ExtraEditor({ guide, extraSectionId, initial, characters, weapon
|
||||
<CardAction className="flex items-center gap-1">
|
||||
<DeleteConfirmationDialog
|
||||
trigger={
|
||||
<Button type="button" variant="ghost" size="icon-sm" />
|
||||
<Button type="button" variant="plain" size="icon-sm" />
|
||||
}
|
||||
title={`ลบ ${section.title || `Extra section ${sectionIndex + 1}`}?`}
|
||||
description="แถว ข้อความ รูปภาพ และเปอร์เซ็นต์ทั้งหมดในส่วนนี้จะถูกลบออกจาก Guide"
|
||||
@@ -415,7 +415,7 @@ export function ExtraEditor({ guide, extraSectionId, initial, characters, weapon
|
||||
trigger={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="plain"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
@@ -470,7 +470,7 @@ export function ExtraEditor({ guide, extraSectionId, initial, characters, weapon
|
||||
trigger={
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
variant="plain"
|
||||
size="icon-sm"
|
||||
className="absolute top-2 right-2"
|
||||
aria-label="ลบภาพ"
|
||||
|
||||
@@ -359,9 +359,9 @@ export function SortableConditionTab({
|
||||
<SimpleTooltip text={`ลากเพื่อจัดลำดับ ${label}`}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="plain"
|
||||
size="icon-sm"
|
||||
className="touch-none rounded-l-none"
|
||||
className="touch-none rounded-l-none cursor-grab active:cursor-grabbing"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
|
||||
@@ -93,9 +93,9 @@ export function SortableTeamItem({
|
||||
<SimpleTooltip text="ลากเพื่อจัดลำดับ Team">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="plain"
|
||||
size="icon-sm"
|
||||
className="touch-none"
|
||||
className="touch-none cursor-grab active:cursor-grabbing"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
@@ -134,9 +134,9 @@ export function SortableTeamMember({
|
||||
<SimpleTooltip text="ลากเพื่อย้ายตำแหน่งตัวละคร">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="plain"
|
||||
size="icon-sm"
|
||||
className="touch-none"
|
||||
className="touch-none cursor-grab active:cursor-grabbing"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
@@ -844,7 +844,7 @@ export function TeamEditor({
|
||||
<div key={preset.id} className="flex min-w-0 max-w-full items-center gap-1">
|
||||
<Button className="min-w-0 max-w-full truncate" type="button" variant="ghost" size="sm" disabled={!selectedTeamIds.size} onClick={() => applyPreset(preset)}>{preset.name}</Button>
|
||||
<SimpleTooltip text={`ลบ preset ${preset.name}`}>
|
||||
<Button type="button" variant="ghost" size="icon-sm" onClick={() => setPresets((current) => current.filter((item) => item.id !== preset.id))} aria-label={`ลบ preset ${preset.name}`}><XIcon /></Button>
|
||||
<Button type="button" variant="plain" size="icon-sm" onClick={() => setPresets((current) => current.filter((item) => item.id !== preset.id))} aria-label={`ลบ preset ${preset.name}`}><XIcon /></Button>
|
||||
</SimpleTooltip>
|
||||
</div>
|
||||
))}
|
||||
@@ -972,7 +972,7 @@ export function TeamEditor({
|
||||
<SimpleTooltip text="Export Team">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="plain"
|
||||
size="icon-sm"
|
||||
onClick={() => void exportTeam(team)}
|
||||
>
|
||||
@@ -985,7 +985,7 @@ export function TeamEditor({
|
||||
<SimpleTooltip text="ทำสำเนา Team">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="plain"
|
||||
size="icon-sm"
|
||||
onClick={() =>
|
||||
setTeams((current) => {
|
||||
@@ -1018,7 +1018,7 @@ export function TeamEditor({
|
||||
trigger={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="plain"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -50,9 +50,9 @@ export function SortableWeaponCard({
|
||||
{children(
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="plain"
|
||||
size="icon-sm"
|
||||
className="touch-none"
|
||||
className="touch-none cursor-grab active:cursor-grabbing"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
@@ -265,7 +265,7 @@ export function WeaponEditor({ guide, weapons, characters, initial }: EditorBase
|
||||
trigger={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="plain"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -331,9 +331,9 @@ function EditableColumnIndicator({
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="plain"
|
||||
size="icon-sm"
|
||||
className="touch-none"
|
||||
className="touch-none cursor-grab active:cursor-grabbing"
|
||||
aria-label={`ลากเพื่อย้าย ${String(label ?? `คอลัมน์ ${column + 1}`)}`}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
@@ -413,9 +413,9 @@ function SortableRowIndicator({
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="plain"
|
||||
size="icon-sm"
|
||||
className="touch-none"
|
||||
className="touch-none cursor-grab active:cursor-grabbing"
|
||||
aria-label={`ลากเพื่อย้ายแถว ${row + 1}`}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
@@ -935,7 +935,7 @@ export function ExtraSheetEditor({
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="plain"
|
||||
size="icon-sm"
|
||||
disabled={!canUndo}
|
||||
aria-label="ย้อนกลับการแก้ไขตาราง"
|
||||
@@ -1004,7 +1004,7 @@ export function ExtraSheetEditor({
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="plain"
|
||||
size="icon-sm"
|
||||
disabled={!canRedo}
|
||||
aria-label="ทำซ้ำการแก้ไขตาราง"
|
||||
@@ -1098,7 +1098,7 @@ export function ExtraSheetEditor({
|
||||
<ToggleGroupItem value="bottom" aria-label="ชิดล่าง"><AlignVerticalJustifyEndIcon /></ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<SimpleTooltip text="ล้างรูปแบบ">
|
||||
<Button type="button" variant="ghost" size="icon-sm" aria-label="ล้างรูปแบบ" onClick={clearFormatting}>
|
||||
<Button type="button" variant="plain" size="icon-sm" aria-label="ล้างรูปแบบ" onClick={clearFormatting}>
|
||||
<EraserIcon />
|
||||
</Button>
|
||||
</SimpleTooltip>
|
||||
|
||||
@@ -159,7 +159,7 @@ export function GlossaryManager({
|
||||
<p className="truncate text-sm text-muted-foreground">{name ?? entry.catalogKey}</p>
|
||||
</div>
|
||||
<Badge variant={name ? "secondary" : "outline"}>{name ? kindLabels[entry.kind as GlossaryKind] : "Unavailable"}</Badge>
|
||||
<Button variant="ghost" size="icon-sm" disabled={pending} aria-label={`ลบ ${entry.alias}`} onClick={() => startTransition(async () => {
|
||||
<Button variant="plain" size="icon-sm" disabled={pending} aria-label={`ลบ ${entry.alias}`} onClick={() => startTransition(async () => {
|
||||
const result = await removeGlossaryAlias(entry.id);
|
||||
if (result.status === "error") toast.error(result.message);
|
||||
else {
|
||||
|
||||
@@ -49,7 +49,7 @@ function SortableExtraCard({ extra, index, characterKey, disabled }: { extra: Ex
|
||||
<CardTitle>{extra.title}</CardTitle>
|
||||
<CardDescription>Extra section {index + 1}</CardDescription>
|
||||
<CardAction>
|
||||
<Button type="button" variant="ghost" size="icon-sm" disabled={disabled} className="touch-none cursor-grab active:cursor-grabbing" aria-label={`จัดลำดับ ${extra.title}`} {...attributes} {...listeners}>
|
||||
<Button type="button" variant="plain" size="icon-sm" disabled={disabled} className="touch-none cursor-grab active:cursor-grabbing" aria-label={`จัดลำดับ ${extra.title}`} {...attributes} {...listeners}>
|
||||
<GripVerticalIcon />
|
||||
</Button>
|
||||
</CardAction>
|
||||
|
||||
@@ -86,11 +86,11 @@ function SortableVideoLink({ link, index, guideName, onChange, onRemove }: { lin
|
||||
return (
|
||||
<Field ref={setNodeRef} data-invalid={invalid} style={{ transform: CSS.Transform.toString(transform), transition }} className={cn("rounded-lg border p-3", isDragging && "relative z-10 opacity-70 shadow-lg")}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="ghost" size="icon-sm" className="touch-none cursor-grab active:cursor-grabbing" aria-label={`จัดลำดับวิดีโอ ${index + 1}`} {...attributes} {...listeners}>
|
||||
<Button type="button" variant="plain" size="icon-sm" className="touch-none cursor-grab active:cursor-grabbing" aria-label={`จัดลำดับวิดีโอ ${index + 1}`} {...attributes} {...listeners}>
|
||||
<GripVerticalIcon data-icon="inline-start" />
|
||||
</Button>
|
||||
<FieldLabel htmlFor={`guide-youtube-url-${link.id}`} className="flex-1">YouTube video {index + 1}</FieldLabel>
|
||||
<Button type="button" variant="ghost" size="icon-sm" onClick={onRemove} aria-label={`ลบวิดีโอ ${index + 1}`}>
|
||||
<Button type="button" variant="plain" size="icon-sm" onClick={onRemove} aria-label={`ลบวิดีโอ ${index + 1}`}>
|
||||
<Trash2Icon data-icon="inline-start" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -110,7 +110,7 @@ function SortableBleedingLayer({ layer, onChange, onRemove }: { layer: BleedingL
|
||||
const selectedMode = blendModeItems.find((mode) => mode.value === layer.blendMode);
|
||||
return (
|
||||
<div ref={setNodeRef} style={{ transform: CSS.Transform.toString(transform), transition }} className={cn("flex items-start gap-2 rounded-lg border bg-card p-3", isDragging && "relative z-10 opacity-70 shadow-lg")}>
|
||||
<Button type="button" variant="ghost" size="icon-sm" className="touch-none cursor-grab active:cursor-grabbing" aria-label="จัดลำดับ Bleeding" {...attributes} {...listeners}><GripVerticalIcon data-icon="inline-start" /></Button>
|
||||
<Button type="button" variant="plain" size="icon-sm" className="touch-none cursor-grab active:cursor-grabbing" aria-label="จัดลำดับ Bleeding" {...attributes} {...listeners}><GripVerticalIcon data-icon="inline-start" /></Button>
|
||||
{layer.imageUrl ? <Image src={layer.imageUrl} alt="" width={48} height={48} className="size-12 rounded object-contain bg-muted" /> : <div className="size-12 rounded bg-muted" />}
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-3">
|
||||
<Combobox
|
||||
@@ -134,7 +134,7 @@ function SortableBleedingLayer({ layer, onChange, onRemove }: { layer: BleedingL
|
||||
<Slider id={`bleeding-amount-${layer.id}`} min={0} max={100} step={5} value={[layer.amount]} onValueChange={(value) => onChange({ amount: typeof value === "number" ? value : value[0] ?? 0 })} />
|
||||
</Field>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="icon-sm" onClick={onRemove} aria-label="ลบ Bleeding"><Trash2Icon data-icon="inline-start" /></Button>
|
||||
<Button type="button" variant="plain" size="icon-sm" onClick={onRemove} aria-label="ลบ Bleeding"><Trash2Icon data-icon="inline-start" /></Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -659,7 +659,7 @@ function SortableBlendLayerRow({
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="plain"
|
||||
size="icon-sm"
|
||||
className="touch-none cursor-grab active:cursor-grabbing"
|
||||
aria-label="ลากเพื่อจัดลำดับ Blend mode"
|
||||
@@ -715,7 +715,7 @@ function SortableBlendLayerRow({
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="plain"
|
||||
size="icon-sm"
|
||||
aria-label="ลบ Blend mode"
|
||||
onClick={onRemove}
|
||||
|
||||
@@ -267,7 +267,7 @@ export function StructuredDashboard({
|
||||
trigger={
|
||||
<Button
|
||||
className="relative z-20"
|
||||
variant="ghost"
|
||||
variant="plain"
|
||||
size="icon"
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -584,7 +584,7 @@ function VisualRecommendation({
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="destructive"
|
||||
variant="plain"
|
||||
size="icon-xs"
|
||||
className="absolute top-2 left-2"
|
||||
aria-label="Remove recommendation"
|
||||
@@ -620,9 +620,9 @@ function SortableRecommendation({
|
||||
{children(
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="plain"
|
||||
size="icon-xs"
|
||||
className="touch-none"
|
||||
className="touch-none cursor-grab active:cursor-grabbing"
|
||||
aria-label="Drag to reorder recommendation"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
@@ -771,7 +771,7 @@ function SortableTeam({
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="plain"
|
||||
size="icon-xs"
|
||||
aria-label={`Copy team ${position + 1}`}
|
||||
title="Copy team"
|
||||
@@ -781,9 +781,9 @@ function SortableTeam({
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="plain"
|
||||
size="icon-xs"
|
||||
className="touch-none"
|
||||
className="touch-none cursor-grab active:cursor-grabbing"
|
||||
aria-label={`Drag to reorder team ${position + 1}`}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
|
||||
@@ -53,7 +53,7 @@ export function TeamImageGenerator({ characters, weapons, artifacts }: { charact
|
||||
{draft.members.map((member, index) => {
|
||||
const character = characters.find((item) => item.key === member.characterKey);
|
||||
const compatibleWeapons = weapons.filter((weapon) => supportsWeaponType(character?.weaponType, weapon.weaponType));
|
||||
return <Card key={index} size="sm"><CardHeader><CardTitle>ตัวละคร #{index + 1}</CardTitle><CardAction><Button type="button" variant="ghost" size="icon-sm" aria-label={`ลบตัวละคร ${index + 1}`} onClick={() => setDraft((current) => ({ ...current, members: current.members.filter((_, memberIndex) => memberIndex !== index) }))}><Trash2Icon /></Button></CardAction></CardHeader><CardContent><FieldGroup>
|
||||
return <Card key={index} size="sm"><CardHeader><CardTitle>ตัวละคร #{index + 1}</CardTitle><CardAction><Button type="button" variant="plain" size="icon-sm" aria-label={`ลบตัวละคร ${index + 1}`} onClick={() => setDraft((current) => ({ ...current, members: current.members.filter((_, memberIndex) => memberIndex !== index) }))}><Trash2Icon /></Button></CardAction></CardHeader><CardContent><FieldGroup>
|
||||
<Field><FieldLabel>ตัวละคร</FieldLabel><CharacterSelect items={characters} value={member.characterKey} placeholder="ค้นหาตัวละคร…" onValueChange={(characterKey) => updateMember(index, { ...emptyMember(), characterKey })} /></Field>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field><FieldLabel>อาวุธ</FieldLabel><WeaponSelect items={compatibleWeapons} value={member.weaponKey} placeholder="ค้นหาอาวุธที่รองรับ…" onValueChange={(weaponKey) => updateMember(index, { weaponKey, refinement: "1" })} /></Field>
|
||||
|
||||
@@ -35,6 +35,7 @@ export function ClearableInputGroupInput({ ref, onChange, onValueChange, ...prop
|
||||
{String(value).length > 0 && !props.disabled && !props.readOnly ? (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton
|
||||
variant="plain"
|
||||
size="icon-xs"
|
||||
aria-label={`ล้าง${props["aria-label"] ?? props.placeholder ?? "ข้อความ"}`}
|
||||
onClick={() => {
|
||||
|
||||
@@ -222,7 +222,7 @@ export function WeaponImageGenerator({
|
||||
<CardAction>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="plain"
|
||||
size="icon-sm"
|
||||
aria-label={`ลบอาวุธ ${index + 1}`}
|
||||
onClick={() => setDraft((current) => ({ ...current, rows: current.rows.filter((item) => item.id !== row.id) }))}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { expect, it, vi } from "vitest";
|
||||
|
||||
import { ArtifactSelect } from "./catalog-select";
|
||||
|
||||
const items = ["Gladiator", "Wanderer", "Emblem"].map((name) => ({
|
||||
key: name.toLowerCase(), name, imageUrl: "/artifact.png", rarity: 5,
|
||||
}));
|
||||
|
||||
function Picker() {
|
||||
const [values, setValues] = useState<string[]>([]);
|
||||
return <ArtifactSelect multiple presentation="dropdown" items={items} values={values}
|
||||
onValuesChange={setValues} label="Artifact sets" placeholder="Search artifacts" />;
|
||||
}
|
||||
|
||||
it("selects at most two artifact sets and allows removing and clearing them", async () => {
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
const option = (name: string) => Array.from(document.querySelectorAll<HTMLElement>("[role=option]"))
|
||||
.find((element) => element.textContent?.includes(name))!;
|
||||
const click = async (element: HTMLElement) => {
|
||||
await act(async () => { element.click(); });
|
||||
};
|
||||
try {
|
||||
await act(async () => { root.render(<Picker />); });
|
||||
const trigger = container.querySelector<HTMLButtonElement>("[data-slot=combobox-trigger]")!;
|
||||
await click(trigger);
|
||||
const input = document.querySelector<HTMLInputElement>('input[placeholder="Search artifacts"]')!;
|
||||
const search = async (value: string) => {
|
||||
await act(async () => {
|
||||
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
};
|
||||
await search("gladiatr");
|
||||
expect(document.querySelectorAll("[role=option]")).toHaveLength(1);
|
||||
expect(option("Gladiator")).toBeDefined();
|
||||
await act(async () => {
|
||||
input.focus();
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
|
||||
});
|
||||
await act(async () => {
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
});
|
||||
expect(trigger.textContent).toContain("Gladiator");
|
||||
await search("");
|
||||
await click(option("Wanderer"));
|
||||
expect(trigger.textContent).toContain("Gladiator + Wanderer");
|
||||
expect(option("Emblem").getAttribute("aria-disabled")).toBe("true");
|
||||
await click(option("Gladiator"));
|
||||
expect(trigger.textContent).toContain("Wanderer");
|
||||
expect(trigger.textContent).not.toContain("Gladiator");
|
||||
expect(option("Emblem").getAttribute("aria-disabled")).not.toBe("true");
|
||||
await click(trigger);
|
||||
await click(trigger);
|
||||
expect(trigger.textContent).toContain("Wanderer");
|
||||
await click(document.querySelector<HTMLElement>("[data-slot=combobox-clear]")!);
|
||||
expect(trigger.textContent).toContain("Artifact sets");
|
||||
} finally {
|
||||
await act(async () => { root.unmount(); });
|
||||
container.remove();
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import type { ReactNode } from "react";
|
||||
|
||||
import { CatalogItemImage, type CatalogItemKind } from "@/components/catalog-item-image";
|
||||
import { TeamArtifactIcon } from "@/components/public/team-search";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
@@ -255,6 +256,7 @@ type MultipleArtifactSelectProps<Item extends AdminCatalogItem> = Omit<
|
||||
onValuesChange: (values: string[]) => void;
|
||||
label: string;
|
||||
max?: number;
|
||||
presentation?: "dropdown" | "slot";
|
||||
className?: string;
|
||||
};
|
||||
|
||||
@@ -272,6 +274,7 @@ export function ArtifactSelect<Item extends AdminCatalogItem>(
|
||||
emptyLabel = "ไม่พบชุด Artifact",
|
||||
invalid = false,
|
||||
max = 2,
|
||||
presentation = "slot",
|
||||
searchNames = (item) => [item.name, ...(item.aliases ?? [])],
|
||||
className,
|
||||
} = props;
|
||||
@@ -298,8 +301,11 @@ export function ArtifactSelect<Item extends AdminCatalogItem>(
|
||||
<ComboboxTrigger
|
||||
aria-label={selected.length ? `${label}: ${selected.map((item) => item.name).join(", ")}` : label}
|
||||
aria-invalid={invalid}
|
||||
render={presentation === "dropdown" ? <Button variant="outline" /> : undefined}
|
||||
className={cn(
|
||||
"relative aspect-square w-full overflow-hidden rounded-md border bg-muted outline-none focus-visible:ring-2 focus-visible:ring-ring aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 [&>svg]:hidden",
|
||||
presentation === "dropdown"
|
||||
? "h-auto min-h-10 w-full min-w-0 justify-start gap-2 py-1.5"
|
||||
: "relative aspect-square w-full overflow-hidden rounded-md border bg-muted outline-none focus-visible:ring-2 focus-visible:ring-ring aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 [&>svg]:hidden",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -307,12 +313,17 @@ export function ArtifactSelect<Item extends AdminCatalogItem>(
|
||||
<TeamArtifactIcon
|
||||
first={iconValue(selected[0])}
|
||||
second={iconValue(selected[1])}
|
||||
className="border-0"
|
||||
sizes="96px"
|
||||
className={presentation === "dropdown" ? "size-8 shrink-0 border-0" : "border-0"}
|
||||
sizes={presentation === "dropdown" ? "32px" : "96px"}
|
||||
/>
|
||||
) : (
|
||||
<CircleHelpIcon className="size-8 text-muted-foreground" aria-hidden="true" />
|
||||
)}
|
||||
{presentation === "dropdown" ? (
|
||||
<span className="min-w-0 flex-1 truncate text-left">
|
||||
{selected.length ? selected.map((item) => item.name).join(" + ") : label}
|
||||
</span>
|
||||
) : null}
|
||||
</ComboboxTrigger>
|
||||
<ComboboxContent className="min-w-72">
|
||||
<ComboboxInput placeholder={placeholder} showClear showTrigger={false} />
|
||||
|
||||
@@ -15,6 +15,8 @@ const buttonVariants = cva(
|
||||
"border-control-border bg-control text-secondary-foreground hover:border-control-border-hover hover:bg-control-hover aria-expanded:border-primary aria-expanded:bg-primary/15 aria-expanded:text-primary",
|
||||
ghost:
|
||||
"border-control-border bg-control text-foreground hover:border-control-border-hover hover:bg-control-hover aria-expanded:border-primary aria-expanded:bg-primary/15 aria-expanded:text-primary aria-[current=page]:border-primary aria-[current=page]:bg-primary/15 aria-[current=page]:text-primary",
|
||||
plain:
|
||||
"border-transparent bg-transparent hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground",
|
||||
destructive:
|
||||
"border-destructive bg-destructive/20 text-destructive hover:bg-destructive/30 focus-visible:border-destructive focus-visible:ring-destructive/40",
|
||||
link: "border-transparent text-primary underline underline-offset-4 hover:decoration-2",
|
||||
|
||||
@@ -62,7 +62,7 @@ function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Clear
|
||||
data-slot="combobox-clear"
|
||||
render={<InputGroupButton variant="ghost" size="icon-xs" />}
|
||||
render={<InputGroupButton variant="plain" size="icon-xs" />}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
>
|
||||
@@ -275,7 +275,7 @@ function ComboboxChip({
|
||||
{children}
|
||||
{showRemove && (
|
||||
<ComboboxPrimitive.ChipRemove
|
||||
render={<Button variant="ghost" size="icon-xs" />}
|
||||
render={<Button variant="plain" size="icon-xs" />}
|
||||
className="-ml-1 opacity-50 hover:opacity-100"
|
||||
data-slot="combobox-chip-remove"
|
||||
>
|
||||
|
||||
@@ -64,7 +64,7 @@ function DialogContent({
|
||||
data-slot="dialog-close"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="plain"
|
||||
className="absolute top-2 right-2"
|
||||
size="icon-sm"
|
||||
/>
|
||||
|
||||
@@ -64,7 +64,7 @@ function SheetContent({
|
||||
data-slot="sheet-close"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="plain"
|
||||
className="absolute top-3 right-3"
|
||||
size="icon-sm"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user