Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions .agents/skills/choicejs-skill/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
---
name: choicejs-skill
description: 基于 Choices.js 官方文档实现可维护、可主题化、无闪烁的 Tag/Select 输入 UI,适用于 Phoenix LiveView 与常规前端项目。
version: 1.0.0
argument-hint: "[implement|polish|audit]"
---

# Choices.js UI Skill

本 skill 用于指导 agent 按 [Choices.js 官方文档](https://github.com/Choices-js/Choices) 实现和优化 UI,重点是:
- 使用官方 `--choices-*` CSS custom properties
- 与项目设计系统(如 daisyUI)对齐
- 保证可输入、无重复渲染、无页面闪烁

## 触发场景
- 用户要求“用 Choices.js 做 tags/select 输入”
- 用户要求“按 CSS custom properties 做主题”
- 出现 `Choices` UI 异常:不可输入、样式割裂、重复 chips、初始化闪烁

## 执行流程

### 1) 先定义全局主题变量(必须)
把基础变量定义到 `:root`(如需深色主题,再覆盖 `[data-theme="dark"]` 或 `@media (prefers-color-scheme: dark)`):

- 颜色相关:`--choices-bg-color` `--choices-bg-color-dropdown` `--choices-keyline-color` `--choices-primary-color` `--choices-item-color` `--choices-highlighted-color`
- 尺寸相关:`--choices-border-radius` `--choices-border-radius-item` `--choices-input-height` `--choices-inner-padding`
- 交互相关:`--choices-button-opacity` `--choices-button-opacity-hover` `--choices-z-index`

原则:
- 全局默认在 `:root`,组件差异只在局部覆盖
- 不要把全部变量只写在组件局部,否则 devtools 常见未定义提示

### 2) 初始化策略(避免闪烁/重复)
- 原始 `<select multiple>` 先隐藏(如 `style="display:none"`)
- `mounted` 初始化 Choices
- `updated` 先 `destroy()` 再重建,避免重复实例
- 初始化成功后打 `is-ready` 类,再显示容器(`visibility: visible`)

### 3) 与 LiveView 事件同步
- `addItem` 推送 `add-tag`
- `removeItem` 推送 `remove-tag`
- 使用 `suppress` 标记避免回写时事件环路
- 从 DOM 反向同步 selected options 到 Choices 实例

### 4) 样式对齐策略
- 目标是“看起来像项目原生 input/badge”,不是“Choices 默认皮肤”
- 优先调变量,不要大量硬编码覆盖内部类
- 组件局部仅处理结构差异(例如 dropdown 阴影、局部间距)

## 推荐最小配置(JS)
```js
new Choices(el, {
removeItemButton: true,
duplicateItemsAllowed: false,
shouldSort: false,
searchEnabled: true,
placeholder: true,
noResultsText: "",
noChoicesText: "",
itemSelectText: "",
addItems: true,
addChoices: true,
delimiter: ",",
})
```

## 验收 checklist
- 能输入新 tag
- 能选择已有 tag
- 删除 tag 正常
- 不出现重复 tag UI
- 页面加载无闪烁(FOUC)
- 深浅色主题都可读
- devtools 不出现核心 `--choices-*` 未定义告警

## 常见故障与修复
- 问题:不可输入
修复:检查 `searchEnabled/addItems/addChoices`、input 是否被遮挡、`updated` 是否反复 destroy 导致焦点丢失

- 问题:样式与系统不一致
修复:先统一 `:root` 变量,再做局部微调;禁止直接复制 demo 样式

- 问题:初始化闪烁
修复:使用 `visibility: hidden -> .is-ready { visibility: visible }`
49 changes: 49 additions & 0 deletions assets/css/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

@import "tailwindcss" source(none);
@import url("https://fonts.googleapis.com/css2?family=Manrope:wght@500;600;700;800&family=Work+Sans:wght@400;500;600&display=swap");
@import "../vendor/choices.min.css";
@source "../css";
@source "../js";
@source "../../lib/vmemo_web";
Expand Down Expand Up @@ -125,6 +126,54 @@
--space-4: 1rem;
--space-6: 1.5rem;
--space-8: 2rem;

/* Choices.js global theme variables (v11.2+ CSS custom properties) */
--choices-darken: black;
--choices-lighten: white;
--choices-bg-color: var(--color-base-100);
--choices-bg-color-disabled: color-mix(in oklch, var(--color-base-100) 92%, var(--color-base-content) 8%);
--choices-bg-color-dropdown: var(--color-base-100);
--choices-text-color: color-mix(in oklch, var(--color-base-content) 92%, transparent);
--choices-keyline-color: color-mix(in oklch, var(--color-base-content) 16%, transparent);
--choices-primary-color: color-mix(in oklch, var(--color-base-content) 8%, var(--color-base-100));
--choices-disabled-color: color-mix(in oklch, var(--color-base-content) 14%, var(--color-base-100));
--choices-item-color: color-mix(in oklch, var(--color-base-content) 74%, var(--color-base-content) 26%);
--choices-item-disabled-color: color-mix(in oklch, var(--color-base-content) 54%, transparent);
--choices-highlighted-color: color-mix(in oklch, var(--color-base-content) 6%, var(--color-base-100));
--choices-highlight-color: color-mix(in oklch, var(--color-primary) 52%, var(--color-base-content) 48%);
--choices-invalid-color: var(--color-error);

--choices-font-size-lg: 0.95rem;
--choices-font-size-md: 0.95rem;
--choices-font-size-sm: 0.82rem;
--choices-guttering: 0;
--choices-width: 100%;
--choices-border-radius: 0.5rem;
--choices-border-radius-item: 0.5rem;
--choices-z-index: 30;
--choices-input-height: 2.75rem;
--choices-base-border: 1px solid;
--choices-multiple-item-margin: 0.25rem;
--choices-multiple-item-padding: 0.18rem 0.6rem;
--choices-dropdown-item-padding: 0.5rem 0.72rem;
--choices-input-margin-bottom: 0;
--choices-input-padding: 0.2rem 0 0.2rem 0.2rem;
--choices-inner-padding: 0.34rem 0.56rem;
--choices-button-offset: 0.36rem;
--choices-button-dimension: 0.58rem;
--choices-button-border-radius: 9999px;
--choices-button-opacity: 0.78;
--choices-button-opacity-hover: 1;
--choices-placeholder-opacity: 0.56;
}

[data-theme="dark"] {
--choices-bg-color: color-mix(in oklch, var(--color-base-100) 95%, var(--color-base-content) 5%);
--choices-bg-color-dropdown: color-mix(in oklch, var(--color-base-100) 96%, var(--color-base-content) 4%);
--choices-keyline-color: color-mix(in oklch, var(--color-base-content) 24%, transparent);
--choices-primary-color: color-mix(in oklch, var(--color-base-content) 10%, var(--color-base-100));
--choices-item-color: color-mix(in oklch, var(--color-base-content) 86%, var(--color-base-content) 14%);
--choices-highlighted-color: color-mix(in oklch, var(--color-base-content) 12%, var(--color-base-100));
}

textarea {
Expand Down
2 changes: 2 additions & 0 deletions assets/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { MoondreamOverlay } from "./hooks/moondream_overlay"
import { DirectoryUpload } from "./hooks/directory_upload"
import { DrawerResize } from "./hooks/drawer_resize"
import { SearchSubmitOnEnter } from "./hooks/search_submit_on_enter"
import { TagInput } from "./hooks/tag_input"

const FormatDatetime = {
format() {
Expand Down Expand Up @@ -70,6 +71,7 @@ let liveSocket = new LiveSocket("/live", Socket, {
FormatDatetime,
DrawerResize,
SearchSubmitOnEnter,
TagInput,
},
longPollFallbackMs: 2500,
params: { _csrf_token: csrfToken },
Expand Down
85 changes: 85 additions & 0 deletions assets/js/hooks/tag_input.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import Choices from "../../vendor/choices.min.js"

export const TagInput = {
mounted() {
this.initChoices()
},

updated() {
this.destroyChoices()
this.initChoices()
},

destroyed() {
this.destroyChoices()
},

initChoices() {
this.suppress = false

this.choices = new Choices(this.el, {
removeItemButton: true,
duplicateItemsAllowed: false,
shouldSort: false,
searchEnabled: true,
searchResultLimit: 8,
placeholder: true,
placeholderValue: this.el.dataset.placeholder || "English Grammar",
noResultsText: "",
noChoicesText: "",
itemSelectText: "",
addItemText: (value) => `Press Enter to add \"${value}\"`,
maxItemText: () => "",
addItems: true,
addChoices: true,
delimiter: ",",
editItems: false,
})

this.el.addEventListener("addItem", (event) => {
if (this.suppress) return
const value = (event.detail.value || "").trim()
if (!value) return
this.pushEvent("add-tag", { tag_input: value })
})

this.el.addEventListener("removeItem", (event) => {
if (this.suppress) return
const value = (event.detail.value || "").trim()
if (!value) return
this.pushEvent("remove-tag", { name: value })
})

this.syncFromDom()

if (this.el.nextElementSibling?.classList?.contains("choices")) {
this.el.nextElementSibling.classList.add("is-ready")
}
},

syncFromDom() {
if (!this.choices) return

const selectedValues = Array.from(this.el.selectedOptions).map((option) => option.value)

this.suppress = true
this.choices.removeActiveItems()

selectedValues.forEach((value) => {
this.choices.setChoiceByValue(value)
})

this.suppress = false
},

destroyChoices() {
if (this.el.nextElementSibling?.classList?.contains("choices")) {
this.el.nextElementSibling.classList.remove("is-ready")
}

if (this.choices) {
this.choices.destroy()
this.choices = null
}
},
}
1 change: 1 addition & 0 deletions assets/vendor/choices.min.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions assets/vendor/choices.min.js

Large diffs are not rendered by default.

Loading
Loading