From b144fb578ec1d9e5ac233dc76efcd687ab61e352 Mon Sep 17 00:00:00 2001 From: jack755051 Date: Mon, 21 Sep 2026 22:46:48 +0800 Subject: [PATCH 01/13] fix(registry): associate the login block remember-me label for eslint Co-authored-by: Cursor --- registry/blocks/login/login.component.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/registry/blocks/login/login.component.ts b/registry/blocks/login/login.component.ts index 3ea4c43..55984ad 100644 --- a/registry/blocks/login/login.component.ts +++ b/registry/blocks/login/login.component.ts @@ -75,6 +75,7 @@ import { LinkDirective } from '../link'; [checked]="rememberMe" (checkedChange)="rememberMe = $event === true" /> + Forgot password? From 169ed16dd33bf873e33024af5da17c44c68ad248 Mon Sep 17 00:00:00 2001 From: jack755051 Date: Mon, 21 Sep 2026 22:48:11 +0800 Subject: [PATCH 02/13] chore: add empty changeset for the login block lint fix Co-authored-by: Cursor --- .changeset/login-remember-me-label.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .changeset/login-remember-me-label.md diff --git a/.changeset/login-remember-me-label.md b/.changeset/login-remember-me-label.md new file mode 100644 index 0000000..a53fc98 --- /dev/null +++ b/.changeset/login-remember-me-label.md @@ -0,0 +1,4 @@ +--- +--- + +Associate the login block remember-me label so eslint template/label-has-associated-control passes. From fb3f68298b241d432fe08ff0cf19cf7def0ff89c Mon Sep 17 00:00:00 2001 From: jack755051 Date: Tue, 22 Sep 2026 10:07:00 +0800 Subject: [PATCH 03/13] docs: track P32 component completeness gaps Record the 52-component API sibling-alignment scan and table sticky follow-ups so roadmap work stays out of the 0.25.0 polish commits. Co-authored-by: Cursor --- ROADMAP.md | 11 ++++++ TODOLIST.md | 39 +++++++++++++++++++ .../ui/src/lib/components/table/todolist.md | 2 + 3 files changed, 52 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index fdceebb..fbeb681 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -8,6 +8,17 @@ This is a snapshot, not a commitment or a timeline. Items move, get reprioritize - **More blocks** — remaining page templates (`register`, `forgot-password`, `settings-page`, `detail-page`, `wizard`, `pricing-page`) on top of the starter three. +## Component completeness + +Sibling alignment and advertised capabilities — not new primitives. Tracked as P32 in [`TODOLIST.md`](TODOLIST.md). + +- **Table** — sticky column background; docs recipes for loading skeleton rows and column show/hide +- **Sheet** — built-in close control and header `align`, to match dialog +- **Radio** — `size` variants aligned with checkbox / switch +- **Dropdown menu** — a real nested submenu (docs keyboard table already describes it) +- **Popover** — four-way `side` plus an accessible name when no title is projected +- **Transfer** — root `disabled` and `ariaLabel` + ## Ecosystem / team use - **Registry Directory** — a docs page listing community/third-party registries, so teams can discover each other's component sets. diff --git a/TODOLIST.md b/TODOLIST.md index 471358a..262a8b4 100644 --- a/TODOLIST.md +++ b/TODOLIST.md @@ -42,3 +42,42 @@ **影響**:shadcn 的 registry 生態是目前採用率的核心驅動之一——開發者能找到、安裝、分享社群元件,讓整個 UI library 不只靠官方維護。Angular 生態目前沒有等價物,這是 Sanring 差異化的機會。 **成本**:高。各子項目可獨立交付。GitHub Registries 與 API Reference 已出貨;下一步是 Directory 和 Auth。 + +--- + +## P32 — 52 元件產品/API 掃描:對齊既有組件,不新增 primitive + +2026-09-22 對 `packages/ui` 52 個正式元件做產品/API 掃描(公開 input/output、兄弟組件對齊、docs 是否超賣能力)。不是重跑 P3/P26/P30 的 a11y 稽核。`check-registry-parity.mjs` 與 `check-registry-sync.mjs` 當日皆綠(52/52)。官方目錄不缺新 primitive;該做的是把兄弟組件對齊,以及把 docs 已經講出去的能力做完。 + +0.25.0 進行中的 input/textarea/combobox count、combobox chip wrap、dialog header `align`/獨立底色**不列入本項**,那些收斂完再看要不要疊。 + +### 該做 + +- [ ] **table sticky 欄背景**:`sticky`/`stickyEnd` 已能傳進 CDK,但 CDK 只加 `position: sticky` + 位移,沒有不透明背景;捲動時文字會透出來。細節與「刻意不做」的 `CdkTextColumn` / CDK flex-layout 見 [packages/ui/src/lib/components/table/todolist.md](packages/ui/src/lib/components/table/todolist.md) +- [ ] **table docs 配方**(不寫新元件):loading skeleton rows(既有 `skeleton`);欄位顯隱(dropdown-menu + checkbox + 動態 `sanringRowDefColumns`)。同上 todolist「使用模式」節 +- [ ] **sheet 對齊 dialog**:dialog content 已有內建 `showClose` / `closeAriaLabel`,header 已有 `align`;sheet 還要自己放 `[sanringSheetClose]`,`sanring-sheet-header` 沒有 `align` +- [ ] **radio `size`**:checkbox / switch / otp-input 有 sm/md/lg;radio 寫死 `RADIO_SIZE_CLASS = 'aspect-square h-4 w-4'`(`radio.styles.ts`) +- [ ] **dropdown-menu 真 submenu**:docs 鍵盤表寫了左右鍵開關子選單,官網範例卻是 `mouseenter` 切兩欄,不是巢狀 `menu`。checkbox / radio 範例用打勾圖示組出來即可,不必先做成一等 primitive +- [ ] **popover `side` + aria fallback**:tooltip / hover-card 已有四向 `side`;popover 只做上/下,而且沒投影 title 時仍綁死 `aria-labelledby`(`popover-content.component.ts`) +- [ ] **transfer 根層 `disabled` + `ariaLabel`**:現在只能 disable 單一 item(`TransferItem.disabled`);雙列表是沒名字的 `role="group"` + +### 可選(有缺口,但已有組合路徑) + +- [ ] **combobox** trigger/input 補 `ariaLabel` / `ariaLabelledBy`(已有 `sanring-combobox-label` 與 field 整合) +- [ ] **select** 補獨立 `required` input(現在只從 `Validators.required` 推導,`aria-required` 吃不到純 template `[required]`) +- [ ] **toast docs** 補 `ToastOptions.class`(型別已有,走 service,不要加元件級 `class` `@Input`) + +### 明確不做(查證後不是缺口,避免下次掃描重開) + +- 新 primitive(`kbd` / `chart` / `toggle-group` / `empty` 等)——官方方向在 blocks 與 registry 生態,不在再堆元件 +- input / textarea 再包一層 `disabled` / `aria-*` input——原生 host 屬性 + `SanringFieldControl` 是刻意的薄 API +- toast 元件級 `class` `@Input`——走 `ToastOptions.class` +- table `CdkTextColumn`、CDK flex-layout ``——見 table todolist,已明示不做 +- dialog 加 `[(isOpen)]`——CDK Dialog 是 service 開啟;sheet / popover 的 `isOpen` model 是另一套 overlay。沒需求不要硬對齊 +- 每個控制項都加 `size`——field 高度契約是共用的 `FIELD_SIZE_CLASS`;radio 是唯一跟 checkbox/switch 並排會明顯不齊的 + +**現況**:52 個元件裡約 44 個判定維持。alert-dialog 沒有自己的 `class` 沒關係(複用 dialog 零件,`showClose` 預設關是對的)。dropdown-menu 的 checkbox/radio docs 範例是組合解法,不是假文件。 + +**影響**:不處理的話,消費者會在「看起來該有的兄弟 API」上卡關(sheet 關閉鈕、radio 尺寸、popover 左右、transfer 整組停用),或照著 dropdown-menu 鍵盤表做出不能用方向鍵開的子選單。 + +**成本**:中。各子項可獨立交付;table docs 配方最低、sheet/radio 次之、dropdown-menu submenu 最重(要接 `@angular/aria/menu` 巢狀,不能沿用現在的兩欄 hover)。 diff --git a/packages/ui/src/lib/components/table/todolist.md b/packages/ui/src/lib/components/table/todolist.md index 3d82571..de3da43 100644 --- a/packages/ui/src/lib/components/table/todolist.md +++ b/packages/ui/src/lib/components/table/todolist.md @@ -1,5 +1,7 @@ # Table 元件待辦清單 +全庫追蹤見根目錄 [TODOLIST.md](../../../../../../TODOLIST.md) P32。本檔只記 table 自己的細節。 + ## 已完成 - `caption.directive.ts` — `TableCaptionDirective`(`caption[sanringCaption]`),投影進 `CdkTable` 內建的 `` From 237b772db26435b934a90446ffd15933e3e9fffc Mon Sep 17 00:00:00 2001 From: jack755051 Date: Tue, 22 Sep 2026 10:07:06 +0800 Subject: [PATCH 04/13] fix(docs): stabilize TOC active section on tall examples Use a viewport-ratio reading line, mark the current item, and tighten active styles so long demos stop leaving the previous section highlighted. Co-authored-by: Cursor --- .../src/app/shell/toc/docs-toc.component.ts | 48 +++++++++++++------ 1 file changed, 34 insertions(+), 14 deletions(-) diff --git a/apps/docs/src/app/shell/toc/docs-toc.component.ts b/apps/docs/src/app/shell/toc/docs-toc.component.ts index 3eba3ba..568b45f 100644 --- a/apps/docs/src/app/shell/toc/docs-toc.component.ts +++ b/apps/docs/src/app/shell/toc/docs-toc.component.ts @@ -4,6 +4,8 @@ import { DocsTocItem, DocsTocService } from './docs-toc.service'; const SCROLL_OFFSET = 76; const ACTIVE_OFFSET = SCROLL_OFFSET + 24; +/** Reading line as a fraction of the viewport. Tall example blocks otherwise keep the previous TOC item active. */ +const ACTIVE_VIEWPORT_RATIO = 0.32; @Component({ selector: 'app-docs-toc', @@ -19,7 +21,12 @@ const ACTIVE_OFFSET = SCROLL_OFFSET + 24; {{ i18n.t('toc.label') }}

@for (item of items(); track item.id) { - + {{ item.label }} } @@ -54,6 +61,7 @@ export class DocsTocComponent { event.preventDefault(); const el = document.getElementById(id); if (!el) return; + this.activeId.set(id); const marginTop = parseInt(getComputedStyle(el).marginTop) || 0; const top = el.getBoundingClientRect().top + window.scrollY - SCROLL_OFFSET - marginTop; window.scrollTo({ top, behavior: 'smooth' }); @@ -67,36 +75,48 @@ export class DocsTocComponent { protected itemClass(item: DocsTocItem) { const active = this.activeId() === item.id; const indentClasses: Record<2 | 3 | 4, string> = { - 2: '', - 3: 'pl-[18px]', - 4: 'pl-9', + 2: 'px-2.5', + 3: 'pl-5 pr-2.5', + 4: 'pl-8 pr-2.5', }; return [ - 'my-3 block border-l border-transparent py-0.5 text-sm no-underline transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--docs-focus-ring)]', + 'my-1 block rounded-[var(--sanring-radius)] border border-transparent py-1.5 text-sm no-underline transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--docs-focus-ring)]', active - ? 'border-[var(--docs-accent)] text-[var(--docs-fg)]' - : 'text-[var(--docs-muted)] hover:text-[var(--docs-fg)]', + ? 'border-[color-mix(in_srgb,var(--docs-accent)_34%,var(--docs-border))] bg-[var(--docs-active)] text-[var(--docs-fg)] shadow-sm' + : 'text-[var(--docs-muted)] hover:bg-[color-mix(in_srgb,var(--docs-elevated)_62%,transparent)] hover:text-[var(--docs-fg)]', indentClasses[item.level ?? 2], - ] - .filter(Boolean) - .join(' '); + ].join(' '); + } + + private activationLine(): number { + return Math.max(ACTIVE_OFFSET, Math.round(window.innerHeight * ACTIVE_VIEWPORT_RATIO)); } private updateActiveSection(): void { const items = this.toc.items(); - let currentId = items[0]?.id ?? null; + if (items.length === 0) { + this.activeId.set(null); + return; + } + + const line = this.activationLine(); + let currentId = items[0].id; for (const item of items) { const el = document.getElementById(item.id); if (!el) continue; - if (el.getBoundingClientRect().top <= ACTIVE_OFFSET) { + if (el.getBoundingClientRect().top <= line) { currentId = item.id; - } else { - break; } } + const nearBottom = + window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 8; + if (nearBottom) { + currentId = items[items.length - 1].id; + } + this.activeId.set(currentId); } } From 0a435e872875fb452ea50112499090359cc126e9 Mon Sep 17 00:00:00 2001 From: jack755051 Date: Tue, 22 Sep 2026 10:07:12 +0800 Subject: [PATCH 05/13] feat(combobox): add wrap control with single-line chip overflow Let chip-input/chips stay on one row and ellipsize overflow, keep the selected/total count inside the field, and mirror the change in registry. Co-authored-by: Cursor --- apps/docs/src/app/i18n/locales/en/common.ts | 6 + .../i18n/locales/en/components/combobox.ts | 7 ++ apps/docs/src/app/i18n/locales/en/index.ts | 1 + apps/docs/src/app/i18n/locales/zh/common.ts | 6 + .../i18n/locales/zh/components/combobox.ts | 10 +- apps/docs/src/app/i18n/locales/zh/index.ts | 1 + apps/docs/src/app/i18n/translations.ts | 2 + .../combobox/combobox-page.component.ts | 103 +++++++++++++++--- .../components/combobox/combobox.docs.ts | 70 ++++++++++-- .../combobox/combobox-chip-input.component.ts | 35 +++++- .../combobox/combobox-chip.component.ts | 27 ++++- .../combobox/combobox-chips.component.ts | 86 ++++++++++++++- .../combobox/combobox-input.component.ts | 8 +- .../combobox/combobox.component.spec.ts | 76 +++++++++++++ .../components/combobox/combobox.component.ts | 2 +- .../combobox/combobox-chip-input.component.ts | 35 +++++- .../combobox/combobox-chip.component.ts | 27 ++++- .../combobox/combobox-chips.component.ts | 86 ++++++++++++++- .../combobox/combobox-input.component.ts | 8 +- .../components/combobox/combobox.component.ts | 2 +- 20 files changed, 548 insertions(+), 50 deletions(-) diff --git a/apps/docs/src/app/i18n/locales/en/common.ts b/apps/docs/src/app/i18n/locales/en/common.ts index ef26662..acbc00a 100644 --- a/apps/docs/src/app/i18n/locales/en/common.ts +++ b/apps/docs/src/app/i18n/locales/en/common.ts @@ -167,4 +167,10 @@ export const commonTranslations = { 'status.maintenance.title': 'Under maintenance', 'status.maintenance.description': 'This component is temporarily under maintenance and hidden from production navigation. It remains reachable directly while we work on it.', + 'combobox.demo.multipleSingleLine': 'Multiple, single line', + 'combobox.demo.multipleMultiLine': 'Multiple, multi-line', + 'combobox.api.chipInputWrap.description': + 'When false, chips and the search input stay on one row instead of wrapping.', + 'combobox.api.chipsWrap.description': + 'When false, selected chips stay on one line and overflow with an ellipsis.', } 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 7518ace..9b86cd7 100644 --- a/apps/docs/src/app/i18n/locales/en/components/combobox.ts +++ b/apps/docs/src/app/i18n/locales/en/components/combobox.ts @@ -1,3 +1,4 @@ +/** Combobox docs copy. */ export const comboboxTranslations = { 'combobox.description': 'Autocomplete input with a list of suggestions.', 'combobox.examples.basic.description': @@ -36,6 +37,12 @@ export const comboboxTranslations = { 'Selected value controlled by the root. Use a string for single select or string array for multiple select.', 'combobox.api.multiple.description': 'Allows selecting more than one item and pairing the field with chips.', + 'combobox.demo.multipleSingleLine': 'Multiple, single line', + 'combobox.demo.multipleMultiLine': 'Multiple, multi-line', + 'combobox.api.chipInputWrap.description': + 'When false, chips and the search input stay on one row instead of wrapping.', + 'combobox.api.chipsWrap.description': + 'When false, selected chips stay on one line and overflow with an ellipsis.', 'combobox.api.disabled.description': 'Disables the combobox input and prevents selection changes.', 'combobox.api.inputId.description': diff --git a/apps/docs/src/app/i18n/locales/en/index.ts b/apps/docs/src/app/i18n/locales/en/index.ts index 320d663..7b68f0c 100644 --- a/apps/docs/src/app/i18n/locales/en/index.ts +++ b/apps/docs/src/app/i18n/locales/en/index.ts @@ -2,6 +2,7 @@ import { commonTranslations } from './common'; import { componentTranslations } from './components'; import { pageTranslations } from './pages'; +/** Locale catalog for English docs copy. */ export const en = { ...commonTranslations, ...pageTranslations, diff --git a/apps/docs/src/app/i18n/locales/zh/common.ts b/apps/docs/src/app/i18n/locales/zh/common.ts index 580fff7..9ba6a0a 100644 --- a/apps/docs/src/app/i18n/locales/zh/common.ts +++ b/apps/docs/src/app/i18n/locales/zh/common.ts @@ -162,4 +162,10 @@ export const commonTranslations = { 'status.maintenance.title': '維護中', 'status.maintenance.description': '此元件目前維護中,已從正式環境的導覽中暫時移除;維護期間仍可透過直接連結訪問。', + 'combobox.demo.multipleSingleLine': '多選、單行', + 'combobox.demo.multipleMultiLine': '多選、多行', + 'combobox.api.chipInputWrap.description': + '設為 false 時,chips 與搜尋框維持單行,不再換行。', + 'combobox.api.chipsWrap.description': + '設為 false 時,已選 chips 維持單行,超出輸入框寬度時以刪節號收斂。', } as const; diff --git a/apps/docs/src/app/i18n/locales/zh/components/combobox.ts b/apps/docs/src/app/i18n/locales/zh/components/combobox.ts index c95fb1e..fb0b259 100644 --- a/apps/docs/src/app/i18n/locales/zh/components/combobox.ts +++ b/apps/docs/src/app/i18n/locales/zh/components/combobox.ts @@ -1,3 +1,4 @@ +/** Combobox docs copy. */ export const comboboxTranslations = { 'combobox.description': '具備建議清單的自動完成輸入元件。', 'combobox.examples.basic.description': @@ -33,7 +34,14 @@ export const comboboxTranslations = { 'combobox.api.description': 'combobox primitives 支援的 inputs 與 models。', 'combobox.api.value.description': '由 root 控制的選取值。單選使用 string,多選使用 string array。', - 'combobox.api.multiple.description': '允許選取多個項目,並可搭配 chips 呈現。', + 'combobox.demo.multipleSingleLine': '多選、單行', + 'combobox.demo.multipleMultiLine': '多選、多行', + 'combobox.api.chipInputWrap.description': + '設為 false 時,chips 與搜尋框維持單行,不再換行。', + 'combobox.api.chipsWrap.description': + '設為 false 時,已選 chips 維持單行,超出輸入框寬度時以刪節號收斂。', + 'combobox.api.multiple.description': + '允許選取多個項目,並可搭配 chips 呈現。', 'combobox.api.disabled.description': '停用 combobox input,並阻止選取狀態變更。', 'combobox.api.inputId.description': 'input 或自訂 trigger 與其 label 共用的 ID;未提供時自動產生,也可覆寫以串接應用程式內的關聯。', diff --git a/apps/docs/src/app/i18n/locales/zh/index.ts b/apps/docs/src/app/i18n/locales/zh/index.ts index 77162e0..f36f8af 100644 --- a/apps/docs/src/app/i18n/locales/zh/index.ts +++ b/apps/docs/src/app/i18n/locales/zh/index.ts @@ -2,6 +2,7 @@ import { commonTranslations } from './common'; import { componentTranslations } from './components'; import { pageTranslations } from './pages'; +/** Locale catalog for Traditional Chinese docs copy. */ export const zh = { ...commonTranslations, ...pageTranslations, diff --git a/apps/docs/src/app/i18n/translations.ts b/apps/docs/src/app/i18n/translations.ts index 5bb67ad..4abf1dc 100644 --- a/apps/docs/src/app/i18n/translations.ts +++ b/apps/docs/src/app/i18n/translations.ts @@ -1,6 +1,8 @@ import { en } from './locales/en/index'; import { zh } from './locales/zh/index'; +/** Docs locale catalogs. TranslationKey is keyof typeof en. */ + export const supportedLocales = ['en', 'zh'] as const; export type Locale = (typeof supportedLocales)[number]; diff --git a/apps/docs/src/app/pages/components/combobox/combobox-page.component.ts b/apps/docs/src/app/pages/components/combobox/combobox-page.component.ts index a5eae60..3279ed9 100644 --- a/apps/docs/src/app/pages/components/combobox/combobox-page.component.ts +++ b/apps/docs/src/app/pages/components/combobox/combobox-page.component.ts @@ -109,21 +109,77 @@ import { comboboxPage, comboboxPageExamples } from './combobox.docs';
- - + +
-
- +
+ + {{ + i18n.t('combobox.demo.frameworks') + }} + + + @for (value of nowrapFrameworkValues(); track value) { + {{ + labelFor(value) + }} + } + + + + {{ nowrapFrameworkValues().length }}/{{ frameworks.length }} + + + + {{ + i18n.t('combobox.demo.empty') + }} + + @for (item of frameworks; track item.value) { + + {{ item.label }} + + } + + + +
+
+ + + + + +
+
+ {{ i18n.t('combobox.demo.frameworks') }} - @for (value of selectedFrameworkValues(); track value) { - {{ - labelFor(value) - }} - } + + @for (value of wrapFrameworkValues(); track value) { + {{ + labelFor(value) + }} + } + + + {{ wrapFrameworkValues().length }}/{{ frameworks.length }} + {{ @@ -138,12 +194,6 @@ import { comboboxPage, comboboxPageExamples } from './combobox.docs'; -

- {{ selectedFrameworkValues().length }}/{{ frameworks.length }} -

@@ -314,7 +364,20 @@ export class ComboboxPageComponent { protected readonly i18n = inject(I18nService); protected framework: string | string[] | null = 'angular'; - protected selectedFrameworks: string | string[] | null = ['angular', 'astro']; + protected wrapFrameworks: string | string[] | null = [ + 'angular', + 'react', + 'sveltekit', + 'nuxt', + 'astro', + ]; + protected nowrapFrameworks: string | string[] | null = [ + 'angular', + 'react', + 'sveltekit', + 'nuxt', + 'astro', + ]; protected library: string | string[] | null = 'angular'; protected country: string | string[] | null = null; protected clearDemoValue: string | string[] | null = 'react'; @@ -346,8 +409,12 @@ export class ComboboxPageComponent { return getComponentPageSection(this.page, id); } - protected selectedFrameworkValues(): string[] { - return Array.isArray(this.selectedFrameworks) ? this.selectedFrameworks : []; + protected wrapFrameworkValues(): string[] { + return Array.isArray(this.wrapFrameworks) ? this.wrapFrameworks : []; + } + + protected nowrapFrameworkValues(): string[] { + return Array.isArray(this.nowrapFrameworks) ? this.nowrapFrameworks : []; } protected labelFor( diff --git a/apps/docs/src/app/pages/components/combobox/combobox.docs.ts b/apps/docs/src/app/pages/components/combobox/combobox.docs.ts index 492b0ed..80ef050 100644 --- a/apps/docs/src/app/pages/components/combobox/combobox.docs.ts +++ b/apps/docs/src/app/pages/components/combobox/combobox.docs.ts @@ -46,7 +46,8 @@ export const comboboxPage = { titleKey: 'toc.examples', level: 2, children: [ - { id: 'example-multiple', titleKey: 'toc.multiple', level: 3 }, + { id: 'example-multiple-single-line', titleKey: 'combobox.demo.multipleSingleLine', level: 3 }, + { id: 'example-multiple-multi-line', titleKey: 'combobox.demo.multipleMultiLine', level: 3 }, { id: 'example-groups', titleKey: 'combobox.demo.groups', level: 3 }, { id: 'example-popup', titleKey: 'combobox.demo.popup', level: 3 }, { id: 'example-clear', titleKey: 'combobox.demo.clearButtonTitle', level: 3 }, @@ -151,6 +152,18 @@ export const comboboxPage = { defaultValue: '—', descriptionKey: 'combobox.api.trigger.description', }, + { + property: 'ComboboxChipInputComponent.wrap', + type: 'boolean', + defaultValue: 'true', + descriptionKey: 'combobox.api.chipInputWrap.description', + }, + { + property: 'ComboboxChipsComponent.wrap', + type: 'boolean', + defaultValue: 'true', + descriptionKey: 'combobox.api.chipsWrap.description', + }, { property: 'class', type: 'string', @@ -236,20 +249,58 @@ export class ExampleComponent {}`, multiple mode — chips and the input share ONE bordered box └── sanring-combobox-chip-input - ├── sanring-combobox-chip (one per selected value) - └── sanring-combobox-input (renders chrome-less when nested here) + ├── sanring-combobox-chips + │ └── sanring-combobox-chip (one per selected value) + ├── sanring-combobox-input (renders chrome-less when nested here) + └── count (optional, sits on the right of the field) popup mode — swap the trigger for the input once open ├── button[sanringComboboxTrigger] // shown while closed └── sanring-combobox-input // shown while open, via #combo="sanringCombobox"`, - multiple: ` + multipleSingleLine: ` + Frameworks + + + @for (value of selectedFrameworks; track value) { + {{ labelFor(value) }} + } + + + + {{ selectedFrameworks.length }}/{{ frameworks.length }} + + + + No frameworks found. + + @for (item of frameworks; track item.value) { + + {{ item.label }} + + } + + +`, + + multipleMultiLine: ` Frameworks - @for (value of selectedFrameworks; track value) { - {{ labelFor(value) }} - } + + @for (value of selectedFrameworks; track value) { + {{ labelFor(value) }} + } + + + {{ selectedFrameworks.length }}/{{ frameworks.length }} + No frameworks found. @@ -261,10 +312,7 @@ popup mode — swap the trigger for the input once open } - -

- {{ selectedFrameworks.length }}/{{ frameworks.length }} -

`, +
`, groups: ` diff --git a/packages/ui/src/lib/components/combobox/combobox-chip-input.component.ts b/packages/ui/src/lib/components/combobox/combobox-chip-input.component.ts index 983611d..182dd24 100644 --- a/packages/ui/src/lib/components/combobox/combobox-chip-input.component.ts +++ b/packages/ui/src/lib/components/combobox/combobox-chip-input.component.ts @@ -1,10 +1,12 @@ import { + booleanAttribute, ChangeDetectionStrategy, Component, computed, ElementRef, inject, input, + signal, } from '@angular/core'; import { ComboboxComponent } from './combobox.component'; import { cn } from '../../utils'; @@ -22,15 +24,44 @@ import { cn } from '../../utils'; }) export class ComboboxChipInputComponent { readonly class = input(); + /** When false, selected chips stay on one row instead of wrapping. */ + readonly wrap = input(true, { transform: booleanAttribute }); // 🪄 依賴注入 protected combobox = inject(ComboboxComponent); - private elementRef = inject(ElementRef); + private elementRef = inject>(ElementRef); + /** Single-line overflow: collapse the search field so chips + count stay flush. */ + readonly chipsOverflowing = signal(false); + + setChipsOverflowing(next: boolean): void { + if (this.chipsOverflowing() !== next) this.chipsOverflowing.set(next); + } + + /** + * Width available for the chips row. Search field is ignored — it only + * consumes leftover space when chips already fit next to the count. + */ + chipLaneWidth(extraReservePx = 0): number { + const el = this.elementRef.nativeElement; + const style = getComputedStyle(el); + const paddingX = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight); + const gap = parseFloat(style.gap) || 0; + let reserved = extraReservePx; + let extras = 0; + for (const child of el.children) { + if (child.tagName === 'SANRING-COMBOBOX-CHIPS') continue; + if (child.tagName === 'SANRING-COMBOBOX-INPUT') continue; + reserved += (child as HTMLElement).offsetWidth; + extras += 1; + } + return el.clientWidth - paddingX - reserved - gap * extras; + } protected readonly wrapperClass = computed(() => cn( // 🌟 排版核心:讓裡面的 Chips 和 Input 能夠在同一行水平排列、自動換行 - 'flex min-h-10 w-full flex-wrap items-center gap-1 rounded-[var(--sanring-radius)] border border-[var(--sanring-border-strong)] bg-[var(--sanring-surface)] px-3 py-1 text-sm text-[var(--sanring-foreground)]', + 'relative flex min-h-10 min-w-0 w-full items-center gap-1 rounded-[var(--sanring-radius)] border border-[var(--sanring-border-strong)] bg-[var(--sanring-surface)] px-3 py-1 text-sm text-[var(--sanring-foreground)]', + this.wrap() ? 'flex-wrap' : 'flex-nowrap overflow-hidden', // 當大腦被禁用時的樣式 this.combobox.isDisabled() ? 'cursor-not-allowed opacity-50' : 'cursor-text', // 聚焦時的外框亮起效果(模擬真實 input 的 focus-within 狀態) diff --git a/packages/ui/src/lib/components/combobox/combobox-chip.component.ts b/packages/ui/src/lib/components/combobox/combobox-chip.component.ts index b02d0af..b16708d 100644 --- a/packages/ui/src/lib/components/combobox/combobox-chip.component.ts +++ b/packages/ui/src/lib/components/combobox/combobox-chip.component.ts @@ -1,6 +1,14 @@ -import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + computed, + ElementRef, + inject, + input, +} from '@angular/core'; import { LucideX } from '@lucide/angular'; import { ComboboxComponent } from './combobox.component'; +import { ComboboxChipsComponent } from './combobox-chips.component'; import { cn } from '../../utils'; @Component({ @@ -39,6 +47,20 @@ export class ComboboxChipComponent { // 🪄 依賴注入:連線到大腦 protected combobox = inject(ComboboxComponent); + private readonly chipsParent = inject(ComboboxChipsComponent, { optional: true }); + private readonly elementRef = inject>(ElementRef); + + protected readonly overflowHidden = computed(() => { + const parent = this.chipsParent; + if (!parent) return false; + const from = parent.hiddenFrom(); + if (from === null) return false; + const node = this.elementRef.nativeElement; + const chips = node.parentElement + ? [...node.parentElement.children].filter((child) => child.tagName === 'SANRING-COMBOBOX-CHIP') + : []; + return chips.indexOf(node) >= from; + }); // 🔍 禁用狀態判斷:如果大腦被禁用了,或者這顆 Chip 自己被禁用了,就鎖死按鈕 protected readonly isDisabled = computed(() => this.disabled() || this.combobox.isDisabled()); @@ -46,11 +68,12 @@ export class ComboboxChipComponent { // 🎨 視覺排版:經典的 Badge/Chip 樣式 protected readonly chipClass = computed(() => cn( - 'inline-flex items-center justify-between rounded-[var(--sanring-radius-xs)] px-1.5 py-0.5 text-xs font-semibold transition-colors', + 'inline-flex shrink-0 items-center justify-between rounded-[var(--sanring-radius-xs)] px-1.5 py-0.5 text-xs font-semibold transition-colors', // 預設樣式:次要背景色 'border border-[var(--sanring-border)] bg-[var(--sanring-surface-strong)] text-[var(--sanring-foreground)]', // 禁用狀態的透明度 this.isDisabled() ? 'opacity-50 pointer-events-none' : 'hover:bg-[var(--sanring-active)]', + this.overflowHidden() && 'pointer-events-none invisible absolute', this.class(), ), ); diff --git a/packages/ui/src/lib/components/combobox/combobox-chips.component.ts b/packages/ui/src/lib/components/combobox/combobox-chips.component.ts index aabae5b..73eb1d6 100644 --- a/packages/ui/src/lib/components/combobox/combobox-chips.component.ts +++ b/packages/ui/src/lib/components/combobox/combobox-chips.component.ts @@ -1,20 +1,98 @@ -import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; +import { + afterEveryRender, + booleanAttribute, + ChangeDetectionStrategy, + Component, + computed, + ElementRef, + inject, + input, + signal, +} from '@angular/core'; +import { ComboboxChipInputComponent } from './combobox-chip-input.component'; import { cn } from '../../utils'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'sanring-combobox-chips', standalone: true, - template: ``, + template: ` + + @if (overflowing()) { + + } + `, host: { '[class]': 'chipsClass()', }, }) export class ComboboxChipsComponent { - // 允許外部微調樣式 readonly class = input(); + /** When false, chips stay on one line and overflow with an ellipsis. */ + readonly wrap = input(true, { transform: booleanAttribute }); + + private readonly elementRef = inject>(ElementRef); + private readonly chipInput = inject(ComboboxChipInputComponent, { optional: true }); + protected readonly overflowing = signal(false); + /** First chip index that no longer fits on a single line. */ + readonly hiddenFrom = signal(null); + + constructor() { + afterEveryRender(() => { + if (this.wrap()) { + if (this.overflowing()) this.overflowing.set(false); + if (this.hiddenFrom() !== null) this.hiddenFrom.set(null); + this.chipInput?.setChipsOverflowing(false); + return; + } + const node = this.elementRef.nativeElement; + const gap = parseFloat(getComputedStyle(node).gap) || 0; + const chips = [...node.children].filter( + (child) => child.tagName === 'SANRING-COMBOBOX-CHIP', + ) as HTMLElement[]; + const widths = chips.map((chip) => chip.offsetWidth); + const total = (count: number) => { + if (count <= 0) return 0; + return ( + widths.slice(0, count).reduce((sum, width) => sum + width, 0) + + gap * Math.max(0, count - 1) + ); + }; + const lane = this.chipInput ? this.chipInput.chipLaneWidth() : node.clientWidth; + const allFit = total(chips.length) <= lane; + if (allFit) { + if (this.overflowing()) this.overflowing.set(false); + if (this.hiddenFrom() !== null) this.hiddenFrom.set(null); + this.chipInput?.setChipsOverflowing(false); + return; + } + + const ellipsis = + node.querySelector('[data-overflow-ellipsis]')?.offsetWidth ?? 14; + let visible = chips.length; + while ( + visible > 1 && + total(visible) + (visible > 0 ? gap : 0) + ellipsis > lane + ) { + visible -= 1; + } + if (!this.overflowing()) this.overflowing.set(true); + this.chipInput?.setChipsOverflowing(true); + const from = visible < chips.length ? visible : chips.length; + if (this.hiddenFrom() !== from) this.hiddenFrom.set(from); + }); + } protected readonly chipsClass = computed(() => - cn('flex flex-wrap items-center gap-1', this.class()), + cn( + this.wrap() + ? 'contents' + : 'flex min-w-0 shrink-0 items-center gap-1 flex-nowrap', + this.class(), + ), ); } diff --git a/packages/ui/src/lib/components/combobox/combobox-input.component.ts b/packages/ui/src/lib/components/combobox/combobox-input.component.ts index 937db89..f93be8c 100644 --- a/packages/ui/src/lib/components/combobox/combobox-input.component.ts +++ b/packages/ui/src/lib/components/combobox/combobox-input.component.ts @@ -32,6 +32,7 @@ import { FIELD_SIZE_CLASS } from '../component-styles'; (focus)="combobox.onFocus()" (blur)="combobox.onBlur()" autocomplete="off" + size="1" role="combobox" [attr.aria-expanded]="combobox.isOpen()" [attr.aria-controls]="combobox.listId()" @@ -70,8 +71,13 @@ export class ComboboxInputComponent { protected readonly inputClass = computed(() => { if (this.chipInputParent) { return cn( - 'min-w-[60px] flex-1 border-0 bg-transparent p-0 text-sm text-[var(--sanring-foreground)] outline-none', + 'border-0 bg-transparent p-0 text-sm text-[var(--sanring-foreground)] outline-none', 'placeholder:text-[var(--sanring-muted)] disabled:cursor-not-allowed disabled:opacity-50', + this.chipInputParent.wrap() + ? 'min-w-[4ch] w-auto' + : this.chipInputParent.chipsOverflowing() && !this.combobox.inputValue() + ? 'w-0 min-w-0 flex-none overflow-hidden' + : 'min-w-0 flex-1', this.class(), ); } diff --git a/packages/ui/src/lib/components/combobox/combobox.component.spec.ts b/packages/ui/src/lib/components/combobox/combobox.component.spec.ts index 5eb7be2..265a046 100644 --- a/packages/ui/src/lib/components/combobox/combobox.component.spec.ts +++ b/packages/ui/src/lib/components/combobox/combobox.component.spec.ts @@ -4,6 +4,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { expectNoA11yViolations } from '../../../testing/axe-a11y'; import { ComboboxChipComponent } from './combobox-chip.component'; import { ComboboxChipInputComponent } from './combobox-chip-input.component'; +import { ComboboxChipsComponent } from './combobox-chips.component'; import { ComboboxContentComponent } from './combobox-content.component'; import { ComboboxInputComponent } from './combobox-input.component'; import { ComboboxItemComponent } from './combobox-item.component'; @@ -372,3 +373,78 @@ describe('ComboboxComponent', () => { }); }); }); + +@Component({ + imports: [ + ComboboxComponent, + ComboboxChipComponent, + ComboboxChipInputComponent, + ComboboxChipsComponent, + ComboboxInputComponent, + ], + template: ` + + + + Angular + + + + + `, +}) +class NowrapComboboxHost {} + +@Component({ + imports: [ + ComboboxComponent, + ComboboxChipComponent, + ComboboxChipInputComponent, + ComboboxChipsComponent, + ComboboxInputComponent, + ], + template: ` + + + + Angular + + + + + `, +}) +class WrapComboboxHost {} + +describe('ComboboxChipsComponent wrap', () => { + it('keeps chips on one line when wrap is false', async () => { + await TestBed.configureTestingModule({ imports: [NowrapComboboxHost] }).compileComponents(); + const fixture = TestBed.createComponent(NowrapComboboxHost); + fixture.detectChanges(); + + const chipInput = fixture.nativeElement.querySelector( + 'sanring-combobox-chip-input', + ) as HTMLElement; + const chips = fixture.nativeElement.querySelector('sanring-combobox-chips') as HTMLElement; + const input = fixture.nativeElement.querySelector('input') as HTMLInputElement; + expect(chipInput.classList.contains('flex-nowrap')).toBe(true); + expect(chips.classList.contains('flex-nowrap')).toBe(true); + expect(chips.classList.contains('shrink-0')).toBe(true); + expect(input.classList.contains('w-auto')).toBe(false); + }); + + it('lets chips wrap without stretching the search field onto its own row', async () => { + await TestBed.configureTestingModule({ imports: [WrapComboboxHost] }).compileComponents(); + const fixture = TestBed.createComponent(WrapComboboxHost); + fixture.detectChanges(); + + const chipInput = fixture.nativeElement.querySelector( + 'sanring-combobox-chip-input', + ) as HTMLElement; + const chips = fixture.nativeElement.querySelector('sanring-combobox-chips') as HTMLElement; + const input = fixture.nativeElement.querySelector('input') as HTMLInputElement; + expect(chipInput.classList.contains('flex-wrap')).toBe(true); + expect(chips.classList.contains('contents')).toBe(true); + expect(input.classList.contains('flex-1')).toBe(false); + }); +}); diff --git a/packages/ui/src/lib/components/combobox/combobox.component.ts b/packages/ui/src/lib/components/combobox/combobox.component.ts index 124b014..cda1f65 100644 --- a/packages/ui/src/lib/components/combobox/combobox.component.ts +++ b/packages/ui/src/lib/components/combobox/combobox.component.ts @@ -92,7 +92,7 @@ export class ComboboxComponent extends SanringCvaBase { readonly isDisabled = computed(() => this.disabled() || this.disabledState()); protected readonly hostClass = computed(() => - cn('relative block w-full', this.isDisabled() && 'opacity-50 cursor-not-allowed', this.class()), + cn('relative block w-full min-w-0', this.isDisabled() && 'opacity-50 cursor-not-allowed', this.class()), ); readonly computedAriaDescribedBy = this.makeComputedAriaDescribedBy(); diff --git a/registry/components/combobox/combobox-chip-input.component.ts b/registry/components/combobox/combobox-chip-input.component.ts index c5de9d0..d2da1fa 100644 --- a/registry/components/combobox/combobox-chip-input.component.ts +++ b/registry/components/combobox/combobox-chip-input.component.ts @@ -1,10 +1,12 @@ import { + booleanAttribute, ChangeDetectionStrategy, Component, computed, ElementRef, inject, input, + signal, } from '@angular/core'; import { ComboboxComponent } from './combobox.component'; import { cn } from '../shared/utils'; @@ -22,15 +24,44 @@ import { cn } from '../shared/utils'; }) export class ComboboxChipInputComponent { readonly class = input(); + /** When false, selected chips stay on one row instead of wrapping. */ + readonly wrap = input(true, { transform: booleanAttribute }); // 🪄 依賴注入 protected combobox = inject(ComboboxComponent); - private elementRef = inject(ElementRef); + private elementRef = inject>(ElementRef); + /** Single-line overflow: collapse the search field so chips + count stay flush. */ + readonly chipsOverflowing = signal(false); + + setChipsOverflowing(next: boolean): void { + if (this.chipsOverflowing() !== next) this.chipsOverflowing.set(next); + } + + /** + * Width available for the chips row. Search field is ignored — it only + * consumes leftover space when chips already fit next to the count. + */ + chipLaneWidth(extraReservePx = 0): number { + const el = this.elementRef.nativeElement; + const style = getComputedStyle(el); + const paddingX = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight); + const gap = parseFloat(style.gap) || 0; + let reserved = extraReservePx; + let extras = 0; + for (const child of el.children) { + if (child.tagName === 'SANRING-COMBOBOX-CHIPS') continue; + if (child.tagName === 'SANRING-COMBOBOX-INPUT') continue; + reserved += (child as HTMLElement).offsetWidth; + extras += 1; + } + return el.clientWidth - paddingX - reserved - gap * extras; + } protected readonly wrapperClass = computed(() => cn( // 🌟 排版核心:讓裡面的 Chips 和 Input 能夠在同一行水平排列、自動換行 - 'flex min-h-10 w-full flex-wrap items-center gap-1 rounded-[var(--sanring-radius)] border border-[var(--sanring-border-strong)] bg-[var(--sanring-surface)] px-3 py-1 text-sm text-[var(--sanring-foreground)]', + 'relative flex min-h-10 min-w-0 w-full items-center gap-1 rounded-[var(--sanring-radius)] border border-[var(--sanring-border-strong)] bg-[var(--sanring-surface)] px-3 py-1 text-sm text-[var(--sanring-foreground)]', + this.wrap() ? 'flex-wrap' : 'flex-nowrap overflow-hidden', // 當大腦被禁用時的樣式 this.combobox.isDisabled() ? 'cursor-not-allowed opacity-50' : 'cursor-text', // 聚焦時的外框亮起效果(模擬真實 input 的 focus-within 狀態) diff --git a/registry/components/combobox/combobox-chip.component.ts b/registry/components/combobox/combobox-chip.component.ts index cf90b8e..2ba20a1 100644 --- a/registry/components/combobox/combobox-chip.component.ts +++ b/registry/components/combobox/combobox-chip.component.ts @@ -1,6 +1,14 @@ -import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + computed, + ElementRef, + inject, + input, +} from '@angular/core'; import { LucideX } from '@lucide/angular'; import { ComboboxComponent } from './combobox.component'; +import { ComboboxChipsComponent } from './combobox-chips.component'; import { cn } from '../shared/utils'; @Component({ @@ -39,6 +47,20 @@ export class ComboboxChipComponent { // 🪄 依賴注入:連線到大腦 protected combobox = inject(ComboboxComponent); + private readonly chipsParent = inject(ComboboxChipsComponent, { optional: true }); + private readonly elementRef = inject>(ElementRef); + + protected readonly overflowHidden = computed(() => { + const parent = this.chipsParent; + if (!parent) return false; + const from = parent.hiddenFrom(); + if (from === null) return false; + const node = this.elementRef.nativeElement; + const chips = node.parentElement + ? [...node.parentElement.children].filter((child) => child.tagName === 'SANRING-COMBOBOX-CHIP') + : []; + return chips.indexOf(node) >= from; + }); // 🔍 禁用狀態判斷:如果大腦被禁用了,或者這顆 Chip 自己被禁用了,就鎖死按鈕 protected readonly isDisabled = computed(() => this.disabled() || this.combobox.isDisabled()); @@ -46,11 +68,12 @@ export class ComboboxChipComponent { // 🎨 視覺排版:經典的 Badge/Chip 樣式 protected readonly chipClass = computed(() => cn( - 'inline-flex items-center justify-between rounded-[var(--sanring-radius-xs)] px-1.5 py-0.5 text-xs font-semibold transition-colors', + 'inline-flex shrink-0 items-center justify-between rounded-[var(--sanring-radius-xs)] px-1.5 py-0.5 text-xs font-semibold transition-colors', // 預設樣式:次要背景色 'border border-[var(--sanring-border)] bg-[var(--sanring-surface-strong)] text-[var(--sanring-foreground)]', // 禁用狀態的透明度 this.isDisabled() ? 'opacity-50 pointer-events-none' : 'hover:bg-[var(--sanring-active)]', + this.overflowHidden() && 'pointer-events-none invisible absolute', this.class(), ), ); diff --git a/registry/components/combobox/combobox-chips.component.ts b/registry/components/combobox/combobox-chips.component.ts index 39a0d6c..b252edc 100644 --- a/registry/components/combobox/combobox-chips.component.ts +++ b/registry/components/combobox/combobox-chips.component.ts @@ -1,20 +1,98 @@ -import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; +import { + afterEveryRender, + booleanAttribute, + ChangeDetectionStrategy, + Component, + computed, + ElementRef, + inject, + input, + signal, +} from '@angular/core'; +import { ComboboxChipInputComponent } from './combobox-chip-input.component'; import { cn } from '../shared/utils'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'sanring-combobox-chips', standalone: true, - template: ``, + template: ` + + @if (overflowing()) { + + } + `, host: { '[class]': 'chipsClass()', }, }) export class ComboboxChipsComponent { - // 允許外部微調樣式 readonly class = input(); + /** When false, chips stay on one line and overflow with an ellipsis. */ + readonly wrap = input(true, { transform: booleanAttribute }); + + private readonly elementRef = inject>(ElementRef); + private readonly chipInput = inject(ComboboxChipInputComponent, { optional: true }); + protected readonly overflowing = signal(false); + /** First chip index that no longer fits on a single line. */ + readonly hiddenFrom = signal(null); + + constructor() { + afterEveryRender(() => { + if (this.wrap()) { + if (this.overflowing()) this.overflowing.set(false); + if (this.hiddenFrom() !== null) this.hiddenFrom.set(null); + this.chipInput?.setChipsOverflowing(false); + return; + } + const node = this.elementRef.nativeElement; + const gap = parseFloat(getComputedStyle(node).gap) || 0; + const chips = [...node.children].filter( + (child) => child.tagName === 'SANRING-COMBOBOX-CHIP', + ) as HTMLElement[]; + const widths = chips.map((chip) => chip.offsetWidth); + const total = (count: number) => { + if (count <= 0) return 0; + return ( + widths.slice(0, count).reduce((sum, width) => sum + width, 0) + + gap * Math.max(0, count - 1) + ); + }; + const lane = this.chipInput ? this.chipInput.chipLaneWidth() : node.clientWidth; + const allFit = total(chips.length) <= lane; + if (allFit) { + if (this.overflowing()) this.overflowing.set(false); + if (this.hiddenFrom() !== null) this.hiddenFrom.set(null); + this.chipInput?.setChipsOverflowing(false); + return; + } + + const ellipsis = + node.querySelector('[data-overflow-ellipsis]')?.offsetWidth ?? 14; + let visible = chips.length; + while ( + visible > 1 && + total(visible) + (visible > 0 ? gap : 0) + ellipsis > lane + ) { + visible -= 1; + } + if (!this.overflowing()) this.overflowing.set(true); + this.chipInput?.setChipsOverflowing(true); + const from = visible < chips.length ? visible : chips.length; + if (this.hiddenFrom() !== from) this.hiddenFrom.set(from); + }); + } protected readonly chipsClass = computed(() => - cn('flex flex-wrap items-center gap-1', this.class()), + cn( + this.wrap() + ? 'contents' + : 'flex min-w-0 shrink-0 items-center gap-1 flex-nowrap', + this.class(), + ), ); } diff --git a/registry/components/combobox/combobox-input.component.ts b/registry/components/combobox/combobox-input.component.ts index 9f154f8..e6eebe5 100644 --- a/registry/components/combobox/combobox-input.component.ts +++ b/registry/components/combobox/combobox-input.component.ts @@ -32,6 +32,7 @@ import { FIELD_SIZE_CLASS } from '../shared/component-styles'; (focus)="combobox.onFocus()" (blur)="combobox.onBlur()" autocomplete="off" + size="1" role="combobox" [attr.aria-expanded]="combobox.isOpen()" [attr.aria-controls]="combobox.listId()" @@ -70,8 +71,13 @@ export class ComboboxInputComponent { protected readonly inputClass = computed(() => { if (this.chipInputParent) { return cn( - 'min-w-[60px] flex-1 border-0 bg-transparent p-0 text-sm text-[var(--sanring-foreground)] outline-none', + 'border-0 bg-transparent p-0 text-sm text-[var(--sanring-foreground)] outline-none', 'placeholder:text-[var(--sanring-muted)] disabled:cursor-not-allowed disabled:opacity-50', + this.chipInputParent.wrap() + ? 'min-w-[4ch] w-auto' + : this.chipInputParent.chipsOverflowing() && !this.combobox.inputValue() + ? 'w-0 min-w-0 flex-none overflow-hidden' + : 'min-w-0 flex-1', this.class(), ); } diff --git a/registry/components/combobox/combobox.component.ts b/registry/components/combobox/combobox.component.ts index 14755d6..c65798c 100644 --- a/registry/components/combobox/combobox.component.ts +++ b/registry/components/combobox/combobox.component.ts @@ -93,7 +93,7 @@ export class ComboboxComponent extends SanringCvaBase { readonly isDisabled = computed(() => this.disabled() || this.disabledState()); protected readonly hostClass = computed(() => - cn('relative block w-full', this.isDisabled() && 'opacity-50 cursor-not-allowed', this.class()), + cn('relative block w-full min-w-0', this.isDisabled() && 'opacity-50 cursor-not-allowed', this.class()), ); readonly computedAriaDescribedBy = this.makeComputedAriaDescribedBy(); From 611bd46033c3a4e7f6d9c14ef1d8d70e22e104bb Mon Sep 17 00:00:00 2001 From: jack755051 Date: Tue, 22 Sep 2026 10:07:16 +0800 Subject: [PATCH 06/13] docs(input): keep character counts inside the control Move input/textarea live counts onto the field so error messages can sit below, and lock the projection order with a field unit test. Co-authored-by: Cursor --- .../components/input/input-page.component.ts | 28 ++++++++--- .../app/pages/components/input/input.docs.ts | 14 ++++-- .../textarea/textarea-page.component.ts | 36 ++++++++++---- .../components/textarea/textarea.docs.ts | 14 ++++-- .../components/field/field.component.spec.ts | 47 +++++++++++++++++++ 5 files changed, 115 insertions(+), 24 deletions(-) diff --git a/apps/docs/src/app/pages/components/input/input-page.component.ts b/apps/docs/src/app/pages/components/input/input-page.component.ts index 439a06d..37aa659 100644 --- a/apps/docs/src/app/pages/components/input/input-page.component.ts +++ b/apps/docs/src/app/pages/components/input/input-page.component.ts @@ -1,5 +1,5 @@ import { Component, inject } from '@angular/core'; -import { FormControl, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; +import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms'; import { DescriptionDirective, ErrorMessageComponent, @@ -29,7 +29,6 @@ import { inputPage, inputPageExamples } from './input.docs'; DescriptionDirective, ErrorMessageComponent, FieldLabelDirective, - FormsModule, InputDirective, ReactiveFormsModule, SanringFieldComponent, @@ -141,10 +140,21 @@ import { InputDirective } from './components/ui/input';" - -

- {{ title.length }}/500 -

+
+ + + {{ titleControl.value.length }}/500 + +
+ Title must be at least 50 characters.
@@ -174,10 +184,14 @@ export class InputPageComponent { nonNullable: true, validators: [Validators.required], }); - protected title = 'Sanring UI demo text'; + protected readonly titleControl = new FormControl('Sanring UI demo text', { + nonNullable: true, + validators: [Validators.minLength(50)], + }); constructor() { this.emailControl.markAsTouched(); + this.titleControl.markAsTouched(); } protected section(id: string) { diff --git a/apps/docs/src/app/pages/components/input/input.docs.ts b/apps/docs/src/app/pages/components/input/input.docs.ts index a793462..89cba90 100644 --- a/apps/docs/src/app/pages/components/input/input.docs.ts +++ b/apps/docs/src/app/pages/components/input/input.docs.ts @@ -129,9 +129,15 @@ import { InputDirective } from './components/ui/input';`, file: ``, characterCount: ` - -

- {{ title.length }}/500 -

+
+ + + {{ titleControl.value.length }}/500 + +
+ Title must be at least 50 characters.
`, } as const; diff --git a/apps/docs/src/app/pages/components/textarea/textarea-page.component.ts b/apps/docs/src/app/pages/components/textarea/textarea-page.component.ts index a4e613c..825d58f 100644 --- a/apps/docs/src/app/pages/components/textarea/textarea-page.component.ts +++ b/apps/docs/src/app/pages/components/textarea/textarea-page.component.ts @@ -1,7 +1,7 @@ import { Component, inject } from '@angular/core'; -import { FormsModule } from '@angular/forms'; +import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms'; import { - DescriptionDirective, + ErrorMessageComponent, FieldLabelDirective, SanringFieldComponent, TextareaDirective, @@ -33,9 +33,9 @@ import { textareaPage, textareaPageExamples } from './textarea.docs'; ComponentPageKeyboardTableComponent, ComponentPageUsageImportsComponent, ComponentPageSectionComponent, - DescriptionDirective, + ErrorMessageComponent, FieldLabelDirective, - FormsModule, + ReactiveFormsModule, SanringFieldComponent, TextareaDirective, ], @@ -105,10 +105,21 @@ import { textareaPage, textareaPageExamples } from './textarea.docs'; - -

- {{ bio.length }}/500 -

+
+ + + {{ bioControl.value.length }}/500 + +
+ Bio must be at least 50 characters.
@@ -134,7 +145,14 @@ export class TextareaPageComponent { protected readonly page = textareaPage; protected readonly examples = textareaPageExamples; protected readonly i18n = inject(I18nService); - protected bio = 'Sanring UI demo text'; + protected readonly bioControl = new FormControl('Sanring UI demo text', { + nonNullable: true, + validators: [Validators.minLength(50)], + }); + + constructor() { + this.bioControl.markAsTouched(); + } protected section(id: string) { return getComponentPageSection(this.page, id); diff --git a/apps/docs/src/app/pages/components/textarea/textarea.docs.ts b/apps/docs/src/app/pages/components/textarea/textarea.docs.ts index 2cda738..d2764bc 100644 --- a/apps/docs/src/app/pages/components/textarea/textarea.docs.ts +++ b/apps/docs/src/app/pages/components/textarea/textarea.docs.ts @@ -97,9 +97,15 @@ export const textareaPageExamples = { resize: ``, characterCount: ` - -

- {{ bio.length }}/500 -

+
+ + + {{ bioControl.value.length }}/500 + +
+ Bio must be at least 50 characters.
`, } as const; diff --git a/packages/ui/src/lib/components/field/field.component.spec.ts b/packages/ui/src/lib/components/field/field.component.spec.ts index 3aa56b3..b85a692 100644 --- a/packages/ui/src/lib/components/field/field.component.spec.ts +++ b/packages/ui/src/lib/components/field/field.component.spec.ts @@ -192,3 +192,50 @@ describe('SanringFieldComponent ambient background auto-detection', () => { fixture.nativeElement.remove(); }); }); + +@Component({ + standalone: true, + imports: [ + SanringFieldComponent, + LabelDirective, + InputDirective, + ErrorMessageComponent, + ReactiveFormsModule, + ], + template: ` + + + +
+ + {{ control.value.length }}/500 +
+ Title must be at least 50 characters. +
+ `, +}) +class CountInsideInputHost { + readonly control = new FormControl('Hi', { + nonNullable: true, + validators: [Validators.minLength(50)], + }); +} + +describe('SanringFieldComponent in-control character count', () => { + it('keeps an overlay count with the input, above the error message', () => { + const fixture = TestBed.createComponent(CountInsideInputHost); + fixture.componentInstance.control.markAsTouched(); + fixture.detectChanges(); + + const field = fixture.nativeElement.querySelector('sanring-field') as HTMLElement; + const input = field.querySelector('input'); + const count = field.querySelector('.count'); + const error = field.querySelector('sanring-error-message'); + expect(input).toBeTruthy(); + expect(count).toBeTruthy(); + expect(error).toBeTruthy(); + expect(input!.compareDocumentPosition(count!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(count!.compareDocumentPosition(error!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(error!.classList.contains('hidden')).toBe(false); + }); +}); From 7e9ad732611934d36337b10044809425bcde9f3b Mon Sep 17 00:00:00 2001 From: jack755051 Date: Tue, 22 Sep 2026 10:07:19 +0800 Subject: [PATCH 07/13] docs(dialog): show header background separate from body Document pairing header class with content overflow-hidden/p-0 so the header surface can differ from the dialog body. Co-authored-by: Cursor --- .../app/i18n/locales/en/components/dialog.ts | 5 +++- .../app/i18n/locales/zh/components/dialog.ts | 5 +++- .../dialog/dialog-page.component.ts | 15 ++++++++---- .../pages/components/dialog/dialog.docs.ts | 23 +++++++++++++++---- .../dialog/dialog.component.spec.ts | 17 ++++++++++++++ 5 files changed, 55 insertions(+), 10 deletions(-) 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 5578f57..9d61673 100644 --- a/apps/docs/src/app/i18n/locales/en/components/dialog.ts +++ b/apps/docs/src/app/i18n/locales/en/components/dialog.ts @@ -1,3 +1,4 @@ +/** Dialog docs copy. */ export const dialogTranslations = { 'dialog.description': 'An overlay primitive built on Angular CDK Dialog for modal tasks and focused decisions.', @@ -8,7 +9,7 @@ export const dialogTranslations = { 'dialog.demo.noClose': 'No Close Button', 'dialog.demo.stickyFooter': 'Sticky Footer', 'dialog.demo.scrollable': 'Scrollable Content', - 'dialog.demo.header': 'Header alignment', + 'dialog.demo.header': 'Header alignment and background', 'dialog.examples.description': 'Common dialog patterns for custom actions, hidden close controls, sticky actions, and dense scrollable content.', 'dialog.examples.basic.description': @@ -35,6 +36,8 @@ export const dialogTranslations = { 'dialog.api.mediaClass.description': 'Additional classes merged with the dialog media container.', 'dialog.api.headerAlign.description': 'Header text alignment. start is left, center is centered at every breakpoint. The default stays centered on small screens and left-aligned from sm up.', + 'dialog.api.headerClass.description': + 'Additional classes merged with the header layout. Use this to give the header a different background from sanring-dialog-content, for example bg-[var(--sanring-surface-strong)]. Pair with overflow-hidden p-0 on content so the fill reaches the panel edges.', 'dialog.api.titleClass.description': 'Additional classes merged with the title styles. Use this to change title color, for example text-[var(--sanring-primary-70)].', 'dialog.accessibility.description': diff --git a/apps/docs/src/app/i18n/locales/zh/components/dialog.ts b/apps/docs/src/app/i18n/locales/zh/components/dialog.ts index bb02f5a..3c15b2c 100644 --- a/apps/docs/src/app/i18n/locales/zh/components/dialog.ts +++ b/apps/docs/src/app/i18n/locales/zh/components/dialog.ts @@ -1,3 +1,4 @@ +/** Dialog docs copy. */ export const dialogTranslations = { 'dialog.description': '建立在 Angular CDK Dialog 上的 overlay primitive,適合 modal 任務與聚焦決策。', @@ -8,7 +9,7 @@ export const dialogTranslations = { 'dialog.demo.noClose': '沒有關閉按鈕', 'dialog.demo.stickyFooter': '固定頁尾', 'dialog.demo.scrollable': '可捲動內容', - 'dialog.demo.header': '標題對齊', + 'dialog.demo.header': '標題對齊與背景', 'dialog.examples.description': '常見 Dialog 模式,包含自訂操作、隱藏關閉控制、固定操作區與大量可捲動內容。', 'dialog.examples.basic.description': @@ -34,6 +35,8 @@ export const dialogTranslations = { 'dialog.api.mediaClass.description': '與 dialog media 容器合併的額外 class。', 'dialog.api.headerAlign.description': '標題列文字對齊。start 靠左,center 在所有斷點置中。預設維持小螢幕置中、sm 以上靠左。', + 'dialog.api.headerClass.description': + '與 header 版面樣式合併的額外 class。用來讓標題列背景跟 sanring-dialog-content 不同,例如 bg-[var(--sanring-surface-strong)]。搭配 content 的 overflow-hidden p-0,底色才能貼齊面板邊緣。', 'dialog.api.titleClass.description': '與標題樣式合併的額外 class。用來改標題顏色,例如 text-[var(--sanring-primary-70)]。', 'dialog.accessibility.description': diff --git a/apps/docs/src/app/pages/components/dialog/dialog-page.component.ts b/apps/docs/src/app/pages/components/dialog/dialog-page.component.ts index e0cf716..d4b18d0 100644 --- a/apps/docs/src/app/pages/components/dialog/dialog-page.component.ts +++ b/apps/docs/src/app/pages/components/dialog/dialog-page.component.ts @@ -271,19 +271,26 @@ import { dialogPage, dialogPageExamples } from './dialog.docs'; - - -

Centered title

+ + +

Edit profile

- Header alignment and title color are inputs, not one-off CSS. + Align the header independently, and give it a background that is not the body surface.

+
+ Dialog body stays on the default content surface. +
`, }) export class DialogPageComponent { + // Header example: align + a header surface that is not the dialog body. protected readonly page = dialogPage; protected readonly examples = dialogPageExamples; protected readonly i18n = inject(I18nService); diff --git a/apps/docs/src/app/pages/components/dialog/dialog.docs.ts b/apps/docs/src/app/pages/components/dialog/dialog.docs.ts index 2dce379..887d48e 100644 --- a/apps/docs/src/app/pages/components/dialog/dialog.docs.ts +++ b/apps/docs/src/app/pages/components/dialog/dialog.docs.ts @@ -157,6 +157,12 @@ export const dialogPage = { defaultValue: 'responsive', descriptionKey: 'dialog.api.headerAlign.description', }, + { + property: 'DialogHeaderComponent.class', + type: 'string', + defaultValue: 'undefined', + descriptionKey: 'dialog.api.headerClass.description', + }, { property: 'DialogTitleDirective.class', type: 'string', @@ -171,6 +177,7 @@ export const dialogPage = { ] satisfies readonly ComponentPageKeyboardRow[], } as const satisfies ComponentPageDefinition; + export const dialogPageExamples = { composition: `[sanringDialogTrigger] sanring-dialog-content @@ -330,13 +337,21 @@ export class ExampleComponent {}`, header: ` - - + +

- Centered title + Edit profile

-

Header alignment and title color are inputs, not one-off CSS.

+

+ Align the header independently, and give it a background that is not the body surface. +

+
+ Dialog body stays on the default content surface. +
`, } as const; diff --git a/packages/ui/src/lib/components/dialog/dialog.component.spec.ts b/packages/ui/src/lib/components/dialog/dialog.component.spec.ts index cd13f58..940fe7c 100644 --- a/packages/ui/src/lib/components/dialog/dialog.component.spec.ts +++ b/packages/ui/src/lib/components/dialog/dialog.component.spec.ts @@ -339,3 +339,20 @@ describe('DialogHeaderComponent', () => { expect(header.classList.contains('items-start')).toBe(true); }); }); + +@Component({ + imports: [DialogHeaderComponent], + template: `Title`, +}) +class DialogHeaderClassHost {} + +describe('DialogHeaderComponent class', () => { + it('merges a consumer class so the header can use a different background', async () => { + await TestBed.configureTestingModule({ imports: [DialogHeaderClassHost] }).compileComponents(); + const fixture = TestBed.createComponent(DialogHeaderClassHost); + fixture.detectChanges(); + + const header = fixture.nativeElement.querySelector('sanring-dialog-header') as HTMLElement; + expect(header.classList.contains('bg-[var(--sanring-surface-strong)]')).toBe(true); + }); +}); From 7b17eb942f4b3eecc91b9a6283e3dbe2c8aba346 Mon Sep 17 00:00:00 2001 From: jack755051 Date: Tue, 22 Sep 2026 10:07:22 +0800 Subject: [PATCH 08/13] chore: note count, combobox wrap, and dialog header polish Add the patch changeset and changelog lines for the in-field counts, combobox wrap/ellipsis, and dialog header background recipe. Co-authored-by: Cursor --- .changeset/count-inside-dialog-header-bg.md | 5 +++++ apps/docs/src/app/pages/changelog/component-changelog.ts | 6 +++--- 2 files changed, 8 insertions(+), 3 deletions(-) create mode 100644 .changeset/count-inside-dialog-header-bg.md diff --git a/.changeset/count-inside-dialog-header-bg.md b/.changeset/count-inside-dialog-header-bg.md new file mode 100644 index 0000000..40a1ba9 --- /dev/null +++ b/.changeset/count-inside-dialog-header-bg.md @@ -0,0 +1,5 @@ +--- +'@sanring/cli': patch +--- + +Keep input/textarea/combobox counts inside the field on the right, add combobox chip wrap/ellipsis, and let dialog headers use a different background from the body. diff --git a/apps/docs/src/app/pages/changelog/component-changelog.ts b/apps/docs/src/app/pages/changelog/component-changelog.ts index cad8420..1d2a7de 100644 --- a/apps/docs/src/app/pages/changelog/component-changelog.ts +++ b/apps/docs/src/app/pages/changelog/component-changelog.ts @@ -47,17 +47,17 @@ export const cliVersionChangelog: readonly CliVersionEntry[] = [ { type: 'added', componentIds: ['dialog'], - text: '`sanring-dialog-header` accepts `align` (`start` | `center`) so title alignment is not only the responsive default. Title color still goes through `sanringDialogTitle` `class`.', + text: '`sanring-dialog-header` accepts `align` (`start` | `center`). Header `class` can set a background that is not the dialog body surface.', }, { type: 'added', componentIds: ['input', 'textarea'], - text: 'Docs examples show a live character count (`20/500`) using native `maxlength` and `sanringDescription`.', + text: 'Docs examples show a live character count inside the control on the right, so `sanring-error-message` can stay below.', }, { type: 'added', componentIds: ['combobox'], - text: 'The multiple-select example shows selected/total count, for example `2/5`.', + text: '`sanring-combobox-chip-input` and `sanring-combobox-chips` accept `wrap`. Single-line multiple selection overflows with an ellipsis; the selected/total count sits on the right of the field.', }, { type: 'changed', From cdc111186e3a14350eae08a70f3061588afd78e4 Mon Sep 17 00:00:00 2001 From: jack755051 Date: Tue, 22 Sep 2026 13:18:30 +0800 Subject: [PATCH 09/13] docs(docs): simplify chrome with shared surface classes Replace one-off color-mix and soft-shadow recipes with shared docs panel/nav/toc utilities, and point Tailwind @source at packages/ui. Co-authored-by: Cursor --- .../component-page-api-table.component.ts | 33 ++--- ...component-page-code-previewer.component.ts | 27 +--- .../component-page-header.component.ts | 36 ++--- ...component-page-recent-changes.component.ts | 29 ++-- .../component-page-section.component.ts | 7 +- .../docs-page-header.component.ts | 19 +-- .../changelog/changelog-page.component.ts | 2 +- .../src/app/pages/cli/cli-page.component.ts | 2 +- .../home/home-components-section.component.ts | 6 +- .../home/home-features-section.component.ts | 2 +- .../home/home-hero-demo-panel.component.ts | 2 +- .../pages/home/home-hero-section.component.ts | 2 +- .../home/home-start-section.component.ts | 6 +- .../introduction-page.component.ts | 2 +- .../src/app/pages/mcp/mcp-page.component.ts | 2 +- .../pages/registry/registry-page.component.ts | 2 +- .../pages/roadmap/roadmap-page.component.ts | 2 +- .../pages/theming/theming-page.component.ts | 2 +- .../shell/header/feature-list.component.ts | 10 +- .../src/app/shell/header/header.component.ts | 2 +- .../app/shell/header/menu-list.component.ts | 6 +- .../shell/sidebar/docs-section.component.ts | 12 +- .../shell/sidebar/docs-sidebar.component.ts | 9 +- .../src/app/shell/toc/docs-toc.component.ts | 24 +-- apps/docs/src/styles.css | 139 +++++++++++++++--- 25 files changed, 200 insertions(+), 185 deletions(-) diff --git a/apps/docs/src/app/layouts/component-page/component-page-api-table.component.ts b/apps/docs/src/app/layouts/component-page/component-page-api-table.component.ts index bb6575a..57277c3 100644 --- a/apps/docs/src/app/layouts/component-page/component-page-api-table.component.ts +++ b/apps/docs/src/app/layouts/component-page/component-page-api-table.component.ts @@ -7,12 +7,9 @@ import { I18nService } from '../../i18n/i18n.service'; standalone: true, template: `
- - + {{ i18n.t('docs.api.surface') }}