();
- protected readonly hostClass = computed(() => cn('flex flex-col gap-1.5', this.class()));
+
+ readonly align = input<'start' | 'center' | undefined>(undefined);
+
+ protected readonly hostClass = computed(() =>
+ cn(
+ 'flex flex-col gap-1.5',
+ this.align() === 'center' && 'items-center text-center',
+ this.align() === 'start' && 'items-start text-left',
+ !this.align() && 'text-center sm:text-left',
+ this.class(),
+ ),
+ );
}
diff --git a/packages/ui/src/lib/components/sheet/sheet.component.spec.ts b/packages/ui/src/lib/components/sheet/sheet.component.spec.ts
index 4fefe9f..e1bf4e7 100644
--- a/packages/ui/src/lib/components/sheet/sheet.component.spec.ts
+++ b/packages/ui/src/lib/components/sheet/sheet.component.spec.ts
@@ -54,12 +54,44 @@ class SheetTestHost {}
})
class UntitledSheetTestHost {}
+@Component({
+ imports: [SheetContentComponent, SheetTriggerDirective, SheetComponent],
+ template: `
+
+ Open
+
+
+ `,
+})
+class HiddenCloseSheetHost {}
+
+@Component({
+ imports: [
+ SheetContentComponent,
+ SheetHeaderComponent,
+ SheetTitleComponent,
+ SheetTriggerDirective,
+ SheetComponent,
+ ],
+ template: `
+
+ Open
+
+
+ Centered
+
+
+
+ `,
+})
+class AlignedHeaderSheetHost {}
+
describe('SheetComponent', () => {
let overlayContainer: OverlayContainer;
beforeEach(async () => {
await TestBed.configureTestingModule({
- imports: [SheetTestHost],
+ imports: [SheetTestHost, HiddenCloseSheetHost, AlignedHeaderSheetHost],
}).compileComponents();
overlayContainer = TestBed.inject(OverlayContainer);
@@ -285,6 +317,52 @@ describe('SheetComponent', () => {
expect(trigger.getAttribute('aria-expanded')).toBe('false');
});
+ it('renders a built-in close button that closes the panel', () => {
+ const fixture = TestBed.createComponent(SheetTestHost);
+ fixture.detectChanges();
+
+ (fixture.nativeElement.querySelector('button[sanringSheetTrigger]') as HTMLElement).click();
+ fixture.detectChanges();
+
+ const closeButton = overlayContainer
+ .getContainerElement()
+ .querySelector('button[aria-label="關閉面板"]') as HTMLElement;
+
+ expect(closeButton).toBeTruthy();
+ closeButton.click();
+ fixture.detectChanges();
+
+ expect(
+ fixture.nativeElement.querySelector('button[sanringSheetTrigger]')?.getAttribute('aria-expanded'),
+ ).toBe('false');
+ });
+
+ it('hides the built-in close button when showClose is false', () => {
+ const fixture = TestBed.createComponent(HiddenCloseSheetHost);
+ fixture.detectChanges();
+
+ (fixture.nativeElement.querySelector('button') as HTMLElement).click();
+ fixture.detectChanges();
+
+ expect(
+ overlayContainer.getContainerElement().querySelector('button[aria-label="關閉面板"]'),
+ ).toBeNull();
+ });
+
+ it('aligns the header when align is set', () => {
+ const fixture = TestBed.createComponent(AlignedHeaderSheetHost);
+ fixture.detectChanges();
+
+ (fixture.nativeElement.querySelector('button') as HTMLElement).click();
+ fixture.detectChanges();
+
+ const header = overlayContainer
+ .getContainerElement()
+ .querySelector('sanring-sheet-header') as HTMLElement;
+ expect(header.classList.contains('items-center')).toBe(true);
+ expect(header.classList.contains('text-center')).toBe(true);
+ });
+
it('closes via a sanringSheetClose button inside the panel', () => {
const fixture = TestBed.createComponent(SheetTestHost);
fixture.detectChanges();
diff --git a/packages/ui/src/lib/components/table/cell.directive.ts b/packages/ui/src/lib/components/table/cell.directive.ts
index 27ee31e..c118ecc 100644
--- a/packages/ui/src/lib/components/table/cell.directive.ts
+++ b/packages/ui/src/lib/components/table/cell.directive.ts
@@ -68,7 +68,9 @@ export class TableHeaderCellDirective {
standalone: true,
// 🧠 當 蛋糕出生時,我們強行把 CDK 原生的 CdkCell 邏輯塞進去!
hostDirectives: [CdkCell],
- host: { class: 'p-4 align-middle [&:has([role=checkbox])]:pr-0' },
+ host: {
+ class: 'p-4 align-middle [&:has([role=checkbox])]:pr-0',
+ },
})
export class TableCellDirective {}
@@ -77,6 +79,8 @@ export class TableCellDirective {}
standalone: true,
// 🧠 當 footer 蛋糕出生時,我們強行把 CDK 原生的 CdkFooterCell 邏輯塞進去!
hostDirectives: [CdkFooterCell],
- host: { class: 'border-t p-4 align-middle font-medium [&:has([role=checkbox])]:pr-0' },
+ host: {
+ class: 'border-t p-4 align-middle font-medium [&:has([role=checkbox])]:pr-0',
+ },
})
export class TableFooterCellDirective {}
diff --git a/packages/ui/src/lib/components/table/table-container.component.ts b/packages/ui/src/lib/components/table/table-container.component.ts
index 089cc52..4f015ab 100644
--- a/packages/ui/src/lib/components/table/table-container.component.ts
+++ b/packages/ui/src/lib/components/table/table-container.component.ts
@@ -1,14 +1,21 @@
-import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
+import { ChangeDetectionStrategy, Component, computed, input, ViewEncapsulation } from '@angular/core';
import { cn } from '../../utils';
@Component({
selector: 'sanring-table-container',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
+ encapsulation: ViewEncapsulation.None,
template: ` `,
host: {
'[class]': 'containerClass()',
},
+ styles: `
+ sanring-table-container .cdk-table-sticky,
+ sanring-table-container .cdk-table-sticky-end {
+ background-color: var(--sanring-background);
+ }
+ `,
})
export class TableContainerComponent {
readonly class = input();
diff --git a/packages/ui/src/lib/components/table/table.component.spec.ts b/packages/ui/src/lib/components/table/table.component.spec.ts
index a62a31c..aa075f4 100644
--- a/packages/ui/src/lib/components/table/table.component.spec.ts
+++ b/packages/ui/src/lib/components/table/table.component.spec.ts
@@ -81,10 +81,43 @@ class TableTestHost {
sort: SortState | null = null;
}
+@Component({
+ imports: [
+ CdkTableModule,
+ TableContainerComponent,
+ TableDirective,
+ TableColumnDefDirective,
+ TableHeaderCellDefDirective,
+ TableCellDefDirective,
+ TableHeaderCellDirective,
+ TableCellDirective,
+ TableHeaderRowDefDirective,
+ TableRowDefDirective,
+ TableHeaderRowDirective,
+ TableRowDirective,
+ ],
+ template: `
+
+
+
+ Name
+ {{ person.name }}
+
+
+
+
+
+ `,
+})
+class StickyTableHost {
+ columns = ['name'];
+ data = [{ name: 'Ada' }];
+}
+
describe('TableComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
- imports: [TableTestHost],
+ imports: [TableTestHost, StickyTableHost],
}).compileComponents();
});
@@ -129,6 +162,18 @@ describe('TableComponent', () => {
expect(sortHeader.getAttribute('aria-sort')).toBe('ascending');
});
+ it('gives sticky cells an opaque background so scrolled text does not show through', () => {
+ const fixture = TestBed.createComponent(StickyTableHost);
+ fixture.detectChanges();
+
+ const header = fixture.nativeElement.querySelector('th') as HTMLElement;
+ const cell = fixture.nativeElement.querySelector('td') as HTMLElement;
+ expect(header.className).toContain('cdk-table-sticky');
+ expect(cell.className).toContain('cdk-table-sticky');
+ expect(getComputedStyle(header).backgroundColor).toBe('var(--sanring-background)');
+ expect(getComputedStyle(cell).backgroundColor).toBe('var(--sanring-background)');
+ });
+
it('has no axe-detectable a11y violations', async () => {
const fixture = TestBed.createComponent(TableTestHost);
fixture.detectChanges();
diff --git a/packages/ui/src/lib/components/table/todolist.md b/packages/ui/src/lib/components/table/todolist.md
index de3da43..6b72ea5 100644
--- a/packages/ui/src/lib/components/table/todolist.md
+++ b/packages/ui/src/lib/components/table/todolist.md
@@ -18,13 +18,13 @@
## 待處理
-- [ ] Sticky 欄位的背景色 —— `sticky`/`stickyEnd` 已能傳進去,但 CDK 只會加 `position: sticky` + 位移,不會加背景色,實際用 sticky checkbox/actions 欄時要自己補不透明背景(例如 `bg-[var(--sanring-surface)]`),不然捲動時文字會透出來。
+- [x] Sticky 欄位的背景色 —— `sanring-table-container` 對投影進去的 `.cdk-table-sticky` / `.cdk-table-sticky-end` 補 `--sanring-background`(sticky 一定包在可捲動 container 裡)。消費者 `class` 仍可覆蓋。
- [ ] `CdkTextColumn` 等效元件 —— 目前決定不做(它是 sealed Component,模板寫死原生 `cdk-*` attribute,要支援等於要重寫一整套 `ViewChild` 註冊邏輯),先讓使用者手動組 `sanringColumnDef` + `sanringCellDef`。
- [ ] CDK flex-layout(`` 自訂標籤,非原生 ``)—— 目前完全不支援。`TableDirective` 的 selector 已收窄成只認 `table[cdk-table][sanringTable]`,因為所有 cell/row directive 都只匹配原生 `th`/`td`/`tr`。真的要支援 flex 模式,需要幫每個 cell/row directive 都做一份平行 selector(例如 `cdk-cell[sanringCell]`),範圍不小,先不做。
## 不用寫新元件,純粹是「使用模式」要補進文件
- [x] Row actions(⋯ 選單)—— docs table 頁面已示範用 `dropdown-menu` 放進最後一欄的 cell
-- [ ] Loading skeleton rows —— 用既有 `skeleton`,loading 時把 tbody 內容換掉;docs 頁面尚未示範
-- [ ] 欄位顯示/隱藏切換 —— dropdown-menu + checkbox 湊 checklist,搭配 `sanringRowDefColumns` 動態陣列;docs 頁面尚未示範
+- [x] Loading skeleton rows —— docs 用既有 `skeleton` 示範 loading 時把 tbody 換成骨架列
+- [x] 欄位顯示/隱藏切換 —— docs 用 dropdown-menu + 打勾圖示 + 動態 `sanringRowDef` columns 示範
- [x] Row selection checkbox 欄 —— docs table 頁面已示範 `sanringColumnDef` + `sanring-checkbox` + `TableRowDirective` 的 `selected` input 組合
diff --git a/packages/ui/src/lib/components/transfer/transfer-panel.component.ts b/packages/ui/src/lib/components/transfer/transfer-panel.component.ts
index 2654d88..b286a15 100644
--- a/packages/ui/src/lib/components/transfer/transfer-panel.component.ts
+++ b/packages/ui/src/lib/components/transfer/transfer-panel.component.ts
@@ -34,7 +34,9 @@ export class TransferPanelComponent {
// target 面板在 one-way 模式下變成唯讀展示:不能勾選,也就無法被搬回 source
readonly interactive = computed(
- () => !(this.direction() === 'target' && this.transfer.mode() === 'one-way'),
+ () =>
+ !this.transfer.isDisabled() &&
+ !(this.direction() === 'target' && this.transfer.mode() === 'one-way'),
);
// List/Item 元件只認得「這個 panel 該顯示哪些 key、勾選狀態怎麼查/切換」,
diff --git a/packages/ui/src/lib/components/transfer/transfer.component.spec.ts b/packages/ui/src/lib/components/transfer/transfer.component.spec.ts
index 3c1a1df..14a5e92 100644
--- a/packages/ui/src/lib/components/transfer/transfer.component.spec.ts
+++ b/packages/ui/src/lib/components/transfer/transfer.component.spec.ts
@@ -52,10 +52,48 @@ class TransferTestHost {
readonly mode = signal('two-way');
}
+@Component({
+ imports: [
+ TransferComponent,
+ TransferPanelComponent,
+ TransferHeaderComponent,
+ TransferListComponent,
+ TransferActionDirective,
+ ],
+ template: `
+
+
+ Available
+
+
+
+ to-target
+
+
+ Selected
+
+
+
+ `,
+})
+class DisabledTransferHost {
+ items = [
+ { key: '1', label: 'One' },
+ { key: '2', label: 'Two' },
+ ];
+ selectedKeys = ['2'];
+}
+
describe('TransferComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
- imports: [TransferTestHost],
+ imports: [TransferTestHost, DisabledTransferHost],
}).compileComponents();
});
@@ -223,6 +261,32 @@ describe('TransferComponent', () => {
expect(four.getAttribute('aria-checked')).toBe('true');
});
+ it('names the group and blocks all moves when the root is disabled', async () => {
+ const fixture = TestBed.createComponent(DisabledTransferHost);
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges();
+
+ const root = fixture.nativeElement.querySelector('sanring-transfer') as HTMLElement;
+ expect(root.getAttribute('role')).toBe('group');
+ expect(root.getAttribute('aria-label')).toBe('Assign languages');
+ expect(root.getAttribute('aria-disabled')).toBe('true');
+
+ const source = root.querySelector('[data-direction="source"]') as HTMLElement;
+ const one = source.querySelector('[role="checkbox"][aria-label="One"]') as HTMLElement;
+ expect(one.getAttribute('aria-disabled')).toBe('true');
+
+ one.click();
+ fixture.detectChanges();
+ Array.from(root.querySelectorAll('button'))
+ .find((button) => button.textContent?.trim() === 'to-target')
+ ?.click();
+ fixture.detectChanges();
+ await fixture.whenStable();
+
+ expect(fixture.componentInstance.selectedKeys).toEqual(['2']);
+ });
+
it('has no axe-detectable a11y violations', async () => {
const fixture = await createFixture();
diff --git a/packages/ui/src/lib/components/transfer/transfer.component.ts b/packages/ui/src/lib/components/transfer/transfer.component.ts
index 79bfdd0..d6a23ad 100644
--- a/packages/ui/src/lib/components/transfer/transfer.component.ts
+++ b/packages/ui/src/lib/components/transfer/transfer.component.ts
@@ -1,4 +1,12 @@
-import { ChangeDetectionStrategy, Component, computed, input, model, signal } from '@angular/core';
+import {
+ ChangeDetectionStrategy,
+ Component,
+ booleanAttribute,
+ computed,
+ input,
+ model,
+ signal,
+} from '@angular/core';
import { cn } from '../../utils';
import { TransferItem, TransferMode } from './transfer.type';
@@ -9,13 +17,19 @@ import { TransferItem, TransferMode } from './transfer.type';
template: ` `,
host: {
'[class]': 'rootClass()',
+ role: 'group',
+ '[attr.aria-label]': 'ariaLabel()',
+ '[attr.aria-disabled]': 'isDisabled() || null',
},
})
export class TransferComponent {
readonly items = input([]);
readonly class = input();
+ readonly disabled = input(false, { transform: booleanAttribute });
+ readonly ariaLabel = input();
protected readonly rootClass = computed(() => cn(this.class()));
+ readonly isDisabled = computed(() => this.disabled());
// 雙向綁定:目前在右側(target)的 key 清單,父層可用 [(selectedKeys)] 拿到結果
readonly selectedKeys = model([]);
readonly mode = input('two-way');
@@ -46,6 +60,7 @@ export class TransferComponent {
}
toggleSourceSelected(key: string): void {
+ if (this.isDisabled()) return;
if (this.itemsByKey().get(key)?.disabled) return;
this.sourceSelectedKeys.update((keys) =>
keys.includes(key) ? keys.filter((k) => k !== key) : [...keys, key],
@@ -54,6 +69,7 @@ export class TransferComponent {
toggleTargetSelected(key: string): void {
// one-way 模式下 target 是唯讀展示,不接受勾選
+ if (this.isDisabled()) return;
if (this.mode() === 'one-way') return;
if (this.itemsByKey().get(key)?.disabled) return;
this.targetSelectedKeys.update((keys) =>
@@ -62,6 +78,7 @@ export class TransferComponent {
}
moveToTarget(): void {
+ if (this.isDisabled()) return;
// 用當下的 sourceItems 過濾一次,避免 items() 從外部變動後 sourceSelectedKeys 裡殘留已經不存在的 key
const validKeys = new Set(this.sourceItems().map((item) => item.key));
const moving = this.sourceSelectedKeys().filter((key) => validKeys.has(key));
@@ -72,6 +89,7 @@ export class TransferComponent {
}
moveToSource(): void {
+ if (this.isDisabled()) return;
if (this.mode() === 'one-way') return;
const moving = new Set(this.targetSelectedKeys().filter((key) => this.itemsByKey().has(key)));
if (moving.size === 0) return;
@@ -81,10 +99,12 @@ export class TransferComponent {
}
setSourceSelectedKeys(keys: string[]): void {
+ if (this.isDisabled()) return;
this.sourceSelectedKeys.set(keys);
}
setTargetSelectedKeys(keys: string[]): void {
+ if (this.isDisabled()) return;
if (this.mode() === 'one-way') return;
this.targetSelectedKeys.set(keys);
}
diff --git a/registry/components/dropdown-menu/dropdown-menu-sub-content.component.ts b/registry/components/dropdown-menu/dropdown-menu-sub-content.component.ts
new file mode 100644
index 0000000..3f6511a
--- /dev/null
+++ b/registry/components/dropdown-menu/dropdown-menu-sub-content.component.ts
@@ -0,0 +1,103 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ ElementRef,
+ OnDestroy,
+ computed,
+ effect,
+ inject,
+ input,
+} from '@angular/core';
+import { Menu as ngMenu } from '@angular/aria/menu';
+import {
+ ConnectionPositionPair,
+ FlexibleConnectedPositionStrategy,
+ Overlay,
+ OverlayRef,
+} from '@angular/cdk/overlay';
+import { DomPortal } from '@angular/cdk/portal';
+import { cn } from '../shared/utils';
+import { OVERLAY_SURFACE_CLASS } from '../shared/component-styles';
+import { DropdownMenuSubComponent } from './dropdown-menu-sub.component';
+
+const SUB_CONTENT_POSITIONS: ConnectionPositionPair[] = [
+ { originX: 'end', originY: 'top', overlayX: 'start', overlayY: 'top' },
+ { originX: 'start', originY: 'top', overlayX: 'end', overlayY: 'top' },
+ { originX: 'end', originY: 'bottom', overlayX: 'start', overlayY: 'bottom' },
+ { originX: 'start', originY: 'bottom', overlayX: 'end', overlayY: 'bottom' },
+];
+
+@Component({
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ selector: 'sanring-dropdown-menu-sub-content',
+ standalone: true,
+ exportAs: 'sanringDropdownMenuSubContent',
+ hostDirectives: [
+ {
+ directive: ngMenu,
+ inputs: ['id', 'wrap', 'typeaheadDelay'],
+ outputs: ['itemSelected'],
+ },
+ ],
+ template: ` `,
+ host: {
+ '[class]': 'contentClass()',
+ },
+})
+export class DropdownMenuSubContentComponent implements OnDestroy {
+ readonly menu = inject(ngMenu, { self: true });
+ private readonly sub = inject(DropdownMenuSubComponent);
+ private readonly elementRef = inject(ElementRef);
+ private readonly overlay = inject(Overlay);
+
+ readonly class = input();
+
+ private overlayRef: OverlayRef | null = null;
+ private positionStrategy: FlexibleConnectedPositionStrategy | null = null;
+
+ protected readonly contentClass = computed(() =>
+ cn(
+ OVERLAY_SURFACE_CLASS,
+ 'z-50 block min-w-32 overflow-hidden rounded-[var(--sanring-radius-sm)] p-1 outline-none',
+ 'data-[visible=false]:hidden',
+ this.menu.visible() ? 'animate-popover-in' : '',
+ this.class(),
+ ),
+ );
+
+ constructor() {
+ effect(() => {
+ const trigger = this.sub.triggerRef();
+ if (trigger && !this.overlayRef) {
+ this.attachOverlay(trigger.nativeElement);
+ }
+ });
+
+ effect(() => {
+ if (this.menu.visible()) {
+ queueMicrotask(() => this.overlayRef?.updatePosition());
+ }
+ });
+ }
+
+ private attachOverlay(trigger: HTMLElement): void {
+ this.positionStrategy = this.overlay
+ .position()
+ .flexibleConnectedTo(trigger)
+ .withPositions(SUB_CONTENT_POSITIONS)
+ .withFlexibleDimensions(false)
+ .withPush(true)
+ .withViewportMargin(8);
+
+ this.overlayRef = this.overlay.create({
+ positionStrategy: this.positionStrategy,
+ scrollStrategy: this.overlay.scrollStrategies.reposition(),
+ });
+
+ this.overlayRef.attach(new DomPortal(this.elementRef.nativeElement));
+ }
+
+ ngOnDestroy(): void {
+ this.overlayRef?.dispose();
+ }
+}
diff --git a/registry/components/dropdown-menu/dropdown-menu-sub-trigger.component.ts b/registry/components/dropdown-menu/dropdown-menu-sub-trigger.component.ts
new file mode 100644
index 0000000..474449e
--- /dev/null
+++ b/registry/components/dropdown-menu/dropdown-menu-sub-trigger.component.ts
@@ -0,0 +1,59 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ ElementRef,
+ computed,
+ inject,
+ input,
+} from '@angular/core';
+import { MenuItem as ngMenuItem } from '@angular/aria/menu';
+import { LucideChevronRight } from '@lucide/angular';
+import { cn } from '../shared/utils';
+import { MENU_ITEM_SIZE_CLASS } from '../shared/component-styles';
+import { DropdownMenuSubComponent } from './dropdown-menu-sub.component';
+import { DropdownMenuItemVariant } from './dropdown-menu.type';
+
+@Component({
+ selector: 'sanring-dropdown-menu-sub-trigger',
+ standalone: true,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [LucideChevronRight],
+ hostDirectives: [
+ {
+ directive: ngMenuItem,
+ inputs: ['value', 'disabled', 'submenu'],
+ },
+ ],
+ host: {
+ '[attr.data-variant]': 'variant()',
+ '[class]': 'itemClass()',
+ },
+ template: `
+
+
+ `,
+})
+export class DropdownMenuSubTriggerComponent {
+ private readonly sub = inject(DropdownMenuSubComponent);
+ private readonly elementRef = inject(ElementRef);
+
+ readonly class = input();
+ readonly variant = input('default');
+
+ protected readonly itemClass = computed(() =>
+ cn(
+ 'relative flex w-full cursor-default select-none items-center justify-start gap-2 rounded-[var(--sanring-radius-xs)] outline-none',
+ MENU_ITEM_SIZE_CLASS,
+ 'text-[var(--sanring-foreground)] transition-colors',
+ 'hover:bg-[var(--sanring-surface-strong)] focus:bg-[var(--sanring-surface-strong)] data-[active=true]:bg-[var(--sanring-surface-strong)] data-[expanded=true]:bg-[var(--sanring-surface-strong)]',
+ this.variant() === 'destructive' &&
+ 'text-[var(--sanring-error-50)] hover:bg-[color-mix(in_srgb,var(--sanring-error-50)_10%,transparent)] focus:bg-[color-mix(in_srgb,var(--sanring-error-50)_10%,transparent)] data-[active=true]:bg-[color-mix(in_srgb,var(--sanring-error-50)_10%,transparent)]',
+ 'aria-disabled:pointer-events-none aria-disabled:opacity-50',
+ this.class(),
+ ),
+ );
+
+ constructor() {
+ this.sub.registerTrigger(this.elementRef);
+ }
+}
diff --git a/registry/components/dropdown-menu/dropdown-menu-sub.component.ts b/registry/components/dropdown-menu/dropdown-menu-sub.component.ts
new file mode 100644
index 0000000..7315d59
--- /dev/null
+++ b/registry/components/dropdown-menu/dropdown-menu-sub.component.ts
@@ -0,0 +1,21 @@
+import { ChangeDetectionStrategy, Component, ElementRef, signal } from '@angular/core';
+
+@Component({
+ selector: 'sanring-dropdown-menu-sub',
+ standalone: true,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ template: ` `,
+ styles: `
+ :host {
+ display: contents;
+ }
+ `,
+})
+export class DropdownMenuSubComponent {
+ private readonly _triggerRef = signal | null>(null);
+ readonly triggerRef = this._triggerRef.asReadonly();
+
+ registerTrigger(ref: ElementRef): void {
+ this._triggerRef.set(ref);
+ }
+}
diff --git a/registry/components/dropdown-menu/dropdown-menu-trigger.directive.ts b/registry/components/dropdown-menu/dropdown-menu-trigger.directive.ts
index a93fc18..697348f 100644
--- a/registry/components/dropdown-menu/dropdown-menu-trigger.directive.ts
+++ b/registry/components/dropdown-menu/dropdown-menu-trigger.directive.ts
@@ -104,10 +104,12 @@ export class DropdownMenuTriggerDirective implements OnDestroy {
this.overlayRef.attach(new DomPortal(contentElement));
this.overlayRef.outsidePointerEvents().subscribe((event) => {
+ const target = event.target as Node | null;
if (
this.ngTrigger.expanded() &&
- !this.elementRef.nativeElement.contains(event.target as Node) &&
- !contentElement.contains(event.target as Node)
+ !this.elementRef.nativeElement.contains(target) &&
+ !contentElement.contains(target) &&
+ !(target instanceof Element && target.closest('sanring-dropdown-menu-sub-content'))
) {
this.ngTrigger.close();
}
diff --git a/registry/components/dropdown-menu/index.ts b/registry/components/dropdown-menu/index.ts
index e1ff029..8fca9ec 100644
--- a/registry/components/dropdown-menu/index.ts
+++ b/registry/components/dropdown-menu/index.ts
@@ -3,6 +3,9 @@ export * from './dropdown-menu-group.component';
export * from './dropdown-menu-item.directive';
export * from './dropdown-menu-label.component';
export * from './dropdown-menu-separator.component';
+export * from './dropdown-menu-sub.component';
+export * from './dropdown-menu-sub-content.component';
+export * from './dropdown-menu-sub-trigger.component';
export * from './dropdown-menu.component';
export * from './dropdown-menu-trigger.directive';
export * from './dropdown-menu.type';
@@ -12,6 +15,9 @@ import { DropdownMenuGroupComponent } from './dropdown-menu-group.component';
import { DropdownMenuItemDirective } from './dropdown-menu-item.directive';
import { DropdownMenuLabelComponent } from './dropdown-menu-label.component';
import { DropdownMenuSeparatorComponent } from './dropdown-menu-separator.component';
+import { DropdownMenuSubComponent } from './dropdown-menu-sub.component';
+import { DropdownMenuSubContentComponent } from './dropdown-menu-sub-content.component';
+import { DropdownMenuSubTriggerComponent } from './dropdown-menu-sub-trigger.component';
import { DropdownMenuComponent } from './dropdown-menu.component';
import { DropdownMenuTriggerDirective } from './dropdown-menu-trigger.directive';
@@ -23,4 +29,7 @@ export const SANRING_DROPDOWN_MENU_IMPORTS = [
DropdownMenuLabelComponent,
DropdownMenuItemDirective,
DropdownMenuSeparatorComponent,
+ DropdownMenuSubComponent,
+ DropdownMenuSubTriggerComponent,
+ DropdownMenuSubContentComponent,
];
diff --git a/registry/components/popover/popover-content.component.ts b/registry/components/popover/popover-content.component.ts
index d8c967d..b268347 100644
--- a/registry/components/popover/popover-content.component.ts
+++ b/registry/components/popover/popover-content.component.ts
@@ -11,9 +11,11 @@ import {
ElementRef,
Signal,
computed,
+ contentChild,
effect,
inject,
input,
+ numberAttribute,
signal,
untracked,
viewChild,
@@ -22,37 +24,49 @@ import { cn } from '../shared/utils';
import { OVERLAY_SURFACE_CLASS, POPOVER_SURFACE_CLASS } from '../shared/component-styles';
import { POPOVER_LEAVE_DURATION_MS } from '../shared/component-timing';
import { PopoverComponent } from './popover.component';
-import type { PopoverAlign } from './popover.type';
+import { PopoverTitleComponent } from './popover-title.component';
+import type { PopoverAlign, PopoverSide } from './popover.type';
-const GAP = 8;
+const DEFAULT_SIDE_OFFSET = 8;
-type PopoverPlacement = 'top' | 'bottom';
-
-const FALLBACK_PLACEMENTS: readonly PopoverPlacement[] = ['bottom', 'top'];
+function fallbackSides(side: PopoverSide): PopoverSide[] {
+ if (side === 'top') return ['top', 'bottom', 'right', 'left'];
+ if (side === 'right') return ['right', 'left', 'bottom', 'top'];
+ if (side === 'left') return ['left', 'right', 'bottom', 'top'];
+ return ['bottom', 'top', 'right', 'left'];
+}
-function positionFor(placement: PopoverPlacement, align: PopoverAlign): ConnectionPositionPair {
- if (placement === 'top') {
+function positionFor(
+ side: PopoverSide,
+ align: PopoverAlign,
+ offset: number,
+): ConnectionPositionPair {
+ if (side === 'top' || side === 'bottom') {
return {
originX: align,
- originY: 'top',
+ originY: side,
overlayX: align,
- overlayY: 'bottom',
- offsetY: -GAP,
+ overlayY: side === 'bottom' ? 'top' : 'bottom',
+ offsetY: side === 'bottom' ? offset : -offset,
};
}
+ const verticalAlign = align === 'start' ? 'top' : align === 'end' ? 'bottom' : 'center';
+
return {
- originX: align,
- originY: 'bottom',
- overlayX: align,
- overlayY: 'top',
- offsetY: GAP,
+ originX: side === 'right' ? 'end' : 'start',
+ originY: verticalAlign,
+ overlayX: side === 'right' ? 'start' : 'end',
+ overlayY: verticalAlign,
+ offsetX: side === 'right' ? offset : -offset,
};
}
-function getPlacementFromPosition(position: ConnectionPositionPair): PopoverPlacement {
+function getSideFromPosition(position: ConnectionPositionPair): PopoverSide {
if (position.originY === 'top' && position.overlayY === 'bottom') return 'top';
- return 'bottom';
+ if (position.originY === 'bottom' && position.overlayY === 'top') return 'bottom';
+ if (position.originX === 'end' && position.overlayX === 'start') return 'right';
+ return 'left';
}
@Component({
@@ -81,9 +95,10 @@ function getPlacementFromPosition(position: ConnectionPositionPair): PopoverPlac
tabindex="-1"
role="dialog"
[id]="popover.contentId"
- [attr.aria-labelledby]="popover.titleId"
+ [attr.aria-label]="computedAriaLabel()"
+ [attr.aria-labelledby]="computedAriaLabelledBy()"
[attr.aria-describedby]="popover.descId"
- [attr.data-placement]="renderedPlacement()"
+ [attr.data-side]="renderedSide()"
[class]="panelClass()"
(animationend)="onLeaveAnimationEnd($event)"
>
@@ -104,10 +119,22 @@ export class PopoverContentComponent {
private readonly destroyRef = inject(DestroyRef);
readonly class = input();
+ readonly side = input('bottom');
+ readonly sideOffset = input(DEFAULT_SIDE_OFFSET, { transform: numberAttribute });
+ readonly ariaLabel = input();
+ readonly ariaLabelledBy = input();
protected readonly scrollStrategy = this.overlay.scrollStrategies.close();
- protected readonly renderedPlacement = signal('bottom');
+ protected readonly renderedSide = signal('bottom');
private readonly panelEl = viewChild>('panelEl');
+ private readonly title = contentChild(PopoverTitleComponent);
+
+ protected readonly computedAriaLabelledBy = computed(
+ () => this.ariaLabelledBy() ?? (this.title() ? this.popover.titleId : null),
+ );
+ protected readonly computedAriaLabel = computed(() =>
+ this.computedAriaLabelledBy() ? null : (this.ariaLabel() ?? null),
+ );
private readonly _leaving = signal(false);
private _leaveTimer: ReturnType | undefined;
@@ -119,13 +146,13 @@ export class PopoverContentComponent {
protected readonly positions: Signal = computed(() => {
const align = this.popover.align();
- return FALLBACK_PLACEMENTS.map(placement => positionFor(placement, align));
+ const offset = this.sideOffset();
+ return fallbackSides(this.side()).map((side) => positionFor(side, align, offset));
});
constructor() {
effect(() => {
- this.popover.align();
- this.renderedPlacement.set('bottom');
+ this.renderedSide.set(this.side());
});
// Watch for external isOpen → false transitions and play leave animation
@@ -152,7 +179,7 @@ export class PopoverContentComponent {
),
);
- protected requestClose(): void {
+ requestClose(): void {
if (this._leaving() || !this.popover.isOpen()) return;
this.popover.setOpen(false);
// isOpen change triggers the effect which calls _startLeave
@@ -164,11 +191,11 @@ export class PopoverContentComponent {
* tabindex="-1" exists specifically as that fallback target since popover content
* is often not just static text — see the docs "Dimensions" example).
*/
- protected onAttach(): void {
+ onAttach(): void {
this.panelEl()?.nativeElement.focus();
}
- protected onDetach(): void {
+ onDetach(): void {
this._endLeave();
if (this.popover.isOpen()) {
this.popover.setOpen(false);
@@ -176,12 +203,12 @@ export class PopoverContentComponent {
}
/** 退場 CSS 動畫(animate-popover-out)真的播完時觸發,是結束 leaving 狀態的主要途徑 */
- protected onLeaveAnimationEnd(event: AnimationEvent): void {
+ onLeaveAnimationEnd(event: AnimationEvent): void {
if (event.target !== event.currentTarget || !this._leaving()) return;
this._endLeave();
}
- protected handleOverlayKeydown(event: KeyboardEvent): void {
+ handleOverlayKeydown(event: KeyboardEvent): void {
if (event.key !== 'Escape') return;
event.preventDefault();
event.stopPropagation();
@@ -192,8 +219,8 @@ export class PopoverContentComponent {
this.popover.triggerOrigin?.elementRef.nativeElement.focus();
}
- protected handlePositionChange(event: ConnectedOverlayPositionChange): void {
- this.renderedPlacement.set(getPlacementFromPosition(event.connectionPair));
+ handlePositionChange(event: ConnectedOverlayPositionChange): void {
+ this.renderedSide.set(getSideFromPosition(event.connectionPair));
}
private _startLeave(): void {
diff --git a/registry/components/popover/popover.type.ts b/registry/components/popover/popover.type.ts
index 6447f23..6a724af 100644
--- a/registry/components/popover/popover.type.ts
+++ b/registry/components/popover/popover.type.ts
@@ -1 +1,2 @@
export type PopoverAlign = 'start' | 'center' | 'end';
+export type PopoverSide = 'top' | 'right' | 'bottom' | 'left';
diff --git a/registry/components/radio/radio-group.component.ts b/registry/components/radio/radio-group.component.ts
index 1ce0961..cf77d12 100644
--- a/registry/components/radio/radio-group.component.ts
+++ b/registry/components/radio/radio-group.component.ts
@@ -14,9 +14,9 @@ import {
import { _IdGenerator } from '@angular/cdk/a11y';
import { NG_VALUE_ACCESSOR } from '@angular/forms';
import { cn } from '../shared/utils';
-import { SanringCvaBase, SanringFieldControlAdapter } from '../shared/cva-base';
import { FieldType, SANRING_FIELD_CONTROL } from '../field/field.type';
-import { RadioOrientation, RadioValue } from './radio.types';
+import { SanringCvaBase, SanringFieldControlAdapter } from '../shared/cva-base';
+import { RadioOrientation, RadioSize, RadioValue } from './radio.types';
import { RadioItemComponent } from './radio-item.component';
@Component({
@@ -67,6 +67,7 @@ export class RadioGroupComponent extends SanringCvaBase {
readonly required = input(false, { transform: booleanAttribute });
readonly disabled = input(false, { transform: booleanAttribute });
readonly orientation = input(RadioOrientation.Vertical);
+ readonly size = input('md');
readonly ariaLabel = input();
readonly ariaLabelledBy = input();
readonly ariaDescribedBy = input();
diff --git a/registry/components/radio/radio-item.component.ts b/registry/components/radio/radio-item.component.ts
index 0db247e..ea0764d 100644
--- a/registry/components/radio/radio-item.component.ts
+++ b/registry/components/radio/radio-item.component.ts
@@ -11,13 +11,9 @@ import {
import { _IdGenerator } from '@angular/cdk/a11y';
import { LucideCircle } from '@lucide/angular';
import { cn } from '../shared/utils';
-import {
- RADIO_INDICATOR_ICON_CLASS,
- SELECTION_CONTROL_BASE_CLASS,
- SELECTION_CONTROL_FOCUS_CLASS,
-} from '../shared/component-styles';
+import { SELECTION_CONTROL_BASE_CLASS, SELECTION_CONTROL_FOCUS_CLASS } from '../shared/component-styles';
import { RadioGroupComponent } from './radio-group.component';
-import { RADIO_SIZE_CLASS } from './radio.styles';
+import { RADIO_INDICATOR_ICON_SIZE_CLASSES, RADIO_SIZE_CLASSES } from './radio.styles';
import { RadioValue } from './radio.types';
@Component({
@@ -48,15 +44,13 @@ import { RadioValue } from './radio.types';
>
@if (isChecked()) {
-
+
}
`,
})
export class RadioItemComponent {
- protected readonly radioIndicatorIconClass = RADIO_INDICATOR_ICON_CLASS;
-
readonly class = input();
readonly id = input(inject(_IdGenerator).getId('sanring-radio-', true));
readonly value = input.required();
@@ -71,6 +65,10 @@ export class RadioItemComponent {
protected isChecked = computed(() => this.group?.valueSignal() === this.value());
protected isDisabled = computed(() => this.disabled() || (this.group?.isDisabled() ?? false));
+ protected readonly size = computed(() => this.group?.size() ?? 'md');
+ protected readonly radioIndicatorIconClass = computed(
+ () => RADIO_INDICATOR_ICON_SIZE_CLASSES[this.size()],
+ );
protected tabIndex = computed(() => {
if (!this.group) return 0;
return this.group.activeTabItem() === this ? 0 : -1;
@@ -79,7 +77,7 @@ export class RadioItemComponent {
cn(
SELECTION_CONTROL_BASE_CLASS,
SELECTION_CONTROL_FOCUS_CLASS,
- RADIO_SIZE_CLASS,
+ RADIO_SIZE_CLASSES[this.size()],
'rounded-full border border-[var(--sanring-primary)] text-[var(--sanring-primary)]',
'data-[state=checked]:border-[var(--sanring-primary)]',
this.class(),
diff --git a/registry/components/radio/radio.styles.ts b/registry/components/radio/radio.styles.ts
index 3d25cf5..6e7cdda 100644
--- a/registry/components/radio/radio.styles.ts
+++ b/registry/components/radio/radio.styles.ts
@@ -1 +1,13 @@
-export const RADIO_SIZE_CLASS = 'aspect-square h-4 w-4';
+import type { RadioSize } from './radio.types';
+
+export const RADIO_SIZE_CLASSES: Record = {
+ sm: 'aspect-square h-3 w-3',
+ md: 'aspect-square h-4 w-4',
+ lg: 'aspect-square h-5 w-5',
+};
+
+export const RADIO_INDICATOR_ICON_SIZE_CLASSES: Record = {
+ sm: 'h-2 w-2 fill-current text-current',
+ md: 'h-2.5 w-2.5 fill-current text-current',
+ lg: 'h-3 w-3 fill-current text-current',
+};
diff --git a/registry/components/radio/radio.types.ts b/registry/components/radio/radio.types.ts
index 6d647ae..1cb162d 100644
--- a/registry/components/radio/radio.types.ts
+++ b/registry/components/radio/radio.types.ts
@@ -1,4 +1,5 @@
export type RadioValue = string;
+export type RadioSize = 'sm' | 'md' | 'lg';
export enum RadioOrientation {
Horizontal = 'horizontal',
diff --git a/registry/components/sheet/sheet-content.component.ts b/registry/components/sheet/sheet-content.component.ts
index ff464fa..9a8e6f7 100644
--- a/registry/components/sheet/sheet-content.component.ts
+++ b/registry/components/sheet/sheet-content.component.ts
@@ -11,6 +11,7 @@ import {
TemplateRef,
ViewContainerRef,
afterNextRender,
+ booleanAttribute,
computed,
effect,
inject,
@@ -20,8 +21,13 @@ import {
viewChild,
contentChild,
} from '@angular/core';
+import { LucideX } from '@lucide/angular';
import { cn } from '../shared/utils';
-import { OVERLAY_SURFACE_CLASS } from '../shared/component-styles';
+import {
+ OVERLAY_ABSOLUTE_CLOSE_BUTTON_CLASS,
+ OVERLAY_CLOSE_ICON_CLASS,
+ OVERLAY_SURFACE_CLASS,
+} from '../shared/component-styles';
import { SHEET_LEAVE_DURATION_MS } from '../shared/component-timing';
import { SheetComponent } from './sheet.component';
import { SheetDescriptionComponent } from './sheet-description.component';
@@ -54,7 +60,7 @@ const SIDE_LEAVE: Record = {
selector: 'sanring-sheet-content',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
- imports: [CdkTrapFocus],
+ imports: [CdkTrapFocus, LucideX],
host: {
'(document:keydown.escape)': 'onEscape()',
},
@@ -77,6 +83,18 @@ const SIDE_LEAVE: Record = {
(animationend)="onLeaveAnimationEnd($event)"
>
+
+ @if (showClose()) {
+
+
+ 關閉
+
+ }
`,
@@ -95,8 +113,13 @@ export class SheetContentComponent {
private readonly title = contentChild(SheetTitleComponent);
private readonly description = contentChild(SheetDescriptionComponent);
+ protected readonly closeButtonClass = OVERLAY_ABSOLUTE_CLOSE_BUTTON_CLASS;
+ protected readonly closeIconClass = OVERLAY_CLOSE_ICON_CLASS;
+
readonly side = input('right');
readonly class = input();
+ readonly showClose = input(true, { transform: booleanAttribute });
+ readonly closeAriaLabel = input('關閉面板');
/** Accessible-name fallback used when no SheetTitle is projected. */
readonly ariaLabel = input('Sheet');
/** Explicit labelling relationship; takes precedence over SheetTitle and ariaLabel. */
diff --git a/registry/components/sheet/sheet-header.component.ts b/registry/components/sheet/sheet-header.component.ts
index ae81838..bf32ae5 100644
--- a/registry/components/sheet/sheet-header.component.ts
+++ b/registry/components/sheet/sheet-header.component.ts
@@ -10,5 +10,16 @@ import { cn } from '../shared/utils';
})
export class SheetHeaderComponent {
readonly class = input();
- protected readonly hostClass = computed(() => cn('flex flex-col gap-1.5', this.class()));
+
+ readonly align = input<'start' | 'center' | undefined>(undefined);
+
+ protected readonly hostClass = computed(() =>
+ cn(
+ 'flex flex-col gap-1.5',
+ this.align() === 'center' && 'items-center text-center',
+ this.align() === 'start' && 'items-start text-left',
+ !this.align() && 'text-center sm:text-left',
+ this.class(),
+ ),
+ );
}
diff --git a/registry/components/table/cell.directive.ts b/registry/components/table/cell.directive.ts
index 27ee31e..c118ecc 100644
--- a/registry/components/table/cell.directive.ts
+++ b/registry/components/table/cell.directive.ts
@@ -68,7 +68,9 @@ export class TableHeaderCellDirective {
standalone: true,
// 🧠 當 蛋糕出生時,我們強行把 CDK 原生的 CdkCell 邏輯塞進去!
hostDirectives: [CdkCell],
- host: { class: 'p-4 align-middle [&:has([role=checkbox])]:pr-0' },
+ host: {
+ class: 'p-4 align-middle [&:has([role=checkbox])]:pr-0',
+ },
})
export class TableCellDirective {}
@@ -77,6 +79,8 @@ export class TableCellDirective {}
standalone: true,
// 🧠 當 footer 蛋糕出生時,我們強行把 CDK 原生的 CdkFooterCell 邏輯塞進去!
hostDirectives: [CdkFooterCell],
- host: { class: 'border-t p-4 align-middle font-medium [&:has([role=checkbox])]:pr-0' },
+ host: {
+ class: 'border-t p-4 align-middle font-medium [&:has([role=checkbox])]:pr-0',
+ },
})
export class TableFooterCellDirective {}
diff --git a/registry/components/table/table-container.component.ts b/registry/components/table/table-container.component.ts
index bb0e6b1..93a9094 100644
--- a/registry/components/table/table-container.component.ts
+++ b/registry/components/table/table-container.component.ts
@@ -1,14 +1,21 @@
-import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
+import { ChangeDetectionStrategy, Component, computed, input, ViewEncapsulation } from '@angular/core';
import { cn } from '../shared/utils';
@Component({
selector: 'sanring-table-container',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
+ encapsulation: ViewEncapsulation.None,
template: ` `,
host: {
'[class]': 'containerClass()',
},
+ styles: `
+ sanring-table-container .cdk-table-sticky,
+ sanring-table-container .cdk-table-sticky-end {
+ background-color: var(--sanring-background);
+ }
+ `,
})
export class TableContainerComponent {
readonly class = input();
diff --git a/registry/components/transfer/transfer-panel.component.ts b/registry/components/transfer/transfer-panel.component.ts
index 14fbf55..6ece486 100644
--- a/registry/components/transfer/transfer-panel.component.ts
+++ b/registry/components/transfer/transfer-panel.component.ts
@@ -34,7 +34,9 @@ export class TransferPanelComponent {
// target 面板在 one-way 模式下變成唯讀展示:不能勾選,也就無法被搬回 source
readonly interactive = computed(
- () => !(this.direction() === 'target' && this.transfer.mode() === 'one-way'),
+ () =>
+ !this.transfer.isDisabled() &&
+ !(this.direction() === 'target' && this.transfer.mode() === 'one-way'),
);
// List/Item 元件只認得「這個 panel 該顯示哪些 key、勾選狀態怎麼查/切換」,
diff --git a/registry/components/transfer/transfer.component.ts b/registry/components/transfer/transfer.component.ts
index 5e50cb0..caccd98 100644
--- a/registry/components/transfer/transfer.component.ts
+++ b/registry/components/transfer/transfer.component.ts
@@ -1,4 +1,12 @@
-import { ChangeDetectionStrategy, Component, computed, input, model, signal } from '@angular/core';
+import {
+ ChangeDetectionStrategy,
+ Component,
+ booleanAttribute,
+ computed,
+ input,
+ model,
+ signal,
+} from '@angular/core';
import { cn } from '../shared/utils';
import { TransferItem, TransferMode } from './transfer.type';
@@ -9,13 +17,19 @@ import { TransferItem, TransferMode } from './transfer.type';
template: ` `,
host: {
'[class]': 'rootClass()',
+ role: 'group',
+ '[attr.aria-label]': 'ariaLabel()',
+ '[attr.aria-disabled]': 'isDisabled() || null',
},
})
export class TransferComponent {
readonly items = input([]);
readonly class = input();
+ readonly disabled = input(false, { transform: booleanAttribute });
+ readonly ariaLabel = input();
protected readonly rootClass = computed(() => cn(this.class()));
+ readonly isDisabled = computed(() => this.disabled());
// 雙向綁定:目前在右側(target)的 key 清單,父層可用 [(selectedKeys)] 拿到結果
readonly selectedKeys = model([]);
readonly mode = input('two-way');
@@ -46,6 +60,7 @@ export class TransferComponent {
}
toggleSourceSelected(key: string): void {
+ if (this.isDisabled()) return;
if (this.itemsByKey().get(key)?.disabled) return;
this.sourceSelectedKeys.update((keys) =>
keys.includes(key) ? keys.filter((k) => k !== key) : [...keys, key],
@@ -54,6 +69,7 @@ export class TransferComponent {
toggleTargetSelected(key: string): void {
// one-way 模式下 target 是唯讀展示,不接受勾選
+ if (this.isDisabled()) return;
if (this.mode() === 'one-way') return;
if (this.itemsByKey().get(key)?.disabled) return;
this.targetSelectedKeys.update((keys) =>
@@ -62,6 +78,7 @@ export class TransferComponent {
}
moveToTarget(): void {
+ if (this.isDisabled()) return;
// 用當下的 sourceItems 過濾一次,避免 items() 從外部變動後 sourceSelectedKeys 裡殘留已經不存在的 key
const validKeys = new Set(this.sourceItems().map((item) => item.key));
const moving = this.sourceSelectedKeys().filter((key) => validKeys.has(key));
@@ -72,6 +89,7 @@ export class TransferComponent {
}
moveToSource(): void {
+ if (this.isDisabled()) return;
if (this.mode() === 'one-way') return;
const moving = new Set(this.targetSelectedKeys().filter((key) => this.itemsByKey().has(key)));
if (moving.size === 0) return;
@@ -81,10 +99,12 @@ export class TransferComponent {
}
setSourceSelectedKeys(keys: string[]): void {
+ if (this.isDisabled()) return;
this.sourceSelectedKeys.set(keys);
}
setTargetSelectedKeys(keys: string[]): void {
+ if (this.isDisabled()) return;
if (this.mode() === 'one-way') return;
this.targetSelectedKeys.set(keys);
}
diff --git a/registry/registry.json b/registry/registry.json
index d017a19..ae86deb 100644
--- a/registry/registry.json
+++ b/registry/registry.json
@@ -618,7 +618,8 @@
"component-timing"
],
"peerDependencies": {
- "@angular/cdk": "^22.0.0"
+ "@angular/cdk": "^22.0.0",
+ "@lucide/angular": "^1.18.0"
},
"files": [
"sheet/sheet.component.ts",
@@ -888,7 +889,8 @@
],
"peerDependencies": {
"@angular/aria": "^22.0.0",
- "@angular/cdk": "^22.0.0"
+ "@angular/cdk": "^22.0.0",
+ "@lucide/angular": "^1.18.0"
},
"files": [
"dropdown-menu/dropdown-menu.component.ts",
@@ -897,6 +899,9 @@
"dropdown-menu/dropdown-menu-item.directive.ts",
"dropdown-menu/dropdown-menu-label.component.ts",
"dropdown-menu/dropdown-menu-separator.component.ts",
+ "dropdown-menu/dropdown-menu-sub.component.ts",
+ "dropdown-menu/dropdown-menu-sub-content.component.ts",
+ "dropdown-menu/dropdown-menu-sub-trigger.component.ts",
"dropdown-menu/dropdown-menu-trigger.directive.ts",
"dropdown-menu/dropdown-menu.type.ts",
"dropdown-menu/index.ts"
diff --git a/registry/shared/component-styles.ts b/registry/shared/component-styles.ts
index 853090f..6e1826c 100644
--- a/registry/shared/component-styles.ts
+++ b/registry/shared/component-styles.ts
@@ -19,6 +19,8 @@ export const OVERLAY_SURFACE_CLASS =
'border border-[var(--sanring-border)] bg-[var(--sanring-elevated)] text-[var(--sanring-foreground)] shadow-lg';
export const POPOVER_SURFACE_CLASS = 'z-50 w-72 rounded-md p-4 shadow-md outline-none';
export const OVERLAY_CLOSE_ICON_CLASS = 'size-4';
+export const OVERLAY_ABSOLUTE_CLOSE_BUTTON_CLASS =
+ 'absolute right-4 top-4 rounded-[var(--sanring-radius-xs)] text-[var(--sanring-muted)] opacity-70 ring-offset-[var(--sanring-surface)] transition-colors transition-opacity hover:text-[var(--sanring-foreground)] hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--sanring-border-strong)] focus-visible:ring-offset-2 disabled:pointer-events-none';
export const SELECTION_CONTROL_BASE_CLASS =
'peer flex shrink-0 items-center justify-center transition-colors disabled:cursor-not-allowed disabled:opacity-50';
From 03dbf7cbf0c24bdf492f26451b046a18312d97ef Mon Sep 17 00:00:00 2001
From: jack755051
Date: Wed, 23 Sep 2026 01:18:05 +0800
Subject: [PATCH 12/13] docs(docs): align recent-changes with page sections
Render recent changes through the shared section chrome, surface it in
the TOC, drop the unused hint component, and restyle the page header.
Co-authored-by: Cursor
---
.../component-page-hint.component.ts | 18 --------
...component-page-recent-changes.component.ts | 43 ++++++++-----------
.../component-page-section.component.ts | 41 ++++++++++--------
.../component-page.component.ts | 17 +++++++-
.../docs-page-header.component.ts | 13 +++++-
.../src/app/layouts/component-page/index.ts | 1 -
.../combobox/combobox-page.component.ts | 18 ++++----
7 files changed, 75 insertions(+), 76 deletions(-)
delete mode 100644 apps/docs/src/app/layouts/component-page/component-page-hint.component.ts
diff --git a/apps/docs/src/app/layouts/component-page/component-page-hint.component.ts b/apps/docs/src/app/layouts/component-page/component-page-hint.component.ts
deleted file mode 100644
index 46520d3..0000000
--- a/apps/docs/src/app/layouts/component-page/component-page-hint.component.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import { Component, computed, input } from '@angular/core';
-import { cn } from '@sanring/ui';
-
-@Component({
- selector: 'app-component-page-hint',
- standalone: true,
- template: ` `,
- host: {
- '[class]': 'hintClass()',
- },
-})
-export class ComponentPageHintComponent {
- readonly class = input();
-
- protected readonly hintClass = computed(() =>
- cn('m-0 text-xs leading-5 text-[var(--docs-muted)]', this.class()),
- );
-}
diff --git a/apps/docs/src/app/layouts/component-page/component-page-recent-changes.component.ts b/apps/docs/src/app/layouts/component-page/component-page-recent-changes.component.ts
index 678f4be..5b31866 100644
--- a/apps/docs/src/app/layouts/component-page/component-page-recent-changes.component.ts
+++ b/apps/docs/src/app/layouts/component-page/component-page-recent-changes.component.ts
@@ -1,5 +1,6 @@
import { Component, computed, inject, input } from '@angular/core';
import { RouterLink } from '@angular/router';
+import { ComponentPageSectionDefinition } from '../../docs-schema/component-page.types';
import { DocsComponentId } from '../../navigation/docs-navigation';
import { I18nService } from '../../i18n/i18n.service';
import {
@@ -7,6 +8,7 @@ import {
ComponentChangeType,
cliVersionChangelog,
} from '../../pages/changelog/component-changelog';
+import { ComponentPageSectionComponent } from './component-page-section.component';
interface ComponentRecentChange {
version: string;
@@ -45,35 +47,20 @@ function renderInlineCode(text: string): string {
@Component({
selector: 'app-component-page-recent-changes',
standalone: true,
- imports: [RouterLink],
+ imports: [RouterLink, ComponentPageSectionComponent],
template: `
@if (changes().length > 0) {
-
-
+ {{ i18n.t('component.recentChanges.viewAll') }}
+
@for (entry of changes(); track entry.version + '-' + $index) {
}
-
+
}
`,
})
export class ComponentPageRecentChangesComponent {
readonly componentId = input(null);
+ protected readonly section = {
+ id: 'recent-changes',
+ titleKey: 'component.recentChanges.title',
+ descriptionKey: 'component.recentChanges.description',
+ } as const satisfies ComponentPageSectionDefinition;
+
protected readonly i18n = inject(I18nService);
protected readonly chipClass = CHIP_CLASS;
protected readonly typeClass = TYPE_CLASS;
diff --git a/apps/docs/src/app/layouts/component-page/component-page-section.component.ts b/apps/docs/src/app/layouts/component-page/component-page-section.component.ts
index e1ace28..abe303b 100644
--- a/apps/docs/src/app/layouts/component-page/component-page-section.component.ts
+++ b/apps/docs/src/app/layouts/component-page/component-page-section.component.ts
@@ -21,27 +21,30 @@ interface StructuredDescriptionItem {
template: `
-
- @if (level === 2) {
-
- }
- @switch (level) {
- @case (4) {
-
- {{ i18n.t(section.titleKey) }}
-
- }
- @case (3) {
-
- {{ i18n.t(section.titleKey) }}
-
+
+
+ @if (level === 2) {
+
}
- @default {
-
- {{ i18n.t(section.titleKey) }}
-
+ @switch (level) {
+ @case (4) {
+
+ {{ i18n.t(section.titleKey) }}
+
+ }
+ @case (3) {
+
+ {{ i18n.t(section.titleKey) }}
+
+ }
+ @default {
+
+ {{ i18n.t(section.titleKey) }}
+
+ }
}
- }
+
+
@if (section.descriptionKey) {
diff --git a/apps/docs/src/app/layouts/component-page/component-page.component.ts b/apps/docs/src/app/layouts/component-page/component-page.component.ts
index 2aefbf6..c701bd6 100644
--- a/apps/docs/src/app/layouts/component-page/component-page.component.ts
+++ b/apps/docs/src/app/layouts/component-page/component-page.component.ts
@@ -2,6 +2,7 @@ import { Component, effect, inject, Input, OnChanges, OnDestroy, input } from '@
import { ComponentPageSectionDefinition } from '../../docs-schema/component-page.types';
import { DocsComponentId } from '../../navigation/docs-navigation';
import { I18nService } from '../../i18n/i18n.service';
+import { cliVersionChangelog } from '../../pages/changelog/component-changelog';
import { DocsTocItem, DocsTocService } from '../../shell/toc/docs-toc.service';
import { ComponentPageRecentChangesComponent } from './component-page-recent-changes.component';
@@ -46,7 +47,21 @@ export class ComponentPageComponent implements OnChanges, OnDestroy {
}
private updateToc() {
- this.toc.setItems(this.flattenSections(this.sections));
+ const items = this.flattenSections(this.sections);
+ const componentId = this.componentId();
+ const hasChanges =
+ !!componentId &&
+ cliVersionChangelog.some((version) =>
+ version.changes.some((change) => change.componentIds?.includes(componentId)),
+ );
+ if (hasChanges) {
+ items.push({
+ id: 'recent-changes',
+ label: this.i18n.t('component.recentChanges.title'),
+ level: 2,
+ });
+ }
+ this.toc.setItems(items);
}
private flattenSections(sections: readonly ComponentPageSectionDefinition[]): DocsTocItem[] {
diff --git a/apps/docs/src/app/layouts/component-page/docs-page-header.component.ts b/apps/docs/src/app/layouts/component-page/docs-page-header.component.ts
index 51bb495..ed843e2 100644
--- a/apps/docs/src/app/layouts/component-page/docs-page-header.component.ts
+++ b/apps/docs/src/app/layouts/component-page/docs-page-header.component.ts
@@ -7,8 +7,17 @@ import { Component, Input } from '@angular/core';
class: 'block min-w-0',
},
template: `
-
-
+
{{ i18n.t('combobox.demo.popupDescription') }}
-
+
@@ -276,9 +274,9 @@ import { comboboxPage, comboboxPageExamples } from './combobox.docs';
-
+
{{ i18n.t('combobox.demo.clearButtonDescription') }}
-
+
From 85c91cbbc18808d721dfc8bbcc61ec2830b3869b Mon Sep 17 00:00:00 2001
From: jack755051
Date: Wed, 23 Sep 2026 01:18:10 +0800
Subject: [PATCH 13/13] chore: release @sanring/cli 0.25.1
Consume pending changesets and point docs version notes / updated
badges at 0.25.1 for P32 and the related field polish.
Co-authored-by: Cursor
---
.changeset/count-inside-dialog-header-bg.md | 5 --
.changeset/login-remember-me-label.md | 4 --
.changeset/p32-sibling-api-alignment.md | 5 --
TODOLIST.md | 2 +-
.../pages/changelog/component-changelog.ts | 64 ++++++++++++-------
packages/cli/CHANGELOG.md | 8 +++
packages/cli/package.json | 2 +-
7 files changed, 50 insertions(+), 40 deletions(-)
delete mode 100644 .changeset/count-inside-dialog-header-bg.md
delete mode 100644 .changeset/login-remember-me-label.md
delete mode 100644 .changeset/p32-sibling-api-alignment.md
diff --git a/.changeset/count-inside-dialog-header-bg.md b/.changeset/count-inside-dialog-header-bg.md
deleted file mode 100644
index 40a1ba9..0000000
--- a/.changeset/count-inside-dialog-header-bg.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@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/.changeset/login-remember-me-label.md b/.changeset/login-remember-me-label.md
deleted file mode 100644
index a53fc98..0000000
--- a/.changeset/login-remember-me-label.md
+++ /dev/null
@@ -1,4 +0,0 @@
----
----
-
-Associate the login block remember-me label so eslint template/label-has-associated-control passes.
diff --git a/.changeset/p32-sibling-api-alignment.md b/.changeset/p32-sibling-api-alignment.md
deleted file mode 100644
index ae967d7..0000000
--- a/.changeset/p32-sibling-api-alignment.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@sanring/cli': patch
----
-
-Align sibling APIs on existing primitives: sheet close/header, radio size, a real dropdown submenu, popover side and aria fallback, transfer root disabled, and table sticky backgrounds.
diff --git a/TODOLIST.md b/TODOLIST.md
index 8a8a464..5ffbdfe 100644
--- a/TODOLIST.md
+++ b/TODOLIST.md
@@ -49,7 +49,7 @@
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`/獨立底色**不列入本項**,那些收斂完再看要不要疊。
+0.25.0 已出(blocks / dialog header align / field textarea projection)。0.25.1 收斂 input/textarea/combobox count、combobox chip wrap、dialog header 獨立底色,以及 P32 sibling API。
### 該做
diff --git a/apps/docs/src/app/pages/changelog/component-changelog.ts b/apps/docs/src/app/pages/changelog/component-changelog.ts
index 8e80ced..9d4b02d 100644
--- a/apps/docs/src/app/pages/changelog/component-changelog.ts
+++ b/apps/docs/src/app/pages/changelog/component-changelog.ts
@@ -36,63 +36,79 @@ function isPatch(version: string): boolean {
*/
export const cliVersionChangelog: readonly CliVersionEntry[] = [
{
- version: '0.25.0',
- date: '2026-09-21',
+ version: '0.25.1',
+ date: '2026-09-23',
changes: [
{
type: 'added',
- notable: true,
- text: 'Registry gains a `blocks` category (`sanring add block/login`) and accepts `github:owner/repo` as a registry source.',
+ componentIds: ['sheet'],
+ text: '`sanring-sheet-content` renders a built-in close button (`showClose`, `closeAriaLabel`). `sanring-sheet-header` accepts `align` (`start` | `center`).',
},
{
type: 'added',
- componentIds: ['dialog'],
- text: '`sanring-dialog-header` accepts `align` (`start` | `center`). Header `class` can set a background that is not the dialog body surface.',
+ componentIds: ['radio'],
+ text: '`sanring-radio-group` accepts `size` (`sm` | `md` | `lg`), matching checkbox and switch.',
},
{
type: 'added',
- componentIds: ['input', 'textarea'],
- text: 'Docs examples show a live character count inside the control on the right, so `sanring-error-message` can stay below.',
+ componentIds: ['dropdown-menu'],
+ text: '`sanring-dropdown-menu-sub`, `sanring-dropdown-menu-sub-trigger`, and `sanring-dropdown-menu-sub-content` open a nested menu with Arrow Right / Arrow Left.',
},
{
type: 'added',
- componentIds: ['combobox'],
- 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.',
+ componentIds: ['popover'],
+ text: '`sanring-popover-content` accepts four-way `side` and only sets `aria-labelledby` when a title is projected; `ariaLabel` is the fallback.',
+ },
+ {
+ type: 'added',
+ componentIds: ['transfer'],
+ text: '`sanring-transfer` accepts root `disabled` and `ariaLabel` on the `role="group"` host.',
},
{
type: 'changed',
- componentIds: ['field'],
- text: '`sanring-field` now projects `textarea[sanringTextarea]` into the control slot, so a character-count description renders below the textarea instead of above it.',
+ componentIds: ['table'],
+ text: 'Sticky header, body, and footer cells use an opaque `--sanring-background` so scrolled text does not show through. Docs add loading skeleton and column-visibility recipes.',
},
{
type: 'added',
- componentIds: ['sheet'],
- text: '`sanring-sheet-content` renders a built-in close button (`showClose`, `closeAriaLabel`). `sanring-sheet-header` accepts `align` (`start` | `center`).',
+ componentIds: ['dialog'],
+ text: '`sanring-dialog-header` `class` can set a background that is not the dialog body surface.',
},
{
type: 'added',
- componentIds: ['radio'],
- text: '`sanring-radio-group` accepts `size` (`sm` | `md` | `lg`), matching checkbox and switch.',
+ componentIds: ['input', 'textarea'],
+ text: 'Docs examples show a live character count inside the control on the right, so `sanring-error-message` can stay below.',
},
{
type: 'added',
- componentIds: ['dropdown-menu'],
- text: '`sanring-dropdown-menu-sub`, `sanring-dropdown-menu-sub-trigger`, and `sanring-dropdown-menu-sub-content` open a nested menu with Arrow Right / Arrow Left.',
+ componentIds: ['combobox'],
+ 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: 'fixed',
+ componentIds: ['field'],
+ text: 'Floating labels animate with `scale` instead of swapping font sizes, and keep the notch gradient in both floated states.',
+ },
+ ],
+ },
+ {
+ version: '0.25.0',
+ date: '2026-09-21',
+ changes: [
{
type: 'added',
- componentIds: ['popover'],
- text: '`sanring-popover-content` accepts four-way `side` and only sets `aria-labelledby` when a title is projected; `ariaLabel` is the fallback.',
+ notable: true,
+ text: 'Registry gains a `blocks` category (`sanring add block/login`) and accepts `github:owner/repo` as a registry source.',
},
{
type: 'added',
- componentIds: ['transfer'],
- text: '`sanring-transfer` accepts root `disabled` and `ariaLabel` on the `role="group"` host.',
+ componentIds: ['dialog'],
+ text: '`sanring-dialog-header` accepts `align` (`start` | `center`).',
},
{
type: 'changed',
- componentIds: ['table'],
- text: 'Sticky header, body, and footer cells use an opaque `--sanring-background` so scrolled text does not show through. Docs add loading skeleton and column-visibility recipes.',
+ componentIds: ['field'],
+ text: '`sanring-field` now projects `textarea[sanringTextarea]` into the control slot, so a character-count description renders below the textarea instead of above it.',
},
],
},
diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md
index f7aa9a8..4bb9614 100644
--- a/packages/cli/CHANGELOG.md
+++ b/packages/cli/CHANGELOG.md
@@ -1,5 +1,13 @@
# @sanring/cli
+## 0.25.1
+
+### Patch Changes
+
+- 7b17eb9: 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.
+- Smooth the floating field label transition with scale instead of font-size swaps, and keep the notch gradient in both floated states.
+- a203334: Align sibling APIs on existing primitives: sheet close/header, radio size, a real dropdown submenu, popover side and aria fallback, transfer root disabled, and table sticky backgrounds.
+
## 0.25.0
### Minor Changes
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 247393f..15ea47f 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "@sanring/cli",
- "version": "0.25.0",
+ "version": "0.25.1",
"description": "CLI for adding Sanring UI components to your Angular project",
"license": "MIT",
"type": "module",