From 2e2833ed057a4d5dcc9665717bea2ee9d755b599 Mon Sep 17 00:00:00 2001 From: jack755051 Date: Sat, 22 Aug 2026 00:02:08 +0800 Subject: [PATCH 1/9] fix(cli): remove exit-code consistency + info/migrate/search test coverage `remove` now distinguishes unknown targets (hard exit 1, matching diff/update) from known-but-not-installed ones (soft skip), instead of silently exiting 0 whenever any target succeeded. Also adds the missing test files for `info`/`migrate`/`search`, which surfaced a real crash in `info`'s alias:component lookup, and pins vitest to the `forks` pool to stop chdir-based command tests from racing on process.cwd(). Co-Authored-By: Claude Sonnet 5 --- DEVLOG.md | 29 ++++ TODOLIST.md | 6 +- packages/cli/src/commands/info.test.ts | 173 +++++++++++++++++++ packages/cli/src/commands/info.ts | 2 +- packages/cli/src/commands/migrate.test.ts | 197 ++++++++++++++++++++++ packages/cli/src/commands/remove.test.ts | 50 +++++- packages/cli/src/commands/remove.ts | 22 ++- packages/cli/src/commands/search.test.ts | 125 ++++++++++++++ packages/cli/vitest.config.ts | 8 + 9 files changed, 600 insertions(+), 12 deletions(-) create mode 100644 packages/cli/src/commands/info.test.ts create mode 100644 packages/cli/src/commands/migrate.test.ts create mode 100644 packages/cli/src/commands/search.test.ts diff --git a/DEVLOG.md b/DEVLOG.md index 045ed496..5428c1ce 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -794,3 +794,32 @@ readonly isDisabled = computed(() => this.disabledInput() || this.disabledState( **TODOLIST.md P30 現況**:❌ 清單全部清空,15 項全部修復或查證排除完畢。 **沒有動姊妹 repo `date-picker`**:`date-picker-core` 刻意「zero DOM/CSS assumptions」,幫它加上這次的 ARIA 慣例會違背它自己的設計目標,也會讓它多一個要跨 repo 發版/更新依賴版本的環節,而這次的問題純粹是 `packages/ui` 自己組裝 grid 結構時的角色選擇,不是 headless engine 該管的事。`@sanring/date-picker`(組裝好的參考實作)也沒有需要跟進的東西——它沒有這個 bug,是因為它的元件形狀本來就不一樣(input+popup vs. inline grid),不是因為它比較新或比較對。 + + +--- + +## P27 — `remove` 混合 target 時 exit code 隱含成功 + +**查證**:`remove.ts` 原本把「registry 裡真的沒有這個元件(typo/未知)」跟「registry 裡有,只是這個專案沒裝」兩種完全不同的狀況混在同一個 `notInstalled` 陣列裡,只要陣列非空就印紅色 `✖` 錯誤,但退出碼只看 `plan.toRemove.length === 0` 這一個條件——只要至少一個 target 真的被移除,函式跑到底就是隱含 exit 0,即使其中混了一個打錯字的元件名稱。 + +對照 `diff.ts`/`update.ts` 兩者共用的 `resolveDiffTargets()` 既有慣例:`missing`(registry 裡完全找不到)一律視為輸入錯誤,印紅字後立即 `process.exit(1)`、不執行任何後續動作;`notInstalled`(registry 裡有,只是專案沒裝)只是軟性提示,印一行 dim 文字後繼續處理其餘 target,不影響最終 exit code。`remove` 從來沒有對齊這個既有區分——它自己的 `notInstalled` 實際上是「上述兩種情況的聯集」。 + +**修法**:`RemovalPlan` 拆成 `notInstalled`(registry 裡有、只是未安裝,沿用既有語意)與新增的 `unknown`(registry 裡完全沒有這個名字)。`planRemoval()` 用 `byName.has(n)` 區分兩者。command action 比照 `diff.ts` 的寫法:`plan.unknown.length > 0` 一律印紅字 `✖ Unknown component(s): ...` 並立即 `process.exit(1)`,在做任何刪除動作之前就擋下,徹底避免半調子的部分成功;`plan.notInstalled` 降級成 `pc.dim` 提示文字,不再影響 exit code——這修正了原本「視覺上宣告失敗但退出碼宣告成功」的不一致,做法是讓 `remove` 的兩種情境分別精確對齊 `diff`/`update` 各自既有的處理方式,而不是發明新規則。 + +**驗證**:`remove.test.ts` 新增/調整 3 個測試——`planRemoval` 單元測試拆成兩個(一個驗證已知但未裝的 `notInstalled`,一個驗證 registry 裡沒有的 `unknown`,原本的測試用例其實誤用了一個 registry 裡不存在的元件名稱去驗證「未安裝」語意,已修正成用真正已知的 `combobox`);整合測試新增「混合已知-未裝 + 可移除 target 時 exit 0」與「混合 unknown + 可移除 target 時 exit 1 且完全不刪除任何檔案」兩case,後者用 `vi.spyOn(process, 'exit')` 攔截驗證真的呼叫了 `exit(1)`,並斷言 `installedHashes` 裡原本可移除的元件的 hash 仍然存在(證明 unknown 檢查真的在任何刪除動作之前就擋下,不是刪了一半才失敗)。`pnpm --filter @sanring/cli exec vitest run`:15 個測試檔、178 個測試全過;`pnpm --filter @sanring/cli exec tsc --noEmit` 通過。 + +**TODOLIST.md P27 現況**:整體流程 4 項(CLI 主流程文件同步、`--json` 補齊、registry 完整性檢查抽共用工具、`fetchRegistry`/`fetchFile` typed error)與 `info`/`migrate`/`search` 缺測試這項仍待處理。 + +--- + +## P27 — `info`/`migrate`/`search` 補測試(過程中發現並修復一個真實 crash + 一個測試套件 flaky race) + +**執行**:`commands/` 下依 `check-registry-parity.mjs` 同一類手法先確認缺口範圍後,比照既有 command test(`doctor.test.ts`/`list.test.ts`)的慣例,新增 `info.test.ts`(7 個測試:project info 模式 `--json`/人類可讀、component 模式 `--json`/人類可讀/已安裝狀態、未知元件 exit 1、`alias:component` 語法)、`search.test.ts`(6 個測試:排序、無結果、`--json` 無結果、`--json` 含 `installed` 欄位的回歸測試、`--group`/`--tag` 過濾)、`migrate.test.ts`(9 個測試:up to date、breaking migration 印出步驟、`fromVersion` 早於已安裝版本時不重複觸發、`--check` 有/無待遷移時的 exit code、registry 裡已移除的元件、`alias:component` key 解析、`noData` 無 baseline 情境、config 不存在時的 exit 1)。 + +**寫測試過程中發現的真實 bug(`info.ts`)**:幫 `info` 補 `alias:component` 語法的回歸測試時(`sanring info other:widget --json`),命令直接丟 `TypeError: Cannot read properties of undefined (reading 'name')`。查證後發現:`info.ts` 稍早的 P27 修復(見 TODOLIST 已勾選項)只改對了「用哪個 registry 抓資料」(`resolveRegistrySource(parsedRef.alias, ...)`)跟「查 registry 用裸名稱」(`resolveInstallSet([bareComponentName], ...)`),但最後一行 `const component = toInstall.find((c) => c.name === componentName)!` 沒有跟著改——`toInstall` 裡的元件 `name` 是裸名稱,但這裡拿去比對的 `componentName` 是原始帶 alias 前綴的完整字串(例如 `"other:widget"`),永遠比對不到,`.find()` 回傳 `undefined`,後面用非空斷言 `!` 硬拆導致 crash。這正是 P27 這個小節的核心論點的具體案例——`info` 先前雖然「查過」也「修過」alias 支援,但因為沒有對應測試,一個明顯會炸掉的殘留 bug 完全沒被抓到。修法:把 `componentName` 改成 `bareComponentName`,一行修復,`sanring info : --json` 手動驗證正確輸出。 + +**寫測試過程中發現並修復的測試套件本身的 flaky race**(跟 command 程式碼無關,是測試基礎設施缺口):新增這三個檔案後,連續跑 `vitest run` 會間歇性(約 2-3/5 次)出現 `registry.test.ts` 裡完全不相關的測試失敗(`Cannot read properties of undefined (reading 'ok')`、`ENOENT: ... 'registry/registry.json'`)。追查後確認:`registry.test.ts` 有幾個測試用**相對路徑**(`'./registry'`)依賴 `process.cwd()` 停在 `packages/cli` 這個固定位置;但 `add`/`remove`/`doctor`/`list`/`info`/`migrate`/`search` 等每一個 command 的整合測試都會在 `beforeEach`/`afterEach` 呼叫 `process.chdir()` 切到各自的 temp project 目錄再切回來。`process.chdir()` 是**整個 process 共享的全域狀態**,不是 per-worker-thread 隔離的——Vitest 預設的 `threads` pool 用 `worker_threads` 在同一個 process 裡並行跑多個測試檔案,所以只要 `registry.test.ts` 剛好在另一個檔案的 `chdir` 視窗內執行,相對路徑就會解析到錯的目錄。用二分法驗證:拿掉新增的三個檔案,原本 178 個測試連續跑 10 次 0 次失敗;加回去後連續跑 5 次有 3 次失敗——不是我新測試邏輯本身有錯,是新增的三個「會 chdir」的檔案數量把既有的競速機率推高到容易觀察到的程度(這個 race 理論上原本就存在於 9 個既有的 chdir 檔案之間,只是機率較低沒被注意到)。**修法**:`packages/cli/vitest.config.ts` 加上 `pool: 'forks'`——改用真正獨立的 OS process(而非共享 process 的 worker thread)跑每個測試檔案,每個 process 有自己獨立的 `cwd`,徹底消除這整類競速,而不是逐一修 `registry.test.ts` 或新檔案去繞開它(那樣治標不治本,下一個新增的 chdir 檔案還是會重新觸發)。 + +**驗證**:`pnpm --filter @sanring/cli exec vitest run` 加上 `pool: 'forks'` 後連續跑 8 次、`pnpm --filter @sanring/cli exec vitest run --pool=forks` 也連續跑 8 次,共 16 次 0 次失敗(相同條件下拿掉這個設定會在 5 次內大概率重現);18 個測試檔、**200** 個測試全過(原 178 + 新增 22:`info` 7 + `search` 6 + `migrate` 9);`pnpm --filter @sanring/cli exec tsc --noEmit` 通過。 + +**TODOLIST.md P27 現況**:整體流程 4 項(CLI 主流程文件同步、`--json` 補齊、registry 完整性檢查抽共用工具、`fetchRegistry`/`fetchFile` typed error)仍待處理,`info`/`migrate`/`search` 缺測試與 `remove` exit code 兩項已完成。 diff --git a/TODOLIST.md b/TODOLIST.md index a8ba0d0c..225afe26 100644 --- a/TODOLIST.md +++ b/TODOLIST.md @@ -102,10 +102,8 @@ Phase 4 已解封:Playwright 截圖 + `Read` 工具可以實際檢視 home(light - [x] `migrate`:`noData` result 型別目前未實際產生;補 legacy/no baseline 情境或移除 dead branch - [x] `mcp`:registry cache 沒有 refresh/invalidate;長時間 agent session 可能看不到 registry 更新,補 refresh tool 或 TTL - [x] `mcp`:目前 agent 只能 list/search/info/plan/add,缺 `diff`、`doctor`、`migrate`/`update` 的安全入口;補 read-only 檢查工具,再評估是否開放更新工具 -- [ ] `info`、`migrate`、`search` 三個 command 完全沒有對應的 `*.test.ts`(`commands/` 下其餘 9 個 command 都有);`search --json` 曾經有一個未被任何測試發現的 TDZ crash(見下方查證),凸顯沒測試的 command 風險較高,優先補齊這三個 - **查證(2026-08-19)**:`diff <(ls packages/cli/src/commands/*.ts | grep -v test) <(ls packages/cli/src/commands/*.test.ts)` 確認只有這三個 command 缺測試檔。 -- [ ] `remove`:混合 target(部分已安裝、部分未安裝)時,未安裝的目標會印出紅色 `✖` 錯誤訊息,但只要至少一個 target 成功移除,整個 process 仍以 exit code 0 結束——視覺上宣告失敗但退出碼宣告成功,跟 `diff`/`update` 對「未安裝視為軟性提示」或「未知一律 exit 1」的既有慣例都不一致,需要決定 remove 的未安裝目標到底該不該讓整體 exit code 非 0 - **查證(2026-08-19)**:`remove.ts:127-136`——只有當 `plan.toRemove.length === 0`(也就是全部都未安裝)才會 `process.exit(plan.notInstalled.length > 0 ? 1 : 0)`;只要有任何一個 target 可以移除,函式跑到底沒有再檢查 `plan.notInstalled`,隱含 exit 0。 +- [x] `info`、`migrate`、`search` 三個 command 完全沒有對應的 `*.test.ts`(`commands/` 下其餘 9 個 command 都有);`search --json` 曾經有一個未被任何測試發現的 TDZ crash(見下方查證),凸顯沒測試的 command 風險較高,優先補齊這三個 +- [x] `remove`:混合 target(部分已安裝、部分未安裝)時,未安裝的目標會印出紅色 `✖` 錯誤訊息,但只要至少一個 target 成功移除,整個 process 仍以 exit code 0 結束——視覺上宣告失敗但退出碼宣告成功,跟 `diff`/`update` 對「未安裝視為軟性提示」或「未知一律 exit 1」的既有慣例都不一致,需要決定 remove 的未安裝目標到底該不該讓整體 exit code 非 0 --- diff --git a/packages/cli/src/commands/info.test.ts b/packages/cli/src/commands/info.test.ts new file mode 100644 index 00000000..58ed85c3 --- /dev/null +++ b/packages/cli/src/commands/info.test.ts @@ -0,0 +1,173 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { getCliVersion, readConfig, writeConfig } from '../utils.js'; +import { writeRegistryFixture } from '../__tests__/registry-fixture.js'; +import { addCommand } from './add.js'; +import { infoCommand } from './info.js'; + +describe('infoCommand (integration)', () => { + let projectDir: string; + let registryDir: string; + let originalCwd: string; + let logs: string[]; + let errors: string[]; + let stdout: string[]; + + beforeEach(() => { + originalCwd = process.cwd(); + projectDir = mkdtempSync(join(tmpdir(), 'sanring-cli-info-project-')); + registryDir = mkdtempSync(join(tmpdir(), 'sanring-cli-info-registry-')); + writeFileSync(join(projectDir, 'angular.json'), '{}', 'utf-8'); + writeRegistryFixture(registryDir, { + utils: 'export function cn() {}\n', + utilsPeerDependencies: { clsx: '^2.0.0' }, + widget: 'export const widget = 1;\n', + }); + process.chdir(projectDir); + + // Commander reuses this module-level Command instance across every + // parseAsync() call in this file and never resets boolean flags back to + // their default on its own (see doctor.test.ts for the same workaround) + // — without this, a `--json` run earlier in the file would leak `true` + // into a later human-readable-output test that never passes `--json`. + infoCommand.setOptionValue('json', false); + infoCommand.setOptionValue('registry', undefined); + infoCommand.setOptionValue('path', undefined); + + logs = []; + errors = []; + stdout = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + logs.push(args.join(' ')); + }); + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + errors.push(args.join(' ')); + }); + // --json output goes through process.stdout.write, not console.log. + vi.spyOn(process.stdout, 'write').mockImplementation((chunk: unknown) => { + stdout.push(String(chunk)); + return true; + }); + }); + + afterEach(() => { + process.chdir(originalCwd); + rmSync(projectDir, { recursive: true, force: true }); + rmSync(registryDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + describe('project info mode (no argument)', () => { + it('reports --json project info with no components installed', async () => { + await infoCommand.parseAsync(['--json', '--registry', registryDir], { from: 'user' }); + + const report = JSON.parse(stdout.join('')) as { + cli: string; + angular: unknown; + config: unknown; + theme: { present: boolean }; + installed: string[]; + }; + expect(report.cli).toBe(getCliVersion()); + expect(report.angular).not.toBe(false); + expect(report.config).toBeNull(); + expect(report.theme.present).toBe(false); + expect(report.installed).toEqual([]); + }); + + it('reports human-readable project info reflecting installed components', async () => { + await addCommand.parseAsync(['widget', '--registry', registryDir], { from: 'user' }); + logs = []; + + await infoCommand.parseAsync(['--registry', registryDir], { from: 'user' }); + + const output = logs.join('\n'); + expect(output).toMatch(/Sanring UI — project info/); + expect(output).toMatch(/Angular.*✔/); + expect(output).toMatch(/sanring\.config\.json/); + expect(output).toMatch(/Installed \(1\).*widget/); + }); + + it('reports --json project info reflecting installed components and config', async () => { + await addCommand.parseAsync(['widget', '--registry', registryDir], { from: 'user' }); + logs = []; + + await infoCommand.parseAsync(['--json', '--registry', registryDir], { from: 'user' }); + + const report = JSON.parse(stdout.join('')) as { installed: string[]; config: { componentPath: string } }; + expect(report.installed).toEqual(['widget']); + expect(report.config.componentPath).toBeTruthy(); + }); + }); + + describe('component info mode', () => { + it('reports --json details for an available, not-yet-installed component', async () => { + await infoCommand.parseAsync(['widget', '--json', '--registry', registryDir], { from: 'user' }); + + const report = JSON.parse(stdout.join('')) as { + name: string; + installed: boolean; + files: string[]; + sharedDeps: string[]; + peerDependencies: Record; + }; + expect(report.name).toBe('widget'); + expect(report.installed).toBe(false); + expect(report.files).toEqual(['widget/index.ts']); + expect(report.sharedDeps).toEqual(['utils']); + // Peer deps roll up transitively from the shared dep. + expect(report.peerDependencies).toEqual({ clsx: '^2.0.0' }); + }); + + it('reports human-readable details and reflects already-installed status', async () => { + await addCommand.parseAsync(['widget', '--registry', registryDir], { from: 'user' }); + logs = []; + + await infoCommand.parseAsync(['widget', '--registry', registryDir], { from: 'user' }); + + const output = logs.join('\n'); + expect(output).toMatch(/widget/); + expect(output).toMatch(/Already installed/); + }); + + it('exits 1 with the list of available components for an unknown component', async () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit'); + }); + + await expect( + infoCommand.parseAsync(['does-not-exist', '--registry', registryDir], { from: 'user' }), + ).rejects.toThrow('process.exit'); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(errors.some((e) => e.includes('Component not found: does-not-exist'))).toBe(true); + expect(errors.some((e) => e.includes('widget'))).toBe(true); + + exitSpy.mockRestore(); + }); + + it('resolves an alias:component reference against the aliased registry, not the default', async () => { + const otherRegistryDir = mkdtempSync(join(tmpdir(), 'sanring-cli-info-registry-other-')); + writeRegistryFixture(otherRegistryDir, { widget: 'export const widget = "other";\n' }); + + // No --registry flag override here — resolution must come purely from + // the alias prefix against sanring.config.json's `registries` map. + writeConfig(projectDir, { + componentPath: readConfig(projectDir)?.componentPath ?? 'src/app/components/ui', + registries: { mine: registryDir, other: otherRegistryDir }, + defaultRegistry: 'mine', + }); + + await infoCommand.parseAsync(['other:widget', '--json'], { from: 'user' }); + + const report = JSON.parse(stdout.join('')) as { name: string; sharedDeps: string[] }; + expect(report.name).toBe('widget'); + // The 'other' fixture has no `utils` shared file, unlike the default registry. + expect(report.sharedDeps).toEqual([]); + + rmSync(otherRegistryDir, { recursive: true, force: true }); + }); + }); +}); diff --git a/packages/cli/src/commands/info.ts b/packages/cli/src/commands/info.ts index 412135e4..f8ff3724 100644 --- a/packages/cli/src/commands/info.ts +++ b/packages/cli/src/commands/info.ts @@ -134,7 +134,7 @@ export const infoCommand = new Command('info') return; } - const component = toInstall.find((c) => c.name === componentName)!; + const component = toInstall.find((c) => c.name === bareComponentName)!; const componentBasePath = resolveComponentBasePath(cwd, options.path, config); const alreadyInstalled = isAngularProject(cwd) && existsSync(join(componentBasePath, component.name)); diff --git a/packages/cli/src/commands/migrate.test.ts b/packages/cli/src/commands/migrate.test.ts new file mode 100644 index 00000000..2d464825 --- /dev/null +++ b/packages/cli/src/commands/migrate.test.ts @@ -0,0 +1,197 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { Registry } from '../registry.js'; +import { readConfig, writeConfig } from '../utils.js'; +import { writeRegistryFixture } from '../__tests__/registry-fixture.js'; +import { addCommand } from './add.js'; +import { migrateCommand } from './migrate.js'; + +describe('migrateCommand (integration)', () => { + let projectDir: string; + let registryDir: string; + let originalCwd: string; + let logs: string[]; + let errors: string[]; + + beforeEach(async () => { + originalCwd = process.cwd(); + projectDir = mkdtempSync(join(tmpdir(), 'sanring-cli-migrate-project-')); + registryDir = mkdtempSync(join(tmpdir(), 'sanring-cli-migrate-registry-')); + writeFileSync(join(projectDir, 'angular.json'), '{}', 'utf-8'); + writeRegistryFixture(registryDir, { + utils: 'export function cn() {}\n', + widget: 'export const widget = 1;\n', + }); + process.chdir(projectDir); + + logs = []; + errors = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + logs.push(args.join(' ')); + }); + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + errors.push(args.join(' ')); + }); + + await addCommand.parseAsync(['widget', '--registry', registryDir], { from: 'user' }); + + // Commander reuses this module-level Command instance across every + // parseAsync() call in this file and doesn't reset boolean flags back to + // their default between calls (see doctor.test.ts for the same pattern). + migrateCommand.setOptionValue('check', false); + migrateCommand.setOptionValue('registry', undefined); + + logs = []; + errors = []; + }); + + afterEach(() => { + process.chdir(originalCwd); + rmSync(projectDir, { recursive: true, force: true }); + rmSync(registryDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + function addMigration(fromVersion: string, breaking: boolean, steps: string[]) { + const registryPath = join(registryDir, 'registry.json'); + const registry: Registry = JSON.parse(readFileSync(registryPath, 'utf-8')); + const widget = registry.components.find((c) => c.name === 'widget')!; + widget.migrations = [...(widget.migrations ?? []), { fromVersion, breaking, steps }]; + writeFileSync(registryPath, JSON.stringify(registry, null, 2), 'utf-8'); + } + + it('reports up to date when the installed component has no migrations ahead of it', async () => { + await migrateCommand.parseAsync(['--registry', registryDir], { from: 'user' }); + + expect(logs.some((line) => line.includes('up to date'))).toBe(true); + }); + + it('prints migration steps for an installed component behind a breaking migration', async () => { + addMigration('0.1.0', true, ['Rename `foo` input to `bar`.']); + // installedVersions records the CLI version at install time; the fixture + // registry has no version info, so this asserts against whatever the + // real add command recorded, read back from disk. + const config = readConfig(projectDir)!; + writeConfig(projectDir, { + ...config, + installedVersions: { ...config.installedVersions, widget: '0.1.0' }, + }); + + await migrateCommand.parseAsync(['--registry', registryDir], { from: 'user' }); + + const output = logs.join('\n'); + expect(output).toMatch(/BREAKING/); + expect(output).toMatch(/Rename `foo` input to `bar`\./); + expect(output).toMatch(/sanring update widget/); + }); + + it('does not surface a migration whose fromVersion is behind the installed version', async () => { + addMigration('0.1.0', true, ['Rename `foo` input to `bar`.']); + const config = readConfig(projectDir)!; + writeConfig(projectDir, { + ...config, + installedVersions: { ...config.installedVersions, widget: '0.2.0' }, + }); + + await migrateCommand.parseAsync(['--registry', registryDir], { from: 'user' }); + + expect(logs.some((line) => line.includes('up to date'))).toBe(true); + expect(logs.some((line) => line.includes('BREAKING'))).toBe(false); + }); + + it('--check exits 1 without printing steps when a migration is needed', async () => { + addMigration('0.1.0', true, ['Rename `foo` input to `bar`.']); + const config = readConfig(projectDir)!; + writeConfig(projectDir, { + ...config, + installedVersions: { ...config.installedVersions, widget: '0.1.0' }, + }); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit'); + }); + + await expect( + migrateCommand.parseAsync(['--check', '--registry', registryDir], { from: 'user' }), + ).rejects.toThrow('process.exit'); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(logs.some((line) => line.includes('Rename `foo`'))).toBe(false); + + exitSpy.mockRestore(); + }); + + it('--check exits cleanly (no process.exit call) when nothing needs migration', async () => { + const exitSpy = vi.spyOn(process, 'exit'); + + await migrateCommand.parseAsync(['--check', '--registry', registryDir], { from: 'user' }); + + expect(exitSpy).not.toHaveBeenCalled(); + expect(logs.some((line) => line.includes('up to date'))).toBe(true); + + exitSpy.mockRestore(); + }); + + it('reports a component no longer present in the registry as not-found and skips it', async () => { + const config = readConfig(projectDir)!; + writeConfig(projectDir, { + ...config, + installedVersions: { ...config.installedVersions, 'removed-component': '0.1.0' }, + }); + + await migrateCommand.parseAsync(['--registry', registryDir], { from: 'user' }); + + expect(logs.some((line) => line.includes('removed-component') && line.includes('not found in registry'))).toBe( + true, + ); + }); + + it('resolves an alias:component installedVersions key against the bare registry component name', async () => { + const config = readConfig(projectDir)!; + // Simulate a multi-registry config where installedVersions keys carry an + // alias prefix — migrate must strip it before looking the name up in the + // registry, not treat the whole "alias:name" string as the component name. + writeConfig(projectDir, { + ...config, + registries: { mine: registryDir }, + defaultRegistry: 'mine', + installedVersions: { 'mine:widget': '0.0.0' }, + }); + addMigration('0.1.0', false, ['Some non-breaking cleanup.']); + + await migrateCommand.parseAsync(['--registry', registryDir], { from: 'user' }); + + const output = logs.join('\n'); + expect(output).toMatch(/widget/); + expect(output).toMatch(/Some non-breaking cleanup\./); + }); + + it('reports a no-baseline component distinctly from a needs-migration one', async () => { + const config = readConfig(projectDir)!; + const { widget: _widget, ...restVersions } = config.installedVersions ?? {}; + writeConfig(projectDir, { ...config, installedVersions: restVersions }); + + await migrateCommand.parseAsync(['--registry', registryDir], { from: 'user' }); + + expect(logs.some((line) => line.includes('widget') && line.includes('no installed version baseline'))).toBe( + true, + ); + }); + + it('errors out when sanring.config.json does not exist', async () => { + rmSync(join(projectDir, 'sanring.config.json')); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit'); + }); + + await expect( + migrateCommand.parseAsync(['--registry', registryDir], { from: 'user' }), + ).rejects.toThrow('process.exit'); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(errors.some((e) => e.includes('sanring.config.json not found'))).toBe(true); + + exitSpy.mockRestore(); + }); +}); diff --git a/packages/cli/src/commands/remove.test.ts b/packages/cli/src/commands/remove.test.ts index 57a66031..f47a0e8a 100644 --- a/packages/cli/src/commands/remove.test.ts +++ b/packages/cli/src/commands/remove.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { Registry, RegistryComponent } from '../registry.js'; @@ -35,9 +35,17 @@ describe('planRemoval', () => { }); it('reports requested-but-not-installed components separately', () => { + const plan = planRemoval(['button', 'combobox'], ['button'], registry); + expect(plan.toRemove).toEqual(['button']); + expect(plan.notInstalled).toEqual(['combobox']); + expect(plan.unknown).toEqual([]); + }); + + it('reports components not present in the registry as unknown, not notInstalled', () => { const plan = planRemoval(['button', 'select'], ['button'], registry); expect(plan.toRemove).toEqual(['button']); - expect(plan.notInstalled).toEqual(['select']); + expect(plan.notInstalled).toEqual([]); + expect(plan.unknown).toEqual(['select']); }); it('blocks removal when a remaining installed component still depends on it', () => { @@ -100,6 +108,44 @@ describe('removeCommand (integration)', () => { expect(config?.installedHashes?.['shared/utils.ts']).toBeDefined(); }); + it('exits 0 when a mixed batch has a known-but-not-installed target alongside a removable one', async () => { + // Register a second component in the registry that's never installed in + // this project, so it's a real (known) target that's just not present. + const registryPath = join(registryDir, 'registry.json'); + const fixtureRegistry: Registry = JSON.parse(readFileSync(registryPath, 'utf-8')); + fixtureRegistry.components.push({ name: 'gizmo', description: '', files: ['gizmo/index.ts'] }); + writeFileSync(registryPath, JSON.stringify(fixtureRegistry, null, 2), 'utf-8'); + + await removeCommand.parseAsync(['widget', 'gizmo', '--registry', registryDir, '--yes'], { + from: 'user', + }); + + expect(readConfig(projectDir)?.installedHashes?.['widget/index.ts']).toBeUndefined(); + }); + + it('exits 1 and removes nothing when the batch includes an unknown component', async () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit'); + }); + const errors: string[] = []; + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + errors.push(args.join(' ')); + }); + + await expect( + removeCommand.parseAsync(['widget', 'does-not-exist', '--registry', registryDir, '--yes'], { + from: 'user', + }), + ).rejects.toThrow('process.exit'); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(errors.some((e) => e.includes('Unknown component: does-not-exist'))).toBe(true); + // Nothing should have been removed — the unknown-target check runs before any deletion. + expect(readConfig(projectDir)?.installedHashes?.['widget/index.ts']).toBeDefined(); + + exitSpy.mockRestore(); + }); + it('preserves registries/defaultRegistry from the existing config on write', async () => { const existing = readConfig(projectDir)!; writeConfig(projectDir, { diff --git a/packages/cli/src/commands/remove.ts b/packages/cli/src/commands/remove.ts index 50b0d4ab..acda50ac 100644 --- a/packages/cli/src/commands/remove.ts +++ b/packages/cli/src/commands/remove.ts @@ -22,7 +22,10 @@ import { parseComponentRef } from './add.js'; export interface RemovalPlan { toRemove: string[]; + /** known in the registry, but not currently installed — soft skip */ notInstalled: string[]; + /** not present in the registry at all — hard input error */ + unknown: string[]; /** name being removed -> still-installed component names that depend on it */ blockedBy: Map; /** shared dep names used only by the components being removed */ @@ -56,7 +59,9 @@ export function planRemoval( : createRegistryIndex(registry).componentsByName; const toRemove = requestedNames.filter((n) => installedSet.has(n)); - const notInstalled = requestedNames.filter((n) => !installedSet.has(n)); + const notRequestedInstalled = requestedNames.filter((n) => !installedSet.has(n)); + const notInstalled = notRequestedInstalled.filter((n) => byName.has(n)); + const unknown = notRequestedInstalled.filter((n) => !byName.has(n)); const remaining = installedNames.filter((n) => !toRemove.includes(n)); const blockedBy = new Map(); @@ -81,7 +86,7 @@ export function planRemoval( (dep) => !sharedStillNeeded.has(dep), ); - return { toRemove, notInstalled, blockedBy, possiblyUnusedShared }; + return { toRemove, notInstalled, unknown, blockedBy, possiblyUnusedShared }; } function confirmRemoval(names: string[], yes: boolean): Promise { @@ -124,14 +129,21 @@ export const removeCommand = new Command('remove') const requestedNames = parsed.map((ref) => ref.name); const plan = planRemoval(requestedNames, installed, registryIndex); - if (plan.notInstalled.length > 0) { + if (plan.unknown.length > 0) { console.error( - pc.red(`✖ Not installed, nothing to remove: ${plan.notInstalled.join(', ')}`), + pc.red(`✖ Unknown component${plan.unknown.length > 1 ? 's' : ''}: ${plan.unknown.join(', ')}`), ); + // Unknown targets are input errors, independent of how the rest of the + // batch turns out — matches diff/update's "unknown target always fails". + process.exit(1); + return; + } + + if (plan.notInstalled.length > 0) { + console.log(pc.dim(` Not installed, nothing to remove: ${plan.notInstalled.join(', ')}`)); } if (plan.toRemove.length === 0) { - process.exit(plan.notInstalled.length > 0 ? 1 : 0); return; } diff --git a/packages/cli/src/commands/search.test.ts b/packages/cli/src/commands/search.test.ts new file mode 100644 index 00000000..0fb35dc9 --- /dev/null +++ b/packages/cli/src/commands/search.test.ts @@ -0,0 +1,125 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { Registry } from '../registry.js'; +import { writeRegistryFixture } from '../__tests__/registry-fixture.js'; +import { addCommand } from './add.js'; +import { searchCommand } from './search.js'; + +describe('searchCommand (integration)', () => { + let projectDir: string; + let registryDir: string; + let originalCwd: string; + let logs: string[]; + + beforeEach(() => { + originalCwd = process.cwd(); + projectDir = mkdtempSync(join(tmpdir(), 'sanring-cli-search-project-')); + registryDir = mkdtempSync(join(tmpdir(), 'sanring-cli-search-registry-')); + writeFileSync(join(projectDir, 'angular.json'), '{}', 'utf-8'); + writeRegistryFixture(registryDir, { + utils: 'export function cn() {}\n', + widget: 'export const widget = 1;\n', + }); + + // Extend the fixture with more components so ranking/group/tag filters + // have something real to differentiate — the base fixture only has one. + for (const name of ['button', 'buttons-group', 'tooltip']) { + mkdirSync(join(registryDir, 'components', name), { recursive: true }); + writeFileSync(join(registryDir, 'components', name, 'index.ts'), `export const ${name.replace(/-/g, '_')} = 1;\n`, 'utf-8'); + } + const registryPath = join(registryDir, 'registry.json'); + const registry: Registry = JSON.parse(readFileSync(registryPath, 'utf-8')); + registry.components.push( + { name: 'button', description: 'A clickable button', files: ['button/index.ts'], tags: ['form'] }, + { name: 'buttons-group', description: 'Groups multiple buttons', files: ['buttons-group/index.ts'], tags: ['form', 'layout'] }, + { name: 'tooltip', description: 'Shows a hint on hover', files: ['tooltip/index.ts'], tags: ['overlay'] }, + ); + registry.groups = [{ id: 'forms', title: 'Forms', components: ['button', 'buttons-group'] }]; + writeFileSync(registryPath, JSON.stringify(registry, null, 2), 'utf-8'); + + process.chdir(projectDir); + + // Commander reuses this module-level Command instance across every + // parseAsync() call in this file and doesn't reset option state back to + // its default between calls (see doctor.test.ts for the same pattern). + searchCommand.setOptionValue('json', false); + searchCommand.setOptionValue('registry', undefined); + searchCommand.setOptionValue('group', undefined); + searchCommand.setOptionValue('tag', undefined); + searchCommand.setOptionValue('path', undefined); + + logs = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + logs.push(args.join(' ')); + }); + }); + + afterEach(() => { + process.chdir(originalCwd); + rmSync(projectDir, { recursive: true, force: true }); + rmSync(registryDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it('ranks an exact name match above a substring match', async () => { + await searchCommand.parseAsync(['button', '--registry', registryDir], { from: 'user' }); + + const output = logs.join('\n'); + const buttonIdx = output.indexOf('button'); + const groupIdx = output.indexOf('buttons-group'); + expect(buttonIdx).toBeGreaterThanOrEqual(0); + expect(groupIdx).toBeGreaterThan(buttonIdx); + }); + + it('reports no matches for a query with no hits', async () => { + await searchCommand.parseAsync(['zzz-nonexistent', '--registry', registryDir], { from: 'user' }); + + expect(logs.some((line) => line.includes('No components matching'))).toBe(true); + }); + + it('--json reports no matches as an empty results array', async () => { + await searchCommand.parseAsync(['zzz-nonexistent', '--json', '--registry', registryDir], { + from: 'user', + }); + + const report = JSON.parse(logs.join('')) as { query: string; results: unknown[] }; + expect(report.query).toBe('zzz-nonexistent'); + expect(report.results).toEqual([]); + }); + + it('--json includes an `installed` flag reflecting the current project state (regression: TDZ crash on installedNames)', async () => { + await addCommand.parseAsync(['button', '--registry', registryDir], { from: 'user' }); + logs = []; + + await searchCommand.parseAsync(['button', '--json', '--registry', registryDir], { from: 'user' }); + + const report = JSON.parse(logs.join('')) as { + results: Array<{ name: string; installed: boolean }>; + }; + const button = report.results.find((r) => r.name === 'button'); + const tooltip = report.results.find((r) => r.name === 'buttons-group'); + expect(button?.installed).toBe(true); + expect(tooltip?.installed).toBe(false); + }); + + it('--group filters results to only the components listed in that group', async () => { + await searchCommand.parseAsync(['e', '--group', 'forms', '--json', '--registry', registryDir], { + from: 'user', + }); + + const report = JSON.parse(logs.join('')) as { results: Array<{ name: string }> }; + const names = report.results.map((r) => r.name).sort(); + expect(names).toEqual(['button', 'buttons-group']); + }); + + it('--tag filters results to components carrying that tag', async () => { + await searchCommand.parseAsync(['e', '--tag', 'overlay', '--json', '--registry', registryDir], { + from: 'user', + }); + + const report = JSON.parse(logs.join('')) as { results: Array<{ name: string }> }; + expect(report.results.map((r) => r.name)).toEqual(['tooltip']); + }); +}); diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index 36eddf33..432cf58a 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -4,5 +4,13 @@ export default defineConfig({ test: { environment: 'node', include: ['src/**/*.test.ts', 'schematics/**/*.test.ts'], + // Many command tests call process.chdir() to point commands at a temp + // project dir. process.chdir() is process-wide, not per-worker-thread, + // so under the default `threads` pool concurrently-running test files + // race on the real cwd — e.g. registry.test.ts's cwd-relative fixture + // reads intermittently fail when another file has chdir'd elsewhere at + // the same moment. `forks` runs each file in its own OS process, giving + // each an independent cwd and eliminating the race. + pool: 'forks', }, }); From a7333632a62ddb13d424281a3cf0db23b35e3443 Mon Sep 17 00:00:00 2001 From: jack755051 Date: Sat, 22 Aug 2026 00:03:41 +0800 Subject: [PATCH 2/9] fix(ui): otp-input overflow scrollbar and native input a11y model Slots now shrink to fit their container instead of triggering a horizontal scrollbar, and the hidden native input covers the full control (type="text", inset-0) so password managers/autofill see a properly sized target instead of a 1px hit area. Active-slot focus styling switches from an offset ring + transform to an inset ring to match the new non-scrolling layout. packages/ui and registry stay in sync; docs previews and locale strings updated to match. Co-Authored-By: Claude Sonnet 5 --- .../i18n/locales/en/components/otp-input.ts | 11 +++--- .../i18n/locales/zh/components/otp-input.ts | 14 +++++--- .../otp-input/otp-input-page.component.ts | 34 +++++++++---------- .../components/otp-input/otp-input.docs.ts | 18 +++++----- .../otp-input/otp-input-slot.component.ts | 13 ++++--- .../otp-input/otp-input.component.spec.ts | 32 +++++++++++++++++ .../otp-input/otp-input.component.ts | 12 +++---- .../otp-input/otp-input-slot.component.ts | 13 ++++--- .../otp-input/otp-input.component.ts | 24 ++++++++----- 9 files changed, 111 insertions(+), 60 deletions(-) diff --git a/apps/docs/src/app/i18n/locales/en/components/otp-input.ts b/apps/docs/src/app/i18n/locales/en/components/otp-input.ts index d146f5ee..ce054e98 100644 --- a/apps/docs/src/app/i18n/locales/en/components/otp-input.ts +++ b/apps/docs/src/app/i18n/locales/en/components/otp-input.ts @@ -63,14 +63,17 @@ export const otpInputTranslations = { 'otpInput.api.stateChange.description': 'Emits the next code string together with slot metadata and completion state.', 'otpInput.api.complete.description': 'Emits when every slot has a value.', - 'otpInput.api.pasted.description': 'Emits normalized paste details after a paste event is handled.', + 'otpInput.api.pasted.description': + 'Emits normalized paste details after a paste event is handled.', 'otpInput.api.slotKeydown.description': 'Emits per-slot keyboard metadata for advanced custom slot handling.', - 'otpInput.accessibility.description': "role='group' on the host, aria-label / aria-labelledby, aria-describedby, aria-invalid, aria-required, and aria-disabled are synced automatically. Each slot is a native . A hidden native input stays in sync so password managers and autofill can propose one-time codes.", - 'otpInput.keyboard.description': 'Each slot is a native text input with navigation glue between slots.', + 'otpInput.accessibility.description': + "role='group' on the host, aria-label / aria-labelledby, aria-describedby, aria-invalid, aria-required, and aria-disabled are synced automatically. The visual slots are hidden from assistive technology, while one transparent native input receives the complete code and supports password managers and one-time-code autofill.", + 'otpInput.keyboard.description': 'The native input provides navigation across the visual slots.', 'otpInput.keyboard.type': 'Type a character to fill the focused slot and advance to the next.', 'otpInput.keyboard.arrowLeftRight': 'Move focus to the adjacent slot.', 'otpInput.keyboard.backspace': 'Clear the current slot and move focus to the previous slot.', 'otpInput.keyboard.delete': 'Clear the current slot without moving focus.', - 'otpInput.stateModel.description': 'Implements ControlValueAccessor. Use [(ngModel)] or formControl. Value type: string. The complete output fires when every slot has a character, making it easy to auto-submit verification forms.', + 'otpInput.stateModel.description': + 'Implements ControlValueAccessor. Use [(ngModel)] or formControl. Value type: string. The complete output fires when every slot has a character, making it easy to auto-submit verification forms.', } as const; diff --git a/apps/docs/src/app/i18n/locales/zh/components/otp-input.ts b/apps/docs/src/app/i18n/locales/zh/components/otp-input.ts index f4735e0a..b4c65077 100644 --- a/apps/docs/src/app/i18n/locales/zh/components/otp-input.ts +++ b/apps/docs/src/app/i18n/locales/zh/components/otp-input.ts @@ -47,7 +47,8 @@ export const otpInputTranslations = { '轉發到隱藏原生 input 的 autocomplete 提示,預設為 one-time-code。', 'otpInput.api.disabled.description': '停用所有 slot 並阻止使用者互動。', 'otpInput.api.readOnly.description': '顯示目前值,但禁止編輯。', - 'otpInput.api.required.description': '將 OTP input 標記為必填,供 field 整合與 aria-required 使用。', + 'otpInput.api.required.description': + '將 OTP input 標記為必填,供 field 整合與 aria-required 使用。', 'otpInput.api.ariaLabel.description': '沒有可見 label 時,提供給群組與隱藏 input 的無障礙標籤。', 'otpInput.api.ariaLabelledBy.description': '用來命名 OTP input 的可見 label 元素 id。', 'otpInput.api.ariaDescribedBy.description': @@ -56,12 +57,15 @@ export const otpInputTranslations = { 'otpInput.api.stateChange.description': '送出下一個驗證碼字串、slot metadata 與完成狀態。', 'otpInput.api.complete.description': '每個 slot 都有值時觸發。', 'otpInput.api.pasted.description': '貼上事件處理後,送出正規化後的貼上資訊。', - 'otpInput.api.slotKeydown.description': '送出單一 slot 的鍵盤 metadata,供進階自訂 slot 行為使用。', - 'otpInput.accessibility.description': "宿主具有 role='group',aria-label / aria-labelledby、aria-describedby、aria-invalid、aria-required 與 aria-disabled 會自動同步。每個插槽是原生的 。一個隱藏的原生 input 會保持同步,讓密碼管理器與自動填入可以提議一次性驗證碼。", - 'otpInput.keyboard.description': '每個插槽都是原生 text input,插槽之間具有導覽連結。', + 'otpInput.api.slotKeydown.description': + '送出單一 slot 的鍵盤 metadata,供進階自訂 slot 行為使用。', + 'otpInput.accessibility.description': + "宿主具有 role='group',aria-label / aria-labelledby、aria-describedby、aria-invalid、aria-required 與 aria-disabled 會自動同步。視覺插槽不會重複暴露給輔助科技;單一透明的原生 input 負責接收完整驗證碼,並支援密碼管理器與 one-time-code 自動填入。", + 'otpInput.keyboard.description': '原生 input 提供視覺插槽之間的鍵盤導覽。', 'otpInput.keyboard.type': '輸入字元以填入目前插槽並自動跳至下一個。', 'otpInput.keyboard.arrowLeftRight': '移動焦點至相鄰插槽。', 'otpInput.keyboard.backspace': '清除目前插槽並將焦點移至前一個插槽。', 'otpInput.keyboard.delete': '清除目前插槽且不移動焦點。', - 'otpInput.stateModel.description': '實作 ControlValueAccessor。使用 [(ngModel)] 或 formControl。值型別:string。complete 事件會在所有插槽都有字元時觸發,方便自動送出驗證表單。', + 'otpInput.stateModel.description': + '實作 ControlValueAccessor。使用 [(ngModel)] 或 formControl。值型別:string。complete 事件會在所有插槽都有字元時觸發,方便自動送出驗證表單。', } as const; diff --git a/apps/docs/src/app/pages/components/otp-input/otp-input-page.component.ts b/apps/docs/src/app/pages/components/otp-input/otp-input-page.component.ts index e493ecfa..1b79bf9c 100644 --- a/apps/docs/src/app/pages/components/otp-input/otp-input-page.component.ts +++ b/apps/docs/src/app/pages/components/otp-input/otp-input-page.component.ts @@ -62,12 +62,12 @@ import { otpInputPage, otpInputPageExamples } from './otp-input.docs';
@@ -94,13 +94,13 @@ import { otpInputPage, otpInputPageExamples } from './otp-input.docs';
@@ -111,13 +111,13 @@ import { otpInputPage, otpInputPageExamples } from './otp-input.docs';
@@ -136,12 +136,12 @@ import { otpInputPage, otpInputPageExamples } from './otp-input.docs';
@@ -153,12 +153,12 @@ import { otpInputPage, otpInputPageExamples } from './otp-input.docs';
@@ -170,7 +170,7 @@ import { otpInputPage, otpInputPageExamples } from './otp-input.docs';
`, }) -export class CalendarComponent implements ControlValueAccessor, OnInit { +export class CalendarComponent extends SanringCvaBase { protected readonly engine = inject(CalendarEngine); private readonly injectedLocale = inject(CALENDAR_LOCALE, { optional: true }); + private readonly elementRef = inject(ElementRef); readonly class = input(); readonly id = input(inject(_IdGenerator).getId('sanring-calendar-', true)); @@ -226,43 +223,15 @@ export class CalendarComponent implements ControlValueAccessor, OnInit { ]; }); - // ========================================== - // Field 整合:id/disabled/required 會跟上面同名的 @Input 撞名,走下面的 fieldXxx getter, - // 由 CalendarFieldControlAdapter 轉接成 SanringFieldControl 介面(見檔案底部)。 - // ========================================== - focused = false; - ngControl: NgControl | null = null; - - private readonly injector = inject(Injector); - private readonly destroyRef = inject(DestroyRef); - private readonly elementRef = inject(ElementRef); - - private readonly stateChangesSubject = new Subject(); - readonly stateChanges = this.stateChangesSubject.asObservable(); - - // 橋接用:ngControl 的 invalid/touched 是 RxJS 驅動、不是 signal,靠這個計數器把它們接進 - // signal graph,errorState/fieldRequired 才能在驗證狀態改變時正確重算。 - private readonly stateVersion = signal(0); - private readonly fieldDescribedByIds = signal([]); - private readonly disabledState = signal(false); - - protected readonly computedAriaDescribedBy = computed(() => { - const ids = [this.ariaDescribedBy(), ...this.fieldDescribedByIds()].filter( - (v): v is string => !!v, - ); - return ids.length ? ids.join(' ') : undefined; - }); - // 表單層級的「整個控制項停用」跟既有的 disabled(哪些日期不可選)是兩件事——停用時額外疊一個 // 永遠回傳 true 的 matcher,讓所有日期都不可選,而不是動到使用者自己傳入的 disabled matcher。 private readonly effectiveDisabled = computed(() => this.disabledState() ? () => true : this.disabled(), ); - get errorState(): boolean { - this.stateVersion(); - return !!(this.ngControl?.invalid && this.ngControl?.touched); - } + protected readonly computedAriaDescribedBy = this.makeComputedAriaDescribedBy( + this.ariaDescribedBy, + ); get fieldValue(): CalendarValue { return this.mode() === 'range' ? this.engine.selectedRange() : this.engine.selectedDate(); @@ -277,15 +246,12 @@ export class CalendarComponent implements ControlValueAccessor, OnInit { return this.disabledState(); } - get fieldRequired(): boolean { - this.stateVersion(); - return this.required() || !!this.ngControl?.control?.hasValidator(Validators.required); + protected override hasInputRequired(): boolean { + return this.required(); } - private onChange: (value: CalendarValue) => void = () => {}; - private onTouched: () => void = () => {}; - constructor() { + super(); effect(() => { const locale = this.locale(); if (locale) this.engine.setLocale(locale); @@ -310,17 +276,6 @@ export class CalendarComponent implements ControlValueAccessor, OnInit { this.emitStateChanges(); } }); - - this.destroyRef.onDestroy(() => this.stateChangesSubject.complete()); - } - - ngOnInit(): void { - // 跟 checkbox/select 一樣的原因:constructor 階段 self-inject NgControl 會跟 NgModel 搭配時 - // 觸發 NG0200 循環依賴(本元件同時透過 NG_VALUE_ACCESSOR 註冊自己),延後到 ngOnInit 才拿。 - this.ngControl = this.injector.get(NgControl, null, { optional: true, self: true }); - this.ngControl?.control?.events - ?.pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(() => this.emitStateChanges()); } readonly isDraftActive = computed(() => this.engine.isDraftActive()); @@ -383,26 +338,11 @@ export class CalendarComponent implements ControlValueAccessor, OnInit { return rows; } - protected onFocus(): void { - this.focused = true; - this.emitStateChanges(); - } - - protected onBlur(): void { - this.focused = false; - this.onTouched(); - this.emitStateChanges(); - } - focus(options?: FocusOptions): void { this.elementRef.nativeElement.focus(options); } - setDescribedByIds(ids: string[]): void { - this.fieldDescribedByIds.set(ids); - } - - writeValue(value: CalendarValue): void { + override writeValue(value: CalendarValue): void { if (this.mode() === 'range') { if (value && typeof value === 'object' && 'start' in value) { const range = value as DateRange; @@ -418,72 +358,4 @@ export class CalendarComponent implements ControlValueAccessor, OnInit { this.engine.clearSelection(); } } - - registerOnChange(fn: (value: CalendarValue) => void): void { - this.onChange = fn; - } - - registerOnTouched(fn: () => void): void { - this.onTouched = fn; - } - - setDisabledState(isDisabled: boolean): void { - this.disabledState.set(isDisabled); - this.emitStateChanges(); - } - - private emitStateChanges(): void { - this.stateVersion.update((v) => v + 1); - this.stateChangesSubject.next(); - } -} - -class CalendarFieldControlAdapter implements SanringFieldControl { - readonly controlType = FieldType.calendar; - - constructor(private readonly host: CalendarComponent) {} - - get id(): string { - return this.host.id(); - } - - get value(): CalendarValue { - return this.host.fieldValue; - } - - get empty(): boolean { - return this.host.fieldEmpty; - } - - get focused(): boolean { - return this.host.focused; - } - - get errorState(): boolean { - return this.host.errorState; - } - - get disabled(): boolean { - return this.host.fieldDisabled; - } - - get required(): boolean { - return this.host.fieldRequired; - } - - get ngControl(): NgControl | null { - return this.host.ngControl; - } - - get stateChanges(): Observable { - return this.host.stateChanges; - } - - focus(options?: FocusOptions): void { - this.host.focus(options); - } - - setDescribedByIds(ids: string[]): void { - this.host.setDescribedByIds(ids); - } } diff --git a/packages/ui/src/lib/components/checkbox/checkbox.component.ts b/packages/ui/src/lib/components/checkbox/checkbox.component.ts index 54dfce58..656e6370 100644 --- a/packages/ui/src/lib/components/checkbox/checkbox.component.ts +++ b/packages/ui/src/lib/components/checkbox/checkbox.component.ts @@ -1,10 +1,7 @@ import { ChangeDetectionStrategy, Component, - DestroyRef, ElementRef, - Injector, - OnInit, ViewChild, booleanAttribute, computed, @@ -16,14 +13,17 @@ import { signal, } from '@angular/core'; import { _IdGenerator } from '@angular/cdk/a11y'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { ControlValueAccessor, NG_VALUE_ACCESSOR, NgControl, Validators } from '@angular/forms'; +import { NG_VALUE_ACCESSOR } from '@angular/forms'; import { LucideCheck, LucideMinus } from '@lucide/angular'; -import { Observable, Subject } from 'rxjs'; import { cn } from '../../utils'; import { SELECTION_CONTROL_BASE_CLASS, SELECTION_CONTROL_FOCUS_CLASS } from '../component-styles'; -import { FieldType, SanringFieldControl, SANRING_FIELD_CONTROL } from '../field/field.type'; -import { CHECKBOX_ICON_SIZE_CLASSES, CHECKBOX_SIZE_CLASSES, CHECKBOX_STATE_CLASS } from './checkbox.styles'; +import { SanringCvaBase, SanringFieldControlAdapter } from '../shared/cva-base'; +import { FieldType, SANRING_FIELD_CONTROL } from '../field/field.type'; +import { + CHECKBOX_ICON_SIZE_CLASSES, + CHECKBOX_SIZE_CLASSES, + CHECKBOX_STATE_CLASS, +} from './checkbox.styles'; import { CheckedState, CheckboxSize } from './checkbox.types'; @Component({ @@ -42,7 +42,8 @@ import { CheckedState, CheckboxSize } from './checkbox.types'; // 禁止用 alias 改名 @Input),所以改用 useFactory 產生一個轉接的 adapter 物件。 { provide: SANRING_FIELD_CONTROL, - useFactory: (host: CheckboxComponent) => new CheckboxFieldControlAdapter(host), + useFactory: (host: CheckboxComponent) => + new SanringFieldControlAdapter(FieldType.checkbox, host), deps: [forwardRef(() => CheckboxComponent)], }, ], @@ -83,7 +84,7 @@ import { CheckedState, CheckboxSize } from './checkbox.types'; `, }) -export class CheckboxComponent implements ControlValueAccessor, OnInit { +export class CheckboxComponent extends SanringCvaBase { readonly class = input(); readonly id = input(inject(_IdGenerator).getId('sanring-checkbox-', true)); readonly disabled = input(false, { transform: booleanAttribute }); @@ -111,44 +112,17 @@ export class CheckboxComponent implements ControlValueAccessor, OnInit { CHECKBOX_STATE_CLASS, // 讀 this.errorState(getter)而不是直接寫條件,是為了讓下面 stateVersion 的橋接生效, // 否則 ngControl.invalid/touched 不是 signal,這個 computed 不會在驗證狀態改變時重算 - this.errorState && 'border-[var(--sanring-error-50)] focus-visible:ring-[var(--sanring-error-40)]', + this.errorState && + 'border-[var(--sanring-error-50)] focus-visible:ring-[var(--sanring-error-40)]', this.class(), ), ); - // ========================================== - // Field 整合:底下這些成員都不會跟上面的 @Input 撞名,可以直接放在元件本身; - // 真正會撞名的 (id/disabled/value/required) 走下面的 fieldXxx getter,由 - // CheckboxFieldControlAdapter 轉接成 SanringFieldControl 介面。 - // ========================================== - readonly controlType = FieldType.checkbox; - focused = false; - ngControl: NgControl | null = null; - - private readonly injector = inject(Injector); - private readonly destroyRef = inject(DestroyRef); - @ViewChild('btn') private btnRef!: ElementRef; - private readonly stateChangesSubject = new Subject(); - readonly stateChanges = this.stateChangesSubject.asObservable(); - - // 橋接用:ngControl 的 invalid/touched 是 RxJS 驅動、不是 signal,靠這個計數器把它們 - // 接進 signal graph,errorState/fieldRequired 才能讓上面的 checkboxClass computed 正確重算 - private readonly stateVersion = signal(0); - - private readonly fieldDescribedByIds = signal([]); - protected readonly computedAriaDescribedBy = computed(() => { - const ids = [this.ariaDescribedBy(), ...this.fieldDescribedByIds()].filter( - (v): v is string => !!v, - ); - return ids.length ? ids.join(' ') : undefined; - }); - - get errorState(): boolean { - this.stateVersion(); - return !!(this.ngControl?.invalid && this.ngControl?.touched); - } + protected readonly computedAriaDescribedBy = this.makeComputedAriaDescribedBy( + this.ariaDescribedBy, + ); get fieldValue(): CheckedState | null { return this.checkedSignal(); @@ -162,40 +136,15 @@ export class CheckboxComponent implements ControlValueAccessor, OnInit { return this.isDisabled(); } - get fieldRequired(): boolean { - this.stateVersion(); - return this.required() || !!this.ngControl?.control?.hasValidator(Validators.required); + protected override hasInputRequired(): boolean { + return this.required(); } - private readonly disabledState = signal(false); - private onChange: (value: CheckedState) => void = () => {}; - private onTouched: () => void = () => {}; - constructor() { + super(); effect(() => { this.checkedSignal.set(this.checked()); }); - - this.destroyRef.onDestroy(() => this.stateChangesSubject.complete()); - } - - ngOnInit(): void { - // 不能像 input/textarea 直接用 `inject(NgControl, { optional: true, self: true })` field - // initializer:本元件同時透過 NG_VALUE_ACCESSOR (forwardRef) 註冊自己,若在 constructor - // 階段就 self-inject NgControl,跟 NgModel 搭配時會觸發 NG0200 循環依賴(NgModel 建構時 - // 需要先解出 value accessor 也就是自己,自己建構時又反過來要拿同一個還沒建構完的 NgModel)。 - // 延後到 ngOnInit 拿,因為 Angular 會先讓同一個節點上的所有 directive 建構完才跑 lifecycle hook。 - this.ngControl = this.injector.get(NgControl, null, { optional: true, self: true }); - - // OnPush 元件被跳過 CD 時 ngDoCheck 不會執行,所以不能像 input/textarea 靠輪詢偵測 - // ngControl 狀態變化。不能只聽 statusChanges——那個 Observable 只在 valid/invalid/ - // pending/disabled 這幾種 status 真的變動時才會 emit,markAsTouched() 純粹改 touched - // flag,不會觸發它,導致外部呼叫 markAllAsTouched() 時錯誤訊息不會跳出來。改聽 - // control.events(Angular v18+ 公開 API),touched/pristine/status/value 任何一種 - // 變化都會經過這裡。 - this.ngControl?.control?.events - ?.pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(() => this.emitStateChanges()); } getState(): string { @@ -212,94 +161,11 @@ export class CheckboxComponent implements ControlValueAccessor, OnInit { this.emitStateChanges(); } - onFocus() { - this.focused = true; - this.emitStateChanges(); - } - - onBlur() { - this.focused = false; - this.onTouched(); - this.emitStateChanges(); - } - focus(options?: FocusOptions): void { this.btnRef?.nativeElement.focus(options); } - setDescribedByIds(ids: string[]): void { - this.fieldDescribedByIds.set(ids); - } - - writeValue(value: CheckedState): void { + override writeValue(value: CheckedState): void { this.checkedSignal.set(value); } - - registerOnChange(fn: (value: CheckedState) => void): void { - this.onChange = fn; - } - - registerOnTouched(fn: () => void): void { - this.onTouched = fn; - } - - setDisabledState(isDisabled: boolean): void { - this.disabledState.set(isDisabled); - this.emitStateChanges(); - } - - private emitStateChanges(): void { - this.stateVersion.update((v) => v + 1); - this.stateChangesSubject.next(); - } -} - -class CheckboxFieldControlAdapter implements SanringFieldControl { - readonly controlType = FieldType.checkbox; - - constructor(private readonly host: CheckboxComponent) {} - - get id(): string { - return this.host.id(); - } - - get value(): CheckedState | null { - return this.host.fieldValue; - } - - get empty(): boolean { - return this.host.fieldEmpty; - } - - get focused(): boolean { - return this.host.focused; - } - - get errorState(): boolean { - return this.host.errorState; - } - - get disabled(): boolean { - return this.host.fieldDisabled; - } - - get required(): boolean { - return this.host.fieldRequired; - } - - get ngControl(): NgControl | null { - return this.host.ngControl; - } - - get stateChanges(): Observable { - return this.host.stateChanges; - } - - focus(options?: FocusOptions): void { - this.host.focus(options); - } - - setDescribedByIds(ids: string[]): void { - this.host.setDescribedByIds(ids); - } } diff --git a/packages/ui/src/lib/components/file-upload/file-upload.component.ts b/packages/ui/src/lib/components/file-upload/file-upload.component.ts index 603bdfd7..fc2f6c44 100644 --- a/packages/ui/src/lib/components/file-upload/file-upload.component.ts +++ b/packages/ui/src/lib/components/file-upload/file-upload.component.ts @@ -1,11 +1,7 @@ import { ChangeDetectionStrategy, Component, - DestroyRef, - Injector, - OnInit, booleanAttribute, - computed, forwardRef, inject, input, @@ -14,9 +10,9 @@ import { signal, } from '@angular/core'; import { _IdGenerator } from '@angular/cdk/a11y'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { ControlValueAccessor, NG_VALUE_ACCESSOR, NgControl, Validators } from '@angular/forms'; -import { Observable, Subject } from 'rxjs'; +import { NG_VALUE_ACCESSOR } from '@angular/forms'; +import { Observable } from 'rxjs'; +import { SanringCvaBase } from '../shared/cva-base'; import { FieldType, SANRING_FIELD_CONTROL, SanringFieldControl } from '../field/field.type'; import { FileRejection, FileUploadErrorCode } from './file-upload.type'; @@ -46,10 +42,11 @@ import { FileRejection, FileUploadErrorCode } from './file-upload.type'; '[attr.aria-describedby]': 'describedByAttr()', }, }) -export class FileUploadComponent implements ControlValueAccessor, OnInit { +export class FileUploadComponent extends SanringCvaBase { // ========================================== // 1. 外部設定 (Inputs) // ========================================== + readonly id = input(inject(_IdGenerator).getId('sanring-file-upload-', true)); readonly accept = input('*'); readonly multiple = input(false, { transform: booleanAttribute }); readonly disabled = input(false, { transform: booleanAttribute }); @@ -63,68 +60,37 @@ export class FileUploadComponent implements ControlValueAccessor, OnInit { readonly files = model([]); readonly rejectedFiles = signal([]); - // ========================================== - // 3. 給子元件 (Dropzone/Trigger) 呼叫的 API - // ========================================== - - readonly id = input(inject(_IdGenerator).getId('sanring-file-upload-', true)); - focused = false; - ngControl: NgControl | null = null; - - private readonly injector = inject(Injector); - private readonly destroyRef = inject(DestroyRef); - private readonly describedByIds = signal([]); - private readonly disabledState = signal(false); - private readonly stateVersion = signal(0); - private readonly stateChangesSubject = new Subject(); - readonly stateChanges = this.stateChangesSubject.asObservable(); - - private triggerInput: HTMLInputElement | null = null; - private onChange: (value: File[]) => void = () => {}; - private onTouched: () => void = () => {}; - - get fieldValue(): File[] { - return this.files(); - } + readonly describedByAttr = this.makeComputedAriaDescribedBy(); - get fieldEmpty(): boolean { - return this.files().length === 0; + get isDisabled(): boolean { + return this.disabled() || this.disabledState(); } - get errorState(): boolean { - this.stateVersion(); + override get errorState(): boolean { + this._stateVersion(); return ( this.rejectedFiles().length > 0 || !!(this.ngControl?.invalid && this.ngControl?.touched) ); } - get isDisabled(): boolean { - return this.disabled() || this.disabledState(); + get fieldValue(): File[] { + return this.files(); } - get fieldRequired(): boolean { - this.stateVersion(); - return this.required() || !!this.ngControl?.control?.hasValidator(Validators.required); + get fieldEmpty(): boolean { + return this.files().length === 0; } - readonly describedByAttr = computed(() => { - const ids = this.describedByIds(); - return ids.length ? ids.join(' ') : null; - }); - - constructor() { - this.destroyRef.onDestroy(() => this.stateChangesSubject.complete()); + get fieldDisabled(): boolean { + return this.isDisabled; } - ngOnInit(): void { - this.ngControl = this.injector.get(NgControl, null, { optional: true, self: true }); - // 不能只聽 statusChanges——markAsTouched() 不會觸發它,改聽 control.events(Angular - // v18+ 公開 API)才能在 markAllAsTouched() 這類外部呼叫時正確更新 errorState。 - this.ngControl?.control?.events - ?.pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(() => this.emitStateChanges()); + protected override hasInputRequired(): boolean { + return this.required(); } + private triggerInput: HTMLInputElement | null = null; + /** 接收來自外部的檔案、驗證後更新狀態 */ handleFiles(newFiles: FileList | File[] | null) { if (this.isDisabled || !newFiles || newFiles.length === 0) return; @@ -185,33 +151,16 @@ export class FileUploadComponent implements ControlValueAccessor, OnInit { } } - writeValue(value: File[] | null): void { + override writeValue(value: File[] | null): void { this.files.set(value ?? []); this.rejectedFiles.set([]); this.emitStateChanges(); } - registerOnChange(fn: (value: File[]) => void): void { - this.onChange = fn; - } - - registerOnTouched(fn: () => void): void { - this.onTouched = fn; - } - - setDisabledState(isDisabled: boolean): void { - this.disabledState.set(isDisabled); - this.emitStateChanges(); - } - focus(options?: FocusOptions): void { this.triggerInput?.focus(options); } - setDescribedByIds(ids: string[]): void { - this.describedByIds.set(ids); - } - markTouched(): void { this.onTouched(); this.emitStateChanges(); @@ -260,11 +209,6 @@ export class FileUploadComponent implements ControlValueAccessor, OnInit { this.onTouched(); this.emitStateChanges(); } - - private emitStateChanges(): void { - this.stateVersion.update((v) => v + 1); - this.stateChangesSubject.next(); - } } class FileUploadFieldControlAdapter implements SanringFieldControl { @@ -300,7 +244,7 @@ class FileUploadFieldControlAdapter implements SanringFieldControl { return this.host.fieldRequired; } - get ngControl(): NgControl | null { + get ngControl() { return this.host.ngControl; } diff --git a/packages/ui/src/lib/components/otp-input/otp-input.component.ts b/packages/ui/src/lib/components/otp-input/otp-input.component.ts index 132c4438..d099e1f4 100644 --- a/packages/ui/src/lib/components/otp-input/otp-input.component.ts +++ b/packages/ui/src/lib/components/otp-input/otp-input.component.ts @@ -1,16 +1,12 @@ import { ChangeDetectionStrategy, Component, - DestroyRef, ElementRef, - Injector, - OnInit, booleanAttribute, computed, contentChildren, effect, forwardRef, - inject, input, numberAttribute, output, @@ -18,12 +14,11 @@ import { untracked, viewChild, } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { ControlValueAccessor, NG_VALUE_ACCESSOR, NgControl, Validators } from '@angular/forms'; -import { Observable, Subject } from 'rxjs'; +import { NG_VALUE_ACCESSOR } from '@angular/forms'; import { cn, uniqueId } from '../../utils'; import { FIELD_SIZE_CLASS } from '../component-styles'; -import { FieldType, SANRING_FIELD_CONTROL, SanringFieldControl } from '../field/field.type'; +import { FieldType, SANRING_FIELD_CONTROL } from '../field/field.type'; +import { SanringCvaBase, SanringFieldControlAdapter } from '../shared/cva-base'; import { OTP_INPUT_ROOT, OtpInputRootContext } from './otp-input.context'; import { OtpInputSeparatorComponent } from './otp-input-separator.component'; import { OtpInputSlotComponent } from './otp-input-slot.component'; @@ -69,7 +64,8 @@ const OTP_INPUT_TEXT_ALIGN_CLASSES: Record = { // 產生一個轉接的 adapter 物件。 { provide: SANRING_FIELD_CONTROL, - useFactory: (host: OtpInputComponent) => new OtpInputFieldControlAdapter(host), + useFactory: (host: OtpInputComponent) => + new SanringFieldControlAdapter(FieldType.otpInput, host), deps: [forwardRef(() => OtpInputComponent)], }, { @@ -135,7 +131,10 @@ const OTP_INPUT_TEXT_ALIGN_CLASSES: Record = { } `, }) -export class OtpInputComponent implements ControlValueAccessor, OnInit, OtpInputRootContext { +export class OtpInputComponent + extends SanringCvaBase + implements OtpInputRootContext +{ readonly class = input(); readonly id = input(uniqueId('sanring-otp-input')); readonly name = input(); @@ -166,22 +165,8 @@ export class OtpInputComponent implements ControlValueAccessor, OnInit, OtpInput protected readonly slotValues = signal([]); protected readonly valueSignal = computed(() => this.slotValues().join('')); protected readonly focusedIndex = signal(null); - private readonly disabledState = signal(false); - private readonly fieldDescribedByIds = signal([]); - // 橋接用:ngControl 的 invalid/touched 是 RxJS 驅動、不是 signal,靠這個計數器把它們 - // 接進 signal graph,讓 errorState/fieldRequired 在 OnPush 下也能正確重算 - private readonly stateVersion = signal(0); protected readonly hasProjectedSlots = computed(() => this.projectedSlots().length > 0); - // ========================================== - // Field 整合:底下這些成員都不會跟上面的 @Input 撞名,可以直接放在元件本身; - // 真正會撞名的 (id/disabled/value/required) 走下面的 fieldXxx getter,由 - // OtpInputFieldControlAdapter 轉接成 SanringFieldControl 介面。 - // ========================================== - readonly controlType = FieldType.otpInput; - focused = false; - ngControl: NgControl | null = null; - protected readonly slotCount = computed(() => { const length = this.length(); return Number.isFinite(length) && length > 0 ? Math.floor(length) : 6; @@ -223,53 +208,23 @@ export class OtpInputComponent implements ControlValueAccessor, OnInit, OtpInput ), ); - protected readonly computedAriaDescribedBy = computed(() => { - const ids = [this.ariaDescribedBy(), ...this.fieldDescribedByIds()].filter( - (v): v is string => !!v, - ); - return ids.length ? ids.join(' ') : undefined; - }); + protected readonly computedAriaDescribedBy = this.makeComputedAriaDescribedBy( + this.ariaDescribedBy, + ); - private readonly injector = inject(Injector); - private readonly destroyRef = inject(DestroyRef); - private readonly stateChangesSubject = new Subject(); - readonly stateChanges: Observable = this.stateChangesSubject.asObservable(); private readonly otpInput = viewChild>('otpInput'); - - private onChange: (value: OtpInputValue) => void = () => {}; - private onTouched: () => void = () => {}; // 手機虛擬鍵盤常常不理會 keydown 的 preventDefault(),字元還是會被瀏覽器插入 // 原生 input,導致 keydown 跟隨後補發的 input 事件各寫入一次、畫面顯示兩次。 // 用這個旗標讓 onInput 偵測到「已經由 keydown 處理過」時直接略過、只把畫面同步回目前的值。 private suppressNextInput = false; constructor() { + super(); effect(() => { const value = this.value(); this.slotCount(); untracked(() => this.setValue(value, false)); }); - - this.destroyRef.onDestroy(() => this.stateChangesSubject.complete()); - } - - ngOnInit(): void { - // 不能在 constructor 做 self-inject NgControl:本元件同時透過 NG_VALUE_ACCESSOR - // (forwardRef) 註冊自己,若在 constructor 階段就 self-inject,跟 NgModel 搭配時 - // 會觸發 NG0200 循環依賴。延後到 ngOnInit,Angular 保證同節點 directive 都建構完後 - // 才跑 lifecycle hook,循環依賴就消失了。 - this.ngControl = this.injector.get(NgControl, null, { optional: true, self: true }); - // 不能只聽 statusChanges——markAsTouched() 只改 touched flag,不觸發它, - // 導致 markAllAsTouched() 後錯誤狀態不更新。改聽 control.events(Angular v18+), - // touched / pristine / status / value 任何變化都會通過這裡。 - this.ngControl?.control?.events - ?.pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(() => this.emitStateChanges()); - } - - get errorState(): boolean { - this.stateVersion(); - return !!(this.ngControl?.invalid && this.ngControl?.touched); } get fieldValue(): OtpInputValue { @@ -284,9 +239,8 @@ export class OtpInputComponent implements ControlValueAccessor, OnInit, OtpInput return this.isDisabled(); } - get fieldRequired(): boolean { - this.stateVersion(); - return this.required() || !!this.ngControl?.control?.hasValidator(Validators.required); + protected override hasInputRequired(): boolean { + return this.required(); } focus(options?: FocusOptions): void { @@ -294,27 +248,10 @@ export class OtpInputComponent implements ControlValueAccessor, OnInit, OtpInput this.focusSlot(nextIndex === -1 ? 0 : nextIndex, options); } - setDescribedByIds(ids: string[]): void { - this.fieldDescribedByIds.set(ids); - } - - writeValue(value: OtpInputValue | null | undefined): void { + override writeValue(value: OtpInputValue | null | undefined): void { this.setValue(value ?? '', false); } - registerOnChange(fn: (value: OtpInputValue) => void): void { - this.onChange = fn; - } - - registerOnTouched(fn: () => void): void { - this.onTouched = fn; - } - - setDisabledState(isDisabled: boolean): void { - this.disabledState.set(isDisabled); - this.emitStateChanges(); - } - getOrientation(): OtpInputOrientation { return this.orientation(); } @@ -675,59 +612,4 @@ export class OtpInputComponent implements ControlValueAccessor, OnInit, OtpInput originalEvent: event, }; } - - private emitStateChanges(): void { - this.stateVersion.update((v) => v + 1); - this.stateChangesSubject.next(); - } -} - -class OtpInputFieldControlAdapter implements SanringFieldControl { - readonly controlType = FieldType.otpInput; - - constructor(private readonly host: OtpInputComponent) {} - - get id(): string { - return this.host.id(); - } - - get value(): OtpInputValue { - return this.host.fieldValue; - } - - get empty(): boolean { - return this.host.fieldEmpty; - } - - get focused(): boolean { - return this.host.focused; - } - - get errorState(): boolean { - return this.host.errorState; - } - - get disabled(): boolean { - return this.host.fieldDisabled; - } - - get required(): boolean { - return this.host.fieldRequired; - } - - get ngControl(): NgControl | null { - return this.host.ngControl; - } - - get stateChanges(): Observable { - return this.host.stateChanges; - } - - focus(options?: FocusOptions): void { - this.host.focus(options); - } - - setDescribedByIds(ids: string[]): void { - this.host.setDescribedByIds(ids); - } } diff --git a/packages/ui/src/lib/components/radio/index.ts b/packages/ui/src/lib/components/radio/index.ts index 9a4c0462..1d69001a 100644 --- a/packages/ui/src/lib/components/radio/index.ts +++ b/packages/ui/src/lib/components/radio/index.ts @@ -5,7 +5,4 @@ export * from './radio.types'; import { RadioGroupComponent } from './radio-group.component'; import { RadioItemComponent } from './radio-item.component'; -export const SANRING_RADIO_IMPORTS = [ - RadioGroupComponent, - RadioItemComponent, -]; +export const SANRING_RADIO_IMPORTS = [RadioGroupComponent, RadioItemComponent]; diff --git a/packages/ui/src/lib/components/radio/radio-group.component.spec.ts b/packages/ui/src/lib/components/radio/radio-group.component.spec.ts index 2c2c75c6..86b40b79 100644 --- a/packages/ui/src/lib/components/radio/radio-group.component.spec.ts +++ b/packages/ui/src/lib/components/radio/radio-group.component.spec.ts @@ -59,7 +59,9 @@ describe('RadioGroupComponent', () => { const fixture = TestBed.createComponent(RadioGroupTestHost); fixture.detectChanges(); - const items = fixture.nativeElement.querySelectorAll('sanring-radio-group')[0].querySelectorAll('[role="radio"]'); + const items = fixture.nativeElement + .querySelectorAll('sanring-radio-group')[0] + .querySelectorAll('[role="radio"]'); (items[1] as HTMLElement).click(); fixture.detectChanges(); @@ -74,7 +76,9 @@ describe('RadioGroupComponent', () => { const fixture = TestBed.createComponent(RadioGroupTestHost); fixture.detectChanges(); - const items = fixture.nativeElement.querySelectorAll('sanring-radio-group')[0].querySelectorAll('[role="radio"]'); + const items = fixture.nativeElement + .querySelectorAll('sanring-radio-group')[0] + .querySelectorAll('[role="radio"]'); expect(items[0].getAttribute('tabindex')).toBe('0'); (items[2] as HTMLElement).click(); @@ -95,9 +99,9 @@ describe('RadioGroupComponent', () => { (items[0] as HTMLElement).dispatchEvent(new FocusEvent('focus')); fixture.detectChanges(); - group.querySelector('[role="radiogroup"]').dispatchEvent( - new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }), - ); + group + .querySelector('[role="radiogroup"]') + .dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); fixture.detectChanges(); expect(items[1].getAttribute('aria-checked')).toBe('true'); diff --git a/packages/ui/src/lib/components/radio/radio-group.component.ts b/packages/ui/src/lib/components/radio/radio-group.component.ts index dfbeaa46..1bbda299 100644 --- a/packages/ui/src/lib/components/radio/radio-group.component.ts +++ b/packages/ui/src/lib/components/radio/radio-group.component.ts @@ -1,9 +1,6 @@ import { ChangeDetectionStrategy, Component, - DestroyRef, - Injector, - OnInit, booleanAttribute, computed, contentChildren, @@ -15,11 +12,10 @@ import { signal, } from '@angular/core'; import { _IdGenerator } from '@angular/cdk/a11y'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { ControlValueAccessor, NG_VALUE_ACCESSOR, NgControl, Validators } from '@angular/forms'; -import { Observable, Subject } from 'rxjs'; +import { NG_VALUE_ACCESSOR } from '@angular/forms'; import { cn } from '../../utils'; -import { FieldType, SanringFieldControl, SANRING_FIELD_CONTROL } from '../field/field.type'; +import { FieldType, SANRING_FIELD_CONTROL } from '../field/field.type'; +import { SanringCvaBase, SanringFieldControlAdapter } from '../shared/cva-base'; import { RadioOrientation, RadioValue } from './radio.types'; import { RadioItemComponent } from './radio-item.component'; @@ -37,7 +33,8 @@ import { RadioItemComponent } from './radio-item.component'; // eslint 規則禁止用 alias 改名,所以改用 useFactory 產生轉接的 adapter 物件。 { provide: SANRING_FIELD_CONTROL, - useFactory: (host: RadioGroupComponent) => new RadioGroupFieldControlAdapter(host), + useFactory: (host: RadioGroupComponent) => + new SanringFieldControlAdapter(FieldType.radioGroup, host), deps: [forwardRef(() => RadioGroupComponent)], }, ], @@ -63,7 +60,7 @@ import { RadioItemComponent } from './radio-item.component';
`, }) -export class RadioGroupComponent implements ControlValueAccessor, OnInit { +export class RadioGroupComponent extends SanringCvaBase { readonly class = input(); readonly id = input(inject(_IdGenerator).getId('sanring-radio-group-', true)); readonly name = input(inject(_IdGenerator).getId('sanring-radio-group-', true)); @@ -87,7 +84,6 @@ export class RadioGroupComponent implements ControlValueAccessor, OnInit { ); private readonly _items = contentChildren(RadioItemComponent, { descendants: true }); - private readonly disabledState = signal(false); private _focusedItem: RadioItemComponent | null = null; readonly activeTabItem = computed(() => { @@ -95,32 +91,9 @@ export class RadioGroupComponent implements ControlValueAccessor, OnInit { return items.find((i) => i.value() === this.valueSignal()) ?? items[0] ?? null; }); - // ========================================== - // Field 整合:controlType/focused/ngControl/stateChanges 不會撞名,直接放元件本身; - // value/required/disabled 走下面的 fieldXxx getter,由 RadioGroupFieldControlAdapter 轉接。 - // ========================================== - readonly controlType = FieldType.radioGroup; - focused = false; - ngControl: NgControl | null = null; - - private readonly injector = inject(Injector); - private readonly destroyRef = inject(DestroyRef); - private readonly stateChangesSubject = new Subject(); - readonly stateChanges: Observable = this.stateChangesSubject.asObservable(); - private readonly stateVersion = signal(0); - private readonly fieldDescribedByIds = signal([]); - - protected readonly computedAriaDescribedBy = computed(() => { - const ids = [this.ariaDescribedBy(), ...this.fieldDescribedByIds()].filter( - (v): v is string => !!v, - ); - return ids.length ? ids.join(' ') : undefined; - }); - - get errorState(): boolean { - this.stateVersion(); - return !!(this.ngControl?.invalid && this.ngControl?.touched); - } + protected readonly computedAriaDescribedBy = this.makeComputedAriaDescribedBy( + this.ariaDescribedBy, + ); get fieldValue(): RadioValue | null { return this.valueSignal(); @@ -134,30 +107,15 @@ export class RadioGroupComponent implements ControlValueAccessor, OnInit { return this.isDisabled(); } - get fieldRequired(): boolean { - this.stateVersion(); - return this.required() || !!this.ngControl?.control?.hasValidator(Validators.required); + protected override hasInputRequired(): boolean { + return this.required(); } constructor() { + super(); effect(() => { this.valueSignal.set(this.value()); }); - - this.destroyRef.onDestroy(() => this.stateChangesSubject.complete()); - } - - ngOnInit(): void { - // 跟 checkbox 一樣的原因:constructor 階段 self-inject NgControl 會跟 NgModel 搭配時 - // 觸發 NG0200 循環依賴,延後到 ngOnInit 才拿。 - this.ngControl = this.injector.get(NgControl, null, { optional: true, self: true }); - // 不能只聽 statusChanges——那個 Observable 只在 valid/invalid/pending/disabled 這幾種 - // status 真的變動時才會 emit,markAsTouched() 純粹改 touched flag,不會觸發它,導致 - // 外部呼叫 markAllAsTouched() 時錯誤訊息不會跳出來。改聽 control.events(Angular v18+ - // 公開 API),touched/pristine/status/value 任何一種變化都會經過這裡。 - this.ngControl?.control?.events - ?.pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(() => this.emitStateChanges()); } setFocusedItem(item: RadioItemComponent): void { @@ -170,10 +128,6 @@ export class RadioGroupComponent implements ControlValueAccessor, OnInit { (this.activeTabItem() ?? this._items()[0])?.focusOnly(options); } - setDescribedByIds(ids: string[]): void { - this.fieldDescribedByIds.set(ids); - } - updateValue(newValue: RadioValue): void { if (this.isDisabled()) return; this.valueSignal.set(newValue); @@ -225,78 +179,7 @@ export class RadioGroupComponent implements ControlValueAccessor, OnInit { } } - private onChange: (value: RadioValue | null) => void = () => {}; - private onTouched: () => void = () => {}; - - writeValue(val: RadioValue | null): void { + override writeValue(val: RadioValue | null): void { this.valueSignal.set(val); } - - registerOnChange(fn: (value: RadioValue | null) => void): void { - this.onChange = fn; - } - - registerOnTouched(fn: () => void): void { - this.onTouched = fn; - } - - setDisabledState(isDisabled: boolean): void { - this.disabledState.set(isDisabled); - this.emitStateChanges(); - } - - private emitStateChanges(): void { - this.stateVersion.update((v) => v + 1); - this.stateChangesSubject.next(); - } -} - -class RadioGroupFieldControlAdapter implements SanringFieldControl { - readonly controlType = FieldType.radioGroup; - - constructor(private readonly host: RadioGroupComponent) {} - - get id(): string { - return this.host.id(); - } - - get value(): RadioValue | null { - return this.host.fieldValue; - } - - get empty(): boolean { - return this.host.fieldEmpty; - } - - get focused(): boolean { - return this.host.focused; - } - - get errorState(): boolean { - return this.host.errorState; - } - - get disabled(): boolean { - return this.host.fieldDisabled; - } - - get required(): boolean { - return this.host.fieldRequired; - } - - get ngControl(): NgControl | null { - return this.host.ngControl; - } - - get stateChanges(): Observable { - return this.host.stateChanges; - } - - focus(options?: FocusOptions): void { - this.host.focus(options); - } - - setDescribedByIds(ids: string[]): void { - this.host.setDescribedByIds(ids); - } } diff --git a/packages/ui/src/lib/components/radio/radio-group.field.spec.ts b/packages/ui/src/lib/components/radio/radio-group.field.spec.ts index 63136d3e..39e96ddf 100644 --- a/packages/ui/src/lib/components/radio/radio-group.field.spec.ts +++ b/packages/ui/src/lib/components/radio/radio-group.field.spec.ts @@ -7,7 +7,13 @@ import { RadioGroupComponent } from './radio-group.component'; import { RadioItemComponent } from './radio-item.component'; @Component({ - imports: [SanringFieldComponent, ErrorMessageComponent, RadioGroupComponent, RadioItemComponent, ReactiveFormsModule], + imports: [ + SanringFieldComponent, + ErrorMessageComponent, + RadioGroupComponent, + RadioItemComponent, + ReactiveFormsModule, + ], template: ` diff --git a/packages/ui/src/lib/components/radio/radio-item.component.ts b/packages/ui/src/lib/components/radio/radio-item.component.ts index 76846e35..801acff2 100644 --- a/packages/ui/src/lib/components/radio/radio-item.component.ts +++ b/packages/ui/src/lib/components/radio/radio-item.component.ts @@ -2,11 +2,11 @@ import { ChangeDetectionStrategy, Component, ElementRef, - ViewChild, booleanAttribute, computed, inject, input, + viewChild, } from '@angular/core'; import { _IdGenerator } from '@angular/cdk/a11y'; import { LucideCircle } from '@lucide/angular'; @@ -65,7 +65,7 @@ export class RadioItemComponent { readonly ariaLabelledBy = input(); readonly ariaDescribedBy = input(); - @ViewChild('btn') private btnRef!: ElementRef; + private readonly btnRef = viewChild.required>('btn'); protected readonly group = inject(RadioGroupComponent, { optional: true }); @@ -92,12 +92,12 @@ export class RadioItemComponent { } focusAndSelect(): void { - this.btnRef?.nativeElement.focus(); + this.btnRef().nativeElement.focus(); this.select(); } focusOnly(options?: FocusOptions): void { - this.btnRef?.nativeElement.focus(options); + this.btnRef().nativeElement.focus(options); } onFocus(): void { diff --git a/packages/ui/src/lib/components/shared/cva-base.ts b/packages/ui/src/lib/components/shared/cva-base.ts new file mode 100644 index 00000000..725b9f80 --- /dev/null +++ b/packages/ui/src/lib/components/shared/cva-base.ts @@ -0,0 +1,178 @@ +import { + DestroyRef, + Directive, + Injector, + OnInit, + Signal, + computed, + inject, + signal, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ControlValueAccessor, NgControl, Validators } from '@angular/forms'; +import { Observable, Subject } from 'rxjs'; +import { FieldType, SanringFieldControl } from '../field/field.type'; + +// Abstract base class for all Sanring form components. Holds the shared +// ControlValueAccessor plumbing, Field integration state, and the bridge +// that keeps errorState/fieldRequired reactive under OnPush change detection. +// +// Why ngOnInit and not a constructor inject for NgControl: components register +// themselves via NG_VALUE_ACCESSOR (forwardRef). Self-injecting NgControl in the +// constructor while also being a NG_VALUE_ACCESSOR causes NG0200 with NgModel +// (NgModel needs to resolve its value accessor — i.e. us — before we exist). +// Deferring to ngOnInit avoids the cycle because Angular finishes constructing +// all directives on the same node before firing lifecycle hooks. +// +// Why control.events and not statusChanges: markAsTouched() does not emit on +// statusChanges (only valid/invalid/pending/disabled transitions do), so an +// external markAllAsTouched() call would not update errorState. control.events +// (Angular v18+) fires for every state mutation including touched changes. +@Directive() +export abstract class SanringCvaBase implements ControlValueAccessor, OnInit { + focused = false; + ngControl: NgControl | null = null; + + protected readonly _injector = inject(Injector); + private readonly _destroyRef = inject(DestroyRef); + private readonly _stateChangesSubject = new Subject(); + readonly stateChanges: Observable = this._stateChangesSubject.asObservable(); + protected readonly _stateVersion = signal(0); + private readonly _fieldDescribedByIds = signal([]); + protected readonly disabledState = signal(false); + + protected onChange: (value: T) => void = () => {}; + protected onTouched: () => void = () => {}; + + constructor() { + this._destroyRef.onDestroy(() => this._stateChangesSubject.complete()); + } + + get errorState(): boolean { + this._stateVersion(); + return !!(this.ngControl?.invalid && this.ngControl?.touched); + } + + get fieldRequired(): boolean { + this._stateVersion(); + return this.hasInputRequired() || !!this.ngControl?.control?.hasValidator(Validators.required); + } + + // Override in subclasses that expose a `required` input. + protected hasInputRequired(): boolean { + return false; + } + + // Builds a computed that merges an optional component-level ariaDescribedBy + // input with any IDs injected by a parent Field via setDescribedByIds(). + protected makeComputedAriaDescribedBy(ariaDescribedBy?: Signal) { + return computed(() => { + const ids = [ariaDescribedBy?.(), ...this._fieldDescribedByIds()].filter( + (v): v is string => !!v, + ); + return ids.length ? ids.join(' ') : undefined; + }); + } + + ngOnInit(): void { + this.ngControl = this._injector.get(NgControl, null, { optional: true, self: true }); + this.ngControl?.control?.events + ?.pipe(takeUntilDestroyed(this._destroyRef)) + .subscribe(() => this.emitStateChanges()); + } + + onFocus(): void { + this.focused = true; + this.emitStateChanges(); + } + + onBlur(): void { + this.focused = false; + this.onTouched(); + this.emitStateChanges(); + } + + setDescribedByIds(ids: string[]): void { + this._fieldDescribedByIds.set(ids); + } + + registerOnChange(fn: (value: T) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabledState.set(isDisabled); + this.emitStateChanges(); + } + + protected emitStateChanges(): void { + this._stateVersion.update((v) => v + 1); + this._stateChangesSubject.next(); + } + + abstract writeValue(value: T): void; +} + +// The component classes cannot directly implement SanringFieldControl because +// SanringFieldControl requires id/disabled/value/required as plain properties, +// which would collide with same-named Angular @Input() signals. This adapter +// bridges the gap: it holds a typed reference to the host and delegates every +// SanringFieldControl accessor through the host's fieldXxx getters. +interface SanringCvaHost { + id(): string; + fieldValue: T | null; + fieldEmpty: boolean; + focused: boolean; + errorState: boolean; + fieldDisabled: boolean; + fieldRequired: boolean; + ngControl: NgControl | null; + stateChanges: Observable; + focus(options?: FocusOptions): void; + setDescribedByIds(ids: string[]): void; +} + +export class SanringFieldControlAdapter implements SanringFieldControl { + constructor( + readonly controlType: FieldType, + private readonly host: SanringCvaHost, + ) {} + + get id(): string { + return this.host.id(); + } + get value(): T | null { + return this.host.fieldValue; + } + get empty(): boolean { + return this.host.fieldEmpty; + } + get focused(): boolean { + return this.host.focused; + } + get errorState(): boolean { + return this.host.errorState; + } + get disabled(): boolean { + return this.host.fieldDisabled; + } + get required(): boolean { + return this.host.fieldRequired; + } + get ngControl(): NgControl | null { + return this.host.ngControl; + } + get stateChanges(): Observable { + return this.host.stateChanges; + } + focus(options?: FocusOptions): void { + this.host.focus(options); + } + setDescribedByIds(ids: string[]): void { + this.host.setDescribedByIds(ids); + } +} diff --git a/packages/ui/src/lib/components/slider/slider.component.ts b/packages/ui/src/lib/components/slider/slider.component.ts index ffd1f7fc..cc79240e 100644 --- a/packages/ui/src/lib/components/slider/slider.component.ts +++ b/packages/ui/src/lib/components/slider/slider.component.ts @@ -1,10 +1,7 @@ import { ChangeDetectionStrategy, Component, - DestroyRef, ElementRef, - Injector, - OnInit, booleanAttribute, computed, effect, @@ -17,12 +14,11 @@ import { untracked, } from '@angular/core'; import { _IdGenerator } from '@angular/cdk/a11y'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { ControlValueAccessor, NG_VALUE_ACCESSOR, NgControl, Validators } from '@angular/forms'; -import { Observable, Subject } from 'rxjs'; +import { NG_VALUE_ACCESSOR } from '@angular/forms'; import { cn } from '../../utils'; import { SELECTION_CONTROL_FOCUS_CLASS } from '../component-styles'; -import { FieldType, SanringFieldControl, SANRING_FIELD_CONTROL } from '../field/field.type'; +import { FieldType, SANRING_FIELD_CONTROL } from '../field/field.type'; +import { SanringCvaBase, SanringFieldControlAdapter } from '../shared/cva-base'; @Component({ selector: 'sanring-slider', @@ -37,7 +33,7 @@ import { FieldType, SanringFieldControl, SANRING_FIELD_CONTROL } from '../field/ // 跟 checkbox 一樣:id/disabled/value 已經被同名 @Input 佔用,改用 useFactory 轉接。 { provide: SANRING_FIELD_CONTROL, - useFactory: (host: SliderComponent) => new SliderFieldControlAdapter(host), + useFactory: (host: SliderComponent) => new SanringFieldControlAdapter(FieldType.slider, host), deps: [forwardRef(() => SliderComponent)], }, ], @@ -79,7 +75,7 @@ import { FieldType, SanringFieldControl, SANRING_FIELD_CONTROL } from '../field/ `, }) -export class SliderComponent implements ControlValueAccessor, OnInit { +export class SliderComponent extends SanringCvaBase { readonly class = input(); readonly id = input(inject(_IdGenerator).getId('sanring-slider-', true)); readonly min = input(0, { transform: numberAttribute }); @@ -126,52 +122,26 @@ export class SliderComponent implements ControlValueAccessor, OnInit { ); private readonly host = inject>(ElementRef); - private readonly disabledState = signal(false); private readonly dragging = signal(false); - private onChange: (value: number) => void = () => {}; - private onTouched: () => void = () => {}; - // ========================================== - // Field 整合:id/disabled/value 已經被同名 @Input 佔用,走下面的 fieldXxx getter, - // 由 SliderFieldControlAdapter 轉接成 SanringFieldControl 介面。 - // ========================================== - readonly controlType = FieldType.slider; - focused = false; - ngControl: NgControl | null = null; - - private readonly injector = inject(Injector); - private readonly destroyRef = inject(DestroyRef); - private readonly stateChangesSubject = new Subject(); - readonly stateChanges: Observable = this.stateChangesSubject.asObservable(); - private readonly stateVersion = signal(0); - private readonly fieldDescribedByIds = signal([]); - - protected readonly computedAriaDescribedBy = computed(() => { - const ids = [this.ariaDescribedBy(), ...this.fieldDescribedByIds()].filter( - (v): v is string => !!v, - ); - return ids.length ? ids.join(' ') : undefined; - }); - - get errorState(): boolean { - this.stateVersion(); - return !!(this.ngControl?.invalid && this.ngControl?.touched); - } + protected readonly computedAriaDescribedBy = this.makeComputedAriaDescribedBy( + this.ariaDescribedBy, + ); get fieldValue(): number { return this.valueSignal(); } - get fieldDisabled(): boolean { - return this.isDisabled(); + get fieldEmpty(): boolean { + return false; } - get fieldRequired(): boolean { - this.stateVersion(); - return !!this.ngControl?.control?.hasValidator(Validators.required); + get fieldDisabled(): boolean { + return this.isDisabled(); } constructor() { + super(); effect(() => { const value = this.value(); this.min(); @@ -179,34 +149,12 @@ export class SliderComponent implements ControlValueAccessor, OnInit { this.step(); untracked(() => this.setValue(value, false)); }); - - this.destroyRef.onDestroy(() => this.stateChangesSubject.complete()); - } - - ngOnInit(): void { - // 跟 checkbox 一樣的原因:constructor 階段 self-inject NgControl 會跟 NgModel 搭配時 - // 觸發 NG0200 循環依賴(本元件同時透過 NG_VALUE_ACCESSOR 註冊自己),延後到 ngOnInit 才拿。 - this.ngControl = this.injector.get(NgControl, null, { optional: true, self: true }); - // 不能只聽 statusChanges——markAsTouched() 不會觸發它,改聽 control.events(Angular - // v18+ 公開 API)才能在 markAllAsTouched() 這類外部呼叫時正確更新 errorState。 - this.ngControl?.control?.events - ?.pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(() => this.emitStateChanges()); - } - - onFocus(): void { - this.focused = true; - this.emitStateChanges(); } focus(options?: FocusOptions): void { this.host.nativeElement.focus(options); } - setDescribedByIds(ids: string[]): void { - this.fieldDescribedByIds.set(ids); - } - onKeydown(event: KeyboardEvent): void { if (this.isDisabled()) return; @@ -272,23 +220,10 @@ export class SliderComponent implements ControlValueAccessor, OnInit { this.emitStateChanges(); } - writeValue(value: number | null | undefined): void { + override writeValue(value: number | null | undefined): void { this.setValue(value ?? this.minValue(), false); } - registerOnChange(fn: (value: number) => void): void { - this.onChange = fn; - } - - registerOnTouched(fn: () => void): void { - this.onTouched = fn; - } - - setDisabledState(isDisabled: boolean): void { - this.disabledState.set(isDisabled); - this.emitStateChanges(); - } - private setValueFromPointer(event: PointerEvent): void { const rect = this.host.nativeElement.getBoundingClientRect(); if (rect.width <= 0) return; @@ -322,60 +257,4 @@ export class SliderComponent implements ControlValueAccessor, OnInit { const step = this.step(); return Number.isFinite(step) && step > 0 ? step : 1; } - - private emitStateChanges(): void { - this.stateVersion.update((v) => v + 1); - this.stateChangesSubject.next(); - } -} - -class SliderFieldControlAdapter implements SanringFieldControl { - readonly controlType = FieldType.slider; - - constructor(private readonly host: SliderComponent) {} - - get id(): string { - return this.host.id(); - } - - get value(): number { - return this.host.fieldValue; - } - - // 滑桿一定有一個數值,不存在「空值」的狀態 - get empty(): boolean { - return false; - } - - get focused(): boolean { - return this.host.focused; - } - - get errorState(): boolean { - return this.host.errorState; - } - - get disabled(): boolean { - return this.host.fieldDisabled; - } - - get required(): boolean { - return this.host.fieldRequired; - } - - get ngControl(): NgControl | null { - return this.host.ngControl; - } - - get stateChanges(): Observable { - return this.host.stateChanges; - } - - focus(options?: FocusOptions): void { - this.host.focus(options); - } - - setDescribedByIds(ids: string[]): void { - this.host.setDescribedByIds(ids); - } } diff --git a/packages/ui/src/lib/components/switch/switch.component.ts b/packages/ui/src/lib/components/switch/switch.component.ts index acde30e4..ba1f6f81 100644 --- a/packages/ui/src/lib/components/switch/switch.component.ts +++ b/packages/ui/src/lib/components/switch/switch.component.ts @@ -2,10 +2,7 @@ import { ChangeDetectionStrategy, booleanAttribute, Component, - DestroyRef, ElementRef, - Injector, - OnInit, ViewChild, computed, effect, @@ -16,12 +13,11 @@ import { signal, } from '@angular/core'; import { _IdGenerator } from '@angular/cdk/a11y'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { ControlValueAccessor, NG_VALUE_ACCESSOR, NgControl, Validators } from '@angular/forms'; -import { Observable, Subject } from 'rxjs'; +import { NG_VALUE_ACCESSOR } from '@angular/forms'; import { cn } from '../../utils'; import { SELECTION_CONTROL_FOCUS_CLASS } from '../component-styles'; -import { FieldType, SanringFieldControl, SANRING_FIELD_CONTROL } from '../field/field.type'; +import { FieldType, SANRING_FIELD_CONTROL } from '../field/field.type'; +import { SanringCvaBase, SanringFieldControlAdapter } from '../shared/cva-base'; import { SWITCH_THUMB_SIZE_CLASSES, SWITCH_THUMB_TRANSLATE_CLASSES, @@ -45,7 +41,7 @@ import { SwitchSize } from './switch.type'; // 跟 checkbox 一樣:id/disabled 已經被同名 @Input 佔用,改用 useFactory 轉接。 { provide: SANRING_FIELD_CONTROL, - useFactory: (host: SwitchComponent) => new SwitchFieldControlAdapter(host), + useFactory: (host: SwitchComponent) => new SanringFieldControlAdapter(FieldType.switch, host), deps: [forwardRef(() => SwitchComponent)], }, ], @@ -85,7 +81,7 @@ import { SwitchSize } from './switch.type'; `, }) -export class SwitchComponent implements ControlValueAccessor, OnInit { +export class SwitchComponent extends SanringCvaBase { readonly class = input(); readonly id = input(inject(_IdGenerator).getId('sanring-switch-', true)); readonly checked = input(false, { transform: booleanAttribute }); @@ -118,37 +114,9 @@ export class SwitchComponent implements ControlValueAccessor, OnInit { ), ); - private readonly disabledState = signal(false); - private onChange: (value: boolean) => void = () => {}; - private onTouched: () => void = () => {}; - - // ========================================== - // Field 整合:id/disabled 已經被同名 @Input 佔用,走下面的 fieldXxx getter, - // 由 SwitchFieldControlAdapter 轉接成 SanringFieldControl 介面。 - // ========================================== - readonly controlType = FieldType.switch; - focused = false; - ngControl: NgControl | null = null; - - private readonly injector = inject(Injector); - private readonly destroyRef = inject(DestroyRef); - @ViewChild('btn') private btnRef!: ElementRef; - private readonly stateChangesSubject = new Subject(); - readonly stateChanges: Observable = this.stateChangesSubject.asObservable(); - private readonly stateVersion = signal(0); - private readonly fieldDescribedByIds = signal([]); - - protected readonly computedAriaDescribedBy = computed(() => { - const ids = [...this.fieldDescribedByIds()].filter((v): v is string => !!v); - return ids.length ? ids.join(' ') : undefined; - }); - - get errorState(): boolean { - this.stateVersion(); - return !!(this.ngControl?.invalid && this.ngControl?.touched); - } + protected readonly computedAriaDescribedBy = this.makeComputedAriaDescribedBy(); get fieldValue(): boolean { return this.checkedSignal(); @@ -162,28 +130,11 @@ export class SwitchComponent implements ControlValueAccessor, OnInit { return this.isDisabled(); } - get fieldRequired(): boolean { - this.stateVersion(); - return !!this.ngControl?.control?.hasValidator(Validators.required); - } - constructor() { + super(); effect(() => { this.checkedSignal.set(this.checked()); }); - - this.destroyRef.onDestroy(() => this.stateChangesSubject.complete()); - } - - ngOnInit(): void { - // 跟 checkbox 一樣的原因:constructor 階段 self-inject NgControl 會跟 NgModel 搭配時 - // 觸發 NG0200 循環依賴(本元件同時透過 NG_VALUE_ACCESSOR 註冊自己),延後到 ngOnInit 才拿。 - this.ngControl = this.injector.get(NgControl, null, { optional: true, self: true }); - // 不能只聽 statusChanges——markAsTouched() 不會觸發它,改聽 control.events(Angular - // v18+ 公開 API)才能在 markAllAsTouched() 這類外部呼叫時正確更新 errorState。 - this.ngControl?.control?.events - ?.pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(() => this.emitStateChanges()); } toggle() { @@ -195,94 +146,11 @@ export class SwitchComponent implements ControlValueAccessor, OnInit { this.emitStateChanges(); } - onFocus() { - this.focused = true; - this.emitStateChanges(); - } - - onBlur() { - this.focused = false; - this.onTouched(); - this.emitStateChanges(); - } - focus(options?: FocusOptions): void { this.btnRef?.nativeElement.focus(options); } - setDescribedByIds(ids: string[]): void { - this.fieldDescribedByIds.set(ids); - } - - writeValue(value: boolean | null | undefined): void { + override writeValue(value: boolean | null | undefined): void { this.checkedSignal.set(!!value); } - - registerOnChange(fn: (value: boolean) => void): void { - this.onChange = fn; - } - - registerOnTouched(fn: () => void): void { - this.onTouched = fn; - } - - setDisabledState(isDisabled: boolean): void { - this.disabledState.set(isDisabled); - this.emitStateChanges(); - } - - private emitStateChanges(): void { - this.stateVersion.update((v) => v + 1); - this.stateChangesSubject.next(); - } -} - -class SwitchFieldControlAdapter implements SanringFieldControl { - readonly controlType = FieldType.switch; - - constructor(private readonly host: SwitchComponent) {} - - get id(): string { - return this.host.id(); - } - - get value(): boolean { - return this.host.fieldValue; - } - - get empty(): boolean { - return this.host.fieldEmpty; - } - - get focused(): boolean { - return this.host.focused; - } - - get errorState(): boolean { - return this.host.errorState; - } - - get disabled(): boolean { - return this.host.fieldDisabled; - } - - get required(): boolean { - return this.host.fieldRequired; - } - - get ngControl(): NgControl | null { - return this.host.ngControl; - } - - get stateChanges(): Observable { - return this.host.stateChanges; - } - - focus(options?: FocusOptions): void { - this.host.focus(options); - } - - setDescribedByIds(ids: string[]): void { - this.host.setDescribedByIds(ids); - } } diff --git a/registry/components/radio/index.ts b/registry/components/radio/index.ts index 9a4c0462..1d69001a 100644 --- a/registry/components/radio/index.ts +++ b/registry/components/radio/index.ts @@ -5,7 +5,4 @@ export * from './radio.types'; import { RadioGroupComponent } from './radio-group.component'; import { RadioItemComponent } from './radio-item.component'; -export const SANRING_RADIO_IMPORTS = [ - RadioGroupComponent, - RadioItemComponent, -]; +export const SANRING_RADIO_IMPORTS = [RadioGroupComponent, RadioItemComponent]; diff --git a/registry/components/radio/radio-group.component.ts b/registry/components/radio/radio-group.component.ts index 4a13c0df..1ce0961c 100644 --- a/registry/components/radio/radio-group.component.ts +++ b/registry/components/radio/radio-group.component.ts @@ -33,7 +33,8 @@ import { RadioItemComponent } from './radio-item.component'; // eslint 規則禁止用 alias 改名,所以改用 useFactory 產生轉接的 adapter 物件。 { provide: SANRING_FIELD_CONTROL, - useFactory: (host: RadioGroupComponent) => new SanringFieldControlAdapter(FieldType.radioGroup, host), + useFactory: (host: RadioGroupComponent) => + new SanringFieldControlAdapter(FieldType.radioGroup, host), deps: [forwardRef(() => RadioGroupComponent)], }, ], @@ -90,7 +91,9 @@ export class RadioGroupComponent extends SanringCvaBase { return items.find((i) => i.value() === this.valueSignal()) ?? items[0] ?? null; }); - protected readonly computedAriaDescribedBy = this.makeComputedAriaDescribedBy(this.ariaDescribedBy); + protected readonly computedAriaDescribedBy = this.makeComputedAriaDescribedBy( + this.ariaDescribedBy, + ); get fieldValue(): RadioValue | null { return this.valueSignal(); diff --git a/registry/components/radio/radio-item.component.ts b/registry/components/radio/radio-item.component.ts index 87a59c60..0db247ef 100644 --- a/registry/components/radio/radio-item.component.ts +++ b/registry/components/radio/radio-item.component.ts @@ -2,11 +2,11 @@ import { ChangeDetectionStrategy, Component, ElementRef, - ViewChild, booleanAttribute, computed, inject, input, + viewChild, } from '@angular/core'; import { _IdGenerator } from '@angular/cdk/a11y'; import { LucideCircle } from '@lucide/angular'; @@ -65,7 +65,7 @@ export class RadioItemComponent { readonly ariaLabelledBy = input(); readonly ariaDescribedBy = input(); - @ViewChild('btn') private btnRef!: ElementRef; + private readonly btnRef = viewChild.required>('btn'); protected readonly group = inject(RadioGroupComponent, { optional: true }); @@ -92,12 +92,12 @@ export class RadioItemComponent { } focusAndSelect(): void { - this.btnRef?.nativeElement.focus(); + this.btnRef().nativeElement.focus(); this.select(); } focusOnly(options?: FocusOptions): void { - this.btnRef?.nativeElement.focus(options); + this.btnRef().nativeElement.focus(options); } onFocus(): void { diff --git a/registry/shared/cva-base.ts b/registry/shared/cva-base.ts index 4a6bf9c6..725b9f80 100644 --- a/registry/shared/cva-base.ts +++ b/registry/shared/cva-base.ts @@ -1,4 +1,13 @@ -import { DestroyRef, Injector, OnInit, Signal, computed, inject, signal } from '@angular/core'; +import { + DestroyRef, + Directive, + Injector, + OnInit, + Signal, + computed, + inject, + signal, +} from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ControlValueAccessor, NgControl, Validators } from '@angular/forms'; import { Observable, Subject } from 'rxjs'; @@ -19,6 +28,7 @@ import { FieldType, SanringFieldControl } from '../field/field.type'; // statusChanges (only valid/invalid/pending/disabled transitions do), so an // external markAllAsTouched() call would not update errorState. control.events // (Angular v18+) fires for every state mutation including touched changes. +@Directive() export abstract class SanringCvaBase implements ControlValueAccessor, OnInit { focused = false; ngControl: NgControl | null = null; @@ -132,15 +142,37 @@ export class SanringFieldControlAdapter implements SanringFieldControl { private readonly host: SanringCvaHost, ) {} - get id(): string { return this.host.id(); } - get value(): T | null { return this.host.fieldValue; } - get empty(): boolean { return this.host.fieldEmpty; } - get focused(): boolean { return this.host.focused; } - get errorState(): boolean { return this.host.errorState; } - get disabled(): boolean { return this.host.fieldDisabled; } - get required(): boolean { return this.host.fieldRequired; } - get ngControl(): NgControl | null { return this.host.ngControl; } - get stateChanges(): Observable { return this.host.stateChanges; } - focus(options?: FocusOptions): void { this.host.focus(options); } - setDescribedByIds(ids: string[]): void { this.host.setDescribedByIds(ids); } + get id(): string { + return this.host.id(); + } + get value(): T | null { + return this.host.fieldValue; + } + get empty(): boolean { + return this.host.fieldEmpty; + } + get focused(): boolean { + return this.host.focused; + } + get errorState(): boolean { + return this.host.errorState; + } + get disabled(): boolean { + return this.host.fieldDisabled; + } + get required(): boolean { + return this.host.fieldRequired; + } + get ngControl(): NgControl | null { + return this.host.ngControl; + } + get stateChanges(): Observable { + return this.host.stateChanges; + } + focus(options?: FocusOptions): void { + this.host.focus(options); + } + setDescribedByIds(ids: string[]): void { + this.host.setDescribedByIds(ids); + } } From 6978b39065388f57f4515dd07db8a5e7a5c0a694 Mon Sep 17 00:00:00 2001 From: jack755051 Date: Sat, 22 Aug 2026 01:28:30 +0800 Subject: [PATCH 5/9] =?UTF-8?q?fix(ui):=20headless=20a11y=20sweep=20?= =?UTF-8?q?=E2=80=94=20remaining=20P30=20findings=20across=2014=20componen?= =?UTF-8?q?ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes out the last 15 "suggested fix" items from the full-library headless audit (P30), touching avatar, breadcrumb, combobox, context-menu, date-picker, dialog/alert-dialog, field, hover-card, select, sheet, sidebar, transfer, and tree, with matching registry/ sources, component specs, and bilingual docs updates. Highlights: - avatar-group-count: disabled boolean coercion + click/keyboard guard. - breadcrumb: localizable ariaLabel. - field/select: consumer-settable id with fallback generation preserved. - dialog/alert-dialog/sheet: explicit-input -> config -> projected-title -> fallback relationship resolution, so a title-less alertdialog still gets an accessible name; signal contentChild + effect handles title being added/removed dynamically without leaving stale attributes. - hover-card: trigger/content linked via aria-controls/aria-expanded. - sidebar: default complementary role + overridable label; menu button/ action get role="button", tab stop, Enter/Space activation, disabled guard, and ignore key-repeat without narrowing their selectors. - combobox: overridable inputId/listId; focus returns to trigger/input after Escape or single-select completion, but not after an outside pointerdown close (so it doesn't steal focus from what the user just clicked). - date-picker: ariaLabel/ariaLabelledBy; disabled now also accepts a plain boolean (including a bare `disabled` attribute) alongside the existing day-matcher API, with selection guards and ARIA state kept consistent either way. - context-menu: single roving tab stop per menu level instead of every item being tabbable; Tab/Shift+Tab closes the whole menu tree and moves focus to the logical next/previous control in document order regardless of the CDK overlay being portaled to the end of . - transfer: each row now owns role="checkbox"/aria-checked/aria-disabled/ roving tabindex/click+Space directly instead of a row-click and a nested checkbox both trying to toggle state; panel supports ArrowUp/Down/Home/End, skipping disabled items. - tree: ariaLabel/ariaLabelledBy on the root, per-node disabled; child lookup now builds one parent map instead of rescanning all descendants per node (was O(n^2) on deep/large trees). - dropdown-menu is deliberately left alone: it already hand-rolls CDK overlay lifecycle because @angular/aria/menu's permanent DomPortal attach-once model conflicts with MenuOverlayController's own create/attach/detach — a documented, intentional divergence, not a correctness gap. Also fixes issues found during cross-review: a protected/public visibility drift on four hover-card-content template handlers between packages/ui and registry, a fully-duplicated onFocus/onBlur left over in registry's combobox after the CVA base migration, and a wrong --sanring-primary-foreground token in transfer (reverted to the existing --sanring-primary-fg). Co-Authored-By: Claude Sonnet 5 --- .../locales/en/components/alert-dialog.ts | 14 +- .../app/i18n/locales/en/components/avatar.ts | 14 +- .../i18n/locales/en/components/breadcrumb.ts | 9 +- .../i18n/locales/en/components/combobox.ts | 9 +- .../locales/en/components/context-menu.ts | 12 +- .../i18n/locales/en/components/date-picker.ts | 19 +- .../app/i18n/locales/en/components/dialog.ts | 8 +- .../app/i18n/locales/en/components/field.ts | 8 +- .../i18n/locales/en/components/hover-card.ts | 6 +- .../app/i18n/locales/en/components/select.ts | 9 +- .../app/i18n/locales/en/components/sheet.ts | 8 +- .../app/i18n/locales/en/components/sidebar.ts | 10 +- .../i18n/locales/en/components/transfer.ts | 9 +- .../app/i18n/locales/en/components/tree.ts | 10 +- .../locales/zh/components/alert-dialog.ts | 8 +- .../app/i18n/locales/zh/components/avatar.ts | 11 +- .../i18n/locales/zh/components/breadcrumb.ts | 5 +- .../i18n/locales/zh/components/combobox.ts | 6 +- .../locales/zh/components/context-menu.ts | 12 +- .../i18n/locales/zh/components/date-picker.ts | 12 +- .../app/i18n/locales/zh/components/dialog.ts | 8 +- .../app/i18n/locales/zh/components/field.ts | 8 +- .../i18n/locales/zh/components/hover-card.ts | 6 +- .../app/i18n/locales/zh/components/select.ts | 7 +- .../app/i18n/locales/zh/components/sheet.ts | 6 +- .../app/i18n/locales/zh/components/sidebar.ts | 11 +- .../i18n/locales/zh/components/transfer.ts | 8 +- .../app/i18n/locales/zh/components/tree.ts | 7 +- .../alert-dialog/alert-dialog.docs.ts | 18 ++ .../pages/components/avatar/avatar.docs.ts | 18 ++ .../breadcrumb/breadcrumb-page.component.ts | 10 +- .../components/breadcrumb/breadcrumb.docs.ts | 10 +- .../components/combobox/combobox.docs.ts | 20 +- .../context-menu/context-menu.docs.ts | 1 + .../date-picker/date-picker.docs.ts | 14 +- .../pages/components/dialog/dialog.docs.ts | 18 ++ .../app/pages/components/field/field.docs.ts | 8 +- .../components/hover-card/hover-card.docs.ts | 12 ++ .../app/pages/components/sheet/sheet.docs.ts | 39 +++- .../pages/components/sidebar/sidebar.docs.ts | 18 ++ .../components/transfer/transfer.docs.ts | 2 + .../components/tree/tree-page.component.ts | 4 +- .../app/pages/components/tree/tree.docs.ts | 23 ++- .../alert-dialog.component.spec.ts | 44 +++- .../avatar/avatar-group-count.component.ts | 23 ++- .../avatar/avatar.component.spec.ts | 22 +- .../breadcrumb/breadcrumb-link.component.ts | 5 +- .../breadcrumb/breadcrumb.component.spec.ts | 16 +- .../breadcrumb/breadcrumb.component.ts | 3 +- .../combobox/combobox-chip-input.component.ts | 9 +- .../combobox/combobox-content.component.ts | 11 +- .../combobox/combobox-empty.component.ts | 4 +- .../combobox/combobox-group.component.ts | 4 +- .../combobox/combobox-input.component.ts | 23 ++- .../combobox/combobox-label.component.ts | 2 +- .../combobox/combobox-list.component.ts | 2 +- .../combobox/combobox-separator.component.ts | 6 +- .../combobox/combobox-trigger.directive.ts | 4 +- .../combobox/combobox.component.spec.ts | 97 ++++++++- .../components/combobox/combobox.component.ts | 142 ++++--------- .../combobox/combobox.field.spec.ts | 8 +- .../context-menu-checkbox-item.component.ts | 2 +- .../context-menu-content.component.ts | 26 ++- .../context-menu-item.component.ts | 2 +- .../context-menu-label.component.ts | 8 +- .../context-menu-radio-item.component.ts | 2 +- .../context-menu-sub-content.component.ts | 27 ++- .../context-menu-sub-trigger.component.ts | 2 +- .../context-menu-trigger.directive.ts | 9 +- .../context-menu.component.spec.ts | 96 ++++++++- .../context-menu/context-menu.component.ts | 15 ++ .../date-picker/date-picker-cell.directive.ts | 16 +- .../date-picker/date-picker.component.spec.ts | 71 ++++++- .../date-picker/date-picker.component.ts | 193 +++++------------- .../dialog/dialog-content.component.ts | 108 +++++++--- .../dialog/dialog.component.spec.ts | 117 ++++++++++- .../components/field/field.component.spec.ts | 26 ++- .../lib/components/field/field.component.ts | 19 +- .../hover-card-content.component.ts | 14 +- .../hover-card-trigger.directive.ts | 1 + .../hover-card/hover-card.component.spec.ts | 5 + .../hover-card/hover-card.component.ts | 12 +- .../select/select-content.component.ts | 18 +- .../select/select-item.component.ts | 5 +- .../select/select-separator.component.ts | 5 +- .../select/select.component.spec.ts | 60 +++++- .../lib/components/select/select.component.ts | 9 +- .../components/select/select.field.spec.ts | 8 +- .../lib/components/shared/menu-navigation.ts | 93 ++++++++- .../sheet/sheet-content.component.ts | 66 +++--- .../sheet/sheet-description.component.ts | 4 +- .../sheet/sheet-footer.component.ts | 4 +- .../sheet/sheet-header.component.ts | 4 +- .../sheet/sheet-trigger.directive.ts | 2 +- .../components/sheet/sheet.component.spec.ts | 86 ++++++-- .../sidebar/sidebar-menu-action.directive.ts | 19 +- .../sidebar/sidebar-menu-button.directive.ts | 19 +- .../sidebar/sidebar.component.spec.ts | 59 +++++- .../components/sidebar/sidebar.component.ts | 10 +- .../transfer-header.component.spec.ts | 8 +- .../transfer/transfer-header.component.ts | 9 +- .../transfer/transfer-item.component.spec.ts | 11 +- .../transfer/transfer-item.component.ts | 56 +++-- .../transfer/transfer-list.component.ts | 26 ++- .../transfer/transfer-panel.component.spec.ts | 7 +- .../transfer/transfer-panel.component.ts | 25 ++- .../transfer/transfer.component.spec.ts | 34 ++- .../components/transfer/transfer.component.ts | 4 +- .../components/tree/tree-node.component.ts | 31 ++- .../components/tree/tree-trigger.directive.ts | 1 + .../components/tree/tree.component.spec.ts | 26 +++ .../src/lib/components/tree/tree.component.ts | 31 +++ .../avatar/avatar-group-count.component.ts | 23 ++- .../breadcrumb/breadcrumb-link.component.ts | 5 +- .../breadcrumb/breadcrumb.component.ts | 3 +- .../combobox/combobox-chip-input.component.ts | 9 +- .../combobox/combobox-content.component.ts | 11 +- .../combobox/combobox-empty.component.ts | 4 +- .../combobox/combobox-group.component.ts | 4 +- .../combobox/combobox-input.component.ts | 23 ++- .../combobox/combobox-label.component.ts | 2 +- .../combobox/combobox-list.component.ts | 2 +- .../combobox/combobox-separator.component.ts | 6 +- .../combobox/combobox-trigger.directive.ts | 4 +- .../components/combobox/combobox.component.ts | 53 ++--- .../context-menu-checkbox-item.component.ts | 2 +- .../context-menu-content.component.ts | 26 ++- .../context-menu-item.component.ts | 2 +- .../context-menu-label.component.ts | 8 +- .../context-menu-radio-item.component.ts | 2 +- .../context-menu-sub-content.component.ts | 27 ++- .../context-menu-sub-trigger.component.ts | 2 +- .../context-menu-trigger.directive.ts | 9 +- .../context-menu/context-menu.component.ts | 15 ++ .../date-picker/date-picker-cell.directive.ts | 16 +- .../date-picker/date-picker.component.ts | 56 +++-- .../dialog/dialog-content.component.ts | 108 +++++++--- registry/components/field/field.component.ts | 19 +- .../hover-card-content.component.ts | 6 + .../hover-card-trigger.directive.ts | 1 + .../hover-card/hover-card.component.ts | 12 +- .../select/select-content.component.ts | 18 +- .../select/select-item.component.ts | 5 +- .../select/select-separator.component.ts | 5 +- .../components/select/select.component.ts | 9 +- .../sheet/sheet-content.component.ts | 66 +++--- .../sheet/sheet-description.component.ts | 4 +- .../sheet/sheet-footer.component.ts | 4 +- .../sheet/sheet-header.component.ts | 4 +- .../sheet/sheet-trigger.directive.ts | 2 +- .../sidebar/sidebar-menu-action.directive.ts | 19 +- .../sidebar/sidebar-menu-button.directive.ts | 19 +- .../components/sidebar/sidebar.component.ts | 10 +- .../transfer/transfer-header.component.ts | 9 +- .../transfer/transfer-item.component.ts | 56 +++-- .../transfer/transfer-list.component.ts | 26 ++- .../transfer/transfer-panel.component.ts | 25 ++- .../components/transfer/transfer.component.ts | 4 +- .../components/tree/tree-node.component.ts | 31 ++- .../components/tree/tree-trigger.directive.ts | 1 + registry/components/tree/tree.component.ts | 48 ++++- registry/shared/menu-navigation.ts | 93 ++++++++- 162 files changed, 2516 insertions(+), 813 deletions(-) diff --git a/apps/docs/src/app/i18n/locales/en/components/alert-dialog.ts b/apps/docs/src/app/i18n/locales/en/components/alert-dialog.ts index 0211c629..67964e30 100644 --- a/apps/docs/src/app/i18n/locales/en/components/alert-dialog.ts +++ b/apps/docs/src/app/i18n/locales/en/components/alert-dialog.ts @@ -29,16 +29,24 @@ export const alertDialogTranslations = { 'Additional classes merged with `AlertDialogContent` layout styles.', 'alertDialog.api.showClose.description': 'Controls whether the built-in close button is rendered. Defaults to `false`, unlike Dialog.', + 'alertDialog.api.ariaLabel.description': + 'Accessible-name fallback used when no sanringDialogTitle is projected. Untitled content defaults to “Alert dialog”.', + 'alertDialog.api.ariaLabelledBy.description': + 'Ids of external elements that label the alert dialog. Takes precedence over the projected title and ariaLabel.', + 'alertDialog.api.ariaDescribedBy.description': + 'Ids of external elements that describe the alert dialog. Takes precedence over sanringDialogDescription.', 'alertDialog.api.action.description': 'Optional result value passed to `DialogRef.close()` when clicked. Defaults to `true`.', 'alertDialog.api.cancel.description': 'Optional result value passed to `DialogRef.close()` when clicked. Defaults to `false`.', 'alertDialog.accessibility.description': - "The dialog container receives role='alertdialog' and aria-modal='true' (enforced by AlertDialogService — callers cannot override these). Backdrop click and Escape are both disabled, requiring users to explicitly choose a confirm or cancel button.", + "The dialog container receives role='alertdialog', aria-modal='true', and an accessible name from its title, ariaLabel, or the 'Alert dialog' fallback. Backdrop click and Escape are both disabled, requiring an explicit choice.", 'alertDialog.keyboard.description': 'Focus is trapped inside the alert dialog while it is open.', 'alertDialog.keyboard.tab': 'Move focus to the next focusable element within the dialog.', - 'alertDialog.keyboard.shiftTab': 'Move focus to the previous focusable element within the dialog.', - 'alertDialog.keyboard.escape': 'Has no effect — backdrop dismiss and Escape are disabled by default to prevent accidental dismissal.', + 'alertDialog.keyboard.shiftTab': + 'Move focus to the previous focusable element within the dialog.', + 'alertDialog.keyboard.escape': + 'Has no effect — backdrop dismiss and Escape are disabled by default to prevent accidental dismissal.', 'alertDialog.stateModel.description': "Service-based. Call AlertDialogService.open(template, config) to open an alert dialog programmatically, or use [sanringAlertDialogTrigger] for template-driven use. The service always forces role='alertdialog' and disableClose:true — callers cannot opt out. Not a form control.", } as const; diff --git a/apps/docs/src/app/i18n/locales/en/components/avatar.ts b/apps/docs/src/app/i18n/locales/en/components/avatar.ts index 1245e179..fc34f50d 100644 --- a/apps/docs/src/app/i18n/locales/en/components/avatar.ts +++ b/apps/docs/src/app/i18n/locales/en/components/avatar.ts @@ -27,7 +27,15 @@ export const avatarTranslations = { 'Places the badge at the visual start or end edge, respecting RTL direction.', 'avatar.api.overlap.description': 'Stack overlap amount in rem for avatar groups.', 'avatar.api.count.description': 'Number displayed by the avatar group count item.', - 'avatar.accessibility.description': "role='img' on the host. Provide ariaLabel or ariaLabelledBy to name a non-decorative avatar. For purely decorative use — such as next to a user name already present in text — add aria-hidden='true' on to suppress redundant announcements.", - 'avatar.keyboard.description': 'Not focusable by default. No keyboard interaction.', - 'avatar.stateModel.description': 'Stateless. src loads the image; on failure the fallback slot renders; initials are a last resort. No internal selection or value state.', + 'avatar.api.clickable.description': + 'Gives the group count button semantics and enables pointer and keyboard activation.', + 'avatar.api.disabled.description': + 'Makes a clickable group count unavailable and removes it from the tab sequence.', + 'avatar.api.clicked.description': 'Emitted when an enabled clickable group count is activated.', + 'avatar.accessibility.description': + "role='img' on the host. Provide ariaLabel or ariaLabelledBy to name a non-decorative avatar. For purely decorative use — such as next to a user name already present in text — add aria-hidden='true' on to suppress redundant announcements. A clickable group count exposes button semantics and reflects disabled state.", + 'avatar.keyboard.description': + 'Avatars are not focusable by default. A clickable group count responds to Enter and Space.', + 'avatar.stateModel.description': + 'Stateless. src loads the image; on failure the fallback slot renders; initials are a last resort. No internal selection or value state.', } as const; diff --git a/apps/docs/src/app/i18n/locales/en/components/breadcrumb.ts b/apps/docs/src/app/i18n/locales/en/components/breadcrumb.ts index e103e03d..6bf965c8 100644 --- a/apps/docs/src/app/i18n/locales/en/components/breadcrumb.ts +++ b/apps/docs/src/app/i18n/locales/en/components/breadcrumb.ts @@ -14,15 +14,20 @@ export const breadcrumbTranslations = { 'breadcrumb.demo.divider': 'Divider', 'breadcrumb.demo.withEllipsis': 'With Ellipsis', 'breadcrumb.demo.customDivider': 'Custom Divider', + 'breadcrumb.demo.ariaLabel': 'Breadcrumb', 'breadcrumb.api.description': 'Inputs supported by the breadcrumb primitives.', + 'breadcrumb.api.ariaLabel.description': + 'Accessible name for the breadcrumb navigation; override it for localization or page-specific context.', 'breadcrumb.api.type.description': "Divider icon: 'chevron' (default, ›) or 'dot' (·). Pass custom content via ng-content to override entirely.", 'breadcrumb.api.routerLink.description': 'Angular RouterLink value passed to the inner anchor element.', 'breadcrumb.api.class.description': 'Additional classes merged onto the host element.', - 'breadcrumb.accessibility.description': "role='navigation' and aria-label='breadcrumb' on the host element. Mark the current page with aria-current='page' on the element so screen readers announce the user's current location.", + 'breadcrumb.accessibility.description': + "The host uses role='navigation'; set ariaLabel to a localized or context-specific accessible name. Mark the current page with aria-current='page' on so screen readers announce the user's current location.", 'breadcrumb.keyboard.description': 'Standard anchor keyboard behavior.', 'breadcrumb.keyboard.tab': 'Navigate forward through breadcrumb links.', 'breadcrumb.keyboard.shiftTab': 'Navigate backward through breadcrumb links.', - 'breadcrumb.stateModel.description': 'Stateless — items are rendered via template projection. No internal selection state.', + 'breadcrumb.stateModel.description': + 'Stateless — items are rendered via template projection. No internal selection state.', } as const; diff --git a/apps/docs/src/app/i18n/locales/en/components/combobox.ts b/apps/docs/src/app/i18n/locales/en/components/combobox.ts index 5d566a3c..7518aced 100644 --- a/apps/docs/src/app/i18n/locales/en/components/combobox.ts +++ b/apps/docs/src/app/i18n/locales/en/components/combobox.ts @@ -38,6 +38,10 @@ export const comboboxTranslations = { 'Allows selecting more than one item and pairing the field with chips.', 'combobox.api.disabled.description': 'Disables the combobox input and prevents selection changes.', + 'combobox.api.inputId.description': + 'ID shared by the input or custom trigger and its label. Generated when omitted and overridable for app-level associations.', + 'combobox.api.listId.description': + 'ID of the listbox referenced by aria-controls. Generated when omitted and overridable when integrating external ARIA relationships.', 'combobox.api.placeholder.description': 'Placeholder text for the combobox input. Pass this from i18n in app code.', 'combobox.api.showClear.description': @@ -52,11 +56,12 @@ export const comboboxTranslations = { 'Additional classes merged with the corresponding combobox primitive.', 'combobox.accessibility.description': "The input element has role='combobox', aria-expanded, aria-controls pointing to the list id, and aria-autocomplete='list'. The list container has role='listbox' and each visible item has role='option'. When inside , aria-required, aria-invalid, and aria-describedby are forwarded automatically.", - 'combobox.keyboard.description': 'Type in the input to filter; navigate the matching options with arrow keys.', + 'combobox.keyboard.description': + 'Type in the input to filter; navigate the matching options with arrow keys.', 'combobox.keyboard.type': 'Filter the option list to matching entries.', 'combobox.keyboard.navigateList': 'Move focus between matching options, skipping disabled items.', 'combobox.keyboard.selectItem': 'Select the focused option and close the list.', 'combobox.keyboard.escape': 'Close the option list without changing the selection.', 'combobox.stateModel.description': - "CVA (ControlValueAccessor). Use [(ngModel)] or [formControl] for Angular Forms. The text typed in drives filtering — the parent component controls which items are rendered. For multi-select, use to display selected values as removable chips.", + 'CVA (ControlValueAccessor). Use [(ngModel)] or [formControl] for Angular Forms. The text typed in drives filtering — the parent component controls which items are rendered. For multi-select, use to display selected values as removable chips.', } as const; diff --git a/apps/docs/src/app/i18n/locales/en/components/context-menu.ts b/apps/docs/src/app/i18n/locales/en/components/context-menu.ts index 2e7c9dbc..d86e5e0f 100644 --- a/apps/docs/src/app/i18n/locales/en/components/context-menu.ts +++ b/apps/docs/src/app/i18n/locales/en/components/context-menu.ts @@ -17,7 +17,7 @@ export const contextMenuTranslations = { 'contextMenu.api.description': 'Inputs, outputs, and classes supported by the context menu primitives.', 'contextMenu.api.itemSelected.description': - "Emits the value of whichever item was activated (click, Enter, or Space), right before the whole menu — including any open submenu — closes. Declared on sanring-context-menu (the root), so it fires no matter how deep the selected item is nested.", + 'Emits the value of whichever item was activated (click, Enter, or Space), right before the whole menu — including any open submenu — closes. Declared on sanring-context-menu (the root), so it fires no matter how deep the selected item is nested.', 'contextMenu.api.value.description': 'The value reported to itemSelected when this item is activated. Required.', 'contextMenu.api.disabled.description': @@ -32,10 +32,14 @@ export const contextMenuTranslations = { 'Additional classes merged with the corresponding context menu primitive.', 'contextMenu.accessibility.description': "The panel has role='menu'. Items receive role='menuitem', role='menuitemcheckbox', or role='menuitemradio'. The trigger zone ([sanringContextMenuTrigger]) does not expose ARIA state — consider pairing it with a visible affordance or keyboard shortcut hint for accessibility.", - 'contextMenu.keyboard.description': 'The context menu can also be opened from a keyboard trigger.', - 'contextMenu.keyboard.navigateItems': 'Move focus between menu items, skipping disabled items (wraps).', + 'contextMenu.keyboard.description': + 'The context menu can also be opened from a keyboard trigger.', + 'contextMenu.keyboard.navigateItems': + 'Move focus between menu items, skipping disabled items (wraps).', 'contextMenu.keyboard.enter': 'Activate the focused menu item.', + 'contextMenu.keyboard.tab': + 'Close the menu and move to the previous or next control beside its trigger.', 'contextMenu.keyboard.escape': 'Close the context menu.', 'contextMenu.stateModel.description': - "Stateless. Items emit events on activation. The trigger context is set by the [sanringContextMenuTrigger] directive on any host element. Open/close is driven by right-click events or programmatically via the open(x, y) method on the ContextMenuComponent.", + 'Stateless. Items emit events on activation. The trigger context is set by the [sanringContextMenuTrigger] directive on any host element. Open/close is driven by right-click events or programmatically via the open(x, y) method on the ContextMenuComponent.', } as const; diff --git a/apps/docs/src/app/i18n/locales/en/components/date-picker.ts b/apps/docs/src/app/i18n/locales/en/components/date-picker.ts index d96edc5d..567889a5 100644 --- a/apps/docs/src/app/i18n/locales/en/components/date-picker.ts +++ b/apps/docs/src/app/i18n/locales/en/components/date-picker.ts @@ -64,11 +64,16 @@ export const datePickerTranslations = { 'Number of cells in the year-granularity sliding window.', 'datePicker.api.gridColumns.description': 'Grid column count used for arrow-key focus math; defaults to 3 for month/year and 4 for quarter.', - 'datePicker.api.disabled.description': 'Date matcher(s) marking periods as unselectable.', + 'datePicker.api.disabled.description': + 'Pass a date matcher (or matcher array) to disable individual periods, or a boolean to disable the entire picker without clearing its value.', 'datePicker.api.allowDeselect.description': 'Whether re-picking a selected value in single mode clears it.', 'datePicker.api.required.description': 'Marks the picker as required for field integration and aria-required.', + 'datePicker.api.ariaLabel.description': + 'Accessible name for the picker host when no visible label names it.', + 'datePicker.api.ariaLabelledBy.description': + 'ID of visible text that provides the accessible name for the picker host.', 'datePicker.api.ariaDescribedBy.description': 'ID of helper text that describes the picker; merged with Field-provided description ids.', 'datePicker.api.rangePeriodCountLimit.description': @@ -91,12 +96,16 @@ export const datePickerTranslations = { 'datePicker.api.removeDate.description': 'Removes a single date from the selection (multi mode only).', 'datePicker.api.focus.description': 'Moves focus to the date-picker host element.', - 'datePicker.accessibility.description': "role='grid' on the calendar panel, aria-label on the grid from the month/year header, role='gridcell' on each cell. The host element receives aria-required, aria-invalid, and aria-describedby, which auto-wire to the surrounding sanring-field.", - 'datePicker.keyboard.description': 'Calendar grid navigation is handled by @sanring/date-picker-core.', + 'datePicker.accessibility.description': + "The host group accepts aria-label or aria-labelledby and receives aria-invalid, aria-disabled, and aria-describedby. The calendar panel has role='grid' with an aria-label derived from its header; each role='gridcell' receives its selection, disabled, and required states. Field descriptions are wired automatically.", + 'datePicker.keyboard.description': + 'Calendar grid navigation is handled by @sanring/date-picker-core.', 'datePicker.keyboard.arrowKeys': 'Navigate between cells in the calendar grid.', - 'datePicker.keyboard.pageUpDown': 'Previous / next page (month, quarter, or year depending on granularity).', + 'datePicker.keyboard.pageUpDown': + 'Previous / next page (month, quarter, or year depending on granularity).', 'datePicker.keyboard.homeEnd': 'Jump to the first / last cell in the current view.', 'datePicker.keyboard.enter': 'Select the focused cell.', 'datePicker.keyboard.escape': 'Close the date-picker popover without committing a selection.', - 'datePicker.stateModel.description': 'Implements ControlValueAccessor. Use [(ngModel)] or formControl. Value type: DatePickerValue (single date, a range pair, or an array of dates depending on mode). mode and granularity can be changed at runtime.', + 'datePicker.stateModel.description': + 'Implements ControlValueAccessor. Use [(ngModel)] or formControl. Value type: DatePickerValue (single date, a range pair, or an array of dates depending on mode). mode and granularity can be changed at runtime.', } as const; diff --git a/apps/docs/src/app/i18n/locales/en/components/dialog.ts b/apps/docs/src/app/i18n/locales/en/components/dialog.ts index 2a484d42..1df6edfa 100644 --- a/apps/docs/src/app/i18n/locales/en/components/dialog.ts +++ b/apps/docs/src/app/i18n/locales/en/components/dialog.ts @@ -21,13 +21,19 @@ export const dialogTranslations = { 'dialog.api.description': 'Inputs supported by the dialog primitives.', 'dialog.api.class.description': 'Additional classes merged with DialogContent layout styles.', 'dialog.api.showClose.description': 'Controls whether the built-in close button is rendered.', + 'dialog.api.ariaLabel.description': + 'Accessible-name fallback used when no sanringDialogTitle is projected. DialogConfig ariaLabel remains supported.', + 'dialog.api.ariaLabelledBy.description': + 'Ids of external elements that label the dialog. Takes precedence over the projected title and ariaLabel.', + 'dialog.api.ariaDescribedBy.description': + 'Ids of external elements that describe the dialog. Takes precedence over sanringDialogDescription.', 'dialog.api.triggerConfig.description': 'CDK DialogConfig passed when sanringDialogTrigger opens the template.', 'dialog.api.closeResult.description': 'Optional result value emitted when sanringDialogClose closes the dialog.', 'dialog.api.mediaClass.description': 'Additional classes merged with the dialog media container.', 'dialog.accessibility.description': - "The CDK Dialog container receives role='dialog' and aria-modal='true'. When sanringDialogTitle or sanringDialogDescription are present, their ids are automatically wired to aria-labelledby and aria-describedby on the container. Angular CDK's FocusTrap keeps Tab and Shift+Tab cycling within the open dialog.", + "The CDK Dialog container receives role='dialog' and aria-modal='true'. Projected titles and descriptions are wired automatically; ariaLabel provides a fallback name for untitled content. Angular CDK's FocusTrap keeps Tab and Shift+Tab cycling within the open dialog.", 'dialog.keyboard.description': 'Focus is trapped inside the dialog while it is open.', 'dialog.keyboard.tab': 'Move focus to the next focusable element within the dialog.', 'dialog.keyboard.shiftTab': 'Move focus to the previous focusable element within the dialog.', diff --git a/apps/docs/src/app/i18n/locales/en/components/field.ts b/apps/docs/src/app/i18n/locales/en/components/field.ts index 4855720c..f17bcc50 100644 --- a/apps/docs/src/app/i18n/locales/en/components/field.ts +++ b/apps/docs/src/app/i18n/locales/en/components/field.ts @@ -15,6 +15,8 @@ export const fieldTranslations = { 'field.composition.description': 'Field only lays out and wires up its children — it never renders a control itself. Project a label, one control that implements SanringFieldControl (InputDirective today), an optional description, and an optional error message.', 'field.api.description': 'Inputs supported by sanring-field and the directives it composes with.', + 'field.api.id.description': + 'Stable ID applied to the field root and used to derive the fallback label target when the projected control does not expose its own ID.', 'field.api.floating.description': 'Floats the label above the control instead of stacking it on top.', 'field.api.labelClass.description': "Additional classes merged with the label's base styles.", @@ -22,7 +24,9 @@ export const fieldTranslations = { "Additional classes merged with the description's base styles.", 'field.api.errorMessageClass.description': "Additional classes merged with the error message's base styles.", - 'field.accessibility.description': 'Wraps a label, control, error text, and helper text with automatic ARIA wiring. CVA controls that inject FieldContext receive aria-labelledby and aria-describedby IDs automatically, without extra attributes in the template.', + 'field.accessibility.description': + 'Wraps a label, control, error text, and helper text with automatic ARIA wiring. CVA controls that inject FieldContext receive aria-labelledby and aria-describedby IDs automatically, without extra attributes in the template.', 'field.keyboard.description': 'Layout container — no keyboard interaction of its own.', - 'field.stateModel.description': 'Not a ControlValueAccessor. Provides FieldContext so nested CVA controls (input, checkbox, radio-group, switch, slider, otp-input, file-upload, date-picker, calendar) can auto-wire aria IDs and validation state. No value or selection state.', + 'field.stateModel.description': + 'Not a ControlValueAccessor. Provides FieldContext so nested CVA controls (input, checkbox, radio-group, switch, slider, otp-input, file-upload, date-picker, calendar) can auto-wire aria IDs and validation state. No value or selection state.', } as const; diff --git a/apps/docs/src/app/i18n/locales/en/components/hover-card.ts b/apps/docs/src/app/i18n/locales/en/components/hover-card.ts index 7d774aa2..8ffce40d 100644 --- a/apps/docs/src/app/i18n/locales/en/components/hover-card.ts +++ b/apps/docs/src/app/i18n/locales/en/components/hover-card.ts @@ -28,8 +28,12 @@ export const hoverCardTranslations = { 'hoverCard.api.sideOffset.description': 'Distance in pixels between the trigger and floating panel.', 'hoverCard.api.class.description': 'Additional classes merged onto the floating content panel.', + 'hoverCard.api.ariaLabel.description': + 'Accessible name for the content region when ariaLabelledBy is not set.', + 'hoverCard.api.ariaLabelledBy.description': + 'Ids of external elements that label the content region. Takes precedence over ariaLabel.', 'hoverCard.accessibility.description': - 'The trigger keeps native focus behavior, the panel can open from keyboard focus, and Escape closes it. Hover Card is best for supplemental context, not required click targets.', + 'The trigger keeps native focus behavior and links aria-expanded/aria-controls to a named content region. Escape closes it. Hover Card is best for supplemental context, not required click targets.', 'hoverCard.keyboard.description': 'Hover Card opens from pointer hover and keyboard focus on the trigger.', 'hoverCard.keyboard.focus': 'Focuses the trigger and opens the floating panel after openDelay.', diff --git a/apps/docs/src/app/i18n/locales/en/components/select.ts b/apps/docs/src/app/i18n/locales/en/components/select.ts index b2f82e39..a1193956 100644 --- a/apps/docs/src/app/i18n/locales/en/components/select.ts +++ b/apps/docs/src/app/i18n/locales/en/components/select.ts @@ -22,7 +22,7 @@ export const selectTranslations = { 'select.api.value.description': 'Read-only selected value getter. Bind with ngModel/formControl for value updates.', 'select.api.id.description': - 'Generated id applied to the trigger and used by Field focus integration.', + 'ID applied to the trigger and used by Field focus integration. Generated automatically unless provided.', 'select.api.contentId.description': 'Generated id applied to the listbox and referenced by aria-controls while open.', 'select.api.isOpen.description': 'Controls whether the floating listbox is open.', @@ -35,13 +35,14 @@ export const selectTranslations = { 'select.api.indicatorPosition.description': 'Places the selected indicator before or after the item text.', 'select.api.showIndicator.description': 'Controls whether the selected indicator is rendered.', - 'select.api.placeholder.description': 'Text shown by sanring-select-value when no value is selected.', + 'select.api.placeholder.description': + 'Text shown by sanring-select-value when no value is selected.', 'select.api.customIndicator.description': 'Projects a custom selected indicator in place of the default check icon.', 'select.api.class.description': 'Additional classes merged with the corresponding select primitive.', 'select.api.triggerAriaLabel.description': - "Accessible name for the trigger. The trigger has role=\"combobox\", so — unlike a plain button — its visible text/placeholder does not count as a name; set this or triggerAriaLabelledBy.", + 'Accessible name for the trigger. The trigger has role="combobox", so — unlike a plain button — its visible text/placeholder does not count as a name; set this or triggerAriaLabelledBy.', 'select.api.triggerAriaLabelledBy.description': 'References the id of an element (e.g. an external