@edmicro/olkit 1.1.5 → 1.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import * as i0 from '@angular/core';
2
- import { inject, Service, input, signal, computed, effect, ChangeDetectionStrategy, Component, forwardRef, model, output, contentChildren, HostListener, DOCUMENT, Injectable, DestroyRef, viewChild, ElementRef, Renderer2, ApplicationRef, EnvironmentInjector, createComponent, Directive, contentChild, ViewContainerRef, ContentChild, ViewChildren, ViewChild } from '@angular/core';
2
+ import { inject, Service, input, signal, computed, effect, ChangeDetectionStrategy, Component, forwardRef, model, output, contentChildren, HostListener, DOCUMENT, Injectable, DestroyRef, viewChild, ElementRef, Renderer2, ApplicationRef, EnvironmentInjector, createComponent, Directive, contentChild, ViewContainerRef, ContentChild, untracked, ViewChildren, ViewChild, afterNextRender } from '@angular/core';
3
3
  import * as i1 from '@angular/common';
4
4
  import { CommonModule, NgTemplateOutlet, NgStyle } from '@angular/common';
5
5
  import { DomSanitizer } from '@angular/platform-browser';
@@ -1226,14 +1226,40 @@ class OlFocusTrapService {
1226
1226
  this.previousActiveElement = document.activeElement;
1227
1227
  this.trapElement = element;
1228
1228
  this.updateFocusableElements();
1229
- if (autoFocus && this.focusableElements.length > 0) {
1230
- // Focus vào element đầu tiên
1231
- this.focusableElements[0].focus();
1232
- }
1229
+ if (autoFocus)
1230
+ this.initialFocusTarget()?.focus();
1233
1231
  // Add event listener cho tab key
1234
1232
  document.addEventListener('keydown', this.handleKeyDown);
1235
1233
  this.lockScroll();
1236
1234
  }
1235
+ /**
1236
+ * Chọn phần tử nhận focus khi overlay mở.
1237
+ *
1238
+ * Bản trước lấy phần tử focus được ĐẦU TIÊN theo thứ tự DOM. Header đứng
1239
+ * trước nội dung nên mở modal/drawer nào tiêu điểm cũng rơi vào nút ✕ —
1240
+ * người dùng phải Tab qua nút đóng mới tới ô nhập đầu tiên, và lỡ Enter là
1241
+ * đóng mất hộp thoại vừa mở (báo 2026-09-28).
1242
+ *
1243
+ * Thứ tự ưu tiên:
1244
+ * 1. phần tử nơi gọi đánh dấu `autofocus` / `data-autofocus` — trình duyệt
1245
+ * chỉ tôn trọng `autofocus` lúc tải trang, không phải lúc chèn hộp thoại,
1246
+ * nên service tự làm
1247
+ * 2. phần tử đầu tiên KHÔNG phải nút đóng (`[data-ol-dismiss]`) — thường là ô
1248
+ * nhập đầu tiên trong nội dung; nội dung không có gì bấm được thì tới nút
1249
+ * đầu tiên ở footer
1250
+ * 3. không còn gì khác mới về nút đóng, như cũ
1251
+ */
1252
+ initialFocusTarget() {
1253
+ const root = this.trapElement;
1254
+ if (!root)
1255
+ return null;
1256
+ const explicit = root.querySelector('[autofocus], [data-autofocus]');
1257
+ if (explicit && this.focusableElements.includes(explicit))
1258
+ return explicit;
1259
+ return (this.focusableElements.find((el) => !el.closest('[data-ol-dismiss]')) ??
1260
+ this.focusableElements[0] ??
1261
+ null);
1262
+ }
1237
1263
  /**
1238
1264
  * Release focus trap và restore focus về element trước đó
1239
1265
  */
@@ -1318,12 +1344,26 @@ class OlFocusTrapService {
1318
1344
  if (!this.trapElement)
1319
1345
  return;
1320
1346
  const elements = Array.from(this.trapElement.querySelectorAll(this.focusableSelector));
1321
- // Filter các elements thực sự visible và có thể interact
1322
- this.focusableElements = elements.filter((el) => {
1323
- return (el.offsetParent !== null &&
1324
- !el.hasAttribute('disabled') &&
1325
- el.getAttribute('tabindex') !== '-1');
1326
- });
1347
+ this.focusableElements = elements.filter((el) => this.isVisible(el) && !el.hasAttribute('disabled') && el.getAttribute('tabindex') !== '-1');
1348
+ }
1349
+ /**
1350
+ * Phần tử có đang hiện không.
1351
+ *
1352
+ * KHÔNG dùng `offsetParent !== null`: nó trả `null` cho chính phần tử
1353
+ * `position: fixed` (nút nổi trong drawer/modal) dù phần tử hiện rõ, và luôn
1354
+ * `null` trong jsdom — nên mọi phần tử bị lọc mất, service không focus được
1355
+ * gì và spec không viết được.
1356
+ */
1357
+ isVisible(el) {
1358
+ if (typeof el.checkVisibility === 'function') {
1359
+ return el.checkVisibility({ visibilityProperty: true });
1360
+ }
1361
+ // jsdom và trình duyệt cũ không có `checkVisibility`.
1362
+ for (let node = el; node; node = node.parentElement) {
1363
+ if (getComputedStyle(node).display === 'none')
1364
+ return false;
1365
+ }
1366
+ return getComputedStyle(el).visibility !== 'hidden';
1327
1367
  }
1328
1368
  /**
1329
1369
  * Handle keyboard navigation trong trap
@@ -1928,12 +1968,250 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImpor
1928
1968
  }]
1929
1969
  }], propDecorators: { scrollbar: [{ type: i0.Input, args: [{ isSignal: true, alias: "scrollbar", required: false }] }], axis: [{ type: i0.Input, args: [{ isSignal: true, alias: "axis", required: false }] }], smooth: [{ type: i0.Input, args: [{ isSignal: true, alias: "smooth", required: false }] }], innerClass: [{ type: i0.Input, args: [{ isSignal: true, alias: "innerClass", required: false }] }], viewportRef: [{ type: i0.ViewChild, args: ['viewport', { isSignal: true }] }] } });
1930
1970
 
1971
+ /**
1972
+ * Mục lục tự đánh dấu phần đang xem.
1973
+ *
1974
+ * Chưa có node Figma — dựng theo design system, mục đang xem dùng
1975
+ * `text-fg-brand` và một vạch `bg-brand` bên trái (dọc) hoặc dưới (ngang).
1976
+ *
1977
+ * ## Vì sao `IntersectionObserver` chứ không nghe sự kiện `scroll`
1978
+ *
1979
+ * Nghe `scroll` thì mỗi lần cuộn là một lần chạy tay tính `getBoundingClientRect()`
1980
+ * cho MỌI phần — trang 63 mục như docs olkit là 63 phép đo mỗi khung hình.
1981
+ * `IntersectionObserver` để trình duyệt tự báo, và chỉ báo phần nào đổi trạng
1982
+ * thái.
1983
+ *
1984
+ * ## `rootMargin` quyết định "đang xem" nghĩa là gì
1985
+ *
1986
+ * Mặc định `-20% 0px -70% 0px` thu vùng quan sát thành một dải ngang mỏng ở
1987
+ * khoảng 1/4 trên màn hình. Không thu thì phần nào chạm mép dưới màn hình cũng
1988
+ * tính là đang xem, và cuộn một nhịp có thể sáng ba mục cùng lúc.
1989
+ *
1990
+ * Dải mỏng cũng xử lý được phần NGẮN hơn màn hình: nếu đợi phần chiếm trọn
1991
+ * viewport mới tính, mục ngắn không bao giờ được đánh dấu.
1992
+ *
1993
+ * ## Phần cuối trang không bao giờ chạm dải
1994
+ *
1995
+ * Cuộn hết cỡ mà phần cuối vẫn nằm dưới dải quan sát thì nó không sáng. Nên
1996
+ * khi chạm đáy trang, component đánh dấu thẳng mục cuối — `atBottom()`.
1997
+ *
1998
+ * ## Lưu ý khi viết test
1999
+ *
2000
+ * jsdom KHÔNG có `IntersectionObserver` (đo 2026-09-26: `typeof` trả
2001
+ * `'undefined'`). Component tự bảo vệ bằng cách kiểm trước khi dùng, nên spec
2002
+ * dựng được và khoá được phần hiển thị; còn việc đánh dấu theo cuộn phải
2003
+ * nghiệm thu trên trình duyệt. Đừng giả lập `IntersectionObserver` rồi tưởng
2004
+ * đã kiểm hành vi thật.
2005
+ */
2006
+ class OlScrollspy {
2007
+ elementRef = inject(ElementRef);
2008
+ destroyRef = inject(DestroyRef);
2009
+ items = input([], /* @ts-ignore */
2010
+ ...(ngDevMode ? [{ debugName: "items" }] : /* istanbul ignore next */ []));
2011
+ orientation = input('vertical', /* @ts-ignore */
2012
+ ...(ngDevMode ? [{ debugName: "orientation" }] : /* istanbul ignore next */ []));
2013
+ ariaLabel = input('Mục lục trang', /* @ts-ignore */
2014
+ ...(ngDevMode ? [{ debugName: "ariaLabel" }] : /* istanbul ignore next */ []));
2015
+ /**
2016
+ * Vùng quan sát, theo cú pháp `rootMargin` của `IntersectionObserver`.
2017
+ *
2018
+ * Đổi giá trị này khi trang có thanh trên cố định: `-80px 0px -70% 0px` đẩy
2019
+ * mép trên xuống dưới thanh đó.
2020
+ */
2021
+ rootMargin = input('-20% 0px -70% 0px', /* @ts-ignore */
2022
+ ...(ngDevMode ? [{ debugName: "rootMargin" }] : /* istanbul ignore next */ []));
2023
+ /** Cuộn mượt khi bấm mục. Tắt nếu trang tự lo việc cuộn. */
2024
+ smoothScroll = input(true, /* @ts-ignore */
2025
+ ...(ngDevMode ? [{ debugName: "smoothScroll" }] : /* istanbul ignore next */ []));
2026
+ /**
2027
+ * Khung cuộn chứa nội dung. `null` (mặc định) nghĩa là cả trang.
2028
+ *
2029
+ * BẮT BUỘC truyền khi nội dung nằm trong một khung `overflow-y-auto` riêng:
2030
+ * `IntersectionObserver` mặc định quan sát theo VIEWPORT, nên phần nào cũng
2031
+ * "đang hiện" khi khung con nằm trong tầm mắt — đo được: cuộn khung con từ
2032
+ * đầu tới đáy mà không mục nào sáng.
2033
+ */
2034
+ scrollRoot = input(null, /* @ts-ignore */
2035
+ ...(ngDevMode ? [{ debugName: "scrollRoot" }] : /* istanbul ignore next */ []));
2036
+ currentId = signal('', /* @ts-ignore */
2037
+ ...(ngDevMode ? [{ debugName: "currentId" }] : /* istanbul ignore next */ []));
2038
+ /** `id` của phần đang xem. Rỗng khi chưa phần nào vào vùng quan sát. */
2039
+ activeId = this.currentId.asReadonly();
2040
+ activeChange = output();
2041
+ observer = null;
2042
+ constructor() {
2043
+ effect(() => {
2044
+ const list = this.items();
2045
+ const margin = this.rootMargin();
2046
+ const root = this.scrollRoot();
2047
+ this.stopObserving();
2048
+ if (!list.length || typeof IntersectionObserver === 'undefined')
2049
+ return;
2050
+ // `queueMicrotask` để phần tử đích đã có trong DOM: component này thường
2051
+ // đứng cạnh nội dung nó theo dõi, và effect chạy trước khi anh em render.
2052
+ queueMicrotask(() => this.startObserving(list, margin, root));
2053
+ });
2054
+ this.destroyRef.onDestroy(() => this.stopObserving());
2055
+ }
2056
+ startObserving(list, margin, root) {
2057
+ const targets = list
2058
+ .map((item) => document.getElementById(item.id))
2059
+ .filter((el) => el !== null);
2060
+ if (!targets.length)
2061
+ return;
2062
+ this.observer = new IntersectionObserver((entries) => {
2063
+ // Cuộn hết cỡ thì phần CUỐI thường nằm dưới dải quan sát và không bao
2064
+ // giờ sáng — đo được: cuộn xuống đáy mà mục áp chót vẫn sáng. Chạm đáy
2065
+ // thì đánh dấu thẳng mục cuối, không đợi observer.
2066
+ if (this.atBottom(root)) {
2067
+ this.setActive(list[list.length - 1].id);
2068
+ return;
2069
+ }
2070
+ for (const entry of entries) {
2071
+ if (entry.isIntersecting)
2072
+ this.setActive(entry.target.id);
2073
+ }
2074
+ }, { root, rootMargin: margin, threshold: 0 });
2075
+ for (const el of targets)
2076
+ this.observer.observe(el);
2077
+ // Đánh dấu ngay mục ĐẦU khi chưa cuộn: không có phép này thì lúc tải
2078
+ // trang mục lục trống, hoặc sáng nhầm mục thứ hai nếu nó tình cờ chạm dải.
2079
+ if (!this.currentId())
2080
+ this.setActive(list[0].id);
2081
+ }
2082
+ /** Khung (hoặc trang) đã cuộn tới sát đáy chưa — sai số 4px cho tròn số lẻ. */
2083
+ atBottom(root) {
2084
+ const el = root ?? document.documentElement;
2085
+ return el.scrollHeight - el.scrollTop - el.clientHeight < 4;
2086
+ }
2087
+ stopObserving() {
2088
+ this.observer?.disconnect();
2089
+ this.observer = null;
2090
+ }
2091
+ setActive(id) {
2092
+ if (id === this.currentId())
2093
+ return;
2094
+ this.currentId.set(id);
2095
+ this.activeChange.emit(id);
2096
+ }
2097
+ /**
2098
+ * Bấm mục thì cuộn tới phần tương ứng và đánh dấu NGAY.
2099
+ *
2100
+ * Không đợi `IntersectionObserver` báo: cuộn mượt mất vài trăm mili giây, và
2101
+ * trong quãng đó mục vừa bấm chưa sáng — người dùng tưởng bấm hụt.
2102
+ */
2103
+ onClick(event, item) {
2104
+ const target = document.getElementById(item.id);
2105
+ if (!target)
2106
+ return;
2107
+ event.preventDefault();
2108
+ this.setActive(item.id);
2109
+ // `scrollIntoView` cuộn MỌI tổ tiên cuộn được, kể cả cả trang — khi nội
2110
+ // dung nằm trong khung riêng thì cả trang cũng nhảy theo. Có `scrollRoot`
2111
+ // thì cuộn chính khung đó bằng `scrollTo`, trang đứng yên.
2112
+ const root = this.scrollRoot();
2113
+ const behavior = this.smoothScroll() ? 'smooth' : 'auto';
2114
+ if (root) {
2115
+ root.scrollTo({ top: target.offsetTop - root.offsetTop, behavior });
2116
+ return;
2117
+ }
2118
+ target.scrollIntoView({ behavior, block: 'start' });
2119
+ }
2120
+ navClasses = computed(() => this.orientation() === 'vertical' ? 'block' : 'block w-full overflow-x-auto', /* @ts-ignore */
2121
+ ...(ngDevMode ? [{ debugName: "navClasses" }] : /* istanbul ignore next */ []));
2122
+ listClasses = computed(() => this.orientation() === 'vertical'
2123
+ ? 'flex flex-col gap-0.5 border-s border-default ps-0'
2124
+ : 'flex items-center gap-1 border-b border-default', /* @ts-ignore */
2125
+ ...(ngDevMode ? [{ debugName: "listClasses" }] : /* istanbul ignore next */ []));
2126
+ /**
2127
+ * Class của một mục.
2128
+ *
2129
+ * Gom vào MỘT chuỗi qua ba-ngôi thay vì nhiều `[class.x]` rời: hai class
2130
+ * cùng thuộc tính thì trình duyệt chọn theo thứ tự luật trong file CSS chứ
2131
+ * không theo thứ tự viết, kết quả đổi theo bản build.
2132
+ */
2133
+ linkClasses(item) {
2134
+ const active = item.id === this.activeId();
2135
+ const isVertical = this.orientation() === 'vertical';
2136
+ const base = 'block text-sm transition-colors cursor-pointer';
2137
+ const space = isVertical
2138
+ ? `-ms-px border-s-2 py-1.5 pe-2 ${item.level === 2 ? 'ps-6' : 'ps-3'}`
2139
+ : `-mb-px border-b-2 px-3 py-2 whitespace-nowrap`;
2140
+ const color = active
2141
+ ? 'border-brand font-semibold text-fg-brand'
2142
+ : 'border-transparent font-medium text-body-subtle hover:text-heading';
2143
+ return `${base} ${space} ${color}`;
2144
+ }
2145
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: OlScrollspy, deps: [], target: i0.ɵɵFactoryTarget.Component });
2146
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.6", type: OlScrollspy, isStandalone: true, selector: "ol-scrollspy", inputs: { items: { classPropertyName: "items", publicName: "items", isSignal: true, isRequired: false, transformFunction: null }, orientation: { classPropertyName: "orientation", publicName: "orientation", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, rootMargin: { classPropertyName: "rootMargin", publicName: "rootMargin", isSignal: true, isRequired: false, transformFunction: null }, smoothScroll: { classPropertyName: "smoothScroll", publicName: "smoothScroll", isSignal: true, isRequired: false, transformFunction: null }, scrollRoot: { classPropertyName: "scrollRoot", publicName: "scrollRoot", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { activeChange: "activeChange" }, ngImport: i0, template: `
2147
+ <nav [class]="navClasses()" [attr.aria-label]="ariaLabel()">
2148
+ <ul [class]="listClasses()">
2149
+ @for (item of items(); track item.id) {
2150
+ <li>
2151
+ <a
2152
+ [href]="'#' + item.id"
2153
+ [class]="linkClasses(item)"
2154
+ [attr.aria-current]="item.id === activeId() ? 'true' : null"
2155
+ (click)="onClick($event, item)"
2156
+ >
2157
+ {{ item.label }}
2158
+ </a>
2159
+ </li>
2160
+ }
2161
+ </ul>
2162
+ </nav>
2163
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
2164
+ }
2165
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: OlScrollspy, decorators: [{
2166
+ type: Component,
2167
+ args: [{
2168
+ selector: 'ol-scrollspy',
2169
+ standalone: true,
2170
+ template: `
2171
+ <nav [class]="navClasses()" [attr.aria-label]="ariaLabel()">
2172
+ <ul [class]="listClasses()">
2173
+ @for (item of items(); track item.id) {
2174
+ <li>
2175
+ <a
2176
+ [href]="'#' + item.id"
2177
+ [class]="linkClasses(item)"
2178
+ [attr.aria-current]="item.id === activeId() ? 'true' : null"
2179
+ (click)="onClick($event, item)"
2180
+ >
2181
+ {{ item.label }}
2182
+ </a>
2183
+ </li>
2184
+ }
2185
+ </ul>
2186
+ </nav>
2187
+ `,
2188
+ changeDetection: ChangeDetectionStrategy.OnPush,
2189
+ }]
2190
+ }], ctorParameters: () => [], propDecorators: { items: [{ type: i0.Input, args: [{ isSignal: true, alias: "items", required: false }] }], orientation: [{ type: i0.Input, args: [{ isSignal: true, alias: "orientation", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], rootMargin: [{ type: i0.Input, args: [{ isSignal: true, alias: "rootMargin", required: false }] }], smoothScroll: [{ type: i0.Input, args: [{ isSignal: true, alias: "smoothScroll", required: false }] }], scrollRoot: [{ type: i0.Input, args: [{ isSignal: true, alias: "scrollRoot", required: false }] }], activeChange: [{ type: i0.Output, args: ["activeChange"] }] } });
2191
+
2192
+ /**
2193
+ * Viền mũi tên chỉ ở HAI cạnh quay ra ngoài hộp.
2194
+ *
2195
+ * Mũi tên là ô vuông 8px xoay 45°, đặt lòi nửa ra ngoài mép hộp (-4px). Viền
2196
+ * cả bốn cạnh thì nửa nằm TRONG hộp cũng có viền — hiện thành hình thoi nhỏ
2197
+ * lấn vào tooltip (báo 2026-09-28, theme `light`). `data-side` là hướng thật
2198
+ * sau khi tự lật, do `OlPositioningService.applyArrowPosition` ghi vào.
2199
+ *
2200
+ * Xoay 45° theo chiều kim đồng hồ: cạnh dưới + phải quay xuống, trên + trái
2201
+ * quay lên, trên + phải quay sang phải, dưới + trái quay sang trái.
2202
+ */
2203
+ const ARROW_OUTER_EDGES = [
2204
+ 'data-[side=top]:border-t-0 data-[side=top]:border-l-0',
2205
+ 'data-[side=bottom]:border-b-0 data-[side=bottom]:border-r-0',
2206
+ 'data-[side=left]:border-b-0 data-[side=left]:border-l-0',
2207
+ 'data-[side=right]:border-t-0 data-[side=right]:border-r-0',
2208
+ ].join(' ');
1931
2209
  class OlTooltip {
1932
2210
  /** Tooltip content text */
1933
2211
  content = input.required(/* @ts-ignore */
1934
2212
  ...(ngDevMode ? [{ debugName: "content" }] : /* istanbul ignore next */ []));
1935
- /** Theme variant */
1936
- theme = input('dark', /* @ts-ignore */
2213
+ /** Theme variant — mặc định `subtle` (nền Primary 50). */
2214
+ theme = input('subtle', /* @ts-ignore */
1937
2215
  ...(ngDevMode ? [{ debugName: "theme" }] : /* istanbul ignore next */ []));
1938
2216
  /** Tooltip ID for ARIA */
1939
2217
  tooltipId = input('', /* @ts-ignore */
@@ -1973,7 +2251,13 @@ class OlTooltip {
1973
2251
  //
1974
2252
  // `bg-neutral-primary` + `text-heading` đã tự đảo theo chế độ tối và theo
1975
2253
  // từng theme, nên bỏ hẳn nhánh `dark:` là đủ.
2254
+ //
2255
+ // `subtle` — nền Primary 50 (`bg-brand-softer` = brand-50, tối: brand-950),
2256
+ // chữ `text-fg-brand-strong` (brand-900, tối: brand-300), viền
2257
+ // `border-brand-subtle` (brand-200, tối: brand-800). Cả ba là token ngữ
2258
+ // nghĩa đã có khối `.dark` — KHÔNG ghim `dark:`, cùng lý do với `light`.
1976
2259
  const themeClasses = {
2260
+ subtle: 'bg-brand-softer text-fg-brand-strong border border-brand-subtle shadow-md',
1977
2261
  dark: 'bg-dark text-white shadow-lg',
1978
2262
  light: 'bg-neutral-primary text-heading border border-default shadow-md',
1979
2263
  brand: 'bg-brand text-white shadow-lg',
@@ -1987,10 +2271,11 @@ class OlTooltip {
1987
2271
  arrowClasses = computed(() => {
1988
2272
  const baseClasses = 'absolute w-2 h-2 rotate-45';
1989
2273
  const themeClasses = {
2274
+ // Phải ĐỒNG BỘ với nền + viền hộp ở `tooltipClasses` — mũi tên là phần
2275
+ // nối liền hộp, lệch màu là thấy ngay đường gãy.
2276
+ subtle: `bg-brand-softer border border-brand-subtle ${ARROW_OUTER_EDGES}`,
1990
2277
  dark: 'bg-dark',
1991
- // Phải ĐỒNG BỘ với nền hộp ở `tooltipClasses` — mũi tên là phần nối
1992
- // liền hộp, lệch màu là thấy ngay đường gãy.
1993
- light: 'bg-neutral-primary border border-default',
2278
+ light: `bg-neutral-primary border border-default ${ARROW_OUTER_EDGES}`,
1994
2279
  brand: 'bg-brand',
1995
2280
  };
1996
2281
  return `${baseClasses} ${themeClasses[this.theme()]}`;
@@ -2153,6 +2438,9 @@ class OlPositioningService {
2153
2438
  arrowElement.style.top = `${y}px`;
2154
2439
  }
2155
2440
  arrowElement.style.setProperty(staticSide, '-4px');
2441
+ // Hướng THẬT sau flip — để mũi tên có viền chỉ vẽ hai cạnh quay ra ngoài
2442
+ // (xem ARROW_OUTER_EDGES trong tooltip.component.ts).
2443
+ arrowElement.dataset['side'] = placement.split('-')[0];
2156
2444
  }
2157
2445
  /**
2158
2446
  * Get opposite side của placement
@@ -2217,7 +2505,7 @@ class OlTooltipDirective {
2217
2505
  tooltipPosition = input('top', /* @ts-ignore */
2218
2506
  ...(ngDevMode ? [{ debugName: "tooltipPosition" }] : /* istanbul ignore next */ []));
2219
2507
  /** Tooltip theme */
2220
- tooltipTheme = input('dark', /* @ts-ignore */
2508
+ tooltipTheme = input('subtle', /* @ts-ignore */
2221
2509
  ...(ngDevMode ? [{ debugName: "tooltipTheme" }] : /* istanbul ignore next */ []));
2222
2510
  /** Delay before showing tooltip (ms) */
2223
2511
  tooltipDelay = input(150, /* @ts-ignore */
@@ -2940,6 +3228,7 @@ class OlModalHeader {
2940
3228
  <button
2941
3229
  type="button"
2942
3230
  (click)="closeClick.emit()"
3231
+ data-ol-dismiss
2943
3232
  aria-label="Đóng hộp thoại"
2944
3233
  class="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md text-body-subtle transition-colors hover:bg-neutral-tertiary hover:text-heading focus:outline-hidden focus:ring-2 focus:ring-brand-subtle cursor-pointer"
2945
3234
  >
@@ -2998,6 +3287,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImpor
2998
3287
  <button
2999
3288
  type="button"
3000
3289
  (click)="closeClick.emit()"
3290
+ data-ol-dismiss
3001
3291
  aria-label="Đóng hộp thoại"
3002
3292
  class="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md text-body-subtle transition-colors hover:bg-neutral-tertiary hover:text-heading focus:outline-hidden focus:ring-2 focus:ring-brand-subtle cursor-pointer"
3003
3293
  >
@@ -7081,11 +7371,11 @@ class OlDrawer {
7081
7371
  this.close();
7082
7372
  }
7083
7373
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: OlDrawer, deps: [], target: i0.ɵɵFactoryTarget.Component });
7084
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.6", type: OlDrawer, isStandalone: true, selector: "ol-drawer", inputs: { isOpen: { classPropertyName: "isOpen", publicName: "isOpen", isSignal: true, isRequired: false, transformFunction: null }, placement: { classPropertyName: "placement", publicName: "placement", isSignal: true, isRequired: false, transformFunction: null }, size: { classPropertyName: "size", publicName: "size", isSignal: true, isRequired: false, transformFunction: null }, backdrop: { classPropertyName: "backdrop", publicName: "backdrop", isSignal: true, isRequired: false, transformFunction: null }, closeOnBackdrop: { classPropertyName: "closeOnBackdrop", publicName: "closeOnBackdrop", isSignal: true, isRequired: false, transformFunction: null }, closeOnEscape: { classPropertyName: "closeOnEscape", publicName: "closeOnEscape", isSignal: true, isRequired: false, transformFunction: null }, showClose: { classPropertyName: "showClose", publicName: "showClose", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { isOpen: "isOpenChange", closed: "closed" }, queries: [{ propertyName: "header", first: true, predicate: OlDrawerHeader, descendants: true, isSignal: true }], viewQueries: [{ propertyName: "panel", first: true, predicate: ["panel"], descendants: true, isSignal: true }], ngImport: i0, template: "@if (isOpen()) {\n @if (backdrop()) {\n <div\n (click)=\"onBackdropClick()\"\n class=\"fixed inset-0 z-drawer-backdrop bg-overlay-backdrop\"\n aria-hidden=\"true\"\n ></div>\n }\n <div\n #panel\n role=\"dialog\"\n aria-modal=\"true\"\n [attr.aria-label]=\"ariaLabel()\"\n tabindex=\"-1\"\n class=\"fixed z-drawer flex flex-col overflow-y-auto bg-neutral-primary border-default p-3 transition-transform duration-300 ease-out\"\n [class]=\"panelClasses()\"\n >\n @if (hienNutDong()) {\n <button\n type=\"button\"\n (click)=\"close()\"\n aria-label=\"\u0110\u00F3ng\"\n class=\"absolute top-2.5 end-2.5 inline-flex h-8 w-8 items-center justify-center rounded-md text-body-subtle transition-colors hover:bg-neutral-tertiary hover:text-heading focus:outline-hidden focus:ring-2 focus:ring-brand-subtle cursor-pointer\"\n >\n <ol-icon name=\"close\" size=\"xs\" />\n <span class=\"sr-only\">\u0110\u00F3ng</span>\n </button>\n }\n <ng-content />\n </div>\n}\n", dependencies: [{ kind: "component", type: OlIcon, selector: "ol-icon", inputs: ["name", "size", "color", "variant", "strokeWidth"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
7374
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.6", type: OlDrawer, isStandalone: true, selector: "ol-drawer", inputs: { isOpen: { classPropertyName: "isOpen", publicName: "isOpen", isSignal: true, isRequired: false, transformFunction: null }, placement: { classPropertyName: "placement", publicName: "placement", isSignal: true, isRequired: false, transformFunction: null }, size: { classPropertyName: "size", publicName: "size", isSignal: true, isRequired: false, transformFunction: null }, backdrop: { classPropertyName: "backdrop", publicName: "backdrop", isSignal: true, isRequired: false, transformFunction: null }, closeOnBackdrop: { classPropertyName: "closeOnBackdrop", publicName: "closeOnBackdrop", isSignal: true, isRequired: false, transformFunction: null }, closeOnEscape: { classPropertyName: "closeOnEscape", publicName: "closeOnEscape", isSignal: true, isRequired: false, transformFunction: null }, showClose: { classPropertyName: "showClose", publicName: "showClose", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { isOpen: "isOpenChange", closed: "closed" }, queries: [{ propertyName: "header", first: true, predicate: OlDrawerHeader, descendants: true, isSignal: true }], viewQueries: [{ propertyName: "panel", first: true, predicate: ["panel"], descendants: true, isSignal: true }], ngImport: i0, template: "@if (isOpen()) {\n @if (backdrop()) {\n <div\n (click)=\"onBackdropClick()\"\n class=\"fixed inset-0 z-drawer-backdrop bg-overlay-backdrop\"\n aria-hidden=\"true\"\n ></div>\n }\n <div\n #panel\n role=\"dialog\"\n aria-modal=\"true\"\n [attr.aria-label]=\"ariaLabel()\"\n tabindex=\"-1\"\n class=\"fixed z-drawer flex flex-col overflow-y-auto bg-neutral-primary border-default p-3 transition-transform duration-300 ease-out\"\n [class]=\"panelClasses()\"\n >\n @if (hienNutDong()) {\n <button\n type=\"button\"\n (click)=\"close()\"\n data-ol-dismiss\n aria-label=\"\u0110\u00F3ng\"\n class=\"absolute top-2.5 end-2.5 inline-flex h-8 w-8 items-center justify-center rounded-md text-body-subtle transition-colors hover:bg-neutral-tertiary hover:text-heading focus:outline-hidden focus:ring-2 focus:ring-brand-subtle cursor-pointer\"\n >\n <ol-icon name=\"close\" size=\"xs\" />\n <span class=\"sr-only\">\u0110\u00F3ng</span>\n </button>\n }\n <ng-content />\n </div>\n}\n", dependencies: [{ kind: "component", type: OlIcon, selector: "ol-icon", inputs: ["name", "size", "color", "variant", "strokeWidth"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
7085
7375
  }
7086
7376
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: OlDrawer, decorators: [{
7087
7377
  type: Component,
7088
- args: [{ selector: 'ol-drawer', standalone: true, imports: [OlIcon], changeDetection: ChangeDetectionStrategy.OnPush, template: "@if (isOpen()) {\n @if (backdrop()) {\n <div\n (click)=\"onBackdropClick()\"\n class=\"fixed inset-0 z-drawer-backdrop bg-overlay-backdrop\"\n aria-hidden=\"true\"\n ></div>\n }\n <div\n #panel\n role=\"dialog\"\n aria-modal=\"true\"\n [attr.aria-label]=\"ariaLabel()\"\n tabindex=\"-1\"\n class=\"fixed z-drawer flex flex-col overflow-y-auto bg-neutral-primary border-default p-3 transition-transform duration-300 ease-out\"\n [class]=\"panelClasses()\"\n >\n @if (hienNutDong()) {\n <button\n type=\"button\"\n (click)=\"close()\"\n aria-label=\"\u0110\u00F3ng\"\n class=\"absolute top-2.5 end-2.5 inline-flex h-8 w-8 items-center justify-center rounded-md text-body-subtle transition-colors hover:bg-neutral-tertiary hover:text-heading focus:outline-hidden focus:ring-2 focus:ring-brand-subtle cursor-pointer\"\n >\n <ol-icon name=\"close\" size=\"xs\" />\n <span class=\"sr-only\">\u0110\u00F3ng</span>\n </button>\n }\n <ng-content />\n </div>\n}\n" }]
7378
+ args: [{ selector: 'ol-drawer', standalone: true, imports: [OlIcon], changeDetection: ChangeDetectionStrategy.OnPush, template: "@if (isOpen()) {\n @if (backdrop()) {\n <div\n (click)=\"onBackdropClick()\"\n class=\"fixed inset-0 z-drawer-backdrop bg-overlay-backdrop\"\n aria-hidden=\"true\"\n ></div>\n }\n <div\n #panel\n role=\"dialog\"\n aria-modal=\"true\"\n [attr.aria-label]=\"ariaLabel()\"\n tabindex=\"-1\"\n class=\"fixed z-drawer flex flex-col overflow-y-auto bg-neutral-primary border-default p-3 transition-transform duration-300 ease-out\"\n [class]=\"panelClasses()\"\n >\n @if (hienNutDong()) {\n <button\n type=\"button\"\n (click)=\"close()\"\n data-ol-dismiss\n aria-label=\"\u0110\u00F3ng\"\n class=\"absolute top-2.5 end-2.5 inline-flex h-8 w-8 items-center justify-center rounded-md text-body-subtle transition-colors hover:bg-neutral-tertiary hover:text-heading focus:outline-hidden focus:ring-2 focus:ring-brand-subtle cursor-pointer\"\n >\n <ol-icon name=\"close\" size=\"xs\" />\n <span class=\"sr-only\">\u0110\u00F3ng</span>\n </button>\n }\n <ng-content />\n </div>\n}\n" }]
7089
7379
  }], ctorParameters: () => [], propDecorators: { panel: [{ type: i0.ViewChild, args: ['panel', { isSignal: true }] }], isOpen: [{ type: i0.Input, args: [{ isSignal: true, alias: "isOpen", required: false }] }, { type: i0.Output, args: ["isOpenChange"] }], placement: [{ type: i0.Input, args: [{ isSignal: true, alias: "placement", required: false }] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }], backdrop: [{ type: i0.Input, args: [{ isSignal: true, alias: "backdrop", required: false }] }], closeOnBackdrop: [{ type: i0.Input, args: [{ isSignal: true, alias: "closeOnBackdrop", required: false }] }], closeOnEscape: [{ type: i0.Input, args: [{ isSignal: true, alias: "closeOnEscape", required: false }] }], showClose: [{ type: i0.Input, args: [{ isSignal: true, alias: "showClose", required: false }] }], header: [{ type: i0.ContentChild, args: [i0.forwardRef(() => OlDrawerHeader), { isSignal: true }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], closed: [{ type: i0.Output, args: ["closed"] }] } });
7090
7380
 
7091
7381
  /**
@@ -7156,6 +7446,8 @@ class OlDropdown {
7156
7446
  detachMenu = null;
7157
7447
  /** Hàm gỡ khỏi ngăn xếp Escape. `null` khi đang đóng. */
7158
7448
  releaseEscape = null;
7449
+ /** Hàm gỡ listener Tab trên panel. `null` khi đang đóng. */
7450
+ releaseMenuKeydown = null;
7159
7451
  label = input(null, /* @ts-ignore */
7160
7452
  ...(ngDevMode ? [{ debugName: "label" }] : /* istanbul ignore next */ []));
7161
7453
  isOpen = model(false, /* @ts-ignore */
@@ -7205,19 +7497,34 @@ class OlDropdown {
7205
7497
  // Rời ngăn xếp Escape khi đóng.
7206
7498
  this.releaseEscape?.();
7207
7499
  this.releaseEscape = null;
7500
+ // Gỡ listener Tab. Phải ở ĐÂY chứ không trong `stopAutoUpdate()`: hàm
7501
+ // đó chạy đầu MỖI lần effect, kể cả những lần chỉ đổi vị trí — gỡ ở đó
7502
+ // thì listener bị tháo giữa chừng và không gắn lại kịp.
7503
+ this.releaseMenuKeydown?.();
7504
+ this.releaseMenuKeydown = null;
7208
7505
  return;
7209
7506
  }
7210
7507
  // Vào ngăn xếp Escape — chỉ lớp trên cùng đóng, nên dropdown mở trong
7211
7508
  // modal không còn kéo modal đóng theo.
7212
7509
  this.releaseEscape ??= this.overlayContainer.register(() => {
7213
7510
  this.hide();
7214
- this.triggerButtonRef()?.nativeElement.focus();
7511
+ this.triggerElement()?.focus();
7215
7512
  });
7216
7513
  // Gắn ra body TRƯỚC khi định vị: floating-ui đo theo vị trí thật trong
7217
7514
  // DOM, đổi cha sau khi tính toạ độ là lệch chỗ.
7218
7515
  if (this.appendTo() === 'body' && !this.detachMenu) {
7219
7516
  this.detachMenu = this.overlayContainer.attach(floating);
7220
7517
  }
7518
+ // Nghe Tab bằng addEventListener chứ KHÔNG `(keydown)` trong template:
7519
+ // ruột panel tới qua `<ng-content>`, mà nội dung chiếu giữ ngữ cảnh DOM
7520
+ // của nơi KHAI BÁO nó, không phải của template này — nên sự kiện từ ruột
7521
+ // không bao giờ chạm listener khai trong `dropdown.component.html`. Đo
7522
+ // 2026-09-25: `defaultPrevented` vẫn false sau khi bong bóng xong.
7523
+ if (!this.releaseMenuKeydown) {
7524
+ const onKeydown = (event) => this.onMenuKeydown(event);
7525
+ floating.addEventListener('keydown', onKeydown);
7526
+ this.releaseMenuKeydown = () => floating.removeEventListener('keydown', onKeydown);
7527
+ }
7221
7528
  const options = {
7222
7529
  placement: this.placement(),
7223
7530
  offset: 8,
@@ -7332,6 +7639,139 @@ class OlDropdown {
7332
7639
  }, HOVER_HIDE_DELAY_MS);
7333
7640
  }
7334
7641
  }
7642
+ /**
7643
+ * Danh sách phần tử nhận được tiêu điểm, theo thứ tự nhìn thấy.
7644
+ *
7645
+ * `tabindex="-1"` bị loại: đó là dấu hiệu tác giả cố ý đưa phần tử ra khỏi
7646
+ * đường Tab. `:disabled` cũng loại — trình duyệt không cho nó nhận tiêu điểm.
7647
+ *
7648
+ * Lọc phần ẩn bằng `[hidden]`/`[aria-hidden]` chứ KHÔNG `offsetParent`:
7649
+ * `offsetParent` là `null` với mọi phần tử trong `position: fixed`, và luôn
7650
+ * `null` trong jsdom — dùng nó thì danh sách rỗng, hàm thoát sớm, và toàn bộ
7651
+ * xử lý Tab lặng lẽ không chạy. Đã mắc: spec đỏ 3 ca trong khi trình duyệt
7652
+ * thật vẫn đúng, vì hai môi trường cho `offsetParent` khác nhau.
7653
+ */
7654
+ focusableItems() {
7655
+ const menu = this.menuRef()?.nativeElement;
7656
+ if (!menu)
7657
+ return [];
7658
+ return [
7659
+ ...menu.querySelectorAll('a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'),
7660
+ ].filter((el) => !el.closest('[hidden], [aria-hidden="true"]'));
7661
+ }
7662
+ /**
7663
+ * Nút mở panel, kể cả khi nơi dùng tự khai qua slot `[trigger]`.
7664
+ *
7665
+ * `triggerButtonRef` CHỈ có giá trị với nút mặc định của component — hai
7666
+ * nhánh `@if` trong template vẽ ra nó. Nơi dùng truyền nút riêng qua
7667
+ * `<ng-content select="[trigger]">` thì cả hai nhánh đều không chạy và ref
7668
+ * là `undefined`. Đo 2026-09-25: header onluyenv4 (streak, chuông) đều dùng
7669
+ * nút slot, nên mọi xử lý bám vào ref đó lặng lẽ không chạy — gồm cả việc
7670
+ * trả tiêu điểm về nút sau khi Escape, vốn có từ trước.
7671
+ *
7672
+ * Nên tìm nút thật trong cây DOM của host khi không có ref.
7673
+ */
7674
+ triggerElement() {
7675
+ const fromRef = this.triggerButtonRef()?.nativeElement;
7676
+ if (fromRef)
7677
+ return fromRef;
7678
+ const host = this.elementRef.nativeElement;
7679
+ return host.querySelector('button:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])');
7680
+ }
7681
+ /**
7682
+ * Phần tử nhận tiêu điểm đứng ngay sau `element` trong thứ tự Tab của trang.
7683
+ *
7684
+ * Quét từ `<body>` và loại chính panel ra: panel treo cuối `<body>` nên nếu
7685
+ * tính cả nó thì "phần tử sau nút trigger" lại rơi vào panel vừa đóng.
7686
+ */
7687
+ nextFocusableAfter(element) {
7688
+ const menu = this.menuRef()?.nativeElement;
7689
+ const all = [
7690
+ ...document.body.querySelectorAll('a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'),
7691
+ ].filter((el) => !menu?.contains(el) && !el.closest('[hidden], [aria-hidden="true"]'));
7692
+ const index = all.indexOf(element);
7693
+ return index === -1 ? null : (all[index + 1] ?? null);
7694
+ }
7695
+ /**
7696
+ * Tab từ nút trigger khi panel đang mở → nhảy VÀO mục đầu panel.
7697
+ *
7698
+ * Vì sao cần: `appendTo="body"` treo panel ra cuối `<body>`, nên trong thứ
7699
+ * tự DOM nó nằm SAU toàn bộ nội dung trang. Bàn phím đi theo thứ tự DOM chứ
7700
+ * không theo vị trí nhìn thấy, nên Tab từ nút trigger nhảy sang phần tử kế
7701
+ * tiếp trong header rồi xuống thân trang — panel mở ngay trước mắt mà không
7702
+ * bao giờ tới được. Đo 2026-09-25: 8 lượt Tab, không lượt nào vào panel.
7703
+ *
7704
+ * KHÔNG cuốn tiêu điểm lúc mở: người dùng chuột không cần, và nhảy tiêu điểm
7705
+ * khi họ chưa yêu cầu là cướp quyền điều khiển. Chỉ khi họ chủ động nhấn Tab
7706
+ * mới dẫn vào — đúng lúc họ đang tìm đường đi tiếp.
7707
+ *
7708
+ * Shift+Tab bỏ qua: người dùng đang muốn lùi RA, giữ họ lại là bẫy.
7709
+ */
7710
+ onTriggerKeydown(event) {
7711
+ if (event.key !== 'Tab' || event.shiftKey || !this.isOpen())
7712
+ return;
7713
+ const first = this.focusableItems()[0];
7714
+ if (!first)
7715
+ return;
7716
+ event.preventDefault();
7717
+ first.focus();
7718
+ }
7719
+ /**
7720
+ * Tab BÊN TRONG panel — giữ đường đi liền mạch với chỗ panel đang hiện.
7721
+ *
7722
+ * Panel nằm cuối `<body>` nên nếu để trình duyệt tự xử, Tab ở mục cuối sẽ
7723
+ * rơi xuống phần tử sau panel trong DOM (thường là hết trang), còn Shift+Tab
7724
+ * ở mục đầu nhảy ngược lên thân trang — cả hai đều bỏ qua nút trigger, tức
7725
+ * người dùng mất mốc quay về.
7726
+ *
7727
+ * KHÔNG bẫy vòng tròn như hộp thoại: chuẩn WAI-ARIA cho menu quy định Tab
7728
+ * phải THOÁT được ra trang. Nên:
7729
+ *
7730
+ * - Tab ở mục CUỐI → đóng panel, trả tiêu điểm về nút trigger rồi để trình
7731
+ * duyệt đi tiếp bình thường (phần tử sau nút trong header)
7732
+ * - Shift+Tab ở mục ĐẦU → về thẳng nút trigger, panel vẫn mở để người dùng
7733
+ * còn thấy mình vừa rời khỏi đâu
7734
+ *
7735
+ * Giữa các mục thì không can thiệp — panel là một khối liền trong DOM nên
7736
+ * trình duyệt tự đi đúng.
7737
+ */
7738
+ onMenuKeydown(event) {
7739
+ if (event.key !== 'Tab' || !this.isOpen())
7740
+ return;
7741
+ const items = this.focusableItems();
7742
+ if (!items.length)
7743
+ return;
7744
+ const current = document.activeElement;
7745
+ const index = current ? items.indexOf(current) : -1;
7746
+ if (index === -1)
7747
+ return;
7748
+ const trigger = this.triggerElement();
7749
+ if (!event.shiftKey && index === items.length - 1) {
7750
+ // Rời khỏi mục cuối: đóng panel rồi đi tiếp NHƯ THỂ panel chưa từng mở,
7751
+ // tức sang phần tử ngay sau nút trigger.
7752
+ //
7753
+ // Phải tự tìm và focus, không thể chỉ `trigger.focus()` rồi thả cho
7754
+ // trình duyệt: sự kiện Tab đang phát từ phần tử TRONG PANEL, nên trình
7755
+ // duyệt tính bước kế tiếp từ panel — mà panel nằm cuối `<body>` nên bước
7756
+ // đó rơi xuống tận thân trang. Đo được: nhảy thẳng vào menu trái, bỏ qua
7757
+ // hai nút còn lại trong header.
7758
+ if (!trigger)
7759
+ return;
7760
+ event.preventDefault();
7761
+ this.hide();
7762
+ const next = this.nextFocusableAfter(trigger);
7763
+ (next ?? trigger).focus();
7764
+ return;
7765
+ }
7766
+ if (event.shiftKey && index === 0) {
7767
+ // Lùi khỏi mục đầu: về nút trigger. Chặn ở đây vì nếu không, trình duyệt
7768
+ // lùi tiếp vào thân trang — bỏ qua chính nút vừa mở panel.
7769
+ if (!trigger)
7770
+ return;
7771
+ event.preventDefault();
7772
+ trigger.focus();
7773
+ }
7774
+ }
7335
7775
  show() {
7336
7776
  if (this.disabled())
7337
7777
  return;
@@ -7363,13 +7803,13 @@ class OlDropdown {
7363
7803
  }
7364
7804
  }
7365
7805
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: OlDropdown, deps: [], target: i0.ɵɵFactoryTarget.Component });
7366
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.6", type: OlDropdown, isStandalone: true, selector: "ol-dropdown", inputs: { label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, isOpen: { classPropertyName: "isOpen", publicName: "isOpen", isSignal: true, isRequired: false, transformFunction: null }, appendTo: { classPropertyName: "appendTo", publicName: "appendTo", isSignal: true, isRequired: false, transformFunction: null }, placement: { classPropertyName: "placement", publicName: "placement", isSignal: true, isRequired: false, transformFunction: null }, trigger: { classPropertyName: "trigger", publicName: "trigger", isSignal: true, isRequired: false, transformFunction: null }, autoClose: { classPropertyName: "autoClose", publicName: "autoClose", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, triggerClass: { classPropertyName: "triggerClass", publicName: "triggerClass", isSignal: true, isRequired: false, transformFunction: null }, menuClass: { classPropertyName: "menuClass", publicName: "menuClass", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { isOpen: "isOpenChange", opened: "opened", closed: "closed" }, host: { listeners: { "document:click": "onDocumentClick($event)" }, classAttribute: "relative inline-block text-left" }, viewQueries: [{ propertyName: "triggerButtonRef", first: true, predicate: ["triggerButton"], descendants: true, isSignal: true }, { propertyName: "menuRef", first: true, predicate: ["menu"], descendants: true, isSignal: true }, { propertyName: "cursorAnchorRef", first: true, predicate: ["cursorAnchor"], descendants: true, isSignal: true }], ngImport: i0, template: "<div\n (mouseenter)=\"onMouseEnter()\"\n (mouseleave)=\"onMouseLeave()\"\n (contextmenu)=\"onContextMenu($event)\"\n>\n <!-- Custom Slot Trigger or Default Button -->\n <div (click)=\"onTriggerClick()\">\n <ng-content select=\"[slot=trigger], [trigger]\" />\n @if (label()) {\n <button\n #triggerButton\n type=\"button\"\n [id]=\"buttonId\"\n [disabled]=\"disabled()\"\n aria-haspopup=\"true\"\n [attr.aria-expanded]=\"isOpen()\"\n [attr.aria-controls]=\"menuId\"\n [class]=\"\n triggerClass()\n ? 'inline-flex items-center gap-2 px-4 py-2 text-sm font-medium cursor-pointer ' +\n triggerClass()\n : 'inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-body bg-neutral-primary border border-default rounded-md hover:bg-neutral-tertiary focus:outline-hidden focus:ring-2 focus:ring-brand-subtle cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed'\n \"\n >\n <ng-content select=\"[triggerIcon]\" />\n <span>{{ label() }}</span>\n <ol-icon\n name=\"chevron-down\"\n size=\"xs\"\n class=\"transition-transform duration-200\"\n [class.rotate-180]=\"isOpen()\"\n ></ol-icon>\n </button>\n } @else if (!label() && triggerClass()) {\n <button\n #triggerButton\n type=\"button\"\n [id]=\"buttonId\"\n [disabled]=\"disabled()\"\n aria-haspopup=\"true\"\n [attr.aria-expanded]=\"isOpen()\"\n [attr.aria-controls]=\"menuId\"\n [class]=\"'inline-flex items-center justify-center p-2.5 cursor-pointer ' + triggerClass()\"\n >\n <ng-content select=\"[triggerIcon]\" />\n </button>\n }\n </div>\n\n @if (trigger() === 'context') {\n <span\n #cursorAnchor\n aria-hidden=\"true\"\n class=\"pointer-events-none fixed h-0 w-0\"\n [style.left.px]=\"cursorX()\"\n [style.top.px]=\"cursorY()\"\n ></span>\n }\n\n <div\n #menu\n [id]=\"menuId\"\n role=\"menu\"\n [attr.aria-labelledby]=\"buttonId\"\n [class]=\"\n 'z-dropdown absolute top-0 left-0 min-w-48 bg-neutral-primary rounded-md shadow-xl border border-default p-1.5 focus:outline-hidden transition-opacity duration-150 ' +\n (menuClass() ? ' ' + menuClass() : '')\n \"\n [class.hidden]=\"!isOpen()\"\n >\n <ng-content />\n </div>\n</div>\n", dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "component", type: OlIcon, selector: "ol-icon", inputs: ["name", "size", "color", "variant", "strokeWidth"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
7806
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.6", type: OlDropdown, isStandalone: true, selector: "ol-dropdown", inputs: { label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, isOpen: { classPropertyName: "isOpen", publicName: "isOpen", isSignal: true, isRequired: false, transformFunction: null }, appendTo: { classPropertyName: "appendTo", publicName: "appendTo", isSignal: true, isRequired: false, transformFunction: null }, placement: { classPropertyName: "placement", publicName: "placement", isSignal: true, isRequired: false, transformFunction: null }, trigger: { classPropertyName: "trigger", publicName: "trigger", isSignal: true, isRequired: false, transformFunction: null }, autoClose: { classPropertyName: "autoClose", publicName: "autoClose", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, triggerClass: { classPropertyName: "triggerClass", publicName: "triggerClass", isSignal: true, isRequired: false, transformFunction: null }, menuClass: { classPropertyName: "menuClass", publicName: "menuClass", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { isOpen: "isOpenChange", opened: "opened", closed: "closed" }, host: { listeners: { "document:click": "onDocumentClick($event)" }, classAttribute: "relative inline-block text-left" }, viewQueries: [{ propertyName: "triggerButtonRef", first: true, predicate: ["triggerButton"], descendants: true, isSignal: true }, { propertyName: "menuRef", first: true, predicate: ["menu"], descendants: true, isSignal: true }, { propertyName: "cursorAnchorRef", first: true, predicate: ["cursorAnchor"], descendants: true, isSignal: true }], ngImport: i0, template: "<div\n (mouseenter)=\"onMouseEnter()\"\n (mouseleave)=\"onMouseLeave()\"\n (contextmenu)=\"onContextMenu($event)\"\n>\n <!-- Custom Slot Trigger or Default Button -->\n <div (click)=\"onTriggerClick()\" (keydown)=\"onTriggerKeydown($event)\">\n <ng-content select=\"[slot=trigger], [trigger]\" />\n @if (label()) {\n <button\n #triggerButton\n type=\"button\"\n [id]=\"buttonId\"\n [disabled]=\"disabled()\"\n aria-haspopup=\"true\"\n [attr.aria-expanded]=\"isOpen()\"\n [attr.aria-controls]=\"menuId\"\n [class]=\"\n triggerClass()\n ? 'inline-flex items-center gap-2 px-4 py-2 text-sm font-medium cursor-pointer ' +\n triggerClass()\n : 'inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-body bg-neutral-primary border border-default rounded-md hover:bg-neutral-tertiary focus:outline-hidden focus:ring-2 focus:ring-brand-subtle cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed'\n \"\n >\n <ng-content select=\"[triggerIcon]\" />\n <span>{{ label() }}</span>\n <ol-icon\n name=\"chevron-down\"\n size=\"xs\"\n class=\"transition-transform duration-200\"\n [class.rotate-180]=\"isOpen()\"\n ></ol-icon>\n </button>\n } @else if (!label() && triggerClass()) {\n <button\n #triggerButton\n type=\"button\"\n [id]=\"buttonId\"\n [disabled]=\"disabled()\"\n aria-haspopup=\"true\"\n [attr.aria-expanded]=\"isOpen()\"\n [attr.aria-controls]=\"menuId\"\n [class]=\"'inline-flex items-center justify-center p-2.5 cursor-pointer ' + triggerClass()\"\n >\n <ng-content select=\"[triggerIcon]\" />\n </button>\n }\n </div>\n\n @if (trigger() === 'context') {\n <span\n #cursorAnchor\n aria-hidden=\"true\"\n class=\"pointer-events-none fixed h-0 w-0\"\n [style.left.px]=\"cursorX()\"\n [style.top.px]=\"cursorY()\"\n ></span>\n }\n\n <div\n #menu\n [id]=\"menuId\"\n role=\"menu\"\n [attr.aria-labelledby]=\"buttonId\"\n [class]=\"\n 'z-dropdown absolute top-0 left-0 min-w-48 bg-neutral-primary rounded-md shadow-lg border border-default p-2 space-y-1.5 focus:outline-hidden transition-opacity duration-150 ' +\n (menuClass() ? ' ' + menuClass() : '')\n \"\n [class.hidden]=\"!isOpen()\"\n >\n <ng-content />\n </div>\n</div>\n", dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "component", type: OlIcon, selector: "ol-icon", inputs: ["name", "size", "color", "variant", "strokeWidth"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
7367
7807
  }
7368
7808
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: OlDropdown, decorators: [{
7369
7809
  type: Component,
7370
7810
  args: [{ selector: 'ol-dropdown', standalone: true, imports: [CommonModule, OlIcon], changeDetection: ChangeDetectionStrategy.OnPush, host: {
7371
7811
  class: 'relative inline-block text-left',
7372
- }, template: "<div\n (mouseenter)=\"onMouseEnter()\"\n (mouseleave)=\"onMouseLeave()\"\n (contextmenu)=\"onContextMenu($event)\"\n>\n <!-- Custom Slot Trigger or Default Button -->\n <div (click)=\"onTriggerClick()\">\n <ng-content select=\"[slot=trigger], [trigger]\" />\n @if (label()) {\n <button\n #triggerButton\n type=\"button\"\n [id]=\"buttonId\"\n [disabled]=\"disabled()\"\n aria-haspopup=\"true\"\n [attr.aria-expanded]=\"isOpen()\"\n [attr.aria-controls]=\"menuId\"\n [class]=\"\n triggerClass()\n ? 'inline-flex items-center gap-2 px-4 py-2 text-sm font-medium cursor-pointer ' +\n triggerClass()\n : 'inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-body bg-neutral-primary border border-default rounded-md hover:bg-neutral-tertiary focus:outline-hidden focus:ring-2 focus:ring-brand-subtle cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed'\n \"\n >\n <ng-content select=\"[triggerIcon]\" />\n <span>{{ label() }}</span>\n <ol-icon\n name=\"chevron-down\"\n size=\"xs\"\n class=\"transition-transform duration-200\"\n [class.rotate-180]=\"isOpen()\"\n ></ol-icon>\n </button>\n } @else if (!label() && triggerClass()) {\n <button\n #triggerButton\n type=\"button\"\n [id]=\"buttonId\"\n [disabled]=\"disabled()\"\n aria-haspopup=\"true\"\n [attr.aria-expanded]=\"isOpen()\"\n [attr.aria-controls]=\"menuId\"\n [class]=\"'inline-flex items-center justify-center p-2.5 cursor-pointer ' + triggerClass()\"\n >\n <ng-content select=\"[triggerIcon]\" />\n </button>\n }\n </div>\n\n @if (trigger() === 'context') {\n <span\n #cursorAnchor\n aria-hidden=\"true\"\n class=\"pointer-events-none fixed h-0 w-0\"\n [style.left.px]=\"cursorX()\"\n [style.top.px]=\"cursorY()\"\n ></span>\n }\n\n <div\n #menu\n [id]=\"menuId\"\n role=\"menu\"\n [attr.aria-labelledby]=\"buttonId\"\n [class]=\"\n 'z-dropdown absolute top-0 left-0 min-w-48 bg-neutral-primary rounded-md shadow-xl border border-default p-1.5 focus:outline-hidden transition-opacity duration-150 ' +\n (menuClass() ? ' ' + menuClass() : '')\n \"\n [class.hidden]=\"!isOpen()\"\n >\n <ng-content />\n </div>\n</div>\n" }]
7812
+ }, template: "<div\n (mouseenter)=\"onMouseEnter()\"\n (mouseleave)=\"onMouseLeave()\"\n (contextmenu)=\"onContextMenu($event)\"\n>\n <!-- Custom Slot Trigger or Default Button -->\n <div (click)=\"onTriggerClick()\" (keydown)=\"onTriggerKeydown($event)\">\n <ng-content select=\"[slot=trigger], [trigger]\" />\n @if (label()) {\n <button\n #triggerButton\n type=\"button\"\n [id]=\"buttonId\"\n [disabled]=\"disabled()\"\n aria-haspopup=\"true\"\n [attr.aria-expanded]=\"isOpen()\"\n [attr.aria-controls]=\"menuId\"\n [class]=\"\n triggerClass()\n ? 'inline-flex items-center gap-2 px-4 py-2 text-sm font-medium cursor-pointer ' +\n triggerClass()\n : 'inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-body bg-neutral-primary border border-default rounded-md hover:bg-neutral-tertiary focus:outline-hidden focus:ring-2 focus:ring-brand-subtle cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed'\n \"\n >\n <ng-content select=\"[triggerIcon]\" />\n <span>{{ label() }}</span>\n <ol-icon\n name=\"chevron-down\"\n size=\"xs\"\n class=\"transition-transform duration-200\"\n [class.rotate-180]=\"isOpen()\"\n ></ol-icon>\n </button>\n } @else if (!label() && triggerClass()) {\n <button\n #triggerButton\n type=\"button\"\n [id]=\"buttonId\"\n [disabled]=\"disabled()\"\n aria-haspopup=\"true\"\n [attr.aria-expanded]=\"isOpen()\"\n [attr.aria-controls]=\"menuId\"\n [class]=\"'inline-flex items-center justify-center p-2.5 cursor-pointer ' + triggerClass()\"\n >\n <ng-content select=\"[triggerIcon]\" />\n </button>\n }\n </div>\n\n @if (trigger() === 'context') {\n <span\n #cursorAnchor\n aria-hidden=\"true\"\n class=\"pointer-events-none fixed h-0 w-0\"\n [style.left.px]=\"cursorX()\"\n [style.top.px]=\"cursorY()\"\n ></span>\n }\n\n <div\n #menu\n [id]=\"menuId\"\n role=\"menu\"\n [attr.aria-labelledby]=\"buttonId\"\n [class]=\"\n 'z-dropdown absolute top-0 left-0 min-w-48 bg-neutral-primary rounded-md shadow-lg border border-default p-2 space-y-1.5 focus:outline-hidden transition-opacity duration-150 ' +\n (menuClass() ? ' ' + menuClass() : '')\n \"\n [class.hidden]=\"!isOpen()\"\n >\n <ng-content />\n </div>\n</div>\n" }]
7373
7813
  }], ctorParameters: () => [], propDecorators: { triggerButtonRef: [{ type: i0.ViewChild, args: ['triggerButton', { isSignal: true }] }], menuRef: [{ type: i0.ViewChild, args: ['menu', { isSignal: true }] }], cursorAnchorRef: [{ type: i0.ViewChild, args: ['cursorAnchor', { isSignal: true }] }], label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }], isOpen: [{ type: i0.Input, args: [{ isSignal: true, alias: "isOpen", required: false }] }, { type: i0.Output, args: ["isOpenChange"] }], appendTo: [{ type: i0.Input, args: [{ isSignal: true, alias: "appendTo", required: false }] }], placement: [{ type: i0.Input, args: [{ isSignal: true, alias: "placement", required: false }] }], trigger: [{ type: i0.Input, args: [{ isSignal: true, alias: "trigger", required: false }] }], autoClose: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoClose", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], triggerClass: [{ type: i0.Input, args: [{ isSignal: true, alias: "triggerClass", required: false }] }], menuClass: [{ type: i0.Input, args: [{ isSignal: true, alias: "menuClass", required: false }] }], opened: [{ type: i0.Output, args: ["opened"] }], closed: [{ type: i0.Output, args: ["closed"] }], onDocumentClick: [{
7374
7814
  type: HostListener,
7375
7815
  args: ['document:click', ['$event']]
@@ -7402,14 +7842,27 @@ class OlDropdownItem {
7402
7842
  parentDropdown = inject(forwardRef(() => OlDropdown), { optional: true });
7403
7843
  isItemActive = computed(() => this.selected() || this.active(), /* @ts-ignore */
7404
7844
  ...(ngDevMode ? [{ debugName: "isItemActive" }] : /* istanbul ignore next */ []));
7845
+ /**
7846
+ * Hình học theo Figma `ol-dropdown-item` (node 1149:39652, đo 2026-09-28):
7847
+ * lề 8px bốn cạnh (`px-2 py-2`, cao 36px), icon 16px cách chữ 6px, bo 8px
7848
+ * (`rounded` = `--radius: 8px`), chữ 14px medium. Bản trước `px-3` (12px),
7849
+ * icon cách chữ 10px, bo 6px.
7850
+ *
7851
+ * Độ đậm nằm TRONG từng nhánh, không gắn vô điều kiện — hai class cùng thuộc
7852
+ * tính thì thắng thua theo thứ tự luật CSS, không theo thứ tự trong chuỗi.
7853
+ *
7854
+ * Trạng thái đang chọn: Figma KHÔNG vẽ (chỉ có Initial / Hover / Disabled).
7855
+ * Bản trước tự đặt `bg-brand-softer` (#F5F8FF trên nền trắng — gần như không
7856
+ * thấy, báo 2026-09-28). Nay `bg-brand-soft` (#E3EAFF) + chữ brand đậm.
7857
+ */
7405
7858
  itemClasses = computed(() => {
7406
7859
  return [
7407
- 'w-full flex items-center justify-between gap-3 px-3 py-2 text-sm font-medium rounded-sm transition-colors cursor-pointer text-left whitespace-nowrap shrink-0',
7860
+ 'w-full flex items-center justify-between gap-3 px-2 py-2 text-sm rounded transition-colors cursor-pointer text-left whitespace-nowrap shrink-0',
7408
7861
  this.danger()
7409
- ? 'text-fg-danger hover:bg-danger-soft'
7862
+ ? 'font-medium text-fg-danger hover:bg-danger-soft'
7410
7863
  : this.isItemActive()
7411
- ? 'bg-brand-softer text-fg-brand-strong font-semibold'
7412
- : 'text-body hover:text-heading hover:bg-neutral-tertiary-medium',
7864
+ ? 'font-semibold bg-brand-soft text-fg-brand-strong'
7865
+ : 'font-medium text-body hover:text-heading hover:bg-neutral-tertiary-medium',
7413
7866
  this.disabled() ? 'opacity-40 pointer-events-none cursor-not-allowed' : '',
7414
7867
  ]
7415
7868
  .filter(Boolean)
@@ -7426,13 +7879,13 @@ class OlDropdownItem {
7426
7879
  }
7427
7880
  }
7428
7881
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: OlDropdownItem, deps: [], target: i0.ɵɵFactoryTarget.Component });
7429
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.6", type: OlDropdownItem, isStandalone: true, selector: "ol-dropdown-item", inputs: { routerLink: { classPropertyName: "routerLink", publicName: "routerLink", isSignal: true, isRequired: false, transformFunction: null }, href: { classPropertyName: "href", publicName: "href", isSignal: true, isRequired: false, transformFunction: null }, icon: { classPropertyName: "icon", publicName: "icon", isSignal: true, isRequired: false, transformFunction: null }, label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, secondaryText: { classPropertyName: "secondaryText", publicName: "secondaryText", isSignal: true, isRequired: false, transformFunction: null }, kbd: { classPropertyName: "kbd", publicName: "kbd", isSignal: true, isRequired: false, transformFunction: null }, suffixIcon: { classPropertyName: "suffixIcon", publicName: "suffixIcon", isSignal: true, isRequired: false, transformFunction: null }, selected: { classPropertyName: "selected", publicName: "selected", isSignal: true, isRequired: false, transformFunction: null }, active: { classPropertyName: "active", publicName: "active", isSignal: true, isRequired: false, transformFunction: null }, danger: { classPropertyName: "danger", publicName: "danger", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { itemClick: "itemClick" }, host: { classAttribute: "block w-full" }, ngImport: i0, template: "<a\n [routerLink]=\"routerLink() || undefined\"\n [href]=\"href() || undefined\"\n (click)=\"handleClick($event)\"\n [class]=\"itemClasses()\"\n role=\"menuitem\"\n [attr.aria-disabled]=\"disabled()\"\n [attr.tabindex]=\"disabled() ? -1 : 0\"\n>\n <div class=\"flex items-center gap-2.5 min-w-0 flex-1 whitespace-nowrap\">\n @if (icon()) {\n <ol-icon\n [name]=\"icon()!\"\n size=\"sm\"\n class=\"shrink-0\"\n [color]=\"danger() ? 'danger' : isItemActive() ? 'brand' : 'current'\"\n ></ol-icon>\n }\n <span class=\"text-sm font-medium whitespace-nowrap\">\n @if (label()) {\n {{ label() }}\n }\n <ng-content />\n </span>\n </div>\n @if (secondaryText()) {\n <span class=\"text-xs text-fg-disabled font-normal\">\n {{ secondaryText() }}\n </span>\n }\n @if (kbd()) {\n <kbd\n class=\"text-2xs font-mono text-body bg-neutral-tertiary px-1.5 py-0.5 rounded border border-default shrink-0\"\n >\n {{ kbd() }}\n </kbd>\n }\n @if (suffixIcon()) {\n <ol-icon [name]=\"suffixIcon()!\" size=\"xs\" class=\"shrink-0 text-body-subtle\"></ol-icon>\n }\n</a>\n", dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: RouterModule }, { kind: "directive", type: i2.RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "browserUrl", "routerLink"] }, { kind: "component", type: OlIcon, selector: "ol-icon", inputs: ["name", "size", "color", "variant", "strokeWidth"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
7882
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.6", type: OlDropdownItem, isStandalone: true, selector: "ol-dropdown-item", inputs: { routerLink: { classPropertyName: "routerLink", publicName: "routerLink", isSignal: true, isRequired: false, transformFunction: null }, href: { classPropertyName: "href", publicName: "href", isSignal: true, isRequired: false, transformFunction: null }, icon: { classPropertyName: "icon", publicName: "icon", isSignal: true, isRequired: false, transformFunction: null }, label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, secondaryText: { classPropertyName: "secondaryText", publicName: "secondaryText", isSignal: true, isRequired: false, transformFunction: null }, kbd: { classPropertyName: "kbd", publicName: "kbd", isSignal: true, isRequired: false, transformFunction: null }, suffixIcon: { classPropertyName: "suffixIcon", publicName: "suffixIcon", isSignal: true, isRequired: false, transformFunction: null }, selected: { classPropertyName: "selected", publicName: "selected", isSignal: true, isRequired: false, transformFunction: null }, active: { classPropertyName: "active", publicName: "active", isSignal: true, isRequired: false, transformFunction: null }, danger: { classPropertyName: "danger", publicName: "danger", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { itemClick: "itemClick" }, host: { classAttribute: "block w-full" }, ngImport: i0, template: "<a\n [routerLink]=\"routerLink() || undefined\"\n [href]=\"href() || undefined\"\n (click)=\"handleClick($event)\"\n [class]=\"itemClasses()\"\n role=\"menuitem\"\n [attr.aria-disabled]=\"disabled()\"\n [attr.tabindex]=\"disabled() ? -1 : 0\"\n>\n <div class=\"flex items-center gap-1.5 min-w-0 flex-1 whitespace-nowrap\">\n @if (icon()) {\n <ol-icon\n [name]=\"icon()!\"\n size=\"sm\"\n class=\"shrink-0\"\n [color]=\"danger() ? 'danger' : isItemActive() ? 'brand' : 'current'\"\n ></ol-icon>\n }\n <!--\n KH\u00D4NG g\u1EAFn font-* \u1EDF \u0111\u00E2y: \u0111\u1ED9 \u0111\u1EADm quy\u1EBFt \u1EDF th\u1EBB ngo\u00E0i theo tr\u1EA1ng th\u00E1i. B\u1EA3n\n tr\u01B0\u1EDBc ghi c\u1EE9ng `font-medium` n\u00EAn m\u1EE5c \u0111ang ch\u1ECDn kh\u00F4ng bao gi\u1EDD \u0111\u1EADm l\u00EAn d\u00F9\n th\u1EBB ngo\u00E0i c\u00F3 `font-semibold`.\n -->\n <span class=\"text-sm whitespace-nowrap\">\n @if (label()) {\n {{ label() }}\n }\n <ng-content />\n </span>\n </div>\n @if (secondaryText()) {\n <span class=\"text-xs text-fg-disabled font-normal\">\n {{ secondaryText() }}\n </span>\n }\n @if (kbd()) {\n <kbd\n class=\"text-2xs font-mono text-body bg-neutral-tertiary px-1.5 py-0.5 rounded border border-default shrink-0\"\n >\n {{ kbd() }}\n </kbd>\n }\n @if (suffixIcon()) {\n <ol-icon [name]=\"suffixIcon()!\" size=\"xs\" class=\"shrink-0 text-body-subtle\"></ol-icon>\n }\n</a>\n", dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: RouterModule }, { kind: "directive", type: i2.RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "browserUrl", "routerLink"] }, { kind: "component", type: OlIcon, selector: "ol-icon", inputs: ["name", "size", "color", "variant", "strokeWidth"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
7430
7883
  }
7431
7884
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: OlDropdownItem, decorators: [{
7432
7885
  type: Component,
7433
7886
  args: [{ selector: 'ol-dropdown-item', standalone: true, imports: [CommonModule, RouterModule, OlIcon], host: {
7434
7887
  class: 'block w-full',
7435
- }, changeDetection: ChangeDetectionStrategy.OnPush, template: "<a\n [routerLink]=\"routerLink() || undefined\"\n [href]=\"href() || undefined\"\n (click)=\"handleClick($event)\"\n [class]=\"itemClasses()\"\n role=\"menuitem\"\n [attr.aria-disabled]=\"disabled()\"\n [attr.tabindex]=\"disabled() ? -1 : 0\"\n>\n <div class=\"flex items-center gap-2.5 min-w-0 flex-1 whitespace-nowrap\">\n @if (icon()) {\n <ol-icon\n [name]=\"icon()!\"\n size=\"sm\"\n class=\"shrink-0\"\n [color]=\"danger() ? 'danger' : isItemActive() ? 'brand' : 'current'\"\n ></ol-icon>\n }\n <span class=\"text-sm font-medium whitespace-nowrap\">\n @if (label()) {\n {{ label() }}\n }\n <ng-content />\n </span>\n </div>\n @if (secondaryText()) {\n <span class=\"text-xs text-fg-disabled font-normal\">\n {{ secondaryText() }}\n </span>\n }\n @if (kbd()) {\n <kbd\n class=\"text-2xs font-mono text-body bg-neutral-tertiary px-1.5 py-0.5 rounded border border-default shrink-0\"\n >\n {{ kbd() }}\n </kbd>\n }\n @if (suffixIcon()) {\n <ol-icon [name]=\"suffixIcon()!\" size=\"xs\" class=\"shrink-0 text-body-subtle\"></ol-icon>\n }\n</a>\n" }]
7888
+ }, changeDetection: ChangeDetectionStrategy.OnPush, template: "<a\n [routerLink]=\"routerLink() || undefined\"\n [href]=\"href() || undefined\"\n (click)=\"handleClick($event)\"\n [class]=\"itemClasses()\"\n role=\"menuitem\"\n [attr.aria-disabled]=\"disabled()\"\n [attr.tabindex]=\"disabled() ? -1 : 0\"\n>\n <div class=\"flex items-center gap-1.5 min-w-0 flex-1 whitespace-nowrap\">\n @if (icon()) {\n <ol-icon\n [name]=\"icon()!\"\n size=\"sm\"\n class=\"shrink-0\"\n [color]=\"danger() ? 'danger' : isItemActive() ? 'brand' : 'current'\"\n ></ol-icon>\n }\n <!--\n KH\u00D4NG g\u1EAFn font-* \u1EDF \u0111\u00E2y: \u0111\u1ED9 \u0111\u1EADm quy\u1EBFt \u1EDF th\u1EBB ngo\u00E0i theo tr\u1EA1ng th\u00E1i. B\u1EA3n\n tr\u01B0\u1EDBc ghi c\u1EE9ng `font-medium` n\u00EAn m\u1EE5c \u0111ang ch\u1ECDn kh\u00F4ng bao gi\u1EDD \u0111\u1EADm l\u00EAn d\u00F9\n th\u1EBB ngo\u00E0i c\u00F3 `font-semibold`.\n -->\n <span class=\"text-sm whitespace-nowrap\">\n @if (label()) {\n {{ label() }}\n }\n <ng-content />\n </span>\n </div>\n @if (secondaryText()) {\n <span class=\"text-xs text-fg-disabled font-normal\">\n {{ secondaryText() }}\n </span>\n }\n @if (kbd()) {\n <kbd\n class=\"text-2xs font-mono text-body bg-neutral-tertiary px-1.5 py-0.5 rounded border border-default shrink-0\"\n >\n {{ kbd() }}\n </kbd>\n }\n @if (suffixIcon()) {\n <ol-icon [name]=\"suffixIcon()!\" size=\"xs\" class=\"shrink-0 text-body-subtle\"></ol-icon>\n }\n</a>\n" }]
7436
7889
  }], propDecorators: { routerLink: [{ type: i0.Input, args: [{ isSignal: true, alias: "routerLink", required: false }] }], href: [{ type: i0.Input, args: [{ isSignal: true, alias: "href", required: false }] }], icon: [{ type: i0.Input, args: [{ isSignal: true, alias: "icon", required: false }] }], label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }], secondaryText: [{ type: i0.Input, args: [{ isSignal: true, alias: "secondaryText", required: false }] }], kbd: [{ type: i0.Input, args: [{ isSignal: true, alias: "kbd", required: false }] }], suffixIcon: [{ type: i0.Input, args: [{ isSignal: true, alias: "suffixIcon", required: false }] }], selected: [{ type: i0.Input, args: [{ isSignal: true, alias: "selected", required: false }] }], active: [{ type: i0.Input, args: [{ isSignal: true, alias: "active", required: false }] }], danger: [{ type: i0.Input, args: [{ isSignal: true, alias: "danger", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], itemClick: [{ type: i0.Output, args: ["itemClick"] }] } });
7437
7890
 
7438
7891
  /**
@@ -7499,6 +7952,96 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImpor
7499
7952
  args: [{ selector: 'ol-footer', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, template: "<footer class=\"bg-neutral-primary rounded-lg shadow-sm m-4\">\n <!--\n `container` quy\u1EBFt \u0111\u1ECBnh c\u00F3 gi\u1EDBi h\u1EA1n b\u1EC1 ngang hay kh\u00F4ng. B\u1EA3n tr\u01B0\u1EDBc khai input\n n\u00E0y nh\u01B0ng template kh\u00F4ng d\u00F9ng \u2014 khung `max-w-screen-xl mx-auto` b\u1EADt v\u00F4 \u0111i\u1EC1u\n ki\u1EC7n, n\u00EAn `container=false` kh\u00F4ng \u0111\u1ED5i g\u00EC, trong khi b\u1EA3ng API c\u1EE7a trang docs\n v\u1EABn c\u00F4ng b\u1ED1 n\u00F3. Angular kh\u00F4ng ki\u1EC3m input c\u00F3 \u0111\u01B0\u1EE3c \u0111\u1ECDc hay kh\u00F4ng n\u00EAn build v\u1EABn\n xanh.\n -->\n <div [class]=\"innerClasses()\">\n <ng-content />\n </div>\n</footer>\n" }]
7500
7953
  }], propDecorators: { container: [{ type: i0.Input, args: [{ isSignal: true, alias: "container", required: false }] }] } });
7501
7954
 
7955
+ /**
7956
+ * Khung bọc nhiều mảng nội dung, khoảng cách đều nhau.
7957
+ *
7958
+ * Chưa có node Figma — dựng theo design system.
7959
+ *
7960
+ * ## Vì sao KHÔNG dùng `ol-card` cho việc này
7961
+ *
7962
+ * `ol-card` là MỘT thẻ nội dung: có ảnh bìa, có `href`, có trạng thái chọn.
7963
+ * Còn đây là cái khung xếp NHIỀU mảng cạnh nhau với khoảng cách đều — như cột
7964
+ * thiết lập, bảng điều khiển, hay danh sách nhóm. Lồng nhiều `ol-card` vào một
7965
+ * `div` rồi tự đặt `space-y-*` cũng ra kết quả, nhưng mỗi nơi dùng lại chọn
7966
+ * một khoảng cách khác và trang trông lộn xộn.
7967
+ *
7968
+ * ## `stacked` bỏ viền giữa, không phải bỏ viền hết
7969
+ *
7970
+ * Xếp chồng thì các mảng dính thành một khối liền, chỉ còn một viền ngoài và
7971
+ * kẻ ngăn mảnh ở giữa — giống `ol-list-group`. Tách rời thì mỗi mảng là một
7972
+ * thẻ riêng có viền đủ bốn cạnh.
7973
+ *
7974
+ * Làm bằng `divide-y` cộng `overflow-hidden` trên khung, KHÔNG bằng
7975
+ * `[&>*:not(:last-child)]:border-b`: biến thể `:last-child` trong chuỗi class
7976
+ * của component con sẽ khớp MỌI thể hiện của con đó, kể cả khi nó là con duy
7977
+ * nhất ở nơi khác — lỗi đã gặp trong repo này.
7978
+ *
7979
+ * ## `ghost` bỏ nền và viền, giữ khoảng cách
7980
+ *
7981
+ * Dùng khi khung nằm trong một thẻ đã có viền rồi — hai lớp viền lồng nhau
7982
+ * trông như lỗi hiển thị.
7983
+ */
7984
+ class OlFrame {
7985
+ spacing = input('base', /* @ts-ignore */
7986
+ ...(ngDevMode ? [{ debugName: "spacing" }] : /* istanbul ignore next */ []));
7987
+ rounded = input('lg', /* @ts-ignore */
7988
+ ...(ngDevMode ? [{ debugName: "rounded" }] : /* istanbul ignore next */ []));
7989
+ variant = input('default', /* @ts-ignore */
7990
+ ...(ngDevMode ? [{ debugName: "variant" }] : /* istanbul ignore next */ []));
7991
+ /** Dính các mảng thành một khối liền, chỉ còn kẻ ngăn ở giữa. */
7992
+ stacked = input(false, /* @ts-ignore */
7993
+ ...(ngDevMode ? [{ debugName: "stacked" }] : /* istanbul ignore next */ []));
7994
+ /** Xếp ngang thay vì dọc. Khoảng cách và kẻ ngăn đổi trục theo. */
7995
+ horizontal = input(false, /* @ts-ignore */
7996
+ ...(ngDevMode ? [{ debugName: "horizontal" }] : /* istanbul ignore next */ []));
7997
+ frameClasses = computed(() => {
7998
+ const isRow = this.horizontal();
7999
+ const stacked = this.stacked();
8000
+ const gap = {
8001
+ xs: isRow ? 'gap-x-1' : 'gap-y-1',
8002
+ sm: isRow ? 'gap-x-2' : 'gap-y-2',
8003
+ base: isRow ? 'gap-x-4' : 'gap-y-4',
8004
+ lg: isRow ? 'gap-x-6' : 'gap-y-6',
8005
+ }[this.spacing()];
8006
+ const radius = { sm: 'rounded-sm', md: 'rounded-md', lg: 'rounded-lg', xl: 'rounded-xl' }[this.rounded()];
8007
+ const direction = isRow ? 'flex-row' : 'flex-col';
8008
+ // Xếp chồng: bỏ khoảng cách, thay bằng kẻ ngăn. `overflow-hidden` để con
8009
+ // ở hai đầu không đè ra ngoài góc bo của khung.
8010
+ const body = stacked
8011
+ ? `${isRow ? 'divide-x' : 'divide-y'} divide-default overflow-hidden`
8012
+ : gap;
8013
+ const shell = this.variant() === 'ghost'
8014
+ ? ''
8015
+ : stacked
8016
+ ? `border border-default bg-neutral-primary ${radius}`
8017
+ : '';
8018
+ // Tách rời + không ghost: viền nằm ở TỪNG mảng, không ở khung — nên khung
8019
+ // chỉ lo khoảng cách. Nơi dùng đặt `ol-card` hoặc thẻ có viền riêng vào.
8020
+ return `flex ${direction} ${body} ${shell}`.replace(/\s+/g, ' ').trim();
8021
+ }, /* @ts-ignore */
8022
+ ...(ngDevMode ? [{ debugName: "frameClasses" }] : /* istanbul ignore next */ []));
8023
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: OlFrame, deps: [], target: i0.ɵɵFactoryTarget.Component });
8024
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "22.1.6", type: OlFrame, isStandalone: true, selector: "ol-frame", inputs: { spacing: { classPropertyName: "spacing", publicName: "spacing", isSignal: true, isRequired: false, transformFunction: null }, rounded: { classPropertyName: "rounded", publicName: "rounded", isSignal: true, isRequired: false, transformFunction: null }, variant: { classPropertyName: "variant", publicName: "variant", isSignal: true, isRequired: false, transformFunction: null }, stacked: { classPropertyName: "stacked", publicName: "stacked", isSignal: true, isRequired: false, transformFunction: null }, horizontal: { classPropertyName: "horizontal", publicName: "horizontal", isSignal: true, isRequired: false, transformFunction: null } }, host: { classAttribute: "block" }, ngImport: i0, template: `
8025
+ <div [class]="frameClasses()">
8026
+ <ng-content />
8027
+ </div>
8028
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
8029
+ }
8030
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: OlFrame, decorators: [{
8031
+ type: Component,
8032
+ args: [{
8033
+ selector: 'ol-frame',
8034
+ standalone: true,
8035
+ template: `
8036
+ <div [class]="frameClasses()">
8037
+ <ng-content />
8038
+ </div>
8039
+ `,
8040
+ changeDetection: ChangeDetectionStrategy.OnPush,
8041
+ host: { class: 'block' },
8042
+ }]
8043
+ }], propDecorators: { spacing: [{ type: i0.Input, args: [{ isSignal: true, alias: "spacing", required: false }] }], rounded: [{ type: i0.Input, args: [{ isSignal: true, alias: "rounded", required: false }] }], variant: [{ type: i0.Input, args: [{ isSignal: true, alias: "variant", required: false }] }], stacked: [{ type: i0.Input, args: [{ isSignal: true, alias: "stacked", required: false }] }], horizontal: [{ type: i0.Input, args: [{ isSignal: true, alias: "horizontal", required: false }] }] } });
8044
+
7502
8045
  /**
7503
8046
  * Nhãn cho ô nhập — khớp Figma `ol-label` (#1149:94727), trục
7504
8047
  * `Type=[Default | Required & icon | Two texts]`.
@@ -10035,109 +10578,1254 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImpor
10035
10578
  }]
10036
10579
  }], propDecorators: { label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }] } });
10037
10580
 
10038
- let nextFileInputId = 0;
10039
- class OlFileInput {
10040
- /** Id tự sinh khi nơi gọi không truyền id — để <label for> và
10041
- * aria-describedby luôn nối được với ô nhập. */
10042
- uid = `ol-file-input-${nextFileInputId++}`;
10043
- inputId = computed(() => this.id() || this.uid, /* @ts-ignore */
10044
- ...(ngDevMode ? [{ debugName: "inputId" }] : /* istanbul ignore next */ []));
10045
- helperId = computed(() => `${this.inputId()}-helper`, /* @ts-ignore */
10046
- ...(ngDevMode ? [{ debugName: "helperId" }] : /* istanbul ignore next */ []));
10047
- describedBy = computed(() => (this.helperText() ? this.helperId() : null), /* @ts-ignore */
10048
- ...(ngDevMode ? [{ debugName: "describedBy" }] : /* istanbul ignore next */ []));
10049
- ariaInvalid = computed(() => null, /* @ts-ignore */
10050
- ...(ngDevMode ? [{ debugName: "ariaInvalid" }] : /* istanbul ignore next */ []));
10051
- label = input('Tải lên tệp', /* @ts-ignore */
10052
- ...(ngDevMode ? [{ debugName: "label" }] : /* istanbul ignore next */ []));
10053
- helperText = input('', /* @ts-ignore */
10054
- ...(ngDevMode ? [{ debugName: "helperText" }] : /* istanbul ignore next */ []));
10055
- disabled = input(false, /* @ts-ignore */
10056
- ...(ngDevMode ? [{ debugName: "disabled" }] : /* istanbul ignore next */ []));
10057
- dropzone = input(false, /* @ts-ignore */
10058
- ...(ngDevMode ? [{ debugName: "dropzone" }] : /* istanbul ignore next */ []));
10059
- title = input('Nhấp để tải lên hoặc kéo và thả vào đây', /* @ts-ignore */
10060
- ...(ngDevMode ? [{ debugName: "title" }] : /* istanbul ignore next */ []));
10061
- description = input('Hỗ trợ SVG, PNG, JPG hoặc GIF (Tối đa 800x400px)', /* @ts-ignore */
10062
- ...(ngDevMode ? [{ debugName: "description" }] : /* istanbul ignore next */ []));
10581
+ /**
10582
+ * Chọn MỘT trong vài chế độ loại trừ nhau, đặt cạnh nhau trên một rãnh xám;
10583
+ * mục đang chọn nổi lên nền trắng (segmented control).
10584
+ *
10585
+ * Khác `ol-tabs`: tabs chuyển PANEL nội dung, đây chọn GIÁ TRỊ (có CVA, dùng
10586
+ * được trong form). Khác `ol-toggle`: công tắc chỉ có hai trạng thái bật/tắt.
10587
+ *
10588
+ * Trợ năng theo mẫu radio group của WAI-ARIA: `role="radiogroup"`, mỗi mục
10589
+ * `role="radio"`, chỉ mục đang chọn nằm trong thứ tự Tab (roving tabindex),
10590
+ * mũi tên trái/phải/lên/xuống chuyển VÀ chọn luôn, Home/End về đầu/cuối.
10591
+ *
10592
+ * Figma chưa có thiết kế (tra `lip4g3...` 2026-09-28: không có component nào
10593
+ * tên segmented) — dựng theo token của hệ thống.
10594
+ */
10595
+ class OlSegmented {
10596
+ options = input([], /* @ts-ignore */
10597
+ ...(ngDevMode ? [{ debugName: "options" }] : /* istanbul ignore next */ []));
10598
+ value = model(null, /* @ts-ignore */
10599
+ ...(ngDevMode ? [{ debugName: "value" }] : /* istanbul ignore next */ []));
10063
10600
  size = input('base', /* @ts-ignore */
10064
10601
  ...(ngDevMode ? [{ debugName: "size" }] : /* istanbul ignore next */ []));
10065
- /** Cỡ đã quy bí danh: `'md'` -> `'base'`. Template so với cái này. */
10066
- resolvedSize = computed(() => (this.size() === 'md' ? 'base' : this.size()), /* @ts-ignore */
10067
- ...(ngDevMode ? [{ debugName: "resolvedSize" }] : /* istanbul ignore next */ []));
10068
- /**
10069
- * Padding theo size. Không dùng [class.p-1.5] trên template vì Angular
10070
- * cắt phần sau dấu chấm — nó render ra "p-1" (4px thay vì 6px).
10071
- */
10072
- paddingClass = computed(() => {
10073
- switch (this.resolvedSize()) {
10074
- case 'sm':
10075
- return 'p-1';
10076
- case 'lg':
10077
- return 'p-2';
10078
- default:
10079
- return 'p-1.5';
10080
- }
10602
+ /** Giãn hết bề ngang khung, các mục chia đều. */
10603
+ fullWidth = input(false, /* @ts-ignore */
10604
+ ...(ngDevMode ? [{ debugName: "fullWidth" }] : /* istanbul ignore next */ []));
10605
+ /** `model()` để `setDisabledState()` của Reactive Forms đặt được. */
10606
+ disabled = model(false, /* @ts-ignore */
10607
+ ...(ngDevMode ? [{ debugName: "disabled" }] : /* istanbul ignore next */ []));
10608
+ /** Tên nhóm cho bộ đọc màn hình — nên đặt, vì nhóm không có nhãn nhìn thấy. */
10609
+ ariaLabel = input('', /* @ts-ignore */
10610
+ ...(ngDevMode ? [{ debugName: "ariaLabel" }] : /* istanbul ignore next */ []));
10611
+ host = inject(ElementRef);
10612
+ onChange = () => { };
10613
+ onTouched = () => { };
10614
+ hostClasses = computed(() => this.fullWidth() ? 'block w-full' : 'inline-block max-w-full', /* @ts-ignore */
10615
+ ...(ngDevMode ? [{ debugName: "hostClasses" }] : /* istanbul ignore next */ []));
10616
+ trackClasses = computed(() => {
10617
+ const width = this.fullWidth() ? 'flex w-full' : 'inline-flex max-w-full';
10618
+ const state = this.disabled() ? 'opacity-50' : '';
10619
+ return `${width} items-center gap-1 p-1 rounded-md bg-neutral-tertiary overflow-x-auto ${state}`;
10081
10620
  }, /* @ts-ignore */
10082
- ...(ngDevMode ? [{ debugName: "paddingClass" }] : /* istanbul ignore next */ []));
10083
- accept = input('*', /* @ts-ignore */
10084
- ...(ngDevMode ? [{ debugName: "accept" }] : /* istanbul ignore next */ []));
10085
- multiple = input(false, /* @ts-ignore */
10086
- ...(ngDevMode ? [{ debugName: "multiple" }] : /* istanbul ignore next */ []));
10087
- id = input('', /* @ts-ignore */
10088
- ...(ngDevMode ? [{ debugName: "id" }] : /* istanbul ignore next */ []));
10089
- selectedFiles = signal([], /* @ts-ignore */
10090
- ...(ngDevMode ? [{ debugName: "selectedFiles" }] : /* istanbul ignore next */ []));
10091
- isDragging = signal(false, /* @ts-ignore */
10092
- ...(ngDevMode ? [{ debugName: "isDragging" }] : /* istanbul ignore next */ []));
10093
- filesChanged = output();
10094
- onFileSelected(event) {
10095
- const inputEl = event.target;
10096
- if (inputEl.files && inputEl.files.length > 0) {
10097
- const filesArray = Array.from(inputEl.files);
10098
- this.selectedFiles.set(filesArray);
10099
- this.filesChanged.emit(filesArray);
10100
- }
10101
- }
10102
- onDragOver(event) {
10103
- if (this.disabled())
10621
+ ...(ngDevMode ? [{ debugName: "trackClasses" }] : /* istanbul ignore next */ []));
10622
+ /** Mục đang Tab vào được: mục đang chọn, không có thì mục bật đầu tiên. */
10623
+ focusIndex = computed(() => {
10624
+ const opts = this.options();
10625
+ const selected = opts.findIndex((o) => o.value === this.value() && !o.disabled);
10626
+ return selected >= 0 ? selected : opts.findIndex((o) => !o.disabled);
10627
+ }, /* @ts-ignore */
10628
+ ...(ngDevMode ? [{ debugName: "focusIndex" }] : /* istanbul ignore next */ []));
10629
+ itemClasses(option) {
10630
+ const size = this.size() === 'sm' ? 'h-7 px-3 text-xs' : 'h-8 px-4 text-sm';
10631
+ const grow = this.fullWidth() ? 'flex-1 min-w-0' : 'shrink-0';
10632
+ const base = `${size} ${grow} inline-flex items-center justify-center gap-1.5 rounded whitespace-nowrap transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-subtle`;
10633
+ if (option.value === this.value()) {
10634
+ return `${base} bg-neutral-primary text-heading font-semibold shadow-xs`;
10635
+ }
10636
+ const blocked = option.disabled || this.disabled();
10637
+ return `${base} text-body font-medium ${blocked ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:text-heading'}`;
10638
+ }
10639
+ select(option) {
10640
+ if (option.disabled || this.disabled() || option.value === this.value())
10104
10641
  return;
10105
- event.preventDefault();
10106
- event.stopPropagation();
10107
- this.isDragging.set(true);
10642
+ this.value.set(option.value);
10643
+ this.onChange(option.value);
10644
+ this.onTouched();
10108
10645
  }
10109
- onDragLeave(event) {
10110
- if (this.disabled())
10646
+ onKeydown(event, index) {
10647
+ const step = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[event.key];
10648
+ const opts = this.options();
10649
+ let target = -1;
10650
+ if (step)
10651
+ target = this.nextEnabled(index, step);
10652
+ else if (event.key === 'Home')
10653
+ target = this.nextEnabled(-1, 1);
10654
+ else if (event.key === 'End')
10655
+ target = this.nextEnabled(opts.length, -1);
10656
+ if (target < 0)
10111
10657
  return;
10112
10658
  event.preventDefault();
10113
- event.stopPropagation();
10114
- this.isDragging.set(false);
10659
+ this.select(opts[target]);
10660
+ this.host.nativeElement.querySelectorAll('[role=radio]')[target]?.focus();
10115
10661
  }
10116
- onDrop(event) {
10117
- if (this.disabled())
10118
- return;
10119
- event.preventDefault();
10120
- event.stopPropagation();
10121
- this.isDragging.set(false);
10122
- if (event.dataTransfer?.files && event.dataTransfer.files.length > 0) {
10123
- const filesArray = Array.from(event.dataTransfer.files);
10124
- this.selectedFiles.set(filesArray);
10125
- this.filesChanged.emit(filesArray);
10662
+ /** Mục bật kế tiếp theo chiều `step`, vòng quanh. -1 khi không có. */
10663
+ nextEnabled(from, step) {
10664
+ const opts = this.options();
10665
+ const n = opts.length;
10666
+ for (let k = 1; k <= n; k++) {
10667
+ const i = (((from + step * k) % n) + n) % n;
10668
+ if (!opts[i].disabled)
10669
+ return i;
10126
10670
  }
10671
+ return -1;
10127
10672
  }
10128
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: OlFileInput, deps: [], target: i0.ɵɵFactoryTarget.Component });
10129
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.6", type: OlFileInput, isStandalone: true, selector: "ol-file-input", inputs: { label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, helperText: { classPropertyName: "helperText", publicName: "helperText", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, dropzone: { classPropertyName: "dropzone", publicName: "dropzone", isSignal: true, isRequired: false, transformFunction: null }, title: { classPropertyName: "title", publicName: "title", isSignal: true, isRequired: false, transformFunction: null }, description: { classPropertyName: "description", publicName: "description", isSignal: true, isRequired: false, transformFunction: null }, size: { classPropertyName: "size", publicName: "size", isSignal: true, isRequired: false, transformFunction: null }, accept: { classPropertyName: "accept", publicName: "accept", isSignal: true, isRequired: false, transformFunction: null }, multiple: { classPropertyName: "multiple", publicName: "multiple", isSignal: true, isRequired: false, transformFunction: null }, id: { classPropertyName: "id", publicName: "id", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { filesChanged: "filesChanged" }, host: { classAttribute: "block w-full" }, ngImport: i0, template: "<div class=\"w-full\">\n @if (!dropzone()) {\n <!-- Standard File Button Input -->\n @if (label()) {\n <label [for]=\"inputId()\" class=\"block mb-1.5 text-sm font-medium text-heading\">\n {{ label() }}\n </label>\n }\n\n <input\n [id]=\"inputId()\"\n [attr.aria-invalid]=\"ariaInvalid()\"\n [attr.aria-describedby]=\"describedBy()\"\n type=\"file\"\n [disabled]=\"disabled()\"\n [accept]=\"accept()\"\n [multiple]=\"multiple()\"\n (change)=\"onFileSelected($event)\"\n class=\"block w-full text-sm text-heading border border-default rounded-md cursor-pointer bg-neutral-primary focus:outline-hidden focus:ring-2 focus:ring-brand-subtle dark:focus:ring-brand-950 file:me-4 file:py-2.5 file:px-4 file:rounded-s-md file:border-0 file:text-sm file:font-medium file:bg-neutral-tertiary file:text-heading hover:file:bg-neutral-tertiary-medium\"\n [class]=\"paddingClass()\"\n />\n\n @if (helperText()) {\n <p class=\"mt-1.5 text-xs text-body\">{{ helperText() }}</p>\n }\n } @else {\n <!-- Figma Dropzone Drag & Drop Area (Figma #1149:48981) -->\n @if (label()) {\n <label [for]=\"inputId()\" class=\"block mb-1.5 text-sm font-medium text-heading\">\n {{ label() }}\n </label>\n }\n\n <div\n (dragover)=\"onDragOver($event)\"\n (dragleave)=\"onDragLeave($event)\"\n (drop)=\"onDrop($event)\"\n class=\"relative flex flex-col items-center justify-center w-full min-h-40 border-2 border-dashed rounded-md cursor-pointer p-6 text-center\"\n [class.border-brand-light]=\"isDragging()\"\n [class.bg-brand-softer]=\"isDragging()\"\n [class.dark:bg-brand-950/20]=\"isDragging()\"\n [class.border-default-strong]=\"!isDragging() && !disabled()\"\n [class.bg-neutral-secondary-medium]=\"!isDragging() && !disabled()\"\n [class.hover:bg-neutral-tertiary]=\"!disabled()\"\n [class.opacity-60]=\"disabled()\"\n [class.cursor-not-allowed]=\"disabled()\"\n >\n <label\n [for]=\"inputId()\"\n class=\"flex flex-col items-center justify-center w-full h-full cursor-pointer\"\n >\n <!-- Cloud Upload Icon -->\n <div\n class=\"mb-3 p-3 rounded-full bg-brand-softer text-fg-brand dark:bg-brand-950/40 dark:text-fg-brand\"\n >\n <svg class=\"w-8 h-8\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n <path\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n stroke-width=\"2\"\n d=\"M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12\"\n />\n </svg>\n </div>\n\n <p class=\"mb-1 text-sm text-heading font-medium\">\n <span class=\"text-fg-brand dark:text-fg-brand font-semibold hover:underline\">{{\n title()\n }}</span>\n </p>\n <p class=\"text-xs text-body-subtle\">{{ description() }}</p>\n\n @if (selectedFiles().length > 0) {\n <div class=\"mt-3 flex flex-wrap gap-2 justify-center\">\n @for (file of selectedFiles(); track file.name) {\n <span\n class=\"inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-medium bg-brand-soft text-fg-brand-medium dark:bg-brand-900/50 dark:text-fg-brand\"\n >\n <ol-icon name=\"document-text\" size=\"xs\" />\n {{ file.name }} ({{ (file.size / 1024).toFixed(1) }} KB)\n </span>\n }\n </div>\n }\n\n <input\n [id]=\"inputId()\"\n [attr.aria-invalid]=\"ariaInvalid()\"\n [attr.aria-describedby]=\"describedBy()\"\n type=\"file\"\n [disabled]=\"disabled()\"\n [accept]=\"accept()\"\n [multiple]=\"multiple()\"\n (change)=\"onFileSelected($event)\"\n class=\"hidden\"\n />\n </label>\n </div>\n\n @if (helperText()) {\n <p class=\"mt-1.5 text-xs text-body\">{{ helperText() }}</p>\n }\n }\n</div>\n", dependencies: [{ kind: "component", type: OlIcon, selector: "ol-icon", inputs: ["name", "size", "color", "variant", "strokeWidth"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
10673
+ writeValue(v) {
10674
+ this.value.set(v ?? null);
10675
+ }
10676
+ registerOnChange(fn) {
10677
+ this.onChange = fn;
10678
+ }
10679
+ registerOnTouched(fn) {
10680
+ this.onTouched = fn;
10681
+ }
10682
+ setDisabledState(isDisabled) {
10683
+ this.disabled.set(isDisabled);
10684
+ }
10685
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: OlSegmented, deps: [], target: i0.ɵɵFactoryTarget.Component });
10686
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.6", type: OlSegmented, isStandalone: true, selector: "ol-segmented", inputs: { options: { classPropertyName: "options", publicName: "options", isSignal: true, isRequired: false, transformFunction: null }, value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: false, transformFunction: null }, size: { classPropertyName: "size", publicName: "size", isSignal: true, isRequired: false, transformFunction: null }, fullWidth: { classPropertyName: "fullWidth", publicName: "fullWidth", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { value: "valueChange", disabled: "disabledChange" }, host: { properties: { "class": "hostClasses()" } }, providers: [
10687
+ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => OlSegmented), multi: true },
10688
+ ], ngImport: i0, template: "<div\n role=\"radiogroup\"\n [attr.aria-label]=\"ariaLabel() || null\"\n [attr.aria-disabled]=\"disabled() || null\"\n [class]=\"trackClasses()\"\n>\n @for (option of options(); track option.value; let i = $index) {\n <button\n type=\"button\"\n role=\"radio\"\n [attr.aria-checked]=\"option.value === value()\"\n [disabled]=\"option.disabled || disabled()\"\n [tabindex]=\"i === focusIndex() ? 0 : -1\"\n [class]=\"itemClasses(option)\"\n (click)=\"select(option)\"\n (keydown)=\"onKeydown($event, i)\"\n >\n @if (option.icon) {\n <ol-icon [name]=\"option.icon\" size=\"sm\" class=\"shrink-0\" />\n }\n <span class=\"truncate\">{{ option.label }}</span>\n </button>\n }\n</div>\n", dependencies: [{ kind: "component", type: OlIcon, selector: "ol-icon", inputs: ["name", "size", "color", "variant", "strokeWidth"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
10130
10689
  }
10131
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: OlFileInput, decorators: [{
10690
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: OlSegmented, decorators: [{
10132
10691
  type: Component,
10133
- args: [{ selector: 'ol-file-input', standalone: true, imports: [OlIcon], host: {
10134
- // Họ form dùng chung một quy ước: host `block w-full`. Không khai thì thẻ
10135
- // tuỳ biến mặc định `display: inline` và bề rộng chạy theo NỘI DUNG —
10136
- // `ol-autocomplete` phình 240 -> 268px khi gõ chữ vì cụm icon phải chèn
10692
+ args: [{ selector: 'ol-segmented', standalone: true, imports: [OlIcon], changeDetection: ChangeDetectionStrategy.OnPush, host: { '[class]': 'hostClasses()' }, providers: [
10693
+ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => OlSegmented), multi: true },
10694
+ ], template: "<div\n role=\"radiogroup\"\n [attr.aria-label]=\"ariaLabel() || null\"\n [attr.aria-disabled]=\"disabled() || null\"\n [class]=\"trackClasses()\"\n>\n @for (option of options(); track option.value; let i = $index) {\n <button\n type=\"button\"\n role=\"radio\"\n [attr.aria-checked]=\"option.value === value()\"\n [disabled]=\"option.disabled || disabled()\"\n [tabindex]=\"i === focusIndex() ? 0 : -1\"\n [class]=\"itemClasses(option)\"\n (click)=\"select(option)\"\n (keydown)=\"onKeydown($event, i)\"\n >\n @if (option.icon) {\n <ol-icon [name]=\"option.icon\" size=\"sm\" class=\"shrink-0\" />\n }\n <span class=\"truncate\">{{ option.label }}</span>\n </button>\n }\n</div>\n" }]
10695
+ }], propDecorators: { options: [{ type: i0.Input, args: [{ isSignal: true, alias: "options", required: false }] }], value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: false }] }, { type: i0.Output, args: ["valueChange"] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }], fullWidth: [{ type: i0.Input, args: [{ isSignal: true, alias: "fullWidth", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }, { type: i0.Output, args: ["disabledChange"] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }] } });
10696
+
10697
+ const FILE_KIND_META = {
10698
+ image: { label: 'Hình ảnh', icon: 'lucide:file-image', color: 'green' },
10699
+ pdf: { label: 'PDF', icon: 'lucide:file-text', color: 'red' },
10700
+ word: { label: 'Tài liệu Word', icon: 'lucide:file-type', color: 'brand' },
10701
+ excel: { label: 'Bảng tính Excel', icon: 'lucide:file-spreadsheet', color: 'green' },
10702
+ powerpoint: { label: 'PowerPoint', icon: 'lucide:presentation', color: 'yellow' },
10703
+ video: { label: 'Tệp video', icon: 'lucide:file-video-camera', color: 'brand' },
10704
+ audio: { label: 'Âm thanh', icon: 'lucide:file-music', color: 'gray' },
10705
+ archive: { label: 'Tệp nén', icon: 'lucide:file-archive', color: 'yellow' },
10706
+ code: { label: 'Mã nguồn', icon: 'lucide:file-code', color: 'gray' },
10707
+ text: { label: 'Văn bản', icon: 'lucide:file-text', color: 'gray' },
10708
+ other: { label: 'Tệp khác', icon: 'lucide:file', color: 'gray' },
10709
+ };
10710
+ const EXTENSIONS = {
10711
+ image: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'heic', 'avif'],
10712
+ pdf: ['pdf'],
10713
+ word: ['doc', 'docx', 'odt', 'rtf'],
10714
+ excel: ['xls', 'xlsx', 'ods', 'csv'],
10715
+ powerpoint: ['ppt', 'pptx', 'odp', 'key'],
10716
+ video: ['mp4', 'mov', 'avi', 'mkv', 'webm', 'm4v'],
10717
+ audio: ['mp3', 'wav', 'm4a', 'ogg', 'aac', 'flac'],
10718
+ archive: ['zip', 'rar', '7z', 'tar', 'gz'],
10719
+ code: ['js', 'ts', 'py', 'java', 'c', 'cpp', 'cs', 'html', 'css', 'json', 'xml', 'sql', 'pas'],
10720
+ text: ['txt', 'md'],
10721
+ };
10722
+ /** Phần mở rộng, chữ thường, không dấu chấm. Không có → chuỗi rỗng. */
10723
+ function fileExtension(name) {
10724
+ const dot = name.lastIndexOf('.');
10725
+ return dot > 0 ? name.slice(dot + 1).toLowerCase() : '';
10726
+ }
10727
+ /**
10728
+ * Nhận biết loại tệp — ưu tiên PHẦN MỞ RỘNG, lùi về MIME.
10729
+ *
10730
+ * Không tin MIME trước: trình duyệt đoán MIME theo đuôi và hệ điều hành, nên
10731
+ * cùng một tệp `.docx` có máy báo `application/vnd.openxml…`, có máy báo rỗng
10732
+ * (Windows thiếu Office), và `.csv` có máy báo `application/vnd.ms-excel`.
10733
+ */
10734
+ function detectFileKind(name, mime = '') {
10735
+ const ext = fileExtension(name);
10736
+ for (const [kind, exts] of Object.entries(EXTENSIONS)) {
10737
+ if (exts.includes(ext))
10738
+ return kind;
10739
+ }
10740
+ if (mime.startsWith('image/'))
10741
+ return 'image';
10742
+ if (mime.startsWith('video/'))
10743
+ return 'video';
10744
+ if (mime.startsWith('audio/'))
10745
+ return 'audio';
10746
+ if (mime === 'application/pdf')
10747
+ return 'pdf';
10748
+ if (mime.startsWith('text/'))
10749
+ return 'text';
10750
+ return 'other';
10751
+ }
10752
+ /**
10753
+ * Byte → chữ ngắn theo cách người Việt đọc: `850 B`, `12,4 KB`, `3,2 MB`.
10754
+ * Dấu phẩy thập phân; số nguyên thì bỏ phần lẻ (`2 MB`, không `2,0 MB`).
10755
+ */
10756
+ function formatFileSize(bytes) {
10757
+ if (!Number.isFinite(bytes) || bytes < 0)
10758
+ return '—';
10759
+ if (bytes < 1024)
10760
+ return `${bytes} B`;
10761
+ const units = ['KB', 'MB', 'GB'];
10762
+ let value = bytes / 1024;
10763
+ let unit = 0;
10764
+ while (value >= 1024 && unit < units.length - 1) {
10765
+ value /= 1024;
10766
+ unit++;
10767
+ }
10768
+ const rounded = Math.round(value * 10) / 10;
10769
+ const text = Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1).replace('.', ',');
10770
+ return `${text} ${units[unit]}`;
10771
+ }
10772
+ /**
10773
+ * Tệp có khớp chuỗi `accept` của thẻ `<input type="file">` không.
10774
+ *
10775
+ * Hiểu đủ ba dạng của chuẩn HTML: đuôi (`.pdf`), MIME đầy đủ
10776
+ * (`application/pdf`), MIME đại diện (`image/*`). `*` hoặc rỗng → nhận hết.
10777
+ *
10778
+ * Cần tự kiểm vì `accept` chỉ LỌC hộp thoại chọn tệp — kéo thả thì trình duyệt
10779
+ * không lọc gì, và người dùng đổi bộ lọc hộp thoại sang "Tất cả tệp" là chọn
10780
+ * được tệp bất kỳ.
10781
+ */
10782
+ function matchesAccept(file, accept) {
10783
+ const rules = accept
10784
+ .split(',')
10785
+ .map((r) => r.trim().toLowerCase())
10786
+ .filter(Boolean);
10787
+ if (!rules.length || rules.includes('*') || rules.includes('*/*'))
10788
+ return true;
10789
+ const ext = `.${fileExtension(file.name)}`;
10790
+ const mime = (file.type || '').toLowerCase();
10791
+ return rules.some((rule) => {
10792
+ if (rule.startsWith('.'))
10793
+ return rule === ext;
10794
+ if (rule.endsWith('/*'))
10795
+ return mime.startsWith(rule.slice(0, -1));
10796
+ return rule === mime;
10797
+ });
10798
+ }
10799
+ /**
10800
+ * Chia tệp thành nhận / loại, kèm lý do từng tệp bị loại.
10801
+ *
10802
+ * Thứ tự kiểm: định dạng → dung lượng → số lượng. Tệp sai định dạng không
10803
+ * chiếm chỗ trong hạn mức số lượng — chọn 3 tệp, 1 tệp `.exe`, giới hạn 2 thì
10804
+ * nhận 2 tệp hợp lệ chứ không loại oan tệp thứ ba vì tệp `.exe` đứng trước.
10805
+ */
10806
+ function validateFiles(files, rules = {}) {
10807
+ const { accept = '*', maxSize = 0, maxFiles = 0, existingCount = 0 } = rules;
10808
+ const accepted = [];
10809
+ const rejected = [];
10810
+ for (const file of files) {
10811
+ if (!matchesAccept(file, accept)) {
10812
+ rejected.push({
10813
+ file,
10814
+ reason: 'type',
10815
+ message: `"${file.name}" không đúng định dạng cho phép.`,
10816
+ });
10817
+ }
10818
+ else if (maxSize > 0 && file.size > maxSize) {
10819
+ rejected.push({
10820
+ file,
10821
+ reason: 'size',
10822
+ message: `"${file.name}" (${formatFileSize(file.size)}) vượt quá ${formatFileSize(maxSize)}.`,
10823
+ });
10824
+ }
10825
+ else if (maxFiles > 0 && existingCount + accepted.length >= maxFiles) {
10826
+ rejected.push({ file, reason: 'count', message: `Chỉ được tải tối đa ${maxFiles} tệp.` });
10827
+ }
10828
+ else {
10829
+ accepted.push(file);
10830
+ }
10831
+ }
10832
+ return { accepted, rejected };
10833
+ }
10834
+ let nextUploadId = 0;
10835
+ /** File → OlUploadFile, gán id duy nhất để `@for track` không lẫn hai tệp trùng tên. */
10836
+ function toUploadFile(file) {
10837
+ return {
10838
+ id: `ol-upload-${nextUploadId++}`,
10839
+ name: file.name,
10840
+ size: file.size,
10841
+ file,
10842
+ status: 'idle',
10843
+ };
10844
+ }
10845
+ /**
10846
+ * Tệp trong một lần dán (Ctrl+V / ⌘V).
10847
+ *
10848
+ * Đọc cả `clipboardData.files` lẫn `items` loại `file`: Chrome đặt ảnh chụp
10849
+ * màn hình ở cả hai, Safari có lúc chỉ ở `items`. Gộp theo đối tượng để không
10850
+ * nhận một ảnh hai lần.
10851
+ *
10852
+ * Ảnh chụp màn hình dán vào LUÔN tên `image.png` — dán ba lần là ba tệp trùng
10853
+ * tên, không phân biệt được trong danh sách. Đổi thành "Ảnh dán 14-32-05.png".
10854
+ */
10855
+ function filesFromClipboard(data, now = new Date()) {
10856
+ if (!data)
10857
+ return [];
10858
+ const found = new Set(Array.from(data.files ?? []));
10859
+ for (const item of Array.from(data.items ?? [])) {
10860
+ if (item.kind !== 'file')
10861
+ continue;
10862
+ const f = item.getAsFile();
10863
+ if (f && ![...found].some((x) => x.name === f.name && x.size === f.size))
10864
+ found.add(f);
10865
+ }
10866
+ const stamp = [now.getHours(), now.getMinutes(), now.getSeconds()]
10867
+ .map((n) => String(n).padStart(2, '0'))
10868
+ .join('-');
10869
+ return [...found].map((f, i) => {
10870
+ if (!/^image\.\w+$/i.test(f.name))
10871
+ return f;
10872
+ const ext = fileExtension(f.name) || 'png';
10873
+ const suffix = found.size > 1 ? ` (${i + 1})` : '';
10874
+ return new File([f], `Ảnh dán ${stamp}${suffix}.${ext}`, {
10875
+ type: f.type,
10876
+ lastModified: f.lastModified,
10877
+ });
10878
+ });
10879
+ }
10880
+ /** Phím tắt dán theo hệ điều hành — để gợi ý đúng chữ người dùng thấy trên bàn phím. */
10881
+ function pasteShortcutLabel(platform = typeof navigator === 'undefined' ? '' : navigator.platform) {
10882
+ return /mac|iphone|ipad/i.test(platform) ? '⌘V' : 'Ctrl+V';
10883
+ }
10884
+ /** Đang gõ trong ô nhập chữ — dán ở đó là dán CHỮ, không được cướp. */
10885
+ function isEditableTarget(target) {
10886
+ const el = target;
10887
+ if (!el || typeof el.closest !== 'function')
10888
+ return false;
10889
+ if (el.isContentEditable)
10890
+ return true;
10891
+ const field = el.closest('input, textarea, select');
10892
+ return !!field && field.type !== 'file';
10893
+ }
10894
+
10895
+ /**
10896
+ * Vùng kéo–thả, theo Figma `ol-file-input-form` Type=Advanced (1149:48977):
10897
+ * icon tải lên · "Nhấn để tải lên hoặc kéo thả" · "Dung lượng tối đa: …" ·
10898
+ * (tuỳ chọn) nút "Chọn tệp".
10899
+ *
10900
+ * ## Cả vùng là MỘT `<button>`
10901
+ *
10902
+ * Bản cũ trong `ol-file-input` đặt `<input type="file" class="hidden">` —
10903
+ * `display: none` nên Tab không tới được, bàn phím không mở được hộp chọn tệp.
10904
+ * Ở đây vùng là nút thật: Tab tới, Enter/Space mở hộp chọn (qua `(pick)`, nơi
10905
+ * gọi gọi `input.click()`), và trình đọc màn hình đọc nhãn của nút.
10906
+ *
10907
+ * Nút "Chọn tệp" bên trong chỉ là HÌNH (`<span>`), không lồng `<button>` trong
10908
+ * `<button>` — HTML cấm, và hai nút lồng nhau làm một lần bấm bắn hai sự kiện.
10909
+ */
10910
+ class OlFileDropzone {
10911
+ title = input('Nhấn để tải lên hoặc kéo thả', /* @ts-ignore */
10912
+ ...(ngDevMode ? [{ debugName: "title" }] : /* istanbul ignore next */ []));
10913
+ /** Dòng phụ, ví dụ "Dung lượng tối đa: 30MB". Rỗng → không vẽ. */
10914
+ hint = input('', /* @ts-ignore */
10915
+ ...(ngDevMode ? [{ debugName: "hint" }] : /* istanbul ignore next */ []));
10916
+ /** Nhãn nút "Chọn tệp" trong vùng (Figma Advanced). Rỗng → không vẽ nút. */
10917
+ ctaLabel = input('', /* @ts-ignore */
10918
+ ...(ngDevMode ? [{ debugName: "ctaLabel" }] : /* istanbul ignore next */ []));
10919
+ icon = input('solar:upload-minimalistic-linear', /* @ts-ignore */
10920
+ ...(ngDevMode ? [{ debugName: "icon" }] : /* istanbul ignore next */ []));
10921
+ disabled = input(false, /* @ts-ignore */
10922
+ ...(ngDevMode ? [{ debugName: "disabled" }] : /* istanbul ignore next */ []));
10923
+ /** Trạng thái viền: lỗi / thành công (Figma State=Danger / Success). */
10924
+ state = input('default', /* @ts-ignore */
10925
+ ...(ngDevMode ? [{ debugName: "state" }] : /* istanbul ignore next */ []));
10926
+ /** Chiều cao tối thiểu — vùng trong khung có tiêu đề thấp hơn vùng đứng riêng. */
10927
+ compact = input(false, /* @ts-ignore */
10928
+ ...(ngDevMode ? [{ debugName: "compact" }] : /* istanbul ignore next */ []));
10929
+ describedBy = input('', /* @ts-ignore */
10930
+ ...(ngDevMode ? [{ debugName: "describedBy" }] : /* istanbul ignore next */ []));
10931
+ /** Dòng "hoặc dán ⌘V / Ctrl+V". Tắt khi nơi gọi không nhận tệp dán. */
10932
+ pasteHint = input(true, /* @ts-ignore */
10933
+ ...(ngDevMode ? [{ debugName: "pasteHint" }] : /* istanbul ignore next */ []));
10934
+ shortcut = pasteShortcutLabel();
10935
+ /** Bấm vào vùng (chuột hoặc bàn phím) — nơi gọi mở hộp chọn tệp. */
10936
+ pick = output();
10937
+ /** Tệp thả hoặc DÁN vào vùng. CHƯA kiểm định dạng — việc của nơi gọi. */
10938
+ filesDropped = output();
10939
+ dragging = signal(false, /* @ts-ignore */
10940
+ ...(ngDevMode ? [{ debugName: "dragging" }] : /* istanbul ignore next */ []));
10941
+ zoneClass = computed(() => {
10942
+ const border = this.dragging()
10943
+ ? 'border-brand bg-brand-softer'
10944
+ : this.state() === 'error'
10945
+ ? 'border-danger bg-neutral-primary'
10946
+ : this.state() === 'success'
10947
+ ? 'border-success bg-neutral-primary'
10948
+ : 'border-default-strong bg-neutral-primary hover:bg-neutral-secondary';
10949
+ return [
10950
+ 'flex w-full flex-col items-center justify-center gap-1.5 rounded-md border border-dashed px-4 text-center transition-colors cursor-pointer',
10951
+ 'focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-brand-subtle',
10952
+ 'disabled:cursor-not-allowed disabled:opacity-60',
10953
+ this.compact() ? 'min-h-28 py-4' : 'min-h-44 py-6',
10954
+ border,
10955
+ ].join(' ');
10956
+ }, /* @ts-ignore */
10957
+ ...(ngDevMode ? [{ debugName: "zoneClass" }] : /* istanbul ignore next */ []));
10958
+ onDragOver(event) {
10959
+ if (this.disabled())
10960
+ return;
10961
+ // Không preventDefault thì trình duyệt không cho thả và MỞ tệp ra tab mới.
10962
+ event.preventDefault();
10963
+ this.dragging.set(true);
10964
+ }
10965
+ onDragLeave(event) {
10966
+ // Rời vào phần tử CON (icon, chữ) cũng bắn dragleave — chỉ tắt khi thật sự
10967
+ // ra ngoài vùng, không thì viền nhấp nháy khi rê qua chữ.
10968
+ const to = event.relatedTarget;
10969
+ if (to && event.currentTarget.contains(to))
10970
+ return;
10971
+ this.dragging.set(false);
10972
+ }
10973
+ /**
10974
+ * Dán khi tiêu điểm đang ở vùng. Có tệp thì nhận và `stopPropagation` — khung
10975
+ * cha (`ol-file-upload`) cũng nghe `paste`, không chặn là nhận hai lần. Không
10976
+ * có tệp (dán chữ) thì để yên cho trình duyệt.
10977
+ */
10978
+ onPaste(event) {
10979
+ if (this.disabled())
10980
+ return;
10981
+ const files = filesFromClipboard(event.clipboardData);
10982
+ if (!files.length)
10983
+ return;
10984
+ event.preventDefault();
10985
+ event.stopPropagation();
10986
+ this.filesDropped.emit(files);
10987
+ }
10988
+ onDrop(event) {
10989
+ event.preventDefault();
10990
+ this.dragging.set(false);
10991
+ if (this.disabled())
10992
+ return;
10993
+ const files = Array.from(event.dataTransfer?.files ?? []);
10994
+ if (files.length)
10995
+ this.filesDropped.emit(files);
10996
+ }
10997
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: OlFileDropzone, deps: [], target: i0.ɵɵFactoryTarget.Component });
10998
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.6", type: OlFileDropzone, isStandalone: true, selector: "ol-file-dropzone", inputs: { title: { classPropertyName: "title", publicName: "title", isSignal: true, isRequired: false, transformFunction: null }, hint: { classPropertyName: "hint", publicName: "hint", isSignal: true, isRequired: false, transformFunction: null }, ctaLabel: { classPropertyName: "ctaLabel", publicName: "ctaLabel", isSignal: true, isRequired: false, transformFunction: null }, icon: { classPropertyName: "icon", publicName: "icon", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, state: { classPropertyName: "state", publicName: "state", isSignal: true, isRequired: false, transformFunction: null }, compact: { classPropertyName: "compact", publicName: "compact", isSignal: true, isRequired: false, transformFunction: null }, describedBy: { classPropertyName: "describedBy", publicName: "describedBy", isSignal: true, isRequired: false, transformFunction: null }, pasteHint: { classPropertyName: "pasteHint", publicName: "pasteHint", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { pick: "pick", filesDropped: "filesDropped" }, host: { classAttribute: "block" }, ngImport: i0, template: `
10999
+ <button
11000
+ type="button"
11001
+ [class]="zoneClass()"
11002
+ [disabled]="disabled()"
11003
+ [attr.aria-describedby]="describedBy() || null"
11004
+ (click)="pick.emit()"
11005
+ (dragenter)="onDragOver($event)"
11006
+ (dragover)="onDragOver($event)"
11007
+ (dragleave)="onDragLeave($event)"
11008
+ (drop)="onDrop($event)"
11009
+ (paste)="onPaste($event)"
11010
+ >
11011
+ <ol-icon [name]="icon()" [size]="24" class="text-fg-brand-strong" />
11012
+ <span class="text-sm text-body">{{ title() }}</span>
11013
+ @if (hint()) {
11014
+ <span class="text-xs font-medium text-heading">{{ hint() }}</span>
11015
+ }
11016
+ @if (pasteHint()) {
11017
+ <span class="text-xs text-body-subtle">
11018
+ hoặc dán
11019
+ <kbd
11020
+ class="rounded-xs border border-default bg-neutral-secondary px-1 font-sans text-2xs text-heading"
11021
+ >{{ shortcut }}</kbd
11022
+ >
11023
+ </span>
11024
+ }
11025
+ @if (ctaLabel()) {
11026
+ <span
11027
+ class="mt-1.5 inline-flex h-8 items-center gap-1.5 rounded-full bg-brand px-3.5 text-sm font-medium text-fg-white"
11028
+ aria-hidden="true"
11029
+ >
11030
+ <ol-icon name="solar:upload-minimalistic-linear" [size]="16" />
11031
+ {{ ctaLabel() }}
11032
+ </span>
11033
+ }
11034
+ </button>
11035
+ `, isInline: true, dependencies: [{ kind: "component", type: OlIcon, selector: "ol-icon", inputs: ["name", "size", "color", "variant", "strokeWidth"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
11036
+ }
11037
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: OlFileDropzone, decorators: [{
11038
+ type: Component,
11039
+ args: [{
11040
+ selector: 'ol-file-dropzone',
11041
+ standalone: true,
11042
+ imports: [OlIcon],
11043
+ template: `
11044
+ <button
11045
+ type="button"
11046
+ [class]="zoneClass()"
11047
+ [disabled]="disabled()"
11048
+ [attr.aria-describedby]="describedBy() || null"
11049
+ (click)="pick.emit()"
11050
+ (dragenter)="onDragOver($event)"
11051
+ (dragover)="onDragOver($event)"
11052
+ (dragleave)="onDragLeave($event)"
11053
+ (drop)="onDrop($event)"
11054
+ (paste)="onPaste($event)"
11055
+ >
11056
+ <ol-icon [name]="icon()" [size]="24" class="text-fg-brand-strong" />
11057
+ <span class="text-sm text-body">{{ title() }}</span>
11058
+ @if (hint()) {
11059
+ <span class="text-xs font-medium text-heading">{{ hint() }}</span>
11060
+ }
11061
+ @if (pasteHint()) {
11062
+ <span class="text-xs text-body-subtle">
11063
+ hoặc dán
11064
+ <kbd
11065
+ class="rounded-xs border border-default bg-neutral-secondary px-1 font-sans text-2xs text-heading"
11066
+ >{{ shortcut }}</kbd
11067
+ >
11068
+ </span>
11069
+ }
11070
+ @if (ctaLabel()) {
11071
+ <span
11072
+ class="mt-1.5 inline-flex h-8 items-center gap-1.5 rounded-full bg-brand px-3.5 text-sm font-medium text-fg-white"
11073
+ aria-hidden="true"
11074
+ >
11075
+ <ol-icon name="solar:upload-minimalistic-linear" [size]="16" />
11076
+ {{ ctaLabel() }}
11077
+ </span>
11078
+ }
11079
+ </button>
11080
+ `,
11081
+ host: { class: 'block' },
11082
+ changeDetection: ChangeDetectionStrategy.OnPush,
11083
+ }]
11084
+ }], propDecorators: { title: [{ type: i0.Input, args: [{ isSignal: true, alias: "title", required: false }] }], hint: [{ type: i0.Input, args: [{ isSignal: true, alias: "hint", required: false }] }], ctaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ctaLabel", required: false }] }], icon: [{ type: i0.Input, args: [{ isSignal: true, alias: "icon", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], state: [{ type: i0.Input, args: [{ isSignal: true, alias: "state", required: false }] }], compact: [{ type: i0.Input, args: [{ isSignal: true, alias: "compact", required: false }] }], describedBy: [{ type: i0.Input, args: [{ isSignal: true, alias: "describedBy", required: false }] }], pasteHint: [{ type: i0.Input, args: [{ isSignal: true, alias: "pasteHint", required: false }] }], pick: [{ type: i0.Output, args: ["pick"] }], filesDropped: [{ type: i0.Output, args: ["filesDropped"] }] } });
11085
+
11086
+ class OlFileItem {
11087
+ file = input.required(/* @ts-ignore */
11088
+ ...(ngDevMode ? [{ debugName: "file" }] : /* istanbul ignore next */ []));
11089
+ variant = input('row', /* @ts-ignore */
11090
+ ...(ngDevMode ? [{ debugName: "variant" }] : /* istanbul ignore next */ []));
11091
+ removable = input(true, /* @ts-ignore */
11092
+ ...(ngDevMode ? [{ debugName: "removable" }] : /* istanbul ignore next */ []));
11093
+ /** Hiện nút xem 👁 — nơi gọi tự mở tệp khi nhận `(preview)`. */
11094
+ previewable = input(false, /* @ts-ignore */
11095
+ ...(ngDevMode ? [{ debugName: "previewable" }] : /* istanbul ignore next */ []));
11096
+ disabled = input(false, /* @ts-ignore */
11097
+ ...(ngDevMode ? [{ debugName: "disabled" }] : /* istanbul ignore next */ []));
11098
+ remove = output();
11099
+ preview = output();
11100
+ retry = output();
11101
+ kind = computed(() => detectFileKind(this.file().name, this.file().file?.type), /* @ts-ignore */
11102
+ ...(ngDevMode ? [{ debugName: "kind" }] : /* istanbul ignore next */ []));
11103
+ meta = computed(() => FILE_KIND_META[this.kind()], /* @ts-ignore */
11104
+ ...(ngDevMode ? [{ debugName: "meta" }] : /* istanbul ignore next */ []));
11105
+ sizeText = computed(() => formatFileSize(this.file().size), /* @ts-ignore */
11106
+ ...(ngDevMode ? [{ debugName: "sizeText" }] : /* istanbul ignore next */ []));
11107
+ status = computed(() => this.file().status ?? 'idle', /* @ts-ignore */
11108
+ ...(ngDevMode ? [{ debugName: "status" }] : /* istanbul ignore next */ []));
11109
+ progress = computed(() => Math.max(0, Math.min(100, this.file().progress ?? 0)), /* @ts-ignore */
11110
+ ...(ngDevMode ? [{ debugName: "progress" }] : /* istanbul ignore next */ []));
11111
+ /** Dòng phụ dưới tên: dung lượng + trạng thái, đọc được cả khi không nhìn màu. */
11112
+ metaText = computed(() => {
11113
+ const size = this.sizeText();
11114
+ switch (this.status()) {
11115
+ case 'uploading':
11116
+ return `${size} · Đang tải ${this.progress()}%`;
11117
+ case 'success':
11118
+ return `${size} · Đã tải lên`;
11119
+ case 'error':
11120
+ return this.file().error || 'Tải lên thất bại';
11121
+ case 'idle':
11122
+ default:
11123
+ return `${size} · ${this.meta().label}`;
11124
+ }
11125
+ }, /* @ts-ignore */
11126
+ ...(ngDevMode ? [{ debugName: "metaText" }] : /* istanbul ignore next */ []));
11127
+ metaClass = computed(() => this.status() === 'error' ? 'text-xs text-fg-danger' : 'text-xs text-body-subtle', /* @ts-ignore */
11128
+ ...(ngDevMode ? [{ debugName: "metaClass" }] : /* istanbul ignore next */ []));
11129
+ rowClass = computed(() => [
11130
+ 'flex items-center gap-3 rounded-md border bg-neutral-primary p-3',
11131
+ this.status() === 'error' ? 'border-danger-subtle' : 'border-default',
11132
+ this.disabled() ? 'opacity-60' : '',
11133
+ ]
11134
+ .filter(Boolean)
11135
+ .join(' '), /* @ts-ignore */
11136
+ ...(ngDevMode ? [{ debugName: "rowClass" }] : /* istanbul ignore next */ []));
11137
+ /** Nút hành động nhỏ — cùng hình với nút đóng của drawer/modal. */
11138
+ actionClass = 'inline-flex size-8 shrink-0 items-center justify-center rounded-md text-body-subtle transition-colors hover:bg-neutral-tertiary hover:text-heading focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-brand-subtle disabled:pointer-events-none disabled:opacity-50';
11139
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: OlFileItem, deps: [], target: i0.ɵɵFactoryTarget.Component });
11140
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.6", type: OlFileItem, isStandalone: true, selector: "ol-file-item", inputs: { file: { classPropertyName: "file", publicName: "file", isSignal: true, isRequired: true, transformFunction: null }, variant: { classPropertyName: "variant", publicName: "variant", isSignal: true, isRequired: false, transformFunction: null }, removable: { classPropertyName: "removable", publicName: "removable", isSignal: true, isRequired: false, transformFunction: null }, previewable: { classPropertyName: "previewable", publicName: "previewable", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { remove: "remove", preview: "preview", retry: "retry" }, host: { classAttribute: "block min-w-0" }, ngImport: i0, template: "@switch (variant()) {\n @case ('chip') {\n <span\n class=\"inline-flex max-w-full items-center gap-1 rounded-md border border-default bg-neutral-primary py-0.5 ps-1 pe-1 text-sm text-heading\"\n >\n @if (previewable()) {\n <button\n type=\"button\"\n class=\"inline-flex size-6 shrink-0 items-center justify-center rounded-sm text-fg-brand hover:bg-neutral-tertiary focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-brand-subtle\"\n [disabled]=\"disabled()\"\n [attr.aria-label]=\"'Xem ' + file().name\"\n (click)=\"preview.emit(file())\"\n >\n <ol-icon name=\"solar:eye-bold\" [size]=\"14\" />\n </button>\n } @else {\n <ol-icon [name]=\"meta().icon\" [size]=\"14\" class=\"ms-1 shrink-0 text-body-subtle\" />\n }\n <span class=\"min-w-0 truncate\" [title]=\"file().name\">{{ file().name }}</span>\n @if (removable()) {\n <button\n type=\"button\"\n class=\"inline-flex size-6 shrink-0 items-center justify-center rounded-sm text-body-subtle hover:bg-neutral-tertiary hover:text-heading focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-brand-subtle\"\n [disabled]=\"disabled()\"\n [attr.aria-label]=\"'Xo\u00E1 ' + file().name\"\n (click)=\"remove.emit(file())\"\n >\n <ol-icon name=\"close\" [size]=\"12\" />\n </button>\n }\n </span>\n }\n\n @case ('card') {\n <div\n class=\"relative flex min-h-19 items-center justify-center gap-2 rounded-md border border-default bg-neutral-primary px-10 py-4\"\n >\n <ol-icon [name]=\"meta().icon\" [size]=\"20\" class=\"shrink-0 text-fg-brand-strong\" />\n <span class=\"min-w-0 truncate text-sm font-medium text-fg-brand-strong\" [title]=\"file().name\">\n {{ file().name }}\n </span>\n @if (removable()) {\n <button\n type=\"button\"\n [class]=\"actionClass + ' absolute end-1.5 top-1.5'\"\n [disabled]=\"disabled()\"\n [attr.aria-label]=\"'Xo\u00E1 ' + file().name\"\n (click)=\"remove.emit(file())\"\n >\n <ol-icon name=\"close\" size=\"xs\" />\n </button>\n }\n </div>\n }\n\n @default {\n <div [class]=\"rowClass()\">\n <ol-icon-shape [icon]=\"meta().icon\" [color]=\"meta().color\" type=\"square\" size=\"sm\" />\n\n <div class=\"min-w-0 flex-1\">\n <div class=\"flex items-center gap-1.5\">\n <span class=\"min-w-0 truncate text-sm font-medium text-heading\" [title]=\"file().name\">\n {{ file().name }}\n </span>\n @if (status() === 'success') {\n <ol-icon name=\"solar:check-circle-bold\" [size]=\"16\" class=\"shrink-0 text-fg-success\" />\n } @else if (status() === 'error') {\n <ol-icon name=\"solar:danger-circle-bold\" [size]=\"16\" class=\"shrink-0 text-fg-danger\" />\n }\n </div>\n <!-- aria-live: tr\u00ECnh \u0111\u1ECDc m\u00E0n h\u00ECnh \u0111\u1ECDc \"\u0110\u00E3 t\u1EA3i l\u00EAn\" / l\u1ED7i khi tr\u1EA1ng th\u00E1i \u0111\u1ED5i. -->\n <p [class]=\"metaClass()\" aria-live=\"polite\">{{ metaText() }}</p>\n @if (status() === 'uploading') {\n <ol-progress\n class=\"mt-1.5 block\"\n size=\"sm\"\n [progress]=\"progress()\"\n [attr.aria-label]=\"'Ti\u1EBFn tr\u00ECnh t\u1EA3i ' + file().name\"\n />\n }\n </div>\n\n <div class=\"flex shrink-0 items-center gap-0.5\">\n @if (previewable() && status() !== 'uploading') {\n <button\n type=\"button\"\n [class]=\"actionClass\"\n [disabled]=\"disabled()\"\n [attr.aria-label]=\"'Xem ' + file().name\"\n (click)=\"preview.emit(file())\"\n >\n <ol-icon name=\"solar:eye-bold\" size=\"sm\" />\n </button>\n }\n @if (status() === 'error') {\n <button\n type=\"button\"\n [class]=\"actionClass\"\n [disabled]=\"disabled()\"\n [attr.aria-label]=\"'Th\u1EED t\u1EA3i l\u1EA1i ' + file().name\"\n (click)=\"retry.emit(file())\"\n >\n <ol-icon name=\"solar:refresh-bold\" size=\"sm\" />\n </button>\n }\n @if (removable()) {\n <button\n type=\"button\"\n [class]=\"actionClass\"\n [disabled]=\"disabled()\"\n [attr.aria-label]=\"(status() === 'uploading' ? 'Hu\u1EF7 t\u1EA3i ' : 'Xo\u00E1 ') + file().name\"\n (click)=\"remove.emit(file())\"\n >\n <ol-icon\n [name]=\"status() === 'uploading' ? 'close' : 'solar:trash-bin-trash-bold'\"\n size=\"sm\"\n />\n </button>\n }\n </div>\n </div>\n }\n}\n", dependencies: [{ kind: "component", type: OlIcon, selector: "ol-icon", inputs: ["name", "size", "color", "variant", "strokeWidth"] }, { kind: "component", type: OlIconShape, selector: "ol-icon-shape", inputs: ["icon", "size", "color", "type"] }, { kind: "component", type: OlProgress, selector: "ol-progress", inputs: ["progress", "color", "size", "labelPosition", "label", "showProgress"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
11141
+ }
11142
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: OlFileItem, decorators: [{
11143
+ type: Component,
11144
+ args: [{ selector: 'ol-file-item', standalone: true, imports: [OlIcon, OlIconShape, OlProgress], host: { class: 'block min-w-0' }, changeDetection: ChangeDetectionStrategy.OnPush, template: "@switch (variant()) {\n @case ('chip') {\n <span\n class=\"inline-flex max-w-full items-center gap-1 rounded-md border border-default bg-neutral-primary py-0.5 ps-1 pe-1 text-sm text-heading\"\n >\n @if (previewable()) {\n <button\n type=\"button\"\n class=\"inline-flex size-6 shrink-0 items-center justify-center rounded-sm text-fg-brand hover:bg-neutral-tertiary focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-brand-subtle\"\n [disabled]=\"disabled()\"\n [attr.aria-label]=\"'Xem ' + file().name\"\n (click)=\"preview.emit(file())\"\n >\n <ol-icon name=\"solar:eye-bold\" [size]=\"14\" />\n </button>\n } @else {\n <ol-icon [name]=\"meta().icon\" [size]=\"14\" class=\"ms-1 shrink-0 text-body-subtle\" />\n }\n <span class=\"min-w-0 truncate\" [title]=\"file().name\">{{ file().name }}</span>\n @if (removable()) {\n <button\n type=\"button\"\n class=\"inline-flex size-6 shrink-0 items-center justify-center rounded-sm text-body-subtle hover:bg-neutral-tertiary hover:text-heading focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-brand-subtle\"\n [disabled]=\"disabled()\"\n [attr.aria-label]=\"'Xo\u00E1 ' + file().name\"\n (click)=\"remove.emit(file())\"\n >\n <ol-icon name=\"close\" [size]=\"12\" />\n </button>\n }\n </span>\n }\n\n @case ('card') {\n <div\n class=\"relative flex min-h-19 items-center justify-center gap-2 rounded-md border border-default bg-neutral-primary px-10 py-4\"\n >\n <ol-icon [name]=\"meta().icon\" [size]=\"20\" class=\"shrink-0 text-fg-brand-strong\" />\n <span class=\"min-w-0 truncate text-sm font-medium text-fg-brand-strong\" [title]=\"file().name\">\n {{ file().name }}\n </span>\n @if (removable()) {\n <button\n type=\"button\"\n [class]=\"actionClass + ' absolute end-1.5 top-1.5'\"\n [disabled]=\"disabled()\"\n [attr.aria-label]=\"'Xo\u00E1 ' + file().name\"\n (click)=\"remove.emit(file())\"\n >\n <ol-icon name=\"close\" size=\"xs\" />\n </button>\n }\n </div>\n }\n\n @default {\n <div [class]=\"rowClass()\">\n <ol-icon-shape [icon]=\"meta().icon\" [color]=\"meta().color\" type=\"square\" size=\"sm\" />\n\n <div class=\"min-w-0 flex-1\">\n <div class=\"flex items-center gap-1.5\">\n <span class=\"min-w-0 truncate text-sm font-medium text-heading\" [title]=\"file().name\">\n {{ file().name }}\n </span>\n @if (status() === 'success') {\n <ol-icon name=\"solar:check-circle-bold\" [size]=\"16\" class=\"shrink-0 text-fg-success\" />\n } @else if (status() === 'error') {\n <ol-icon name=\"solar:danger-circle-bold\" [size]=\"16\" class=\"shrink-0 text-fg-danger\" />\n }\n </div>\n <!-- aria-live: tr\u00ECnh \u0111\u1ECDc m\u00E0n h\u00ECnh \u0111\u1ECDc \"\u0110\u00E3 t\u1EA3i l\u00EAn\" / l\u1ED7i khi tr\u1EA1ng th\u00E1i \u0111\u1ED5i. -->\n <p [class]=\"metaClass()\" aria-live=\"polite\">{{ metaText() }}</p>\n @if (status() === 'uploading') {\n <ol-progress\n class=\"mt-1.5 block\"\n size=\"sm\"\n [progress]=\"progress()\"\n [attr.aria-label]=\"'Ti\u1EBFn tr\u00ECnh t\u1EA3i ' + file().name\"\n />\n }\n </div>\n\n <div class=\"flex shrink-0 items-center gap-0.5\">\n @if (previewable() && status() !== 'uploading') {\n <button\n type=\"button\"\n [class]=\"actionClass\"\n [disabled]=\"disabled()\"\n [attr.aria-label]=\"'Xem ' + file().name\"\n (click)=\"preview.emit(file())\"\n >\n <ol-icon name=\"solar:eye-bold\" size=\"sm\" />\n </button>\n }\n @if (status() === 'error') {\n <button\n type=\"button\"\n [class]=\"actionClass\"\n [disabled]=\"disabled()\"\n [attr.aria-label]=\"'Th\u1EED t\u1EA3i l\u1EA1i ' + file().name\"\n (click)=\"retry.emit(file())\"\n >\n <ol-icon name=\"solar:refresh-bold\" size=\"sm\" />\n </button>\n }\n @if (removable()) {\n <button\n type=\"button\"\n [class]=\"actionClass\"\n [disabled]=\"disabled()\"\n [attr.aria-label]=\"(status() === 'uploading' ? 'Hu\u1EF7 t\u1EA3i ' : 'Xo\u00E1 ') + file().name\"\n (click)=\"remove.emit(file())\"\n >\n <ol-icon\n [name]=\"status() === 'uploading' ? 'close' : 'solar:trash-bin-trash-bold'\"\n size=\"sm\"\n />\n </button>\n }\n </div>\n </div>\n }\n}\n" }]
11145
+ }], propDecorators: { file: [{ type: i0.Input, args: [{ isSignal: true, alias: "file", required: true }] }], variant: [{ type: i0.Input, args: [{ isSignal: true, alias: "variant", required: false }] }], removable: [{ type: i0.Input, args: [{ isSignal: true, alias: "removable", required: false }] }], previewable: [{ type: i0.Input, args: [{ isSignal: true, alias: "previewable", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], remove: [{ type: i0.Output, args: ["remove"] }], preview: [{ type: i0.Output, args: ["preview"] }], retry: [{ type: i0.Output, args: ["retry"] }] } });
11146
+
11147
+ let nextFileInputId = 0;
11148
+ class OlFileInput {
11149
+ /** Id tự sinh khi nơi gọi không truyền id — để <label for> và
11150
+ * aria-describedby luôn nối được với ô nhập. */
11151
+ uid = `ol-file-input-${nextFileInputId++}`;
11152
+ inputId = computed(() => this.id() || this.uid, /* @ts-ignore */
11153
+ ...(ngDevMode ? [{ debugName: "inputId" }] : /* istanbul ignore next */ []));
11154
+ helperId = computed(() => `${this.inputId()}-helper`, /* @ts-ignore */
11155
+ ...(ngDevMode ? [{ debugName: "helperId" }] : /* istanbul ignore next */ []));
11156
+ describedBy = computed(() => (this.helperText() ? this.helperId() : null), /* @ts-ignore */
11157
+ ...(ngDevMode ? [{ debugName: "describedBy" }] : /* istanbul ignore next */ []));
11158
+ ariaInvalid = computed(() => null, /* @ts-ignore */
11159
+ ...(ngDevMode ? [{ debugName: "ariaInvalid" }] : /* istanbul ignore next */ []));
11160
+ label = input('Tải lên tệp', /* @ts-ignore */
11161
+ ...(ngDevMode ? [{ debugName: "label" }] : /* istanbul ignore next */ []));
11162
+ helperText = input('', /* @ts-ignore */
11163
+ ...(ngDevMode ? [{ debugName: "helperText" }] : /* istanbul ignore next */ []));
11164
+ disabled = input(false, /* @ts-ignore */
11165
+ ...(ngDevMode ? [{ debugName: "disabled" }] : /* istanbul ignore next */ []));
11166
+ dropzone = input(false, /* @ts-ignore */
11167
+ ...(ngDevMode ? [{ debugName: "dropzone" }] : /* istanbul ignore next */ []));
11168
+ title = input('Nhấp để tải lên hoặc kéo và thả vào đây', /* @ts-ignore */
11169
+ ...(ngDevMode ? [{ debugName: "title" }] : /* istanbul ignore next */ []));
11170
+ description = input('Hỗ trợ SVG, PNG, JPG hoặc GIF (Tối đa 800x400px)', /* @ts-ignore */
11171
+ ...(ngDevMode ? [{ debugName: "description" }] : /* istanbul ignore next */ []));
11172
+ size = input('base', /* @ts-ignore */
11173
+ ...(ngDevMode ? [{ debugName: "size" }] : /* istanbul ignore next */ []));
11174
+ /** Cỡ đã quy bí danh: `'md'` -> `'base'`. Template so với cái này. */
11175
+ resolvedSize = computed(() => (this.size() === 'md' ? 'base' : this.size()), /* @ts-ignore */
11176
+ ...(ngDevMode ? [{ debugName: "resolvedSize" }] : /* istanbul ignore next */ []));
11177
+ /**
11178
+ * Padding theo size. Không dùng [class.p-1.5] trên template vì Angular
11179
+ * cắt phần sau dấu chấm — nó render ra "p-1" (4px thay vì 6px).
11180
+ */
11181
+ paddingClass = computed(() => {
11182
+ switch (this.resolvedSize()) {
11183
+ case 'sm':
11184
+ return 'p-1';
11185
+ case 'lg':
11186
+ return 'p-2';
11187
+ default:
11188
+ return 'p-1.5';
11189
+ }
11190
+ }, /* @ts-ignore */
11191
+ ...(ngDevMode ? [{ debugName: "paddingClass" }] : /* istanbul ignore next */ []));
11192
+ accept = input('*', /* @ts-ignore */
11193
+ ...(ngDevMode ? [{ debugName: "accept" }] : /* istanbul ignore next */ []));
11194
+ multiple = input(false, /* @ts-ignore */
11195
+ ...(ngDevMode ? [{ debugName: "multiple" }] : /* istanbul ignore next */ []));
11196
+ id = input('', /* @ts-ignore */
11197
+ ...(ngDevMode ? [{ debugName: "id" }] : /* istanbul ignore next */ []));
11198
+ /** Byte. 0 → không giới hạn. Có giá trị thì vùng kéo thả ghi "Dung lượng tối đa". */
11199
+ maxSize = input(0, /* @ts-ignore */
11200
+ ...(ngDevMode ? [{ debugName: "maxSize" }] : /* istanbul ignore next */ []));
11201
+ /** Nhãn nút trong vùng kéo thả (Figma Advanced). Rỗng → không vẽ nút. */
11202
+ ctaLabel = input('Chọn tệp', /* @ts-ignore */
11203
+ ...(ngDevMode ? [{ debugName: "ctaLabel" }] : /* istanbul ignore next */ []));
11204
+ /** Figma State: Danger / Success. Tệp bị loại tự chuyển sang `error`. */
11205
+ state = input('default', /* @ts-ignore */
11206
+ ...(ngDevMode ? [{ debugName: "state" }] : /* istanbul ignore next */ []));
11207
+ /** Tệp đã nhận (sau khi kiểm). */
11208
+ selectedFiles = signal([], /* @ts-ignore */
11209
+ ...(ngDevMode ? [{ debugName: "selectedFiles" }] : /* istanbul ignore next */ []));
11210
+ items = signal([], /* @ts-ignore */
11211
+ ...(ngDevMode ? [{ debugName: "items" }] : /* istanbul ignore next */ []));
11212
+ errors = signal([], /* @ts-ignore */
11213
+ ...(ngDevMode ? [{ debugName: "errors" }] : /* istanbul ignore next */ []));
11214
+ filesChanged = output();
11215
+ /** Tệp bị loại kèm lý do — component đã tự hiện câu lỗi. */
11216
+ rejected = output();
11217
+ dropInput = viewChild('dropInput', /* @ts-ignore */
11218
+ ...(ngDevMode ? [{ debugName: "dropInput" }] : /* istanbul ignore next */ []));
11219
+ host = inject(ElementRef);
11220
+ zoneState = computed(() => (this.errors().length ? 'error' : this.state()), /* @ts-ignore */
11221
+ ...(ngDevMode ? [{ debugName: "zoneState" }] : /* istanbul ignore next */ []));
11222
+ /**
11223
+ * Dòng phụ trong vùng: "Dung lượng tối đa" nếu có `maxSize`, không thì
11224
+ * `description` — giữ nguyên chữ của mã cũ đang truyền `description`.
11225
+ */
11226
+ zoneHint = computed(() => this.maxSize() > 0
11227
+ ? `Dung lượng tối đa: ${formatFileSize(this.maxSize())}`
11228
+ : this.description(), /* @ts-ignore */
11229
+ ...(ngDevMode ? [{ debugName: "zoneHint" }] : /* istanbul ignore next */ []));
11230
+ onFileSelected(event) {
11231
+ const inputEl = event.target;
11232
+ this.receive(Array.from(inputEl.files ?? []));
11233
+ // Cho chọn LẠI đúng tệp vừa xoá mà vẫn bắn `change`.
11234
+ if (this.dropzone())
11235
+ inputEl.value = '';
11236
+ }
11237
+ openPicker() {
11238
+ if (!this.disabled())
11239
+ this.dropInput()?.nativeElement.click();
11240
+ }
11241
+ /** Nhận tệp từ hộp chọn hoặc kéo thả: kiểm định dạng + dung lượng rồi phát. */
11242
+ receive(incoming) {
11243
+ if (this.disabled() || !incoming.length)
11244
+ return;
11245
+ const list = this.multiple() ? incoming : incoming.slice(0, 1);
11246
+ const { accepted, rejected } = validateFiles(list, {
11247
+ accept: this.accept(),
11248
+ maxSize: this.maxSize(),
11249
+ });
11250
+ this.errors.set(rejected.map((r) => r.message));
11251
+ if (rejected.length)
11252
+ this.rejected.emit(rejected);
11253
+ if (!accepted.length)
11254
+ return;
11255
+ const next = this.multiple() ? [...this.selectedFiles(), ...accepted] : accepted;
11256
+ this.selectedFiles.set(next);
11257
+ this.items.set(this.multiple()
11258
+ ? [...this.items(), ...accepted.map(toUploadFile)]
11259
+ : accepted.map(toUploadFile));
11260
+ this.filesChanged.emit(next);
11261
+ }
11262
+ /** Dán tệp (Ctrl+V / ⌘V) khi tiêu điểm ở ô hoặc vùng kéo thả. */
11263
+ onPaste(event) {
11264
+ if (this.disabled())
11265
+ return;
11266
+ const files = filesFromClipboard(event.clipboardData);
11267
+ if (!files.length)
11268
+ return;
11269
+ event.preventDefault();
11270
+ this.receive(files);
11271
+ this.syncNativeInput();
11272
+ }
11273
+ /**
11274
+ * Ô thường (không dropzone) hiện tên tệp bằng CHÍNH `<input type="file">` —
11275
+ * nhận tệp dán mà không gán vào nó thì ô vẫn ghi "Chưa chọn tệp" dù đã nhận.
11276
+ * `DataTransfer` là cách duy nhất gán `files` cho ô tệp.
11277
+ */
11278
+ syncNativeInput() {
11279
+ if (this.dropzone() || typeof DataTransfer === 'undefined')
11280
+ return;
11281
+ const el = this.host.nativeElement.querySelector('input[type=file]');
11282
+ if (!el)
11283
+ return;
11284
+ const dt = new DataTransfer();
11285
+ for (const f of this.selectedFiles())
11286
+ dt.items.add(f);
11287
+ el.files = dt.files;
11288
+ }
11289
+ removeItem(item) {
11290
+ this.items.set(this.items().filter((i) => i.id !== item.id));
11291
+ const next = this.selectedFiles().filter((f) => f !== item.file);
11292
+ this.selectedFiles.set(next);
11293
+ this.errors.set([]);
11294
+ this.filesChanged.emit(next);
11295
+ }
11296
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: OlFileInput, deps: [], target: i0.ɵɵFactoryTarget.Component });
11297
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.6", type: OlFileInput, isStandalone: true, selector: "ol-file-input", inputs: { label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, helperText: { classPropertyName: "helperText", publicName: "helperText", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, dropzone: { classPropertyName: "dropzone", publicName: "dropzone", isSignal: true, isRequired: false, transformFunction: null }, title: { classPropertyName: "title", publicName: "title", isSignal: true, isRequired: false, transformFunction: null }, description: { classPropertyName: "description", publicName: "description", isSignal: true, isRequired: false, transformFunction: null }, size: { classPropertyName: "size", publicName: "size", isSignal: true, isRequired: false, transformFunction: null }, accept: { classPropertyName: "accept", publicName: "accept", isSignal: true, isRequired: false, transformFunction: null }, multiple: { classPropertyName: "multiple", publicName: "multiple", isSignal: true, isRequired: false, transformFunction: null }, id: { classPropertyName: "id", publicName: "id", isSignal: true, isRequired: false, transformFunction: null }, maxSize: { classPropertyName: "maxSize", publicName: "maxSize", isSignal: true, isRequired: false, transformFunction: null }, ctaLabel: { classPropertyName: "ctaLabel", publicName: "ctaLabel", isSignal: true, isRequired: false, transformFunction: null }, state: { classPropertyName: "state", publicName: "state", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { filesChanged: "filesChanged", rejected: "rejected" }, host: { listeners: { "paste": "onPaste($event)" }, classAttribute: "block w-full" }, viewQueries: [{ propertyName: "dropInput", first: true, predicate: ["dropInput"], descendants: true, isSignal: true }], ngImport: i0, template: "<div class=\"w-full\">\n @if (!dropzone()) {\n <!-- Standard File Button Input -->\n @if (label()) {\n <label [for]=\"inputId()\" class=\"block mb-1.5 text-sm font-medium text-heading\">\n {{ label() }}\n </label>\n }\n\n <input\n [id]=\"inputId()\"\n [attr.aria-invalid]=\"ariaInvalid()\"\n [attr.aria-describedby]=\"describedBy()\"\n type=\"file\"\n [disabled]=\"disabled()\"\n [accept]=\"accept()\"\n [multiple]=\"multiple()\"\n (change)=\"onFileSelected($event)\"\n class=\"block w-full text-sm text-heading border border-default rounded-md cursor-pointer bg-neutral-primary focus:outline-hidden focus:ring-2 focus:ring-brand-subtle dark:focus:ring-brand-950 file:me-4 file:py-2.5 file:px-4 file:rounded-s-md file:border-0 file:text-sm file:font-medium file:bg-neutral-tertiary file:text-heading hover:file:bg-neutral-tertiary-medium\"\n [class]=\"paddingClass()\"\n />\n\n @if (helperText()) {\n <!-- id kh\u1EDBp aria-describedby c\u1EE7a \u00F4 \u2014 thi\u1EBFu id l\u00E0 tr\u00ECnh \u0111\u1ECDc m\u00E0n h\u00ECnh kh\u00F4ng \u0111\u1ECDc g\u1EE3i \u00FD. -->\n <p [id]=\"helperId()\" class=\"mt-1.5 text-xs text-body\">{{ helperText() }}</p>\n }\n } @else {\n <!--\n V\u00F9ng k\u00E9o th\u1EA3 theo Figma Type=Advanced (1149:48977), d\u00F9ng chung\n `ol-file-dropzone`. B\u1EA3n c\u0169: `<input class=\"hidden\">` (display:none \u2014 b\u00E0n\n ph\u00EDm kh\u00F4ng m\u1EDF \u0111\u01B0\u1EE3c), icon SVG v\u1EBD tay, kh\u00F4ng c\u00F3 n\u00FAt \"Ch\u1ECDn t\u1EC7p\" v\u00E0 d\u00F2ng\n dung l\u01B0\u1EE3ng, nh\u00E1nh `dark:` ghim m\u00E0u.\n -->\n @if (label()) {\n <label [for]=\"inputId()\" class=\"block mb-1.5 text-sm font-medium text-heading\">\n {{ label() }}\n </label>\n }\n\n <ol-file-dropzone\n [title]=\"title()\"\n [hint]=\"zoneHint()\"\n [ctaLabel]=\"ctaLabel()\"\n [state]=\"zoneState()\"\n [disabled]=\"disabled()\"\n [describedBy]=\"describedBy() ?? ''\"\n (pick)=\"openPicker()\"\n (filesDropped)=\"receive($event)\"\n />\n\n <!-- sr-only ch\u1EE9 kh\u00F4ng hidden: v\u1EABn n\u1EB1m trong c\u00E2y tr\u1EE3 n\u0103ng; Tab d\u1EEBng \u1EDF v\u00F9ng. -->\n <input\n #dropInput\n [id]=\"inputId()\"\n type=\"file\"\n class=\"sr-only\"\n tabindex=\"-1\"\n [disabled]=\"disabled()\"\n [accept]=\"accept()\"\n [multiple]=\"multiple()\"\n (change)=\"onFileSelected($event)\"\n />\n\n @if (errors().length) {\n <ul class=\"mt-1.5 space-y-0.5 text-xs text-fg-danger\" role=\"alert\">\n @for (msg of errors(); track msg) {\n <li>{{ msg }}</li>\n }\n </ul>\n }\n\n @if (items().length) {\n <ul class=\"mt-2 flex flex-col gap-2\">\n @for (item of items(); track item.id) {\n <li>\n <ol-file-item [file]=\"item\" [disabled]=\"disabled()\" (remove)=\"removeItem($event)\" />\n </li>\n }\n </ul>\n }\n\n @if (helperText()) {\n <p [id]=\"helperId()\" class=\"mt-1.5 text-xs text-body\">{{ helperText() }}</p>\n }\n }\n</div>\n", dependencies: [{ kind: "component", type: OlFileDropzone, selector: "ol-file-dropzone", inputs: ["title", "hint", "ctaLabel", "icon", "disabled", "state", "compact", "describedBy", "pasteHint"], outputs: ["pick", "filesDropped"] }, { kind: "component", type: OlFileItem, selector: "ol-file-item", inputs: ["file", "variant", "removable", "previewable", "disabled"], outputs: ["remove", "preview", "retry"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
11298
+ }
11299
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: OlFileInput, decorators: [{
11300
+ type: Component,
11301
+ args: [{ selector: 'ol-file-input', standalone: true, imports: [OlFileDropzone, OlFileItem], host: {
11302
+ // Họ form dùng chung một quy ước: host `block w-full`. Không khai thì thẻ
11303
+ // tuỳ biến mặc định `display: inline` và bề rộng chạy theo NỘI DUNG —
11304
+ // `ol-autocomplete` phình 240 -> 268px khi gõ chữ vì cụm icon phải chèn
10137
11305
  // thêm nút xoá. Sửa từng cái là vá lẻ, nên chốt cho cả họ.
10138
11306
  class: 'block w-full',
10139
- }, changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"w-full\">\n @if (!dropzone()) {\n <!-- Standard File Button Input -->\n @if (label()) {\n <label [for]=\"inputId()\" class=\"block mb-1.5 text-sm font-medium text-heading\">\n {{ label() }}\n </label>\n }\n\n <input\n [id]=\"inputId()\"\n [attr.aria-invalid]=\"ariaInvalid()\"\n [attr.aria-describedby]=\"describedBy()\"\n type=\"file\"\n [disabled]=\"disabled()\"\n [accept]=\"accept()\"\n [multiple]=\"multiple()\"\n (change)=\"onFileSelected($event)\"\n class=\"block w-full text-sm text-heading border border-default rounded-md cursor-pointer bg-neutral-primary focus:outline-hidden focus:ring-2 focus:ring-brand-subtle dark:focus:ring-brand-950 file:me-4 file:py-2.5 file:px-4 file:rounded-s-md file:border-0 file:text-sm file:font-medium file:bg-neutral-tertiary file:text-heading hover:file:bg-neutral-tertiary-medium\"\n [class]=\"paddingClass()\"\n />\n\n @if (helperText()) {\n <p class=\"mt-1.5 text-xs text-body\">{{ helperText() }}</p>\n }\n } @else {\n <!-- Figma Dropzone Drag & Drop Area (Figma #1149:48981) -->\n @if (label()) {\n <label [for]=\"inputId()\" class=\"block mb-1.5 text-sm font-medium text-heading\">\n {{ label() }}\n </label>\n }\n\n <div\n (dragover)=\"onDragOver($event)\"\n (dragleave)=\"onDragLeave($event)\"\n (drop)=\"onDrop($event)\"\n class=\"relative flex flex-col items-center justify-center w-full min-h-40 border-2 border-dashed rounded-md cursor-pointer p-6 text-center\"\n [class.border-brand-light]=\"isDragging()\"\n [class.bg-brand-softer]=\"isDragging()\"\n [class.dark:bg-brand-950/20]=\"isDragging()\"\n [class.border-default-strong]=\"!isDragging() && !disabled()\"\n [class.bg-neutral-secondary-medium]=\"!isDragging() && !disabled()\"\n [class.hover:bg-neutral-tertiary]=\"!disabled()\"\n [class.opacity-60]=\"disabled()\"\n [class.cursor-not-allowed]=\"disabled()\"\n >\n <label\n [for]=\"inputId()\"\n class=\"flex flex-col items-center justify-center w-full h-full cursor-pointer\"\n >\n <!-- Cloud Upload Icon -->\n <div\n class=\"mb-3 p-3 rounded-full bg-brand-softer text-fg-brand dark:bg-brand-950/40 dark:text-fg-brand\"\n >\n <svg class=\"w-8 h-8\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n <path\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n stroke-width=\"2\"\n d=\"M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12\"\n />\n </svg>\n </div>\n\n <p class=\"mb-1 text-sm text-heading font-medium\">\n <span class=\"text-fg-brand dark:text-fg-brand font-semibold hover:underline\">{{\n title()\n }}</span>\n </p>\n <p class=\"text-xs text-body-subtle\">{{ description() }}</p>\n\n @if (selectedFiles().length > 0) {\n <div class=\"mt-3 flex flex-wrap gap-2 justify-center\">\n @for (file of selectedFiles(); track file.name) {\n <span\n class=\"inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-medium bg-brand-soft text-fg-brand-medium dark:bg-brand-900/50 dark:text-fg-brand\"\n >\n <ol-icon name=\"document-text\" size=\"xs\" />\n {{ file.name }} ({{ (file.size / 1024).toFixed(1) }} KB)\n </span>\n }\n </div>\n }\n\n <input\n [id]=\"inputId()\"\n [attr.aria-invalid]=\"ariaInvalid()\"\n [attr.aria-describedby]=\"describedBy()\"\n type=\"file\"\n [disabled]=\"disabled()\"\n [accept]=\"accept()\"\n [multiple]=\"multiple()\"\n (change)=\"onFileSelected($event)\"\n class=\"hidden\"\n />\n </label>\n </div>\n\n @if (helperText()) {\n <p class=\"mt-1.5 text-xs text-body\">{{ helperText() }}</p>\n }\n }\n</div>\n" }]
10140
- }], propDecorators: { label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }], helperText: [{ type: i0.Input, args: [{ isSignal: true, alias: "helperText", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], dropzone: [{ type: i0.Input, args: [{ isSignal: true, alias: "dropzone", required: false }] }], title: [{ type: i0.Input, args: [{ isSignal: true, alias: "title", required: false }] }], description: [{ type: i0.Input, args: [{ isSignal: true, alias: "description", required: false }] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }], accept: [{ type: i0.Input, args: [{ isSignal: true, alias: "accept", required: false }] }], multiple: [{ type: i0.Input, args: [{ isSignal: true, alias: "multiple", required: false }] }], id: [{ type: i0.Input, args: [{ isSignal: true, alias: "id", required: false }] }], filesChanged: [{ type: i0.Output, args: ["filesChanged"] }] } });
11307
+ '(paste)': 'onPaste($event)',
11308
+ }, changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"w-full\">\n @if (!dropzone()) {\n <!-- Standard File Button Input -->\n @if (label()) {\n <label [for]=\"inputId()\" class=\"block mb-1.5 text-sm font-medium text-heading\">\n {{ label() }}\n </label>\n }\n\n <input\n [id]=\"inputId()\"\n [attr.aria-invalid]=\"ariaInvalid()\"\n [attr.aria-describedby]=\"describedBy()\"\n type=\"file\"\n [disabled]=\"disabled()\"\n [accept]=\"accept()\"\n [multiple]=\"multiple()\"\n (change)=\"onFileSelected($event)\"\n class=\"block w-full text-sm text-heading border border-default rounded-md cursor-pointer bg-neutral-primary focus:outline-hidden focus:ring-2 focus:ring-brand-subtle dark:focus:ring-brand-950 file:me-4 file:py-2.5 file:px-4 file:rounded-s-md file:border-0 file:text-sm file:font-medium file:bg-neutral-tertiary file:text-heading hover:file:bg-neutral-tertiary-medium\"\n [class]=\"paddingClass()\"\n />\n\n @if (helperText()) {\n <!-- id kh\u1EDBp aria-describedby c\u1EE7a \u00F4 \u2014 thi\u1EBFu id l\u00E0 tr\u00ECnh \u0111\u1ECDc m\u00E0n h\u00ECnh kh\u00F4ng \u0111\u1ECDc g\u1EE3i \u00FD. -->\n <p [id]=\"helperId()\" class=\"mt-1.5 text-xs text-body\">{{ helperText() }}</p>\n }\n } @else {\n <!--\n V\u00F9ng k\u00E9o th\u1EA3 theo Figma Type=Advanced (1149:48977), d\u00F9ng chung\n `ol-file-dropzone`. B\u1EA3n c\u0169: `<input class=\"hidden\">` (display:none \u2014 b\u00E0n\n ph\u00EDm kh\u00F4ng m\u1EDF \u0111\u01B0\u1EE3c), icon SVG v\u1EBD tay, kh\u00F4ng c\u00F3 n\u00FAt \"Ch\u1ECDn t\u1EC7p\" v\u00E0 d\u00F2ng\n dung l\u01B0\u1EE3ng, nh\u00E1nh `dark:` ghim m\u00E0u.\n -->\n @if (label()) {\n <label [for]=\"inputId()\" class=\"block mb-1.5 text-sm font-medium text-heading\">\n {{ label() }}\n </label>\n }\n\n <ol-file-dropzone\n [title]=\"title()\"\n [hint]=\"zoneHint()\"\n [ctaLabel]=\"ctaLabel()\"\n [state]=\"zoneState()\"\n [disabled]=\"disabled()\"\n [describedBy]=\"describedBy() ?? ''\"\n (pick)=\"openPicker()\"\n (filesDropped)=\"receive($event)\"\n />\n\n <!-- sr-only ch\u1EE9 kh\u00F4ng hidden: v\u1EABn n\u1EB1m trong c\u00E2y tr\u1EE3 n\u0103ng; Tab d\u1EEBng \u1EDF v\u00F9ng. -->\n <input\n #dropInput\n [id]=\"inputId()\"\n type=\"file\"\n class=\"sr-only\"\n tabindex=\"-1\"\n [disabled]=\"disabled()\"\n [accept]=\"accept()\"\n [multiple]=\"multiple()\"\n (change)=\"onFileSelected($event)\"\n />\n\n @if (errors().length) {\n <ul class=\"mt-1.5 space-y-0.5 text-xs text-fg-danger\" role=\"alert\">\n @for (msg of errors(); track msg) {\n <li>{{ msg }}</li>\n }\n </ul>\n }\n\n @if (items().length) {\n <ul class=\"mt-2 flex flex-col gap-2\">\n @for (item of items(); track item.id) {\n <li>\n <ol-file-item [file]=\"item\" [disabled]=\"disabled()\" (remove)=\"removeItem($event)\" />\n </li>\n }\n </ul>\n }\n\n @if (helperText()) {\n <p [id]=\"helperId()\" class=\"mt-1.5 text-xs text-body\">{{ helperText() }}</p>\n }\n }\n</div>\n" }]
11309
+ }], propDecorators: { label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }], helperText: [{ type: i0.Input, args: [{ isSignal: true, alias: "helperText", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], dropzone: [{ type: i0.Input, args: [{ isSignal: true, alias: "dropzone", required: false }] }], title: [{ type: i0.Input, args: [{ isSignal: true, alias: "title", required: false }] }], description: [{ type: i0.Input, args: [{ isSignal: true, alias: "description", required: false }] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }], accept: [{ type: i0.Input, args: [{ isSignal: true, alias: "accept", required: false }] }], multiple: [{ type: i0.Input, args: [{ isSignal: true, alias: "multiple", required: false }] }], id: [{ type: i0.Input, args: [{ isSignal: true, alias: "id", required: false }] }], maxSize: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxSize", required: false }] }], ctaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ctaLabel", required: false }] }], state: [{ type: i0.Input, args: [{ isSignal: true, alias: "state", required: false }] }], filesChanged: [{ type: i0.Output, args: ["filesChanged"] }], rejected: [{ type: i0.Output, args: ["rejected"] }], dropInput: [{ type: i0.ViewChild, args: ['dropInput', { isSignal: true }] }] } });
11310
+
11311
+ const MIN_ZOOM = 1;
11312
+ const MAX_ZOOM = 4;
11313
+ const ZOOM_STEP = 1.5;
11314
+ const IDENTITY = { scale: 1, x: 0, y: 0 };
11315
+ const clampScale = (s) => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, s));
11316
+ /**
11317
+ * Giới hạn kéo: mép ảnh phóng to không được rời mép khung ảnh gốc — kéo quá là
11318
+ * lộ nền trống. Ở `scale = 1` thì không kéo được.
11319
+ */
11320
+ function clampOffset(state, width, height) {
11321
+ const maxX = ((state.scale - 1) * width) / 2;
11322
+ const maxY = ((state.scale - 1) * height) / 2;
11323
+ const clamp = (v, m) => Math.min(m, Math.max(-m, v)) || 0;
11324
+ return { scale: state.scale, x: clamp(state.x, maxX), y: clamp(state.y, maxY) };
11325
+ }
11326
+ /** Phóng tới `nextScale`, giữ điểm `(qx, qy)` (tính từ tâm khung) đứng yên. */
11327
+ function zoomAt(state, nextScale, qx, qy, width, height) {
11328
+ const scale = clampScale(nextScale);
11329
+ const px = (qx - state.x) / state.scale;
11330
+ const py = (qy - state.y) / state.scale;
11331
+ return clampOffset({ scale, x: qx - scale * px, y: qy - scale * py }, width, height);
11332
+ }
11333
+ /** Kéo thêm một đoạn `(dx, dy)`, trong giới hạn. */
11334
+ function panBy(state, dx, dy, width, height) {
11335
+ return clampOffset({ ...state, x: state.x + dx, y: state.y + dy }, width, height);
11336
+ }
11337
+
11338
+ /** Di chuyển quá chừng này px thì là KÉO, không phải bấm. */
11339
+ const DRAG_THRESHOLD = 5;
11340
+ /**
11341
+ * Xem ảnh phóng to trên lớp phủ toàn màn (lightbox) — một ảnh hoặc lướt qua
11342
+ * nhiều ảnh.
11343
+ *
11344
+ * - Nhiều ảnh: nút ‹ › hai bên, số thứ tự "2 / 5", phím ← → chuyển ảnh, vòng quanh.
11345
+ * - Phóng to/thu nhỏ (1×–4×): bấm ảnh để phóng 2× đúng chỗ bấm, bấm lại để về;
11346
+ * nút − / + trên thanh, phím + − 0; cuộn chuột / chụm trackpad quanh con
11347
+ * trỏ; chụm hai ngón trên điện thoại. Đang phóng thì kéo để xem chỗ khác.
11348
+ * - Escape, nút ✕ hoặc bấm ra nền tối để đóng. Tiêu điểm bị giữ trong lớp phủ
11349
+ * và trả về chỗ cũ khi đóng; nền trang khoá cuộn (`OlFocusTrapService`).
11350
+ * - Escape đi qua ngăn xếp `OlOverlayContainer`: mở trình xem trong modal rồi
11351
+ * nhấn Escape chỉ đóng trình xem, modal còn nguyên.
11352
+ *
11353
+ * Dùng riêng được (`[(open)]` + `[images]` + `[(index)]`); `ol-file-upload`
11354
+ * chế độ `image`/`images` dùng nó để xem ảnh vừa chọn.
11355
+ */
11356
+ class OlImagePreview {
11357
+ images = input([], /* @ts-ignore */
11358
+ ...(ngDevMode ? [{ debugName: "images" }] : /* istanbul ignore next */ []));
11359
+ open = model(false, /* @ts-ignore */
11360
+ ...(ngDevMode ? [{ debugName: "open" }] : /* istanbul ignore next */ []));
11361
+ /** Ảnh đang xem, tính từ 0. */
11362
+ index = model(0, /* @ts-ignore */
11363
+ ...(ngDevMode ? [{ debugName: "index" }] : /* istanbul ignore next */ []));
11364
+ closed = output();
11365
+ panel = viewChild('panel', /* @ts-ignore */
11366
+ ...(ngDevMode ? [{ debugName: "panel" }] : /* istanbul ignore next */ []));
11367
+ thumbStrip = viewChild('thumbStrip', /* @ts-ignore */
11368
+ ...(ngDevMode ? [{ debugName: "thumbStrip" }] : /* istanbul ignore next */ []));
11369
+ imageEl = viewChild('mainImage', /* @ts-ignore */
11370
+ ...(ngDevMode ? [{ debugName: "imageEl" }] : /* istanbul ignore next */ []));
11371
+ zoom = signal(IDENTITY, /* @ts-ignore */
11372
+ ...(ngDevMode ? [{ debugName: "zoom" }] : /* istanbul ignore next */ []));
11373
+ dragging = signal(false, /* @ts-ignore */
11374
+ ...(ngDevMode ? [{ debugName: "dragging" }] : /* istanbul ignore next */ []));
11375
+ zoomPercent = computed(() => Math.round(this.zoom().scale * 100), /* @ts-ignore */
11376
+ ...(ngDevMode ? [{ debugName: "zoomPercent" }] : /* istanbul ignore next */ []));
11377
+ canZoomIn = computed(() => this.zoom().scale < MAX_ZOOM, /* @ts-ignore */
11378
+ ...(ngDevMode ? [{ debugName: "canZoomIn" }] : /* istanbul ignore next */ []));
11379
+ canZoomOut = computed(() => this.zoom().scale > MIN_ZOOM, /* @ts-ignore */
11380
+ ...(ngDevMode ? [{ debugName: "canZoomOut" }] : /* istanbul ignore next */ []));
11381
+ imageStyle = computed(() => {
11382
+ const { scale, x, y } = this.zoom();
11383
+ return `transform: translate(${x}px, ${y}px) scale(${scale})`;
11384
+ }, /* @ts-ignore */
11385
+ ...(ngDevMode ? [{ debugName: "imageStyle" }] : /* istanbul ignore next */ []));
11386
+ imageClasses = computed(() => {
11387
+ const base = 'max-h-full max-w-full rounded-md object-contain shadow-xl select-none touch-none origin-center will-change-transform focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-brand-subtle';
11388
+ // Đang kéo/chụm thì bỏ transition — ảnh phải bám ngón tay, không trễ.
11389
+ const motion = this.dragging() ? '' : 'transition-transform duration-200 ease-out';
11390
+ const cursor = this.zoom().scale > 1
11391
+ ? this.dragging()
11392
+ ? 'cursor-grabbing'
11393
+ : 'cursor-grab'
11394
+ : 'cursor-zoom-in';
11395
+ return `${base} ${motion} ${cursor}`;
11396
+ }, /* @ts-ignore */
11397
+ ...(ngDevMode ? [{ debugName: "imageClasses" }] : /* istanbul ignore next */ []));
11398
+ /** Ngón tay / chuột đang ấn trên ảnh, theo pointerId. */
11399
+ pointers = new Map();
11400
+ pinchStart = null;
11401
+ moved = 0;
11402
+ /** Vừa kéo xong: cú `click` theo sau không được tính là bấm (phóng / đóng). */
11403
+ suppressClick = false;
11404
+ focusTrap = inject(OlFocusTrapService);
11405
+ overlayContainer = inject(OlOverlayContainer);
11406
+ releaseEscape = null;
11407
+ count = computed(() => this.images().length, /* @ts-ignore */
11408
+ ...(ngDevMode ? [{ debugName: "count" }] : /* istanbul ignore next */ []));
11409
+ /** Kẹp chỉ số: danh sách ảnh có thể ngắn lại (vừa xoá ảnh) trong lúc đang mở. */
11410
+ current = computed(() => {
11411
+ const n = this.count();
11412
+ return n ? Math.min(Math.max(this.index(), 0), n - 1) : 0;
11413
+ }, /* @ts-ignore */
11414
+ ...(ngDevMode ? [{ debugName: "current" }] : /* istanbul ignore next */ []));
11415
+ image = computed(() => this.images()[this.current()], /* @ts-ignore */
11416
+ ...(ngDevMode ? [{ debugName: "image" }] : /* istanbul ignore next */ []));
11417
+ constructor() {
11418
+ effect(() => {
11419
+ const el = this.panel()?.nativeElement;
11420
+ if (this.open() && el && this.count()) {
11421
+ if (!this.releaseEscape) {
11422
+ this.focusTrap.trap(el);
11423
+ this.releaseEscape = this.overlayContainer.register(() => this.close());
11424
+ }
11425
+ }
11426
+ else {
11427
+ this.releaseTraps();
11428
+ }
11429
+ });
11430
+ // Ảnh đang xem luôn nằm trong tầm nhìn của dải ảnh nhỏ (điện thoại chỉ
11431
+ // thấy 4–5 ô; lướt → tới ảnh thứ 8 thì ô của nó phải trượt vào).
11432
+ effect(() => {
11433
+ const strip = this.thumbStrip()?.nativeElement;
11434
+ const thumb = strip?.children[this.current()];
11435
+ thumb?.scrollIntoView?.({ block: 'nearest', inline: 'center', behavior: 'smooth' });
11436
+ });
11437
+ // Đổi ảnh hoặc mở lại: về cỡ gốc — không mang mức phóng của ảnh trước.
11438
+ effect(() => {
11439
+ this.current();
11440
+ this.open();
11441
+ untracked(() => this.resetZoom());
11442
+ });
11443
+ inject(DestroyRef).onDestroy(() => this.releaseTraps());
11444
+ }
11445
+ /** Ô ảnh nhỏ: ảnh đang xem rõ + viền trắng; ảnh khác mờ, rê chuột thì rõ. */
11446
+ thumbClasses(i) {
11447
+ const base = 'size-14 shrink-0 overflow-hidden rounded-md transition focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-brand-subtle sm:size-16';
11448
+ return i === this.current()
11449
+ ? `${base} ring-2 ring-fg-white opacity-100`
11450
+ : `${base} opacity-50 hover:opacity-100`;
11451
+ }
11452
+ close() {
11453
+ if (!this.open())
11454
+ return;
11455
+ this.pointers.clear();
11456
+ this.open.set(false);
11457
+ this.closed.emit();
11458
+ }
11459
+ next() {
11460
+ this.go(1);
11461
+ }
11462
+ prev() {
11463
+ this.go(-1);
11464
+ }
11465
+ go(step) {
11466
+ const n = this.count();
11467
+ if (n < 2)
11468
+ return;
11469
+ this.index.set((((this.current() + step) % n) + n) % n);
11470
+ }
11471
+ onKeydown(event) {
11472
+ if (event.key === 'ArrowRight')
11473
+ this.next();
11474
+ else if (event.key === 'ArrowLeft')
11475
+ this.prev();
11476
+ else if (event.key === '+' || event.key === '=')
11477
+ this.zoomIn();
11478
+ else if (event.key === '-')
11479
+ this.zoomOut();
11480
+ else if (event.key === '0')
11481
+ this.resetZoom();
11482
+ else
11483
+ return;
11484
+ event.preventDefault();
11485
+ }
11486
+ /** Bấm đúng nền tối (không phải ảnh hay nút) thì đóng. */
11487
+ onBackdropClick(event) {
11488
+ if (this.suppressClick) {
11489
+ this.suppressClick = false;
11490
+ return;
11491
+ }
11492
+ if (event.target === event.currentTarget)
11493
+ this.close();
11494
+ }
11495
+ // ── Phóng / kéo ────────────────────────────────────────────────────
11496
+ zoomIn() {
11497
+ this.zoomAround(this.zoom().scale * ZOOM_STEP, 0, 0);
11498
+ }
11499
+ zoomOut() {
11500
+ this.zoomAround(this.zoom().scale / ZOOM_STEP, 0, 0);
11501
+ }
11502
+ /** Bàn phím trên ảnh (Enter / Space): phóng 2× quanh tâm, hoặc về cỡ gốc. */
11503
+ toggleZoom() {
11504
+ if (this.zoom().scale > 1)
11505
+ this.resetZoom();
11506
+ else
11507
+ this.zoomAround(2, 0, 0);
11508
+ }
11509
+ resetZoom() {
11510
+ this.zoom.set(IDENTITY);
11511
+ }
11512
+ /** Kích thước ảnh khi CHƯA phóng (offsetWidth không tính transform). */
11513
+ imageSize() {
11514
+ const img = this.imageEl()?.nativeElement;
11515
+ return { width: img?.offsetWidth ?? 0, height: img?.offsetHeight ?? 0 };
11516
+ }
11517
+ /**
11518
+ * Điểm màn hình → toạ độ tính từ TÂM ảnh lúc chưa phóng. Hộp đã biến đổi có
11519
+ * tâm = tâm gốc + (x, y) — phóng quanh tâm không dời tâm.
11520
+ */
11521
+ toCenter(clientX, clientY) {
11522
+ const rect = this.imageEl().nativeElement.getBoundingClientRect();
11523
+ const { x, y } = this.zoom();
11524
+ return {
11525
+ qx: clientX - (rect.left + rect.width / 2) + x,
11526
+ qy: clientY - (rect.top + rect.height / 2) + y,
11527
+ };
11528
+ }
11529
+ zoomAround(scale, qx, qy) {
11530
+ const { width, height } = this.imageSize();
11531
+ this.zoom.set(zoomAt(this.zoom(), scale, qx, qy, width, height));
11532
+ }
11533
+ /** Bấm ảnh: đang ở cỡ gốc thì phóng 2× đúng chỗ bấm, đang phóng thì về. */
11534
+ onImageClick(event) {
11535
+ event.stopPropagation();
11536
+ if (this.suppressClick) {
11537
+ this.suppressClick = false;
11538
+ return;
11539
+ }
11540
+ if (this.zoom().scale > 1)
11541
+ return this.resetZoom();
11542
+ const { qx, qy } = this.toCenter(event.clientX, event.clientY);
11543
+ this.zoomAround(2, qx, qy);
11544
+ }
11545
+ /** Cuộn chuột / chụm trackpad (trình duyệt gửi wheel + ctrlKey): phóng quanh con trỏ. */
11546
+ onWheel(event) {
11547
+ event.preventDefault();
11548
+ const { qx, qy } = this.toCenter(event.clientX, event.clientY);
11549
+ this.zoomAround(this.zoom().scale * Math.exp(-event.deltaY * 0.002), qx, qy);
11550
+ }
11551
+ onPointerDown(event) {
11552
+ event.target.setPointerCapture?.(event.pointerId);
11553
+ this.pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
11554
+ this.moved = 0;
11555
+ // Kéo bằng cảm ứng thì trình duyệt KHÔNG bắn click sau đó — cờ từ lần kéo
11556
+ // trước sẽ treo và nuốt mất lần chạm kế tiếp (đo iPhone 2026-09-28).
11557
+ this.suppressClick = false;
11558
+ if (this.pointers.size === 2) {
11559
+ this.pinchStart = { distance: this.pinchDistance(), scale: this.zoom().scale };
11560
+ }
11561
+ this.dragging.set(this.pointers.size === 2 || this.zoom().scale > 1);
11562
+ }
11563
+ onPointerMove(event) {
11564
+ const last = this.pointers.get(event.pointerId);
11565
+ if (!last)
11566
+ return;
11567
+ const dx = event.clientX - last.x;
11568
+ const dy = event.clientY - last.y;
11569
+ this.pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
11570
+ this.moved += Math.abs(dx) + Math.abs(dy);
11571
+ if (this.pointers.size === 2 && this.pinchStart) {
11572
+ const [a, b] = [...this.pointers.values()];
11573
+ const { qx, qy } = this.toCenter((a.x + b.x) / 2, (a.y + b.y) / 2);
11574
+ const ratio = this.pinchDistance() / (this.pinchStart.distance || 1);
11575
+ this.zoomAround(this.pinchStart.scale * ratio, qx, qy);
11576
+ }
11577
+ else if (this.pointers.size === 1 && this.zoom().scale > 1) {
11578
+ const { width, height } = this.imageSize();
11579
+ this.zoom.set(panBy(this.zoom(), dx, dy, width, height));
11580
+ }
11581
+ }
11582
+ onPointerUp(event) {
11583
+ this.pointers.delete(event.pointerId);
11584
+ if (this.pointers.size < 2)
11585
+ this.pinchStart = null;
11586
+ if (this.pointers.size === 0) {
11587
+ this.dragging.set(false);
11588
+ if (this.moved > DRAG_THRESHOLD)
11589
+ this.suppressClick = true;
11590
+ }
11591
+ }
11592
+ pinchDistance() {
11593
+ const [a, b] = [...this.pointers.values()];
11594
+ return Math.hypot(a.x - b.x, a.y - b.y);
11595
+ }
11596
+ releaseTraps() {
11597
+ if (!this.releaseEscape)
11598
+ return;
11599
+ this.focusTrap.release();
11600
+ this.releaseEscape();
11601
+ this.releaseEscape = null;
11602
+ }
11603
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: OlImagePreview, deps: [], target: i0.ɵɵFactoryTarget.Component });
11604
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.6", type: OlImagePreview, isStandalone: true, selector: "ol-image-preview", inputs: { images: { classPropertyName: "images", publicName: "images", isSignal: true, isRequired: false, transformFunction: null }, open: { classPropertyName: "open", publicName: "open", isSignal: true, isRequired: false, transformFunction: null }, index: { classPropertyName: "index", publicName: "index", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { open: "openChange", index: "indexChange", closed: "closed" }, viewQueries: [{ propertyName: "panel", first: true, predicate: ["panel"], descendants: true, isSignal: true }, { propertyName: "thumbStrip", first: true, predicate: ["thumbStrip"], descendants: true, isSignal: true }, { propertyName: "imageEl", first: true, predicate: ["mainImage"], descendants: true, isSignal: true }], ngImport: i0, template: "@if (open() && image(); as img) {\n <div\n #panel\n role=\"dialog\"\n aria-modal=\"true\"\n tabindex=\"-1\"\n [attr.aria-label]=\"'Xem \u1EA3nh ' + (current() + 1) + ' / ' + count()\"\n class=\"fixed inset-0 z-modal flex flex-col bg-overlay-backdrop backdrop-blur-xs outline-hidden\"\n (keydown)=\"onKeydown($event)\"\n >\n <!-- Thanh tr\u00EAn: s\u1ED1 th\u1EE9 t\u1EF1 \u00B7 ph\u00F3ng/thu \u00B7 \u0111\u00F3ng. -->\n <div class=\"flex items-center justify-between gap-3 px-4 py-3 text-fg-white\">\n <span class=\"text-sm font-medium tabular-nums\" aria-live=\"polite\">\n @if (count() > 1) {\n {{ current() + 1 }} / {{ count() }}\n }\n </span>\n <div\n class=\"ms-auto flex items-center gap-1 rounded-full bg-neutral-primary p-1 text-heading shadow-lg\"\n >\n <button\n type=\"button\"\n aria-label=\"Thu nh\u1ECF\"\n class=\"inline-flex size-8 items-center justify-center rounded-full transition-colors hover:bg-neutral-secondary focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-brand-subtle disabled:cursor-not-allowed disabled:opacity-40\"\n [disabled]=\"!canZoomOut()\"\n (click)=\"zoomOut()\"\n >\n <ol-icon name=\"lucide:zoom-out\" size=\"sm\" />\n </button>\n <button\n type=\"button\"\n aria-label=\"V\u1EC1 c\u1EE1 g\u1ED1c\"\n class=\"min-w-12 rounded-full px-1 text-center text-xs font-semibold tabular-nums transition-colors hover:bg-neutral-secondary focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-brand-subtle\"\n (click)=\"resetZoom()\"\n >\n {{ zoomPercent() }}%\n </button>\n <button\n type=\"button\"\n aria-label=\"Ph\u00F3ng to\"\n class=\"inline-flex size-8 items-center justify-center rounded-full transition-colors hover:bg-neutral-secondary focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-brand-subtle disabled:cursor-not-allowed disabled:opacity-40\"\n [disabled]=\"!canZoomIn()\"\n (click)=\"zoomIn()\"\n >\n <ol-icon name=\"lucide:zoom-in\" size=\"sm\" />\n </button>\n </div>\n <button\n type=\"button\"\n data-ol-dismiss\n aria-label=\"\u0110\u00F3ng xem \u1EA3nh\"\n class=\"inline-flex size-10 items-center justify-center rounded-full bg-neutral-primary text-heading shadow-lg transition-colors hover:bg-neutral-secondary focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-brand-subtle\"\n (click)=\"close()\"\n >\n <ol-icon name=\"close\" size=\"base\" />\n </button>\n </div>\n\n <!--\n \u1EA2nh: v\u1EEBa khung, kh\u00F4ng c\u1EAFt (object-contain). B\u1EA5m n\u1EC1n t\u1ED1i quanh \u1EA3nh \u0111\u1EC3 \u0111\u00F3ng.\n `overflow-hidden`: \u1EA3nh ph\u00F3ng to kh\u00F4ng \u0111\u01B0\u1EE3c tr\u00E0n \u0111\u00E8 l\u00EAn thanh tr\u00EAn v\u00E0 d\u1EA3i\n \u1EA3nh nh\u1ECF \u2014 \u0111o 2026-09-28, \u1EA3nh 364% che n\u00FAt \"Ph\u00F3ng to\", kh\u00F4ng b\u1EA5m \u0111\u01B0\u1EE3c.\n `tabindex=\"-1\"` \u1EDF khung ngo\u00E0i: b\u1EA5m v\u00E0o \u1EA3nh (kh\u00F4ng focus \u0111\u01B0\u1EE3c) th\u00EC ti\u00EAu\n \u0111i\u1EC3m \u1EDF l\u1EA1i tr\u00ECnh xem, ph\u00EDm + \u2212 0 \u2190 \u2192 v\u1EABn ch\u1EA1y.\n -->\n <div\n class=\"relative flex min-h-0 flex-1 items-center justify-center overflow-hidden px-4 pb-4\"\n (click)=\"onBackdropClick($event)\"\n >\n <img\n #mainImage\n [src]=\"img.src\"\n [alt]=\"img.alt || ''\"\n [class]=\"imageClasses()\"\n [style]=\"imageStyle()\"\n draggable=\"false\"\n tabindex=\"0\"\n (click)=\"onImageClick($event)\"\n (keydown.enter)=\"toggleZoom(); $event.preventDefault()\"\n (keydown.space)=\"toggleZoom(); $event.preventDefault()\"\n (wheel)=\"onWheel($event)\"\n (pointerdown)=\"onPointerDown($event)\"\n (pointermove)=\"onPointerMove($event)\"\n (pointerup)=\"onPointerUp($event)\"\n (pointercancel)=\"onPointerUp($event)\"\n />\n\n @if (count() > 1) {\n <button\n type=\"button\"\n aria-label=\"\u1EA2nh tr\u01B0\u1EDBc\"\n class=\"absolute start-3 top-1/2 inline-flex size-11 -translate-y-1/2 items-center justify-center rounded-full bg-neutral-primary text-heading shadow-lg transition-colors hover:bg-neutral-secondary focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-brand-subtle\"\n (click)=\"prev()\"\n >\n <ol-icon name=\"chevron-left\" size=\"base\" />\n </button>\n <button\n type=\"button\"\n aria-label=\"\u1EA2nh sau\"\n class=\"absolute end-3 top-1/2 inline-flex size-11 -translate-y-1/2 items-center justify-center rounded-full bg-neutral-primary text-heading shadow-lg transition-colors hover:bg-neutral-secondary focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-brand-subtle\"\n (click)=\"next()\"\n >\n <ol-icon name=\"chevron-right\" size=\"base\" />\n </button>\n }\n </div>\n\n @if (img.alt) {\n <p class=\"truncate px-4 pb-3 text-center text-sm text-fg-white\">{{ img.alt }}</p>\n }\n\n <!--\n D\u1EA3i \u1EA3nh nh\u1ECF: th\u1EA5y c\u00E1c \u1EA3nh c\u00F2n l\u1EA1i, b\u1EA5m \u0111\u1EC3 nh\u1EA3y t\u1EDBi; \u1EA3nh \u0111ang xem c\u00F3 vi\u1EC1n\n v\u00E0 t\u1EF1 cu\u1ED9n v\u00E0o t\u1EA7m nh\u00ECn. Cu\u1ED9n ngang khi d\u00E0i (\u0111i\u1EC7n tho\u1EA1i), c\u0103n gi\u1EEFa khi\n ng\u1EAFn \u2014 b\u1EB1ng ms-auto/me-auto \u1EDF \u00F4 \u0111\u1EA7u/cu\u1ED1i, KH\u00D4NG justify-center: n\u00F3 c\u1EAFt\n m\u1EA5t c\u00E1c \u00F4 \u0111\u1EA7u khi d\u1EA3i tr\u00E0n v\u00E0 kh\u00F4ng cu\u1ED9n t\u1EDBi \u0111\u01B0\u1EE3c. L\u1EC1 d\u01B0\u1EDBi max() v\u1EDBi\n safe-area: ch\u1EEBa v\u1EA1ch Home c\u1EE7a iPhone.\n -->\n @if (count() > 1) {\n <div\n #thumbStrip\n class=\"flex shrink-0 gap-2 overflow-x-auto px-4 pt-1 pb-[max(1rem,env(safe-area-inset-bottom))] [scrollbar-width:none] [&>:first-child]:ms-auto [&>:last-child]:me-auto\"\n role=\"group\"\n aria-label=\"Danh s\u00E1ch \u1EA3nh\"\n >\n @for (thumb of images(); track $index; let i = $index) {\n <button\n type=\"button\"\n [attr.aria-label]=\"'Xem \u1EA3nh ' + (i + 1) + (thumb.alt ? ': ' + thumb.alt : '')\"\n [attr.aria-current]=\"i === current() ? 'true' : null\"\n [class]=\"thumbClasses(i)\"\n (click)=\"index.set(i)\"\n >\n <img [src]=\"thumb.src\" alt=\"\" class=\"size-full object-cover\" loading=\"lazy\" />\n </button>\n }\n </div>\n }\n </div>\n}\n", dependencies: [{ kind: "component", type: OlIcon, selector: "ol-icon", inputs: ["name", "size", "color", "variant", "strokeWidth"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
11605
+ }
11606
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: OlImagePreview, decorators: [{
11607
+ type: Component,
11608
+ args: [{ selector: 'ol-image-preview', standalone: true, imports: [OlIcon], changeDetection: ChangeDetectionStrategy.OnPush, template: "@if (open() && image(); as img) {\n <div\n #panel\n role=\"dialog\"\n aria-modal=\"true\"\n tabindex=\"-1\"\n [attr.aria-label]=\"'Xem \u1EA3nh ' + (current() + 1) + ' / ' + count()\"\n class=\"fixed inset-0 z-modal flex flex-col bg-overlay-backdrop backdrop-blur-xs outline-hidden\"\n (keydown)=\"onKeydown($event)\"\n >\n <!-- Thanh tr\u00EAn: s\u1ED1 th\u1EE9 t\u1EF1 \u00B7 ph\u00F3ng/thu \u00B7 \u0111\u00F3ng. -->\n <div class=\"flex items-center justify-between gap-3 px-4 py-3 text-fg-white\">\n <span class=\"text-sm font-medium tabular-nums\" aria-live=\"polite\">\n @if (count() > 1) {\n {{ current() + 1 }} / {{ count() }}\n }\n </span>\n <div\n class=\"ms-auto flex items-center gap-1 rounded-full bg-neutral-primary p-1 text-heading shadow-lg\"\n >\n <button\n type=\"button\"\n aria-label=\"Thu nh\u1ECF\"\n class=\"inline-flex size-8 items-center justify-center rounded-full transition-colors hover:bg-neutral-secondary focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-brand-subtle disabled:cursor-not-allowed disabled:opacity-40\"\n [disabled]=\"!canZoomOut()\"\n (click)=\"zoomOut()\"\n >\n <ol-icon name=\"lucide:zoom-out\" size=\"sm\" />\n </button>\n <button\n type=\"button\"\n aria-label=\"V\u1EC1 c\u1EE1 g\u1ED1c\"\n class=\"min-w-12 rounded-full px-1 text-center text-xs font-semibold tabular-nums transition-colors hover:bg-neutral-secondary focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-brand-subtle\"\n (click)=\"resetZoom()\"\n >\n {{ zoomPercent() }}%\n </button>\n <button\n type=\"button\"\n aria-label=\"Ph\u00F3ng to\"\n class=\"inline-flex size-8 items-center justify-center rounded-full transition-colors hover:bg-neutral-secondary focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-brand-subtle disabled:cursor-not-allowed disabled:opacity-40\"\n [disabled]=\"!canZoomIn()\"\n (click)=\"zoomIn()\"\n >\n <ol-icon name=\"lucide:zoom-in\" size=\"sm\" />\n </button>\n </div>\n <button\n type=\"button\"\n data-ol-dismiss\n aria-label=\"\u0110\u00F3ng xem \u1EA3nh\"\n class=\"inline-flex size-10 items-center justify-center rounded-full bg-neutral-primary text-heading shadow-lg transition-colors hover:bg-neutral-secondary focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-brand-subtle\"\n (click)=\"close()\"\n >\n <ol-icon name=\"close\" size=\"base\" />\n </button>\n </div>\n\n <!--\n \u1EA2nh: v\u1EEBa khung, kh\u00F4ng c\u1EAFt (object-contain). B\u1EA5m n\u1EC1n t\u1ED1i quanh \u1EA3nh \u0111\u1EC3 \u0111\u00F3ng.\n `overflow-hidden`: \u1EA3nh ph\u00F3ng to kh\u00F4ng \u0111\u01B0\u1EE3c tr\u00E0n \u0111\u00E8 l\u00EAn thanh tr\u00EAn v\u00E0 d\u1EA3i\n \u1EA3nh nh\u1ECF \u2014 \u0111o 2026-09-28, \u1EA3nh 364% che n\u00FAt \"Ph\u00F3ng to\", kh\u00F4ng b\u1EA5m \u0111\u01B0\u1EE3c.\n `tabindex=\"-1\"` \u1EDF khung ngo\u00E0i: b\u1EA5m v\u00E0o \u1EA3nh (kh\u00F4ng focus \u0111\u01B0\u1EE3c) th\u00EC ti\u00EAu\n \u0111i\u1EC3m \u1EDF l\u1EA1i tr\u00ECnh xem, ph\u00EDm + \u2212 0 \u2190 \u2192 v\u1EABn ch\u1EA1y.\n -->\n <div\n class=\"relative flex min-h-0 flex-1 items-center justify-center overflow-hidden px-4 pb-4\"\n (click)=\"onBackdropClick($event)\"\n >\n <img\n #mainImage\n [src]=\"img.src\"\n [alt]=\"img.alt || ''\"\n [class]=\"imageClasses()\"\n [style]=\"imageStyle()\"\n draggable=\"false\"\n tabindex=\"0\"\n (click)=\"onImageClick($event)\"\n (keydown.enter)=\"toggleZoom(); $event.preventDefault()\"\n (keydown.space)=\"toggleZoom(); $event.preventDefault()\"\n (wheel)=\"onWheel($event)\"\n (pointerdown)=\"onPointerDown($event)\"\n (pointermove)=\"onPointerMove($event)\"\n (pointerup)=\"onPointerUp($event)\"\n (pointercancel)=\"onPointerUp($event)\"\n />\n\n @if (count() > 1) {\n <button\n type=\"button\"\n aria-label=\"\u1EA2nh tr\u01B0\u1EDBc\"\n class=\"absolute start-3 top-1/2 inline-flex size-11 -translate-y-1/2 items-center justify-center rounded-full bg-neutral-primary text-heading shadow-lg transition-colors hover:bg-neutral-secondary focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-brand-subtle\"\n (click)=\"prev()\"\n >\n <ol-icon name=\"chevron-left\" size=\"base\" />\n </button>\n <button\n type=\"button\"\n aria-label=\"\u1EA2nh sau\"\n class=\"absolute end-3 top-1/2 inline-flex size-11 -translate-y-1/2 items-center justify-center rounded-full bg-neutral-primary text-heading shadow-lg transition-colors hover:bg-neutral-secondary focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-brand-subtle\"\n (click)=\"next()\"\n >\n <ol-icon name=\"chevron-right\" size=\"base\" />\n </button>\n }\n </div>\n\n @if (img.alt) {\n <p class=\"truncate px-4 pb-3 text-center text-sm text-fg-white\">{{ img.alt }}</p>\n }\n\n <!--\n D\u1EA3i \u1EA3nh nh\u1ECF: th\u1EA5y c\u00E1c \u1EA3nh c\u00F2n l\u1EA1i, b\u1EA5m \u0111\u1EC3 nh\u1EA3y t\u1EDBi; \u1EA3nh \u0111ang xem c\u00F3 vi\u1EC1n\n v\u00E0 t\u1EF1 cu\u1ED9n v\u00E0o t\u1EA7m nh\u00ECn. Cu\u1ED9n ngang khi d\u00E0i (\u0111i\u1EC7n tho\u1EA1i), c\u0103n gi\u1EEFa khi\n ng\u1EAFn \u2014 b\u1EB1ng ms-auto/me-auto \u1EDF \u00F4 \u0111\u1EA7u/cu\u1ED1i, KH\u00D4NG justify-center: n\u00F3 c\u1EAFt\n m\u1EA5t c\u00E1c \u00F4 \u0111\u1EA7u khi d\u1EA3i tr\u00E0n v\u00E0 kh\u00F4ng cu\u1ED9n t\u1EDBi \u0111\u01B0\u1EE3c. L\u1EC1 d\u01B0\u1EDBi max() v\u1EDBi\n safe-area: ch\u1EEBa v\u1EA1ch Home c\u1EE7a iPhone.\n -->\n @if (count() > 1) {\n <div\n #thumbStrip\n class=\"flex shrink-0 gap-2 overflow-x-auto px-4 pt-1 pb-[max(1rem,env(safe-area-inset-bottom))] [scrollbar-width:none] [&>:first-child]:ms-auto [&>:last-child]:me-auto\"\n role=\"group\"\n aria-label=\"Danh s\u00E1ch \u1EA3nh\"\n >\n @for (thumb of images(); track $index; let i = $index) {\n <button\n type=\"button\"\n [attr.aria-label]=\"'Xem \u1EA3nh ' + (i + 1) + (thumb.alt ? ': ' + thumb.alt : '')\"\n [attr.aria-current]=\"i === current() ? 'true' : null\"\n [class]=\"thumbClasses(i)\"\n (click)=\"index.set(i)\"\n >\n <img [src]=\"thumb.src\" alt=\"\" class=\"size-full object-cover\" loading=\"lazy\" />\n </button>\n }\n </div>\n }\n </div>\n}\n" }]
11609
+ }], ctorParameters: () => [], propDecorators: { images: [{ type: i0.Input, args: [{ isSignal: true, alias: "images", required: false }] }], open: [{ type: i0.Input, args: [{ isSignal: true, alias: "open", required: false }] }, { type: i0.Output, args: ["openChange"] }], index: [{ type: i0.Input, args: [{ isSignal: true, alias: "index", required: false }] }, { type: i0.Output, args: ["indexChange"] }], closed: [{ type: i0.Output, args: ["closed"] }], panel: [{ type: i0.ViewChild, args: ['panel', { isSignal: true }] }], thumbStrip: [{ type: i0.ViewChild, args: ['thumbStrip', { isSignal: true }] }], imageEl: [{ type: i0.ViewChild, args: ['mainImage', { isSignal: true }] }] } });
11610
+
11611
+ let nextId = 0;
11612
+ /**
11613
+ * Khung tải tệp có tiêu đề — Figma `ol-file-input-form` "Button header + …"
11614
+ * (3587:6834 · 3592:6990 · 3603:2606 · 3611:3494).
11615
+ *
11616
+ * ## Component KHÔNG tự tải lên máy chủ
11617
+ *
11618
+ * Nó nhận tệp, kiểm định dạng/dung lượng/số lượng, thêm vào `files` với
11619
+ * `status: 'idle'` rồi phát `(filesAdded)`. Nơi gọi gửi HTTP và cập nhật
11620
+ * `status`/`progress` của từng tệp qua `[(files)]`. Để component tự gọi HTTP
11621
+ * thì nó phải biết URL, header xác thực, dạng phản hồi — ba thứ khác nhau ở
11622
+ * mỗi màn.
11623
+ */
11624
+ class OlFileUpload {
11625
+ title = input('', /* @ts-ignore */
11626
+ ...(ngDevMode ? [{ debugName: "title" }] : /* istanbul ignore next */ []));
11627
+ required = input(false, /* @ts-ignore */
11628
+ ...(ngDevMode ? [{ debugName: "required" }] : /* istanbul ignore next */ []));
11629
+ mode = input('single', /* @ts-ignore */
11630
+ ...(ngDevMode ? [{ debugName: "mode" }] : /* istanbul ignore next */ []));
11631
+ /** Chuỗi `accept` của `<input type="file">` — cũng dùng để KIỂM tệp kéo thả. */
11632
+ accept = input('*', /* @ts-ignore */
11633
+ ...(ngDevMode ? [{ debugName: "accept" }] : /* istanbul ignore next */ []));
11634
+ /** Byte. 0 → không giới hạn. */
11635
+ maxSize = input(0, /* @ts-ignore */
11636
+ ...(ngDevMode ? [{ debugName: "maxSize" }] : /* istanbul ignore next */ []));
11637
+ /** Chỉ cho `multiple`/`list`. 0 → không giới hạn. */
11638
+ maxFiles = input(0, /* @ts-ignore */
11639
+ ...(ngDevMode ? [{ debugName: "maxFiles" }] : /* istanbul ignore next */ []));
11640
+ /** Nhãn nút ở đầu khung. Rỗng → tự chọn theo chế độ và trạng thái. */
11641
+ buttonLabel = input('', /* @ts-ignore */
11642
+ ...(ngDevMode ? [{ debugName: "buttonLabel" }] : /* istanbul ignore next */ []));
11643
+ /** Hiện nút 👁 trên từng tệp. */
11644
+ previewable = input(false, /* @ts-ignore */
11645
+ ...(ngDevMode ? [{ debugName: "previewable" }] : /* istanbul ignore next */ []));
11646
+ disabled = input(false, /* @ts-ignore */
11647
+ ...(ngDevMode ? [{ debugName: "disabled" }] : /* istanbul ignore next */ []));
11648
+ /**
11649
+ * Nhận tệp dán (Ctrl+V / ⌘V) ở BẤT KỲ ĐÂU trên trang, không chỉ khi tiêu
11650
+ * điểm ở trong khung. Chỉ bật khi màn có MỘT khung tải — hai khung cùng bật
11651
+ * thì một lần dán vào cả hai. Đang gõ trong ô nhập chữ thì bỏ qua.
11652
+ */
11653
+ pasteFromPage = input(false, /* @ts-ignore */
11654
+ ...(ngDevMode ? [{ debugName: "pasteFromPage" }] : /* istanbul ignore next */ []));
11655
+ /** Dòng gợi ý dưới khung, ví dụ định dạng cho phép. */
11656
+ helperText = input('', /* @ts-ignore */
11657
+ ...(ngDevMode ? [{ debugName: "helperText" }] : /* istanbul ignore next */ []));
11658
+ files = model([], /* @ts-ignore */
11659
+ ...(ngDevMode ? [{ debugName: "files" }] : /* istanbul ignore next */ []));
11660
+ /** Tệp VỪA nhận, đã qua kiểm — nơi gọi bắt đầu tải lên từ đây. */
11661
+ filesAdded = output();
11662
+ fileRemove = output();
11663
+ filePreview = output();
11664
+ fileRetry = output();
11665
+ /** Tệp bị loại kèm lý do — component đã tự hiện câu lỗi dưới khung. */
11666
+ rejected = output();
11667
+ fileInput = viewChild.required('fileInput', /* @ts-ignore */
11668
+ ...(ngDevMode ? [{ debugName: "fileInput" }] : /* istanbul ignore next */ []));
11669
+ host = inject(ElementRef);
11670
+ uid = `ol-file-upload-${nextId++}`;
11671
+ errors = signal([], /* @ts-ignore */
11672
+ ...(ngDevMode ? [{ debugName: "errors" }] : /* istanbul ignore next */ []));
11673
+ isMultiple = computed(() => this.mode() === 'multiple' || this.mode() === 'list' || this.mode() === 'images', /* @ts-ignore */
11674
+ ...(ngDevMode ? [{ debugName: "isMultiple" }] : /* istanbul ignore next */ []));
11675
+ isImageMode = computed(() => this.mode() === 'image' || this.mode() === 'images', /* @ts-ignore */
11676
+ ...(ngDevMode ? [{ debugName: "isImageMode" }] : /* istanbul ignore next */ []));
11677
+ hasFiles = computed(() => this.files().length > 0, /* @ts-ignore */
11678
+ ...(ngDevMode ? [{ debugName: "hasFiles" }] : /* istanbul ignore next */ []));
11679
+ limitReached = computed(() => this.isMultiple() && this.maxFiles() > 0 && this.files().length >= this.maxFiles(), /* @ts-ignore */
11680
+ ...(ngDevMode ? [{ debugName: "limitReached" }] : /* istanbul ignore next */ []));
11681
+ resolvedButtonLabel = computed(() => {
11682
+ if (this.buttonLabel())
11683
+ return this.buttonLabel();
11684
+ if (this.isMultiple())
11685
+ return 'Tải lên';
11686
+ return this.hasFiles() ? 'Thay đổi' : 'Chọn tệp';
11687
+ }, /* @ts-ignore */
11688
+ ...(ngDevMode ? [{ debugName: "resolvedButtonLabel" }] : /* istanbul ignore next */ []));
11689
+ hintText = computed(() => this.maxSize() > 0 ? `Dung lượng tối đa: ${formatFileSize(this.maxSize())}` : '', /* @ts-ignore */
11690
+ ...(ngDevMode ? [{ debugName: "hintText" }] : /* istanbul ignore next */ []));
11691
+ describedBy = computed(() => [this.helperText() ? `${this.uid}-help` : '', this.errors().length ? `${this.uid}-err` : '']
11692
+ .filter(Boolean)
11693
+ .join(' '), /* @ts-ignore */
11694
+ ...(ngDevMode ? [{ debugName: "describedBy" }] : /* istanbul ignore next */ []));
11695
+ /**
11696
+ * Ảnh xem trước cho chế độ `image`/`images`, theo id tệp: `url` có sẵn (ảnh
11697
+ * đã trên máy chủ) hoặc object URL tạo từ `File`.
11698
+ *
11699
+ * Object URL phải THU HỒI khi tệp bị xoá / component huỷ, không thì mỗi ảnh
11700
+ * rò một khối bộ nhớ bằng cả ảnh. Giữ theo id để thêm một ảnh không tạo lại
11701
+ * URL của các ảnh cũ (ảnh cũ sẽ nháy vì `src` đổi).
11702
+ */
11703
+ imageUrls = signal({}, /* @ts-ignore */
11704
+ ...(ngDevMode ? [{ debugName: "imageUrls" }] : /* istanbul ignore next */ []));
11705
+ objectUrls = new Map();
11706
+ /** Ảnh cho trình xem — chỉ tệp đã có URL. */
11707
+ previewImages = computed(() => this.files()
11708
+ .filter((f) => this.imageUrls()[f.id])
11709
+ .map((f) => ({ src: this.imageUrls()[f.id], alt: f.name })), /* @ts-ignore */
11710
+ ...(ngDevMode ? [{ debugName: "previewImages" }] : /* istanbul ignore next */ []));
11711
+ previewOpen = signal(false, /* @ts-ignore */
11712
+ ...(ngDevMode ? [{ debugName: "previewOpen" }] : /* istanbul ignore next */ []));
11713
+ previewIndex = signal(0, /* @ts-ignore */
11714
+ ...(ngDevMode ? [{ debugName: "previewIndex" }] : /* istanbul ignore next */ []));
11715
+ constructor() {
11716
+ effect((onCleanup) => {
11717
+ if (!this.pasteFromPage() || typeof document === 'undefined')
11718
+ return;
11719
+ const handler = (e) => {
11720
+ // Dán TRONG khung thì host đã xử lý (sự kiện nổi lên document sau).
11721
+ if (this.host.nativeElement.contains(e.target))
11722
+ return;
11723
+ // Một khung/ô tải KHÁC trên trang đã nhận tệp này — không nhận lần hai.
11724
+ if (e.defaultPrevented)
11725
+ return;
11726
+ if (isEditableTarget(e.target))
11727
+ return;
11728
+ this.onPaste(e);
11729
+ };
11730
+ document.addEventListener('paste', handler);
11731
+ onCleanup(() => document.removeEventListener('paste', handler));
11732
+ });
11733
+ effect(() => this.syncImageUrls());
11734
+ inject(DestroyRef).onDestroy(() => {
11735
+ this.objectUrls.forEach((url) => URL.revokeObjectURL(url));
11736
+ this.objectUrls.clear();
11737
+ });
11738
+ }
11739
+ /** Tạo URL cho ảnh mới, thu hồi URL của ảnh đã bị xoá. */
11740
+ syncImageUrls() {
11741
+ const files = this.isImageMode() ? this.files() : [];
11742
+ const alive = new Set(files.map((f) => f.id));
11743
+ for (const [id, url] of this.objectUrls) {
11744
+ if (!alive.has(id)) {
11745
+ URL.revokeObjectURL(url);
11746
+ this.objectUrls.delete(id);
11747
+ }
11748
+ }
11749
+ const urls = {};
11750
+ for (const f of files) {
11751
+ if (f.url)
11752
+ urls[f.id] = f.url;
11753
+ else if (f.file && typeof URL.createObjectURL === 'function') {
11754
+ if (!this.objectUrls.has(f.id))
11755
+ this.objectUrls.set(f.id, URL.createObjectURL(f.file));
11756
+ urls[f.id] = this.objectUrls.get(f.id);
11757
+ }
11758
+ }
11759
+ this.imageUrls.set(urls);
11760
+ }
11761
+ /** Mở trình xem tại ảnh của tệp này, và phát `(filePreview)`. */
11762
+ openPreview(file) {
11763
+ const index = this.previewImages().findIndex((img) => img.src === this.imageUrls()[file.id]);
11764
+ if (index < 0)
11765
+ return;
11766
+ this.previewIndex.set(index);
11767
+ this.previewOpen.set(true);
11768
+ this.filePreview.emit(file);
11769
+ }
11770
+ openPicker() {
11771
+ if (this.disabled())
11772
+ return;
11773
+ this.fileInput().nativeElement.click();
11774
+ }
11775
+ onInputChange(event) {
11776
+ const el = event.target;
11777
+ this.addFiles(Array.from(el.files ?? []));
11778
+ // Xoá giá trị để chọn LẠI đúng tệp vừa xoá vẫn bắn `change`.
11779
+ el.value = '';
11780
+ }
11781
+ /** Nhận tệp từ hộp chọn hoặc kéo thả: kiểm, thêm vào `files`, phát sự kiện. */
11782
+ addFiles(incoming) {
11783
+ if (this.disabled() || !incoming.length)
11784
+ return;
11785
+ const single = !this.isMultiple();
11786
+ const { accepted, rejected } = validateFiles(single ? incoming.slice(0, 1) : incoming, {
11787
+ accept: this.accept(),
11788
+ maxSize: this.maxSize(),
11789
+ maxFiles: single ? 0 : this.maxFiles(),
11790
+ existingCount: single ? 0 : this.files().length,
11791
+ });
11792
+ this.errors.set(rejected.map((r) => r.message));
11793
+ if (rejected.length)
11794
+ this.rejected.emit(rejected);
11795
+ if (!accepted.length)
11796
+ return;
11797
+ const added = accepted.map(toUploadFile);
11798
+ // Một tệp: tệp mới THAY tệp cũ — đúng nghĩa nút "Thay đổi".
11799
+ this.files.set(single ? added : [...this.files(), ...added]);
11800
+ this.filesAdded.emit(accepted);
11801
+ }
11802
+ /** Dán tệp khi tiêu điểm trong khung (nút, chip, dòng tệp). */
11803
+ onPaste(event) {
11804
+ if (this.disabled())
11805
+ return;
11806
+ const files = filesFromClipboard(event.clipboardData);
11807
+ if (!files.length)
11808
+ return;
11809
+ event.preventDefault();
11810
+ this.addFiles(files);
11811
+ }
11812
+ /** Thả thêm tệp vào vùng chip đã có tệp. */
11813
+ onListDrop(event) {
11814
+ event.preventDefault();
11815
+ this.addFiles(Array.from(event.dataTransfer?.files ?? []));
11816
+ }
11817
+ removeFile(file) {
11818
+ this.files.set(this.files().filter((f) => f.id !== file.id));
11819
+ this.errors.set([]);
11820
+ this.fileRemove.emit(file);
11821
+ }
11822
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: OlFileUpload, deps: [], target: i0.ɵɵFactoryTarget.Component });
11823
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.6", type: OlFileUpload, isStandalone: true, selector: "ol-file-upload", inputs: { title: { classPropertyName: "title", publicName: "title", isSignal: true, isRequired: false, transformFunction: null }, required: { classPropertyName: "required", publicName: "required", isSignal: true, isRequired: false, transformFunction: null }, mode: { classPropertyName: "mode", publicName: "mode", isSignal: true, isRequired: false, transformFunction: null }, accept: { classPropertyName: "accept", publicName: "accept", isSignal: true, isRequired: false, transformFunction: null }, maxSize: { classPropertyName: "maxSize", publicName: "maxSize", isSignal: true, isRequired: false, transformFunction: null }, maxFiles: { classPropertyName: "maxFiles", publicName: "maxFiles", isSignal: true, isRequired: false, transformFunction: null }, buttonLabel: { classPropertyName: "buttonLabel", publicName: "buttonLabel", isSignal: true, isRequired: false, transformFunction: null }, previewable: { classPropertyName: "previewable", publicName: "previewable", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, pasteFromPage: { classPropertyName: "pasteFromPage", publicName: "pasteFromPage", isSignal: true, isRequired: false, transformFunction: null }, helperText: { classPropertyName: "helperText", publicName: "helperText", isSignal: true, isRequired: false, transformFunction: null }, files: { classPropertyName: "files", publicName: "files", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { files: "filesChange", filesAdded: "filesAdded", fileRemove: "fileRemove", filePreview: "filePreview", fileRetry: "fileRetry", rejected: "rejected" }, host: { listeners: { "paste": "onPaste($event)" }, classAttribute: "block w-full" }, viewQueries: [{ propertyName: "fileInput", first: true, predicate: ["fileInput"], descendants: true, isSignal: true }], ngImport: i0, template: "<div\n class=\"overflow-hidden rounded-md border bg-neutral-primary\"\n [class]=\"errors().length ? 'border-danger' : 'border-default'\"\n [attr.aria-disabled]=\"disabled() || null\"\n>\n <!-- \u0110\u1EA7u khung: ti\u00EAu \u0111\u1EC1 + n\u00FAt (Figma \"Button header\"). Slot [actions] thay n\u00FAt. -->\n <div class=\"flex items-center justify-between gap-3 border-b border-default px-3 py-2\">\n <span class=\"min-w-0 truncate text-sm font-medium text-fg-brand-strong\" [id]=\"uid + '-title'\">\n {{ title() }}\n @if (required()) {\n <span class=\"text-fg-danger\" aria-hidden=\"true\">*</span>\n }\n </span>\n <div class=\"flex shrink-0 items-center gap-2\">\n <ng-content select=\"[actions]\" />\n <ol-button\n size=\"sm\"\n [pill]=\"true\"\n [disabled]=\"disabled() || limitReached()\"\n [ariaLabel]=\"resolvedButtonLabel() + (title() ? ' \u2014 ' + title() : '')\"\n (btnClick)=\"openPicker()\"\n >\n <ol-icon name=\"solar:upload-minimalistic-linear\" [size]=\"16\" />\n {{ resolvedButtonLabel() }}\n </ol-button>\n </div>\n </div>\n\n <div class=\"bg-neutral-secondary p-3\">\n @if (!hasFiles()) {\n <ol-file-dropzone\n [compact]=\"true\"\n [hint]=\"hintText()\"\n [icon]=\"isImageMode() ? 'solar:gallery-add-linear' : 'solar:upload-minimalistic-linear'\"\n [title]=\"isImageMode() ? 'Nh\u1EA5n \u0111\u1EC3 ch\u1ECDn \u1EA3nh ho\u1EB7c k\u00E9o th\u1EA3' : 'Nh\u1EA5n \u0111\u1EC3 t\u1EA3i l\u00EAn ho\u1EB7c k\u00E9o th\u1EA3'\"\n [disabled]=\"disabled()\"\n [state]=\"errors().length ? 'error' : 'default'\"\n [describedBy]=\"describedBy()\"\n (pick)=\"openPicker()\"\n (filesDropped)=\"addFiles($event)\"\n />\n } @else {\n @switch (mode()) {\n @case ('image') {\n <div class=\"relative overflow-hidden rounded-md bg-neutral-tertiary\">\n @if (imageUrls()[files()[0].id]; as src) {\n <!-- C\u1EA3 \u1EA3nh l\u00E0 n\u00FAt: b\u1EA5m \u0111\u1EC3 xem ph\u00F3ng to. -->\n <button\n type=\"button\"\n class=\"group block w-full cursor-zoom-in focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-brand-subtle focus-visible:ring-inset\"\n [attr.aria-label]=\"'Xem \u1EA3nh ' + files()[0].name\"\n (click)=\"openPreview(files()[0])\"\n >\n <img [src]=\"src\" [alt]=\"files()[0].name\" class=\"block h-44 w-full object-cover\" />\n <span\n class=\"absolute start-2 bottom-2 inline-flex items-center gap-1 rounded-full bg-neutral-primary px-2.5 py-1 text-xs font-medium text-heading shadow-sm\"\n aria-hidden=\"true\"\n >\n <ol-icon name=\"lucide:zoom-in\" size=\"xs\" />\n Xem \u1EA3nh\n </span>\n </button>\n } @else {\n <div class=\"flex h-44 items-center justify-center text-sm text-body-subtle\">\n {{ files()[0].name }}\n </div>\n }\n <button\n type=\"button\"\n class=\"absolute end-2 top-2 inline-flex size-8 items-center justify-center rounded-full bg-neutral-primary text-heading shadow-sm hover:bg-neutral-secondary focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-brand-subtle\"\n [disabled]=\"disabled()\"\n [attr.aria-label]=\"'Xo\u00E1 \u1EA3nh ' + files()[0].name\"\n (click)=\"removeFile(files()[0])\"\n >\n <ol-icon name=\"close\" size=\"xs\" />\n </button>\n </div>\n }\n @case ('images') {\n <!--\n L\u01B0\u1EDBi \u00F4 vu\u00F4ng. B\u1EA5m \u00F4 \u0111\u1EC3 xem, trong tr\u00ECnh xem l\u01B0\u1EDBt \u2039 \u203A qua m\u1ECDi \u1EA3nh.\n \u00D4 cu\u1ED1i \"+\" th\u00EAm \u1EA3nh (\u1EA9n khi \u0111\u1EE7 maxFiles). Th\u1EA3 t\u1EC7p v\u00E0o l\u01B0\u1EDBi v\u1EABn nh\u1EADn.\n -->\n <ul\n class=\"grid grid-cols-3 gap-2 sm:grid-cols-4\"\n [attr.aria-labelledby]=\"uid + '-title'\"\n (dragover)=\"$event.preventDefault()\"\n (drop)=\"onListDrop($event)\"\n >\n @for (f of files(); track f.id) {\n <li class=\"relative aspect-square overflow-hidden rounded-md bg-neutral-tertiary\">\n @if (imageUrls()[f.id]; as src) {\n <button\n type=\"button\"\n class=\"block size-full cursor-zoom-in focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-brand-subtle focus-visible:ring-inset\"\n [attr.aria-label]=\"'Xem \u1EA3nh ' + f.name\"\n (click)=\"openPreview(f)\"\n >\n <img [src]=\"src\" [alt]=\"f.name\" class=\"size-full object-cover\" />\n </button>\n } @else {\n <span\n class=\"flex size-full items-center justify-center p-2 text-center text-xs break-all text-body-subtle\"\n >\n {{ f.name }}\n </span>\n }\n <button\n type=\"button\"\n class=\"absolute end-1 top-1 inline-flex size-7 items-center justify-center rounded-full bg-neutral-primary text-heading shadow-sm hover:bg-neutral-secondary focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-brand-subtle\"\n [disabled]=\"disabled()\"\n [attr.aria-label]=\"'Xo\u00E1 \u1EA3nh ' + f.name\"\n (click)=\"removeFile(f)\"\n >\n <ol-icon name=\"close\" size=\"xs\" />\n </button>\n </li>\n }\n @if (!limitReached()) {\n <li class=\"aspect-square\">\n <button\n type=\"button\"\n class=\"flex size-full flex-col items-center justify-center gap-1 rounded-md border border-dashed border-default text-xs font-medium text-body-subtle transition-colors hover:border-brand hover:text-fg-brand focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-brand-subtle disabled:cursor-not-allowed disabled:opacity-50\"\n [disabled]=\"disabled()\"\n (click)=\"openPicker()\"\n >\n <ol-icon name=\"solar:gallery-add-linear\" size=\"base\" />\n Th\u00EAm \u1EA3nh\n </button>\n </li>\n }\n </ul>\n }\n @case ('single') {\n <ol-file-item\n variant=\"card\"\n [file]=\"files()[0]\"\n [disabled]=\"disabled()\"\n (remove)=\"removeFile($event)\"\n />\n }\n @case ('multiple') {\n <!--\n Th\u1EA3 th\u00EAm t\u1EC7p v\u00E0o v\u00F9ng chip v\u1EABn nh\u1EADn \u2014 k\u00E9o th\u1EA3 kh\u00F4ng b\u1EAFt bu\u1ED9c ph\u1EA3i\n nh\u1EAFm \u0111\u00FAng \u00F4 tr\u1ED1ng. max-h + cu\u1ED9n d\u1ECDc: danh s\u00E1ch d\u00E0i kh\u00F4ng \u0111\u1EA9y form.\n -->\n <ul\n class=\"flex max-h-36 flex-wrap gap-1.5 overflow-y-auto\"\n [attr.aria-labelledby]=\"uid + '-title'\"\n (dragover)=\"$event.preventDefault()\"\n (drop)=\"onListDrop($event)\"\n >\n @for (f of files(); track f.id) {\n <li class=\"min-w-0 max-w-full\">\n <ol-file-item\n variant=\"chip\"\n [file]=\"f\"\n [previewable]=\"previewable()\"\n [disabled]=\"disabled()\"\n (remove)=\"removeFile($event)\"\n (preview)=\"filePreview.emit($event)\"\n />\n </li>\n }\n </ul>\n }\n @default {\n <ul class=\"flex flex-col gap-2\" [attr.aria-labelledby]=\"uid + '-title'\">\n @for (f of files(); track f.id) {\n <li>\n <ol-file-item\n [file]=\"f\"\n [previewable]=\"previewable()\"\n [disabled]=\"disabled()\"\n (remove)=\"removeFile($event)\"\n (preview)=\"filePreview.emit($event)\"\n (retry)=\"fileRetry.emit($event)\"\n />\n </li>\n }\n </ul>\n }\n }\n }\n </div>\n</div>\n\n<!--\n `sr-only` ch\u1EE9 KH\u00D4NG `hidden`: `display: none` lo\u1EA1i \u00F4 kh\u1ECFi c\u00E2y tr\u1EE3 n\u0103ng. M\u1EDF\n b\u1EB1ng n\u00FAt \u0111\u1EA7u khung ho\u1EB7c v\u00F9ng k\u00E9o th\u1EA3; tabindex -1 \u0111\u1EC3 Tab kh\u00F4ng d\u1EEBng hai l\u1EA7n.\n-->\n<input\n #fileInput\n type=\"file\"\n class=\"sr-only\"\n tabindex=\"-1\"\n aria-hidden=\"true\"\n [accept]=\"accept()\"\n [multiple]=\"isMultiple()\"\n [disabled]=\"disabled()\"\n (change)=\"onInputChange($event)\"\n/>\n\n@if (errors().length) {\n <ul [id]=\"uid + '-err'\" class=\"mt-1.5 space-y-0.5 text-xs text-fg-danger\" role=\"alert\">\n @for (msg of errors(); track msg) {\n <li>{{ msg }}</li>\n }\n </ul>\n}\n<ol-image-preview [images]=\"previewImages()\" [(open)]=\"previewOpen\" [(index)]=\"previewIndex\" />\n\n@if (helperText()) {\n <p [id]=\"uid + '-help'\" class=\"mt-1.5 text-xs text-body-subtle\">{{ helperText() }}</p>\n}\n", dependencies: [{ kind: "component", type: OlButton, selector: "ol-button", inputs: ["color", "size", "pill", "outline", "iconOnly", "loading", "disabled", "type", "ariaLabel", "ariaExpanded", "ariaControls", "ariaPressed"], outputs: ["btnClick"] }, { kind: "component", type: OlIcon, selector: "ol-icon", inputs: ["name", "size", "color", "variant", "strokeWidth"] }, { kind: "component", type: OlFileDropzone, selector: "ol-file-dropzone", inputs: ["title", "hint", "ctaLabel", "icon", "disabled", "state", "compact", "describedBy", "pasteHint"], outputs: ["pick", "filesDropped"] }, { kind: "component", type: OlFileItem, selector: "ol-file-item", inputs: ["file", "variant", "removable", "previewable", "disabled"], outputs: ["remove", "preview", "retry"] }, { kind: "component", type: OlImagePreview, selector: "ol-image-preview", inputs: ["images", "open", "index"], outputs: ["openChange", "indexChange", "closed"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
11824
+ }
11825
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: OlFileUpload, decorators: [{
11826
+ type: Component,
11827
+ args: [{ selector: 'ol-file-upload', standalone: true, imports: [OlButton, OlIcon, OlFileDropzone, OlFileItem, OlImagePreview], host: { class: 'block w-full', '(paste)': 'onPaste($event)' }, changeDetection: ChangeDetectionStrategy.OnPush, template: "<div\n class=\"overflow-hidden rounded-md border bg-neutral-primary\"\n [class]=\"errors().length ? 'border-danger' : 'border-default'\"\n [attr.aria-disabled]=\"disabled() || null\"\n>\n <!-- \u0110\u1EA7u khung: ti\u00EAu \u0111\u1EC1 + n\u00FAt (Figma \"Button header\"). Slot [actions] thay n\u00FAt. -->\n <div class=\"flex items-center justify-between gap-3 border-b border-default px-3 py-2\">\n <span class=\"min-w-0 truncate text-sm font-medium text-fg-brand-strong\" [id]=\"uid + '-title'\">\n {{ title() }}\n @if (required()) {\n <span class=\"text-fg-danger\" aria-hidden=\"true\">*</span>\n }\n </span>\n <div class=\"flex shrink-0 items-center gap-2\">\n <ng-content select=\"[actions]\" />\n <ol-button\n size=\"sm\"\n [pill]=\"true\"\n [disabled]=\"disabled() || limitReached()\"\n [ariaLabel]=\"resolvedButtonLabel() + (title() ? ' \u2014 ' + title() : '')\"\n (btnClick)=\"openPicker()\"\n >\n <ol-icon name=\"solar:upload-minimalistic-linear\" [size]=\"16\" />\n {{ resolvedButtonLabel() }}\n </ol-button>\n </div>\n </div>\n\n <div class=\"bg-neutral-secondary p-3\">\n @if (!hasFiles()) {\n <ol-file-dropzone\n [compact]=\"true\"\n [hint]=\"hintText()\"\n [icon]=\"isImageMode() ? 'solar:gallery-add-linear' : 'solar:upload-minimalistic-linear'\"\n [title]=\"isImageMode() ? 'Nh\u1EA5n \u0111\u1EC3 ch\u1ECDn \u1EA3nh ho\u1EB7c k\u00E9o th\u1EA3' : 'Nh\u1EA5n \u0111\u1EC3 t\u1EA3i l\u00EAn ho\u1EB7c k\u00E9o th\u1EA3'\"\n [disabled]=\"disabled()\"\n [state]=\"errors().length ? 'error' : 'default'\"\n [describedBy]=\"describedBy()\"\n (pick)=\"openPicker()\"\n (filesDropped)=\"addFiles($event)\"\n />\n } @else {\n @switch (mode()) {\n @case ('image') {\n <div class=\"relative overflow-hidden rounded-md bg-neutral-tertiary\">\n @if (imageUrls()[files()[0].id]; as src) {\n <!-- C\u1EA3 \u1EA3nh l\u00E0 n\u00FAt: b\u1EA5m \u0111\u1EC3 xem ph\u00F3ng to. -->\n <button\n type=\"button\"\n class=\"group block w-full cursor-zoom-in focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-brand-subtle focus-visible:ring-inset\"\n [attr.aria-label]=\"'Xem \u1EA3nh ' + files()[0].name\"\n (click)=\"openPreview(files()[0])\"\n >\n <img [src]=\"src\" [alt]=\"files()[0].name\" class=\"block h-44 w-full object-cover\" />\n <span\n class=\"absolute start-2 bottom-2 inline-flex items-center gap-1 rounded-full bg-neutral-primary px-2.5 py-1 text-xs font-medium text-heading shadow-sm\"\n aria-hidden=\"true\"\n >\n <ol-icon name=\"lucide:zoom-in\" size=\"xs\" />\n Xem \u1EA3nh\n </span>\n </button>\n } @else {\n <div class=\"flex h-44 items-center justify-center text-sm text-body-subtle\">\n {{ files()[0].name }}\n </div>\n }\n <button\n type=\"button\"\n class=\"absolute end-2 top-2 inline-flex size-8 items-center justify-center rounded-full bg-neutral-primary text-heading shadow-sm hover:bg-neutral-secondary focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-brand-subtle\"\n [disabled]=\"disabled()\"\n [attr.aria-label]=\"'Xo\u00E1 \u1EA3nh ' + files()[0].name\"\n (click)=\"removeFile(files()[0])\"\n >\n <ol-icon name=\"close\" size=\"xs\" />\n </button>\n </div>\n }\n @case ('images') {\n <!--\n L\u01B0\u1EDBi \u00F4 vu\u00F4ng. B\u1EA5m \u00F4 \u0111\u1EC3 xem, trong tr\u00ECnh xem l\u01B0\u1EDBt \u2039 \u203A qua m\u1ECDi \u1EA3nh.\n \u00D4 cu\u1ED1i \"+\" th\u00EAm \u1EA3nh (\u1EA9n khi \u0111\u1EE7 maxFiles). Th\u1EA3 t\u1EC7p v\u00E0o l\u01B0\u1EDBi v\u1EABn nh\u1EADn.\n -->\n <ul\n class=\"grid grid-cols-3 gap-2 sm:grid-cols-4\"\n [attr.aria-labelledby]=\"uid + '-title'\"\n (dragover)=\"$event.preventDefault()\"\n (drop)=\"onListDrop($event)\"\n >\n @for (f of files(); track f.id) {\n <li class=\"relative aspect-square overflow-hidden rounded-md bg-neutral-tertiary\">\n @if (imageUrls()[f.id]; as src) {\n <button\n type=\"button\"\n class=\"block size-full cursor-zoom-in focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-brand-subtle focus-visible:ring-inset\"\n [attr.aria-label]=\"'Xem \u1EA3nh ' + f.name\"\n (click)=\"openPreview(f)\"\n >\n <img [src]=\"src\" [alt]=\"f.name\" class=\"size-full object-cover\" />\n </button>\n } @else {\n <span\n class=\"flex size-full items-center justify-center p-2 text-center text-xs break-all text-body-subtle\"\n >\n {{ f.name }}\n </span>\n }\n <button\n type=\"button\"\n class=\"absolute end-1 top-1 inline-flex size-7 items-center justify-center rounded-full bg-neutral-primary text-heading shadow-sm hover:bg-neutral-secondary focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-brand-subtle\"\n [disabled]=\"disabled()\"\n [attr.aria-label]=\"'Xo\u00E1 \u1EA3nh ' + f.name\"\n (click)=\"removeFile(f)\"\n >\n <ol-icon name=\"close\" size=\"xs\" />\n </button>\n </li>\n }\n @if (!limitReached()) {\n <li class=\"aspect-square\">\n <button\n type=\"button\"\n class=\"flex size-full flex-col items-center justify-center gap-1 rounded-md border border-dashed border-default text-xs font-medium text-body-subtle transition-colors hover:border-brand hover:text-fg-brand focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-brand-subtle disabled:cursor-not-allowed disabled:opacity-50\"\n [disabled]=\"disabled()\"\n (click)=\"openPicker()\"\n >\n <ol-icon name=\"solar:gallery-add-linear\" size=\"base\" />\n Th\u00EAm \u1EA3nh\n </button>\n </li>\n }\n </ul>\n }\n @case ('single') {\n <ol-file-item\n variant=\"card\"\n [file]=\"files()[0]\"\n [disabled]=\"disabled()\"\n (remove)=\"removeFile($event)\"\n />\n }\n @case ('multiple') {\n <!--\n Th\u1EA3 th\u00EAm t\u1EC7p v\u00E0o v\u00F9ng chip v\u1EABn nh\u1EADn \u2014 k\u00E9o th\u1EA3 kh\u00F4ng b\u1EAFt bu\u1ED9c ph\u1EA3i\n nh\u1EAFm \u0111\u00FAng \u00F4 tr\u1ED1ng. max-h + cu\u1ED9n d\u1ECDc: danh s\u00E1ch d\u00E0i kh\u00F4ng \u0111\u1EA9y form.\n -->\n <ul\n class=\"flex max-h-36 flex-wrap gap-1.5 overflow-y-auto\"\n [attr.aria-labelledby]=\"uid + '-title'\"\n (dragover)=\"$event.preventDefault()\"\n (drop)=\"onListDrop($event)\"\n >\n @for (f of files(); track f.id) {\n <li class=\"min-w-0 max-w-full\">\n <ol-file-item\n variant=\"chip\"\n [file]=\"f\"\n [previewable]=\"previewable()\"\n [disabled]=\"disabled()\"\n (remove)=\"removeFile($event)\"\n (preview)=\"filePreview.emit($event)\"\n />\n </li>\n }\n </ul>\n }\n @default {\n <ul class=\"flex flex-col gap-2\" [attr.aria-labelledby]=\"uid + '-title'\">\n @for (f of files(); track f.id) {\n <li>\n <ol-file-item\n [file]=\"f\"\n [previewable]=\"previewable()\"\n [disabled]=\"disabled()\"\n (remove)=\"removeFile($event)\"\n (preview)=\"filePreview.emit($event)\"\n (retry)=\"fileRetry.emit($event)\"\n />\n </li>\n }\n </ul>\n }\n }\n }\n </div>\n</div>\n\n<!--\n `sr-only` ch\u1EE9 KH\u00D4NG `hidden`: `display: none` lo\u1EA1i \u00F4 kh\u1ECFi c\u00E2y tr\u1EE3 n\u0103ng. M\u1EDF\n b\u1EB1ng n\u00FAt \u0111\u1EA7u khung ho\u1EB7c v\u00F9ng k\u00E9o th\u1EA3; tabindex -1 \u0111\u1EC3 Tab kh\u00F4ng d\u1EEBng hai l\u1EA7n.\n-->\n<input\n #fileInput\n type=\"file\"\n class=\"sr-only\"\n tabindex=\"-1\"\n aria-hidden=\"true\"\n [accept]=\"accept()\"\n [multiple]=\"isMultiple()\"\n [disabled]=\"disabled()\"\n (change)=\"onInputChange($event)\"\n/>\n\n@if (errors().length) {\n <ul [id]=\"uid + '-err'\" class=\"mt-1.5 space-y-0.5 text-xs text-fg-danger\" role=\"alert\">\n @for (msg of errors(); track msg) {\n <li>{{ msg }}</li>\n }\n </ul>\n}\n<ol-image-preview [images]=\"previewImages()\" [(open)]=\"previewOpen\" [(index)]=\"previewIndex\" />\n\n@if (helperText()) {\n <p [id]=\"uid + '-help'\" class=\"mt-1.5 text-xs text-body-subtle\">{{ helperText() }}</p>\n}\n" }]
11828
+ }], ctorParameters: () => [], propDecorators: { title: [{ type: i0.Input, args: [{ isSignal: true, alias: "title", required: false }] }], required: [{ type: i0.Input, args: [{ isSignal: true, alias: "required", required: false }] }], mode: [{ type: i0.Input, args: [{ isSignal: true, alias: "mode", required: false }] }], accept: [{ type: i0.Input, args: [{ isSignal: true, alias: "accept", required: false }] }], maxSize: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxSize", required: false }] }], maxFiles: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxFiles", required: false }] }], buttonLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "buttonLabel", required: false }] }], previewable: [{ type: i0.Input, args: [{ isSignal: true, alias: "previewable", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], pasteFromPage: [{ type: i0.Input, args: [{ isSignal: true, alias: "pasteFromPage", required: false }] }], helperText: [{ type: i0.Input, args: [{ isSignal: true, alias: "helperText", required: false }] }], files: [{ type: i0.Input, args: [{ isSignal: true, alias: "files", required: false }] }, { type: i0.Output, args: ["filesChange"] }], filesAdded: [{ type: i0.Output, args: ["filesAdded"] }], fileRemove: [{ type: i0.Output, args: ["fileRemove"] }], filePreview: [{ type: i0.Output, args: ["filePreview"] }], fileRetry: [{ type: i0.Output, args: ["fileRetry"] }], rejected: [{ type: i0.Output, args: ["rejected"] }], fileInput: [{ type: i0.ViewChild, args: ['fileInput', { isSignal: true }] }] } });
10141
11829
 
10142
11830
  let uniqueRangeId = 0;
10143
11831
  class OlRange {
@@ -12256,6 +13944,27 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImpor
12256
13944
  }]
12257
13945
  }], propDecorators: { layout: [{ type: i0.Input, args: [{ isSignal: true, alias: "layout", required: false }] }] } });
12258
13946
 
13947
+ /**
13948
+ * Mục menu xếp dọc trong ☰. Nơi gọi hay bọc mục trong một
13949
+ * `<div slot="navigation" class="flex items-center">` — wrapper đó cũng phải
13950
+ * xếp dọc, không thì mục vẫn nằm ngang và panel dropdown của mục thứ hai tràn
13951
+ * phải (đo /navbar ví dụ 6 trên màn 390px: panel 172→476px).
13952
+ */
13953
+ /**
13954
+ * Panel ☰ NỔI dưới thanh, đè lên nội dung (báo 2026-09-28: "menu mobile phải
13955
+ * đè lên nội dung chứ không đẩy nội dung xuống"). Khi khối mục menu cũng đang
13956
+ * xếp dọc nổi (không có mobile-links), panel hạ xuống dưới nó theo
13957
+ * `--ol-nav-stack-h` — hai lớp nổi cùng neo `top-full` sẽ chồng lên nhau.
13958
+ * Giới hạn cao theo màn và tự cuộn: menu dài trên điện thoại không bị cắt.
13959
+ */
13960
+ const OVERLAY = 'absolute inset-x-0 top-[calc(100%+var(--ol-nav-stack-h,0px))] z-dropdown max-h-[calc(100dvh-5rem)] overflow-y-auto';
13961
+ const STACKED = [
13962
+ // NỔI dưới thanh (absolute) — đè lên nội dung trang, không đẩy xuống.
13963
+ 'flex flex-col items-stretch gap-1 absolute inset-x-0 top-full z-dropdown bg-neutral-primary border-t border-default px-4 py-3 shadow-xl',
13964
+ '@[78rem]:static @[78rem]:bg-transparent @[78rem]:shadow-none',
13965
+ '[&>[slot=navigation]]:flex-col [&>[slot=navigation]]:items-stretch',
13966
+ '@[78rem]:[&>[slot=navigation]]:flex-row @[78rem]:[&>[slot=navigation]]:items-center',
13967
+ ].join(' ');
12259
13968
  class OlNavbar {
12260
13969
  brandLogo = input('', /* @ts-ignore */
12261
13970
  ...(ngDevMode ? [{ debugName: "brandLogo" }] : /* istanbul ignore next */ []));
@@ -12277,22 +13986,109 @@ class OlNavbar {
12277
13986
  onToggleMobileMenu = output();
12278
13987
  searchQuery = '';
12279
13988
  elementRef = inject(ElementRef);
13989
+ mobilePanel = viewChild('mobilePanel', /* @ts-ignore */
13990
+ ...(ngDevMode ? [{ debugName: "mobilePanel" }] : /* istanbul ignore next */ []));
13991
+ navWrap = viewChild('navWrap', /* @ts-ignore */
13992
+ ...(ngDevMode ? [{ debugName: "navWrap" }] : /* istanbul ignore next */ []));
13993
+ destroyRef = inject(DestroyRef);
13994
+ /** Nơi gọi có khai `slot="mobile-links"` (hoặc `slot="mobile"`) riêng. */
13995
+ hasMobileLinks = signal(false, /* @ts-ignore */
13996
+ ...(ngDevMode ? [{ debugName: "hasMobileLinks" }] : /* istanbul ignore next */ []));
13997
+ /** Panel ☰ có gì để hiện — không thì đừng vẽ một dải trống có padding. */
13998
+ hasPanelContent = signal(false, /* @ts-ignore */
13999
+ ...(ngDevMode ? [{ debugName: "hasPanelContent" }] : /* istanbul ignore next */ []));
14000
+ constructor() {
14001
+ afterNextRender(() => {
14002
+ this.detectMobileSlots();
14003
+ this.observeStackHeight();
14004
+ });
14005
+ }
14006
+ /** Chiều cao khối mục xếp dọc (0 khi không xếp dọc) → `--ol-nav-stack-h`. */
14007
+ observeStackHeight() {
14008
+ const wrap = this.navWrap()?.nativeElement;
14009
+ const root = this.elementRef.nativeElement;
14010
+ if (!wrap || typeof ResizeObserver === 'undefined')
14011
+ return;
14012
+ const update = () => {
14013
+ const h = getComputedStyle(wrap).position === 'absolute' ? wrap.offsetHeight : 0;
14014
+ root.style.setProperty('--ol-nav-stack-h', `${h}px`);
14015
+ };
14016
+ const observer = new ResizeObserver(update);
14017
+ observer.observe(wrap);
14018
+ this.destroyRef.onDestroy(() => observer.disconnect());
14019
+ }
14020
+ isStacked() {
14021
+ return !this.isCollapsed() && !this.hasMobileLinks();
14022
+ }
14023
+ /**
14024
+ * Đo lại mỗi lần mở ☰, không chỉ lúc đầu: nội dung chiếu vào có thể nằm
14025
+ * trong @if của nơi gọi và xuất hiện muộn.
14026
+ */
14027
+ detectMobileSlots() {
14028
+ const panel = this.mobilePanel()?.nativeElement;
14029
+ if (!panel)
14030
+ return;
14031
+ this.hasMobileLinks.set(!!panel.querySelector('[slot=mobile], [slot=mobile-links]'));
14032
+ this.hasPanelContent.set(panel.childElementCount > 0);
14033
+ }
14034
+ /**
14035
+ * Khối mục menu. Khung ≥ 78rem: nằm ngang cạnh logo.
14036
+ *
14037
+ * Khung hẹp mà ☰ đang mở và KHÔNG có menu di động riêng: xuống dòng
14038
+ * (`col-span-full row-start-2`) và xếp dọc — chính các mục desktop thành menu
14039
+ * di động. Bản trước chỉ ẩn chúng, nên 5/7 thanh trên /navbar và /blocks bấm
14040
+ * ☰ ra bảng trống (đo iPhone 13 giả lập, 2026-09-28).
14041
+ */
14042
+ navWrapClasses = computed(() => {
14043
+ const wide = '@[78rem]:flex @[78rem]:flex-row @[78rem]:items-center @[78rem]:p-0 @[78rem]:border-0 whitespace-nowrap min-w-0';
14044
+ const stacked = this.isStacked();
14045
+ return stacked ? `${wide} ${STACKED}` : `${wide} hidden gap-1`;
14046
+ }, /* @ts-ignore */
14047
+ ...(ngDevMode ? [{ debugName: "navWrapClasses" }] : /* istanbul ignore next */ []));
14048
+ mobilePanelClasses = computed(() => {
14049
+ const show = !this.isCollapsed() && this.hasPanelContent();
14050
+ return show
14051
+ ? `@[78rem]:hidden ${OVERLAY} border-t border-default bg-neutral-primary px-4 pt-4 pb-6 space-y-4 animate-in fade-in slide-in-from-top-2 duration-150 shadow-xl`
14052
+ : 'hidden';
14053
+ }, /* @ts-ignore */
14054
+ ...(ngDevMode ? [{ debugName: "mobilePanelClasses" }] : /* istanbul ignore next */ []));
12280
14055
  navbarClasses = computed(() => {
12281
- const classes = ['w-full bg-neutral-primary transition-colors duration-200 z-sticky'];
14056
+ /**
14057
+ * `@container`: bố cục đổi theo bề rộng CHÍNH THANH, không theo màn hình.
14058
+ *
14059
+ * Thanh hay nằm trong khung hẹp hơn màn (cạnh sidebar, trong khung xem trước
14060
+ * của docs). Bản trước dùng `xl:` theo MÀN HÌNH: đo /navbar 2026-09-28, màn
14061
+ * 1440px khung chỉ 982px mà vẫn vẽ bố cục desktop — năm mục menu + năm học
14062
+ * + ba nút cần ~1075px, chật và tràn.
14063
+ *
14064
+ * Ngưỡng giữ đúng số rem của breakpoint cũ (sm 40rem, md 48rem, lg 64rem),
14065
+ * riêng xl là 78rem chứ không 80rem: thanh full màn ở 1280px trừ thanh cuộn
14066
+ * còn ~1265px — đặt 80rem là màn 1280px mất bố cục desktop vốn đang có.
14067
+ */
14068
+ const classes = [
14069
+ '@container w-full bg-neutral-primary transition-colors duration-200 z-sticky',
14070
+ ];
12282
14071
  if (this.sticky()) {
12283
14072
  classes.push('sticky top-0');
12284
14073
  }
12285
14074
  if (this.bordered()) {
12286
14075
  classes.push('border-b border-default');
12287
14076
  }
14077
+ // Neo cho menu ☰ nổi (absolute). `sticky` đã là phần tử định vị.
14078
+ if (!this.sticky()) {
14079
+ classes.push('relative');
14080
+ }
12288
14081
  return classes.join(' ');
12289
14082
  }, /* @ts-ignore */
12290
14083
  ...(ngDevMode ? [{ debugName: "navbarClasses" }] : /* istanbul ignore next */ []));
12291
14084
  containerClasses = computed(() => {
12292
- return this.fluid() ? 'w-full px-4 sm:px-6' : 'max-w-7xl mx-auto px-4 sm:px-6 lg:px-8';
14085
+ return this.fluid()
14086
+ ? 'w-full px-4 @[40rem]:px-6'
14087
+ : 'max-w-7xl mx-auto px-4 @[40rem]:px-6 @5xl:px-8';
12293
14088
  }, /* @ts-ignore */
12294
14089
  ...(ngDevMode ? [{ debugName: "containerClasses" }] : /* istanbul ignore next */ []));
12295
14090
  toggleMobileMenu() {
14091
+ this.detectMobileSlots();
12296
14092
  this.isCollapsed.update((v) => !v);
12297
14093
  this.onToggleMobileMenu.emit(!this.isCollapsed());
12298
14094
  }
@@ -12316,12 +14112,12 @@ class OlNavbar {
12316
14112
  }
12317
14113
  }
12318
14114
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: OlNavbar, deps: [], target: i0.ɵɵFactoryTarget.Component });
12319
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.6", type: OlNavbar, isStandalone: true, selector: "ol-navbar", inputs: { brandLogo: { classPropertyName: "brandLogo", publicName: "brandLogo", isSignal: true, isRequired: false, transformFunction: null }, brandName: { classPropertyName: "brandName", publicName: "brandName", isSignal: true, isRequired: false, transformFunction: null }, sticky: { classPropertyName: "sticky", publicName: "sticky", isSignal: true, isRequired: false, transformFunction: null }, bordered: { classPropertyName: "bordered", publicName: "bordered", isSignal: true, isRequired: false, transformFunction: null }, fluid: { classPropertyName: "fluid", publicName: "fluid", isSignal: true, isRequired: false, transformFunction: null }, showSearch: { classPropertyName: "showSearch", publicName: "showSearch", isSignal: true, isRequired: false, transformFunction: null }, searchPlaceholder: { classPropertyName: "searchPlaceholder", publicName: "searchPlaceholder", isSignal: true, isRequired: false, transformFunction: null }, isCollapsed: { classPropertyName: "isCollapsed", publicName: "isCollapsed", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { isCollapsed: "isCollapsedChange", onSearch: "onSearch", onToggleMobileMenu: "onToggleMobileMenu" }, host: { listeners: { "document:keydown": "handleGlobalKeydown($event)" } }, ngImport: i0, template: "<nav [class]=\"navbarClasses()\" role=\"navigation\" aria-label=\"Thanh \u0111i\u1EC1u h\u01B0\u1EDBng ch\u00EDnh\">\n <div [class]=\"containerClasses()\">\n <div class=\"flex items-center justify-between h-16 gap-3 sm:gap-4 min-w-0 w-full\">\n <!-- Left: Brand / Logo & Desktop Links -->\n <div class=\"flex items-center gap-3 xl:gap-6 min-w-0\">\n <ng-content select=\"[slot=brand], [brand]\">\n <!--\n Logo v\u00E0 t\u00EAn l\u00E0 HAI th\u1EE9 \u0111\u1ED9c l\u1EADp: c\u00F3 th\u1EC3 ch\u1EC9 c\u00F3 t\u00EAn, ch\u1EC9 c\u00F3 logo, ho\u1EB7c\n c\u1EA3 hai. B\u1EA3n tr\u01B0\u1EDBc l\u1ED3ng t\u00EAn v\u00E0o trong nh\u00E1nh @if (brandLogo()) n\u00EAn\n truy\u1EC1n t\u00EAn m\u00E0 kh\u00F4ng truy\u1EC1n logo th\u00EC t\u00EAn bi\u1EBFn m\u1EA5t im l\u1EB7ng.\n -->\n @if (brandLogo() || brandName()) {\n <a\n href=\"/\"\n class=\"flex items-center gap-2.5 shrink-0 focus:outline-none focus:ring-2 focus:ring-brand-subtle rounded-lg whitespace-nowrap\"\n >\n @if (brandLogo()) {\n <img [src]=\"brandLogo()\" [alt]=\"brandName()\" class=\"h-9 w-auto\" />\n }\n @if (brandName()) {\n <span class=\"font-bold text-heading text-lg whitespace-nowrap\">{{\n brandName()\n }}</span>\n }\n </a>\n }\n </ng-content>\n\n <!-- Desktop Navigation Items -->\n <div class=\"hidden xl:flex items-center gap-1 min-w-0 whitespace-nowrap\">\n <ng-content select=\"[slot=navigation], [navigation], ol-navbar-item, ol-nav-dropdown\" />\n </div>\n </div>\n\n <!-- Center: Global Search Input -->\n @if (showSearch()) {\n <div class=\"hidden xl:flex flex-1 max-w-50 2xl:max-w-xs mx-2 lg:mx-4 min-w-0\">\n <div class=\"relative w-full\">\n <div\n class=\"absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none text-body-subtle\"\n >\n <ol-icon name=\"search\" size=\"sm\"></ol-icon>\n </div>\n <input\n type=\"text\"\n [value]=\"searchQuery\"\n (input)=\"handleSearchInput($event)\"\n (keydown.enter)=\"handleSearchSubmit()\"\n [placeholder]=\"searchPlaceholder()\"\n class=\"w-full pl-9 pr-14 py-2 text-sm bg-neutral-tertiary text-heading rounded-md border border-default focus:outline-none focus:ring-2 focus:ring-brand-subtle/20 focus:border-brand transition\"\n />\n <div class=\"absolute inset-y-0 right-0 flex items-center pr-2.5 pointer-events-none\">\n <kbd\n class=\"px-1.5 py-0.5 text-2xs font-semibold text-heading bg-neutral-tertiary-medium rounded border border-default\"\n >Ctrl K</kbd\n >\n </div>\n </div>\n </div>\n }\n\n <!-- Right: Actions Slot & Mobile Hamburger Button -->\n <div class=\"flex items-center gap-2 sm:gap-3 shrink-0 whitespace-nowrap\">\n <ng-content select=\"[slot=actions], [actions]\" />\n\n <!-- Mobile Menu Toggle Button (Visible below xl) -->\n <button\n type=\"button\"\n (click)=\"toggleMobileMenu()\"\n class=\"xl:hidden p-2 rounded-md text-body-subtle hover:text-heading hover:bg-neutral-secondary-medium focus:outline-none focus:ring-2 focus:ring-brand-subtle cursor-pointer\"\n [attr.aria-expanded]=\"!isCollapsed()\"\n aria-label=\"\u0110\u00F3ng/M\u1EDF menu\"\n >\n @if (isCollapsed()) {\n <ol-icon name=\"menu\" size=\"base\"></ol-icon>\n } @else {\n <ol-icon name=\"close\" size=\"base\"></ol-icon>\n }\n </button>\n </div>\n </div>\n </div>\n\n <!-- Mobile Drawer / Dropdown Panel matching Figma 3279:21067 / #1149:75488 -->\n @if (!isCollapsed()) {\n <div\n class=\"xl:hidden border-t border-default bg-neutral-primary px-4 pt-4 pb-6 space-y-4 animate-in fade-in slide-in-from-top-2 duration-150 shadow-xl\"\n >\n <!-- Mobile User Profile Slot -->\n <ng-content select=\"[slot=mobile-user]\" />\n\n <!-- Mobile Search Input -->\n @if (showSearch()) {\n <div class=\"relative w-full md:hidden\">\n <div\n class=\"absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none text-body-subtle\"\n >\n <ol-icon name=\"search\" size=\"sm\"></ol-icon>\n </div>\n <input\n type=\"text\"\n [value]=\"searchQuery\"\n (input)=\"handleSearchInput($event)\"\n [placeholder]=\"searchPlaceholder()\"\n class=\"w-full pl-9 pr-3 py-2.5 text-sm bg-neutral-tertiary text-heading rounded-md border border-default focus:outline-none focus:ring-2 focus:ring-brand-subtle\"\n />\n </div>\n }\n\n <!-- Mobile Links Slot -->\n <ng-content select=\"[slot=mobile], [slot=mobile-links]\" />\n\n <!-- Mobile Actions Slot -->\n <ng-content select=\"[slot=mobile-actions]\" />\n\n <!-- Mobile Footer Contact Info Slot -->\n <ng-content select=\"[slot=mobile-footer]\" />\n </div>\n }\n</nav>\n", dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: FormsModule }, { kind: "component", type: OlIcon, selector: "ol-icon", inputs: ["name", "size", "color", "variant", "strokeWidth"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
14115
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.6", type: OlNavbar, isStandalone: true, selector: "ol-navbar", inputs: { brandLogo: { classPropertyName: "brandLogo", publicName: "brandLogo", isSignal: true, isRequired: false, transformFunction: null }, brandName: { classPropertyName: "brandName", publicName: "brandName", isSignal: true, isRequired: false, transformFunction: null }, sticky: { classPropertyName: "sticky", publicName: "sticky", isSignal: true, isRequired: false, transformFunction: null }, bordered: { classPropertyName: "bordered", publicName: "bordered", isSignal: true, isRequired: false, transformFunction: null }, fluid: { classPropertyName: "fluid", publicName: "fluid", isSignal: true, isRequired: false, transformFunction: null }, showSearch: { classPropertyName: "showSearch", publicName: "showSearch", isSignal: true, isRequired: false, transformFunction: null }, searchPlaceholder: { classPropertyName: "searchPlaceholder", publicName: "searchPlaceholder", isSignal: true, isRequired: false, transformFunction: null }, isCollapsed: { classPropertyName: "isCollapsed", publicName: "isCollapsed", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { isCollapsed: "isCollapsedChange", onSearch: "onSearch", onToggleMobileMenu: "onToggleMobileMenu" }, host: { listeners: { "document:keydown": "handleGlobalKeydown($event)" }, classAttribute: "block w-full min-w-0" }, viewQueries: [{ propertyName: "mobilePanel", first: true, predicate: ["mobilePanel"], descendants: true, isSignal: true }, { propertyName: "navWrap", first: true, predicate: ["navWrap"], descendants: true, isSignal: true }], ngImport: i0, template: "<nav [class]=\"navbarClasses()\" role=\"navigation\" aria-label=\"Thanh \u0111i\u1EC1u h\u01B0\u1EDBng ch\u00EDnh\">\n <div [class]=\"containerClasses()\">\n <!--\n Khung h\u1EB9p: l\u01B0\u1EDBi hai c\u1ED9t (logo | n\u00FAt), kh\u1ED1i m\u1EE5c menu khi \u2630 m\u1EDF chi\u1EBFm tr\u1ECDn\n h\u00E0ng th\u1EE9 hai (xem STACKED). Kh\u00F4ng d\u00F9ng `flex-wrap`: n\u00F3 th\u1EA3 nh\u00F3m n\u00FAt\n xu\u1ED1ng d\u00F2ng d\u01B0\u1EDBi logo khi hai th\u1EE9 c\u1ED9ng l\u1EA1i r\u1ED9ng h\u01A1n khung. Khung r\u1ED9ng: flex.\n -->\n <div\n class=\"grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 @[40rem]:gap-x-4 min-h-16 min-w-0 w-full @[78rem]:flex\"\n >\n <!-- Left: Brand / Logo -->\n <div class=\"flex h-16 items-center min-w-0\">\n <ng-content select=\"[slot=brand], [brand]\">\n <!--\n Logo v\u00E0 t\u00EAn l\u00E0 HAI th\u1EE9 \u0111\u1ED9c l\u1EADp: c\u00F3 th\u1EC3 ch\u1EC9 c\u00F3 t\u00EAn, ch\u1EC9 c\u00F3 logo, ho\u1EB7c\n c\u1EA3 hai. B\u1EA3n tr\u01B0\u1EDBc l\u1ED3ng t\u00EAn v\u00E0o trong nh\u00E1nh @if (brandLogo()) n\u00EAn\n truy\u1EC1n t\u00EAn m\u00E0 kh\u00F4ng truy\u1EC1n logo th\u00EC t\u00EAn bi\u1EBFn m\u1EA5t im l\u1EB7ng.\n -->\n @if (brandLogo() || brandName()) {\n <a\n href=\"/\"\n class=\"flex items-center gap-2.5 shrink-0 focus:outline-none focus:ring-2 focus:ring-brand-subtle rounded-lg whitespace-nowrap\"\n >\n @if (brandLogo()) {\n <img [src]=\"brandLogo()\" [alt]=\"brandName()\" class=\"h-9 w-auto\" />\n }\n @if (brandName()) {\n <span class=\"font-bold text-heading text-lg whitespace-nowrap\">{{\n brandName()\n }}</span>\n }\n </a>\n }\n </ng-content>\n </div>\n\n <!--\n M\u1EE5c menu \u2014 M\u1ED8T ch\u1ED7 chi\u1EBFu duy nh\u1EA5t, d\u00F9ng cho c\u1EA3 hai b\u1ED1 c\u1EE5c (Angular ch\u1EC9\n chi\u1EBFu m\u1ED9t n\u1ED9i dung v\u00E0o m\u1ED9t ch\u1ED7). Khung r\u1ED9ng: n\u1EB1m ngang c\u1EA1nh logo. Khung\n h\u1EB9p: \u1EA9n, tr\u1EEB khi \u2630 \u0111ang m\u1EDF V\u00C0 n\u01A1i g\u1ECDi kh\u00F4ng khai menu di \u0111\u1ED9ng ri\u00EAng \u2014\n khi \u0111\u00F3 x\u1EBFp d\u1ECDc, N\u1ED4I d\u01B0\u1EDBi thanh (\u0111\u00E8 n\u1ED9i dung). Xem navWrapClasses().\n -->\n <div #navWrap [class]=\"navWrapClasses()\">\n <ng-content select=\"[slot=navigation], [navigation], ol-navbar-item, ol-nav-dropdown\" />\n </div>\n\n <!-- Center: Global Search Input -->\n @if (showSearch()) {\n <div class=\"hidden @[78rem]:flex flex-1 max-w-50 @[96rem]:max-w-xs mx-2 @5xl:mx-4 min-w-0\">\n <div class=\"relative w-full\">\n <div\n class=\"absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none text-body-subtle\"\n >\n <ol-icon name=\"search\" size=\"sm\"></ol-icon>\n </div>\n <input\n type=\"text\"\n [value]=\"searchQuery\"\n (input)=\"handleSearchInput($event)\"\n (keydown.enter)=\"handleSearchSubmit()\"\n [placeholder]=\"searchPlaceholder()\"\n class=\"w-full pl-9 pr-14 py-2 text-sm bg-neutral-tertiary text-heading rounded-md border border-default focus:outline-none focus:ring-2 focus:ring-brand-subtle/20 focus:border-brand transition\"\n />\n <div class=\"absolute inset-y-0 right-0 flex items-center pr-2.5 pointer-events-none\">\n <kbd\n class=\"px-1.5 py-0.5 text-2xs font-semibold text-heading bg-neutral-tertiary-medium rounded border border-default\"\n >Ctrl K</kbd\n >\n </div>\n </div>\n </div>\n }\n\n <!-- Right: Actions Slot & Mobile Hamburger Button \u2014 ml-auto thay justify-between. -->\n <div class=\"ml-auto flex h-16 items-center gap-2 @[40rem]:gap-3 shrink-0 whitespace-nowrap\">\n <ng-content select=\"[slot=actions], [actions]\" />\n\n <!-- Mobile Menu Toggle Button (Visible below xl) -->\n <button\n type=\"button\"\n (click)=\"toggleMobileMenu()\"\n class=\"@[78rem]:hidden p-2 rounded-md text-body-subtle hover:text-heading hover:bg-neutral-secondary-medium focus:outline-none focus:ring-2 focus:ring-brand-subtle cursor-pointer\"\n [attr.aria-expanded]=\"!isCollapsed()\"\n aria-label=\"\u0110\u00F3ng/M\u1EDF menu\"\n >\n @if (isCollapsed()) {\n <ol-icon name=\"menu\" size=\"base\"></ol-icon>\n } @else {\n <ol-icon name=\"close\" size=\"base\"></ol-icon>\n }\n </button>\n </div>\n </div>\n </div>\n\n <!-- Mobile Drawer / Dropdown Panel matching Figma 3279:21067 / #1149:75488 -->\n <!--\n Lu\u00F4n D\u1EF0NG, \u1EA9n b\u1EB1ng `hidden` khi \u0111\u00F3ng \u2014 kh\u00F4ng b\u1ECDc trong @if. N\u1ED9i dung chi\u1EBFu\n v\u00E0o nh\u00E1nh @if \u0111ang t\u1EAFt kh\u00F4ng n\u1EB1m trong DOM, n\u00EAn kh\u00F4ng ph\u00E1t hi\u1EC7n \u0111\u01B0\u1EE3c n\u01A1i g\u1ECDi\n c\u00F3 khai `slot=\"mobile-links\"` hay kh\u00F4ng (xem detectMobileSlots()).\n -->\n <div #mobilePanel [class]=\"mobilePanelClasses()\">\n <!-- Mobile User Profile Slot -->\n <ng-content select=\"[slot=mobile-user]\" />\n\n <!-- Mobile Search Input -->\n @if (showSearch()) {\n <div class=\"relative w-full @3xl:hidden\">\n <div\n class=\"absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none text-body-subtle\"\n >\n <ol-icon name=\"search\" size=\"sm\"></ol-icon>\n </div>\n <input\n type=\"text\"\n [value]=\"searchQuery\"\n (input)=\"handleSearchInput($event)\"\n [placeholder]=\"searchPlaceholder()\"\n class=\"w-full pl-9 pr-3 py-2.5 text-sm bg-neutral-tertiary text-heading rounded-md border border-default focus:outline-none focus:ring-2 focus:ring-brand-subtle\"\n />\n </div>\n }\n\n <!-- Mobile Links Slot -->\n <ng-content select=\"[slot=mobile], [slot=mobile-links]\" />\n\n <!-- Mobile Actions Slot -->\n <ng-content select=\"[slot=mobile-actions]\" />\n\n <!-- Mobile Footer Contact Info Slot -->\n <ng-content select=\"[slot=mobile-footer]\" />\n </div>\n</nav>\n", dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: FormsModule }, { kind: "component", type: OlIcon, selector: "ol-icon", inputs: ["name", "size", "color", "variant", "strokeWidth"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
12320
14116
  }
12321
14117
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: OlNavbar, decorators: [{
12322
14118
  type: Component,
12323
- args: [{ selector: 'ol-navbar', standalone: true, imports: [CommonModule, FormsModule, OlIcon], changeDetection: ChangeDetectionStrategy.OnPush, template: "<nav [class]=\"navbarClasses()\" role=\"navigation\" aria-label=\"Thanh \u0111i\u1EC1u h\u01B0\u1EDBng ch\u00EDnh\">\n <div [class]=\"containerClasses()\">\n <div class=\"flex items-center justify-between h-16 gap-3 sm:gap-4 min-w-0 w-full\">\n <!-- Left: Brand / Logo & Desktop Links -->\n <div class=\"flex items-center gap-3 xl:gap-6 min-w-0\">\n <ng-content select=\"[slot=brand], [brand]\">\n <!--\n Logo v\u00E0 t\u00EAn l\u00E0 HAI th\u1EE9 \u0111\u1ED9c l\u1EADp: c\u00F3 th\u1EC3 ch\u1EC9 c\u00F3 t\u00EAn, ch\u1EC9 c\u00F3 logo, ho\u1EB7c\n c\u1EA3 hai. B\u1EA3n tr\u01B0\u1EDBc l\u1ED3ng t\u00EAn v\u00E0o trong nh\u00E1nh @if (brandLogo()) n\u00EAn\n truy\u1EC1n t\u00EAn m\u00E0 kh\u00F4ng truy\u1EC1n logo th\u00EC t\u00EAn bi\u1EBFn m\u1EA5t im l\u1EB7ng.\n -->\n @if (brandLogo() || brandName()) {\n <a\n href=\"/\"\n class=\"flex items-center gap-2.5 shrink-0 focus:outline-none focus:ring-2 focus:ring-brand-subtle rounded-lg whitespace-nowrap\"\n >\n @if (brandLogo()) {\n <img [src]=\"brandLogo()\" [alt]=\"brandName()\" class=\"h-9 w-auto\" />\n }\n @if (brandName()) {\n <span class=\"font-bold text-heading text-lg whitespace-nowrap\">{{\n brandName()\n }}</span>\n }\n </a>\n }\n </ng-content>\n\n <!-- Desktop Navigation Items -->\n <div class=\"hidden xl:flex items-center gap-1 min-w-0 whitespace-nowrap\">\n <ng-content select=\"[slot=navigation], [navigation], ol-navbar-item, ol-nav-dropdown\" />\n </div>\n </div>\n\n <!-- Center: Global Search Input -->\n @if (showSearch()) {\n <div class=\"hidden xl:flex flex-1 max-w-50 2xl:max-w-xs mx-2 lg:mx-4 min-w-0\">\n <div class=\"relative w-full\">\n <div\n class=\"absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none text-body-subtle\"\n >\n <ol-icon name=\"search\" size=\"sm\"></ol-icon>\n </div>\n <input\n type=\"text\"\n [value]=\"searchQuery\"\n (input)=\"handleSearchInput($event)\"\n (keydown.enter)=\"handleSearchSubmit()\"\n [placeholder]=\"searchPlaceholder()\"\n class=\"w-full pl-9 pr-14 py-2 text-sm bg-neutral-tertiary text-heading rounded-md border border-default focus:outline-none focus:ring-2 focus:ring-brand-subtle/20 focus:border-brand transition\"\n />\n <div class=\"absolute inset-y-0 right-0 flex items-center pr-2.5 pointer-events-none\">\n <kbd\n class=\"px-1.5 py-0.5 text-2xs font-semibold text-heading bg-neutral-tertiary-medium rounded border border-default\"\n >Ctrl K</kbd\n >\n </div>\n </div>\n </div>\n }\n\n <!-- Right: Actions Slot & Mobile Hamburger Button -->\n <div class=\"flex items-center gap-2 sm:gap-3 shrink-0 whitespace-nowrap\">\n <ng-content select=\"[slot=actions], [actions]\" />\n\n <!-- Mobile Menu Toggle Button (Visible below xl) -->\n <button\n type=\"button\"\n (click)=\"toggleMobileMenu()\"\n class=\"xl:hidden p-2 rounded-md text-body-subtle hover:text-heading hover:bg-neutral-secondary-medium focus:outline-none focus:ring-2 focus:ring-brand-subtle cursor-pointer\"\n [attr.aria-expanded]=\"!isCollapsed()\"\n aria-label=\"\u0110\u00F3ng/M\u1EDF menu\"\n >\n @if (isCollapsed()) {\n <ol-icon name=\"menu\" size=\"base\"></ol-icon>\n } @else {\n <ol-icon name=\"close\" size=\"base\"></ol-icon>\n }\n </button>\n </div>\n </div>\n </div>\n\n <!-- Mobile Drawer / Dropdown Panel matching Figma 3279:21067 / #1149:75488 -->\n @if (!isCollapsed()) {\n <div\n class=\"xl:hidden border-t border-default bg-neutral-primary px-4 pt-4 pb-6 space-y-4 animate-in fade-in slide-in-from-top-2 duration-150 shadow-xl\"\n >\n <!-- Mobile User Profile Slot -->\n <ng-content select=\"[slot=mobile-user]\" />\n\n <!-- Mobile Search Input -->\n @if (showSearch()) {\n <div class=\"relative w-full md:hidden\">\n <div\n class=\"absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none text-body-subtle\"\n >\n <ol-icon name=\"search\" size=\"sm\"></ol-icon>\n </div>\n <input\n type=\"text\"\n [value]=\"searchQuery\"\n (input)=\"handleSearchInput($event)\"\n [placeholder]=\"searchPlaceholder()\"\n class=\"w-full pl-9 pr-3 py-2.5 text-sm bg-neutral-tertiary text-heading rounded-md border border-default focus:outline-none focus:ring-2 focus:ring-brand-subtle\"\n />\n </div>\n }\n\n <!-- Mobile Links Slot -->\n <ng-content select=\"[slot=mobile], [slot=mobile-links]\" />\n\n <!-- Mobile Actions Slot -->\n <ng-content select=\"[slot=mobile-actions]\" />\n\n <!-- Mobile Footer Contact Info Slot -->\n <ng-content select=\"[slot=mobile-footer]\" />\n </div>\n }\n</nav>\n" }]
12324
- }], propDecorators: { brandLogo: [{ type: i0.Input, args: [{ isSignal: true, alias: "brandLogo", required: false }] }], brandName: [{ type: i0.Input, args: [{ isSignal: true, alias: "brandName", required: false }] }], sticky: [{ type: i0.Input, args: [{ isSignal: true, alias: "sticky", required: false }] }], bordered: [{ type: i0.Input, args: [{ isSignal: true, alias: "bordered", required: false }] }], fluid: [{ type: i0.Input, args: [{ isSignal: true, alias: "fluid", required: false }] }], showSearch: [{ type: i0.Input, args: [{ isSignal: true, alias: "showSearch", required: false }] }], searchPlaceholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "searchPlaceholder", required: false }] }], isCollapsed: [{ type: i0.Input, args: [{ isSignal: true, alias: "isCollapsed", required: false }] }, { type: i0.Output, args: ["isCollapsedChange"] }], onSearch: [{ type: i0.Output, args: ["onSearch"] }], onToggleMobileMenu: [{ type: i0.Output, args: ["onToggleMobileMenu"] }], handleGlobalKeydown: [{
14119
+ args: [{ selector: 'ol-navbar', standalone: true, imports: [CommonModule, FormsModule, OlIcon], changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'block w-full min-w-0' }, template: "<nav [class]=\"navbarClasses()\" role=\"navigation\" aria-label=\"Thanh \u0111i\u1EC1u h\u01B0\u1EDBng ch\u00EDnh\">\n <div [class]=\"containerClasses()\">\n <!--\n Khung h\u1EB9p: l\u01B0\u1EDBi hai c\u1ED9t (logo | n\u00FAt), kh\u1ED1i m\u1EE5c menu khi \u2630 m\u1EDF chi\u1EBFm tr\u1ECDn\n h\u00E0ng th\u1EE9 hai (xem STACKED). Kh\u00F4ng d\u00F9ng `flex-wrap`: n\u00F3 th\u1EA3 nh\u00F3m n\u00FAt\n xu\u1ED1ng d\u00F2ng d\u01B0\u1EDBi logo khi hai th\u1EE9 c\u1ED9ng l\u1EA1i r\u1ED9ng h\u01A1n khung. Khung r\u1ED9ng: flex.\n -->\n <div\n class=\"grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 @[40rem]:gap-x-4 min-h-16 min-w-0 w-full @[78rem]:flex\"\n >\n <!-- Left: Brand / Logo -->\n <div class=\"flex h-16 items-center min-w-0\">\n <ng-content select=\"[slot=brand], [brand]\">\n <!--\n Logo v\u00E0 t\u00EAn l\u00E0 HAI th\u1EE9 \u0111\u1ED9c l\u1EADp: c\u00F3 th\u1EC3 ch\u1EC9 c\u00F3 t\u00EAn, ch\u1EC9 c\u00F3 logo, ho\u1EB7c\n c\u1EA3 hai. B\u1EA3n tr\u01B0\u1EDBc l\u1ED3ng t\u00EAn v\u00E0o trong nh\u00E1nh @if (brandLogo()) n\u00EAn\n truy\u1EC1n t\u00EAn m\u00E0 kh\u00F4ng truy\u1EC1n logo th\u00EC t\u00EAn bi\u1EBFn m\u1EA5t im l\u1EB7ng.\n -->\n @if (brandLogo() || brandName()) {\n <a\n href=\"/\"\n class=\"flex items-center gap-2.5 shrink-0 focus:outline-none focus:ring-2 focus:ring-brand-subtle rounded-lg whitespace-nowrap\"\n >\n @if (brandLogo()) {\n <img [src]=\"brandLogo()\" [alt]=\"brandName()\" class=\"h-9 w-auto\" />\n }\n @if (brandName()) {\n <span class=\"font-bold text-heading text-lg whitespace-nowrap\">{{\n brandName()\n }}</span>\n }\n </a>\n }\n </ng-content>\n </div>\n\n <!--\n M\u1EE5c menu \u2014 M\u1ED8T ch\u1ED7 chi\u1EBFu duy nh\u1EA5t, d\u00F9ng cho c\u1EA3 hai b\u1ED1 c\u1EE5c (Angular ch\u1EC9\n chi\u1EBFu m\u1ED9t n\u1ED9i dung v\u00E0o m\u1ED9t ch\u1ED7). Khung r\u1ED9ng: n\u1EB1m ngang c\u1EA1nh logo. Khung\n h\u1EB9p: \u1EA9n, tr\u1EEB khi \u2630 \u0111ang m\u1EDF V\u00C0 n\u01A1i g\u1ECDi kh\u00F4ng khai menu di \u0111\u1ED9ng ri\u00EAng \u2014\n khi \u0111\u00F3 x\u1EBFp d\u1ECDc, N\u1ED4I d\u01B0\u1EDBi thanh (\u0111\u00E8 n\u1ED9i dung). Xem navWrapClasses().\n -->\n <div #navWrap [class]=\"navWrapClasses()\">\n <ng-content select=\"[slot=navigation], [navigation], ol-navbar-item, ol-nav-dropdown\" />\n </div>\n\n <!-- Center: Global Search Input -->\n @if (showSearch()) {\n <div class=\"hidden @[78rem]:flex flex-1 max-w-50 @[96rem]:max-w-xs mx-2 @5xl:mx-4 min-w-0\">\n <div class=\"relative w-full\">\n <div\n class=\"absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none text-body-subtle\"\n >\n <ol-icon name=\"search\" size=\"sm\"></ol-icon>\n </div>\n <input\n type=\"text\"\n [value]=\"searchQuery\"\n (input)=\"handleSearchInput($event)\"\n (keydown.enter)=\"handleSearchSubmit()\"\n [placeholder]=\"searchPlaceholder()\"\n class=\"w-full pl-9 pr-14 py-2 text-sm bg-neutral-tertiary text-heading rounded-md border border-default focus:outline-none focus:ring-2 focus:ring-brand-subtle/20 focus:border-brand transition\"\n />\n <div class=\"absolute inset-y-0 right-0 flex items-center pr-2.5 pointer-events-none\">\n <kbd\n class=\"px-1.5 py-0.5 text-2xs font-semibold text-heading bg-neutral-tertiary-medium rounded border border-default\"\n >Ctrl K</kbd\n >\n </div>\n </div>\n </div>\n }\n\n <!-- Right: Actions Slot & Mobile Hamburger Button \u2014 ml-auto thay justify-between. -->\n <div class=\"ml-auto flex h-16 items-center gap-2 @[40rem]:gap-3 shrink-0 whitespace-nowrap\">\n <ng-content select=\"[slot=actions], [actions]\" />\n\n <!-- Mobile Menu Toggle Button (Visible below xl) -->\n <button\n type=\"button\"\n (click)=\"toggleMobileMenu()\"\n class=\"@[78rem]:hidden p-2 rounded-md text-body-subtle hover:text-heading hover:bg-neutral-secondary-medium focus:outline-none focus:ring-2 focus:ring-brand-subtle cursor-pointer\"\n [attr.aria-expanded]=\"!isCollapsed()\"\n aria-label=\"\u0110\u00F3ng/M\u1EDF menu\"\n >\n @if (isCollapsed()) {\n <ol-icon name=\"menu\" size=\"base\"></ol-icon>\n } @else {\n <ol-icon name=\"close\" size=\"base\"></ol-icon>\n }\n </button>\n </div>\n </div>\n </div>\n\n <!-- Mobile Drawer / Dropdown Panel matching Figma 3279:21067 / #1149:75488 -->\n <!--\n Lu\u00F4n D\u1EF0NG, \u1EA9n b\u1EB1ng `hidden` khi \u0111\u00F3ng \u2014 kh\u00F4ng b\u1ECDc trong @if. N\u1ED9i dung chi\u1EBFu\n v\u00E0o nh\u00E1nh @if \u0111ang t\u1EAFt kh\u00F4ng n\u1EB1m trong DOM, n\u00EAn kh\u00F4ng ph\u00E1t hi\u1EC7n \u0111\u01B0\u1EE3c n\u01A1i g\u1ECDi\n c\u00F3 khai `slot=\"mobile-links\"` hay kh\u00F4ng (xem detectMobileSlots()).\n -->\n <div #mobilePanel [class]=\"mobilePanelClasses()\">\n <!-- Mobile User Profile Slot -->\n <ng-content select=\"[slot=mobile-user]\" />\n\n <!-- Mobile Search Input -->\n @if (showSearch()) {\n <div class=\"relative w-full @3xl:hidden\">\n <div\n class=\"absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none text-body-subtle\"\n >\n <ol-icon name=\"search\" size=\"sm\"></ol-icon>\n </div>\n <input\n type=\"text\"\n [value]=\"searchQuery\"\n (input)=\"handleSearchInput($event)\"\n [placeholder]=\"searchPlaceholder()\"\n class=\"w-full pl-9 pr-3 py-2.5 text-sm bg-neutral-tertiary text-heading rounded-md border border-default focus:outline-none focus:ring-2 focus:ring-brand-subtle\"\n />\n </div>\n }\n\n <!-- Mobile Links Slot -->\n <ng-content select=\"[slot=mobile], [slot=mobile-links]\" />\n\n <!-- Mobile Actions Slot -->\n <ng-content select=\"[slot=mobile-actions]\" />\n\n <!-- Mobile Footer Contact Info Slot -->\n <ng-content select=\"[slot=mobile-footer]\" />\n </div>\n</nav>\n" }]
14120
+ }], ctorParameters: () => [], propDecorators: { brandLogo: [{ type: i0.Input, args: [{ isSignal: true, alias: "brandLogo", required: false }] }], brandName: [{ type: i0.Input, args: [{ isSignal: true, alias: "brandName", required: false }] }], sticky: [{ type: i0.Input, args: [{ isSignal: true, alias: "sticky", required: false }] }], bordered: [{ type: i0.Input, args: [{ isSignal: true, alias: "bordered", required: false }] }], fluid: [{ type: i0.Input, args: [{ isSignal: true, alias: "fluid", required: false }] }], showSearch: [{ type: i0.Input, args: [{ isSignal: true, alias: "showSearch", required: false }] }], searchPlaceholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "searchPlaceholder", required: false }] }], isCollapsed: [{ type: i0.Input, args: [{ isSignal: true, alias: "isCollapsed", required: false }] }, { type: i0.Output, args: ["isCollapsedChange"] }], onSearch: [{ type: i0.Output, args: ["onSearch"] }], onToggleMobileMenu: [{ type: i0.Output, args: ["onToggleMobileMenu"] }], mobilePanel: [{ type: i0.ViewChild, args: ['mobilePanel', { isSignal: true }] }], navWrap: [{ type: i0.ViewChild, args: ['navWrap', { isSignal: true }] }], handleGlobalKeydown: [{
12325
14121
  type: HostListener,
12326
14122
  args: ['document:keydown', ['$event']]
12327
14123
  }] } });
@@ -12385,11 +14181,11 @@ class OlNavbarItem {
12385
14181
  }, /* @ts-ignore */
12386
14182
  ...(ngDevMode ? [{ debugName: "itemClasses" }] : /* istanbul ignore next */ []));
12387
14183
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: OlNavbarItem, deps: [], target: i0.ɵɵFactoryTarget.Component });
12388
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.6", type: OlNavbarItem, isStandalone: true, selector: "ol-navbar-item", inputs: { routerLink: { classPropertyName: "routerLink", publicName: "routerLink", isSignal: true, isRequired: false, transformFunction: null }, routerLinkActive: { classPropertyName: "routerLinkActive", publicName: "routerLinkActive", isSignal: true, isRequired: false, transformFunction: null }, routerLinkActiveOptions: { classPropertyName: "routerLinkActiveOptions", publicName: "routerLinkActiveOptions", isSignal: true, isRequired: false, transformFunction: null }, href: { classPropertyName: "href", publicName: "href", isSignal: true, isRequired: false, transformFunction: null }, icon: { classPropertyName: "icon", publicName: "icon", isSignal: true, isRequired: false, transformFunction: null }, label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, active: { classPropertyName: "active", publicName: "active", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: "<!--\n BA NH\u00C1NH, `routerLinkActive` CH\u1EC8 \u1EDF nh\u00E1nh c\u00F3 `routerLink`.\n\n B\u1EA3n c\u0169 d\u00F9ng M\u1ED8T `<a>` mang c\u1EA3 `[routerLink]`, `[routerLinkActive]` v\u00E0\n `[class]=\"itemClasses()\"`. Hai th\u1EE9 sau c\u00F9ng ghi v\u00E0o thu\u1ED9c t\u00EDnh `class`, v\u00E0\n `RouterLinkActive` ghi SAU n\u00EAn n\u00F3 th\u1EAFng: chu\u1ED7i `'active \u2026'` m\u00E0 `itemClasses()`\n th\u00EAm khi `[active]=\"true\"` b\u1ECB nu\u1ED1t s\u1EA1ch.\n\n T\u00E1ch nh\u00E1nh th\u00F4i KH\u00D4NG \u0111\u1EE7 \u2014 nh\u00E1nh `routerLink` v\u1EABn c\u00F3 c\u1EA3 hai directive tr\u00EAn\n c\u00F9ng th\u1EBB. N\u00EAn `routerLinkActive` ch\u1EC9 g\u1EAFn khi `active()` T\u1EAET: \u0111\u00E3 \u00E9p active\n b\u1EB1ng input th\u00EC kh\u00F4ng c\u00F2n g\u00EC \u0111\u1EC3 directive t\u00EDnh, hai b\u00EAn h\u1EBFt tranh nhau.\n\n H\u1ECFng im l\u1EB7ng \u2014 n\u1EC1n v\u00E0 ch\u1EEF v\u1EABn \u0111\u00FAng (ch\u00FAng n\u1EB1m trong c\u00F9ng chu\u1ED7i), ch\u1EC9 ri\u00EAng\n ch\u1EEF `active` m\u1EA5t. H\u1EADu qu\u1EA3 ch\u1EC9 l\u1ED9 ra khi r\u00EA chu\u1ED9t: \u0111o /navbar 2026-09-24, m\u1EE5c\n \u0111ang m\u1EDF NH\u1EA0T \u0110I (#e3eaff -> #f5f8ff) v\u00EC `not-[.active]:hover:bg-brand-softer`\n v\u1EABn \u00E1p d\u1EE5ng, c\u00F2n `[&.active]:hover:bg-brand-medium` th\u00EC kh\u00F4ng. Sidebar c\u00F9ng\n b\u1ED9 class nh\u01B0ng \u0110\u1EACM L\u00CAN \u0111\u00FAng (#d2dcfa) v\u00EC n\u00F3 \u0111\u00E3 t\u00E1ch nh\u00E1nh t\u1EEB tr\u01B0\u1EDBc.\n\n Unit test KH\u00D4NG b\u1EAFt \u0111\u01B0\u1EE3c: `provideRouter([])` kh\u00F4ng c\u00F3 route n\u00E0o kh\u1EDBp n\u00EAn\n directive kh\u00F4ng ch\u1EA1m v\u00E0o `class`, test xanh trong khi tr\u00ECnh duy\u1EC7t h\u1ECFng.\n\n N\u1ED9i dung g\u00F3i trong `ng-template` v\u00E0 chi\u1EBFu l\u1EA1i b\u1EB1ng `ngTemplateOutlet`: khai\n nhi\u1EC1u `<ng-content>` kh\u00F4ng `select` \u2014 k\u1EC3 c\u1EA3 \u1EDF c\u00E1c nh\u00E1nh lo\u1EA1i tr\u1EEB nhau \u2014\n th\u00EC Angular ch\u1EC9 chi\u1EBFu v\u00E0o M\u1ED8T ch\u1ED7 v\u00E0 th\u1EF1c t\u1EBF m\u1EA5t \u1EDF m\u1ECDi nh\u00E1nh.\n-->\n<ng-template #itemContent>\n @if (icon()) {\n <ol-icon [name]=\"icon()!\" size=\"sm\" class=\"shrink-0\"></ol-icon>\n }\n @if (label()) {\n <span class=\"whitespace-nowrap\">{{ label() }}</span>\n }\n <ng-content />\n</ng-template>\n\n@if (routerLink()) {\n <a\n [routerLink]=\"routerLink()\"\n [routerLinkActive]=\"active() ? '' : routerLinkActive()\"\n [routerLinkActiveOptions]=\"routerLinkActiveOptions()\"\n [class]=\"itemClasses()\"\n [attr.aria-disabled]=\"disabled()\"\n >\n <ng-container [ngTemplateOutlet]=\"itemContent\" />\n </a>\n} @else if (href()) {\n <a [href]=\"href()\" [class]=\"itemClasses()\" [attr.aria-disabled]=\"disabled()\">\n <ng-container [ngTemplateOutlet]=\"itemContent\" />\n </a>\n} @else {\n <a [class]=\"itemClasses()\" [attr.aria-disabled]=\"disabled()\">\n <ng-container [ngTemplateOutlet]=\"itemContent\" />\n </a>\n}\n", dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "ngmodule", type: RouterModule }, { kind: "directive", type: i2.RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "browserUrl", "routerLink"] }, { kind: "directive", type: i2.RouterLinkActive, selector: "[routerLinkActive]", inputs: ["routerLinkActiveOptions", "ariaCurrentWhenActive", "routerLinkActive"], outputs: ["isActiveChange"], exportAs: ["routerLinkActive"] }, { kind: "component", type: OlIcon, selector: "ol-icon", inputs: ["name", "size", "color", "variant", "strokeWidth"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
14184
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.6", type: OlNavbarItem, isStandalone: true, selector: "ol-navbar-item", inputs: { routerLink: { classPropertyName: "routerLink", publicName: "routerLink", isSignal: true, isRequired: false, transformFunction: null }, routerLinkActive: { classPropertyName: "routerLinkActive", publicName: "routerLinkActive", isSignal: true, isRequired: false, transformFunction: null }, routerLinkActiveOptions: { classPropertyName: "routerLinkActiveOptions", publicName: "routerLinkActiveOptions", isSignal: true, isRequired: false, transformFunction: null }, href: { classPropertyName: "href", publicName: "href", isSignal: true, isRequired: false, transformFunction: null }, icon: { classPropertyName: "icon", publicName: "icon", isSignal: true, isRequired: false, transformFunction: null }, label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, active: { classPropertyName: "active", publicName: "active", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: "<!--\n BA NH\u00C1NH, `routerLinkActive` CH\u1EC8 \u1EDF nh\u00E1nh c\u00F3 `routerLink`.\n\n B\u1EA3n c\u0169 d\u00F9ng M\u1ED8T `<a>` mang c\u1EA3 `[routerLink]`, `[routerLinkActive]` v\u00E0\n `[class]=\"itemClasses()\"`. Hai th\u1EE9 sau c\u00F9ng ghi v\u00E0o thu\u1ED9c t\u00EDnh `class`, v\u00E0\n `RouterLinkActive` ghi SAU n\u00EAn n\u00F3 th\u1EAFng: chu\u1ED7i `'active \u2026'` m\u00E0 `itemClasses()`\n th\u00EAm khi `[active]=\"true\"` b\u1ECB nu\u1ED1t s\u1EA1ch.\n\n T\u00E1ch nh\u00E1nh th\u00F4i KH\u00D4NG \u0111\u1EE7 \u2014 nh\u00E1nh `routerLink` v\u1EABn c\u00F3 c\u1EA3 hai directive tr\u00EAn\n c\u00F9ng th\u1EBB. N\u00EAn `routerLinkActive` ch\u1EC9 g\u1EAFn khi `active()` T\u1EAET: \u0111\u00E3 \u00E9p active\n b\u1EB1ng input th\u00EC kh\u00F4ng c\u00F2n g\u00EC \u0111\u1EC3 directive t\u00EDnh, hai b\u00EAn h\u1EBFt tranh nhau.\n\n H\u1ECFng im l\u1EB7ng \u2014 n\u1EC1n v\u00E0 ch\u1EEF v\u1EABn \u0111\u00FAng (ch\u00FAng n\u1EB1m trong c\u00F9ng chu\u1ED7i), ch\u1EC9 ri\u00EAng\n ch\u1EEF `active` m\u1EA5t. H\u1EADu qu\u1EA3 ch\u1EC9 l\u1ED9 ra khi r\u00EA chu\u1ED9t: \u0111o /navbar 2026-09-24, m\u1EE5c\n \u0111ang m\u1EDF NH\u1EA0T \u0110I (#e3eaff -> #f5f8ff) v\u00EC `not-[.active]:hover:bg-brand-softer`\n v\u1EABn \u00E1p d\u1EE5ng, c\u00F2n `[&.active]:hover:bg-brand-medium` th\u00EC kh\u00F4ng. Sidebar c\u00F9ng\n b\u1ED9 class nh\u01B0ng \u0110\u1EACM L\u00CAN \u0111\u00FAng (#d2dcfa) v\u00EC n\u00F3 \u0111\u00E3 t\u00E1ch nh\u00E1nh t\u1EEB tr\u01B0\u1EDBc.\n\n Unit test KH\u00D4NG b\u1EAFt \u0111\u01B0\u1EE3c: `provideRouter([])` kh\u00F4ng c\u00F3 route n\u00E0o kh\u1EDBp n\u00EAn\n directive kh\u00F4ng ch\u1EA1m v\u00E0o `class`, test xanh trong khi tr\u00ECnh duy\u1EC7t h\u1ECFng.\n\n N\u1ED9i dung g\u00F3i trong `ng-template` v\u00E0 chi\u1EBFu l\u1EA1i b\u1EB1ng `ngTemplateOutlet`: khai\n nhi\u1EC1u `<ng-content>` kh\u00F4ng `select` \u2014 k\u1EC3 c\u1EA3 \u1EDF c\u00E1c nh\u00E1nh lo\u1EA1i tr\u1EEB nhau \u2014\n th\u00EC Angular ch\u1EC9 chi\u1EBFu v\u00E0o M\u1ED8T ch\u1ED7 v\u00E0 th\u1EF1c t\u1EBF m\u1EA5t \u1EDF m\u1ECDi nh\u00E1nh.\n-->\n<ng-template #itemContent>\n @if (icon()) {\n <ol-icon [name]=\"icon()!\" size=\"sm\" class=\"shrink-0\"></ol-icon>\n }\n @if (label()) {\n <span class=\"whitespace-nowrap\">{{ label() }}</span>\n }\n <ng-content />\n</ng-template>\n\n@if (routerLink()) {\n <a\n [routerLink]=\"routerLink()\"\n [routerLinkActive]=\"active() ? '' : routerLinkActive()\"\n [routerLinkActiveOptions]=\"routerLinkActiveOptions()\"\n [class]=\"itemClasses()\"\n [attr.aria-disabled]=\"disabled()\"\n >\n <ng-container [ngTemplateOutlet]=\"itemContent\" />\n </a>\n} @else if (href()) {\n <a [href]=\"href()\" [class]=\"itemClasses()\" [attr.aria-disabled]=\"disabled()\">\n <ng-container [ngTemplateOutlet]=\"itemContent\" />\n </a>\n} @else {\n <!--\n Kh\u00F4ng li\u00EAn k\u1EBFt th\u00EC l\u00E0 N\u00DAT, kh\u00F4ng ph\u1EA3i `<a>` tr\u1EA7n. `<a>` kh\u00F4ng `href` kh\u00F4ng\n nh\u1EADn Tab v\u00E0 Enter kh\u00F4ng k\u00EDch ho\u1EA1t \u0111\u01B0\u1EE3c: m\u1EE5c d\u00F9ng \u0111\u1EC3 \u0111\u1ED5i tab t\u1EA1i ch\u1ED7 (kh\u00F4ng\n \u0111i\u1EC1u h\u01B0\u1EDBng) th\u00EC b\u00E0n ph\u00EDm kh\u00F4ng b\u1EA5m \u0111\u01B0\u1EE3c (\u0111o /navbar 2026-09-28).\n -->\n <button\n type=\"button\"\n [class]=\"itemClasses()\"\n [disabled]=\"disabled()\"\n [attr.aria-current]=\"active() ? 'page' : null\"\n >\n <ng-container [ngTemplateOutlet]=\"itemContent\" />\n </button>\n}\n", dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "ngmodule", type: RouterModule }, { kind: "directive", type: i2.RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "browserUrl", "routerLink"] }, { kind: "directive", type: i2.RouterLinkActive, selector: "[routerLinkActive]", inputs: ["routerLinkActiveOptions", "ariaCurrentWhenActive", "routerLinkActive"], outputs: ["isActiveChange"], exportAs: ["routerLinkActive"] }, { kind: "component", type: OlIcon, selector: "ol-icon", inputs: ["name", "size", "color", "variant", "strokeWidth"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
12389
14185
  }
12390
14186
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: OlNavbarItem, decorators: [{
12391
14187
  type: Component,
12392
- args: [{ selector: 'ol-navbar-item', standalone: true, imports: [CommonModule, RouterModule, OlIcon], changeDetection: ChangeDetectionStrategy.OnPush, template: "<!--\n BA NH\u00C1NH, `routerLinkActive` CH\u1EC8 \u1EDF nh\u00E1nh c\u00F3 `routerLink`.\n\n B\u1EA3n c\u0169 d\u00F9ng M\u1ED8T `<a>` mang c\u1EA3 `[routerLink]`, `[routerLinkActive]` v\u00E0\n `[class]=\"itemClasses()\"`. Hai th\u1EE9 sau c\u00F9ng ghi v\u00E0o thu\u1ED9c t\u00EDnh `class`, v\u00E0\n `RouterLinkActive` ghi SAU n\u00EAn n\u00F3 th\u1EAFng: chu\u1ED7i `'active \u2026'` m\u00E0 `itemClasses()`\n th\u00EAm khi `[active]=\"true\"` b\u1ECB nu\u1ED1t s\u1EA1ch.\n\n T\u00E1ch nh\u00E1nh th\u00F4i KH\u00D4NG \u0111\u1EE7 \u2014 nh\u00E1nh `routerLink` v\u1EABn c\u00F3 c\u1EA3 hai directive tr\u00EAn\n c\u00F9ng th\u1EBB. N\u00EAn `routerLinkActive` ch\u1EC9 g\u1EAFn khi `active()` T\u1EAET: \u0111\u00E3 \u00E9p active\n b\u1EB1ng input th\u00EC kh\u00F4ng c\u00F2n g\u00EC \u0111\u1EC3 directive t\u00EDnh, hai b\u00EAn h\u1EBFt tranh nhau.\n\n H\u1ECFng im l\u1EB7ng \u2014 n\u1EC1n v\u00E0 ch\u1EEF v\u1EABn \u0111\u00FAng (ch\u00FAng n\u1EB1m trong c\u00F9ng chu\u1ED7i), ch\u1EC9 ri\u00EAng\n ch\u1EEF `active` m\u1EA5t. H\u1EADu qu\u1EA3 ch\u1EC9 l\u1ED9 ra khi r\u00EA chu\u1ED9t: \u0111o /navbar 2026-09-24, m\u1EE5c\n \u0111ang m\u1EDF NH\u1EA0T \u0110I (#e3eaff -> #f5f8ff) v\u00EC `not-[.active]:hover:bg-brand-softer`\n v\u1EABn \u00E1p d\u1EE5ng, c\u00F2n `[&.active]:hover:bg-brand-medium` th\u00EC kh\u00F4ng. Sidebar c\u00F9ng\n b\u1ED9 class nh\u01B0ng \u0110\u1EACM L\u00CAN \u0111\u00FAng (#d2dcfa) v\u00EC n\u00F3 \u0111\u00E3 t\u00E1ch nh\u00E1nh t\u1EEB tr\u01B0\u1EDBc.\n\n Unit test KH\u00D4NG b\u1EAFt \u0111\u01B0\u1EE3c: `provideRouter([])` kh\u00F4ng c\u00F3 route n\u00E0o kh\u1EDBp n\u00EAn\n directive kh\u00F4ng ch\u1EA1m v\u00E0o `class`, test xanh trong khi tr\u00ECnh duy\u1EC7t h\u1ECFng.\n\n N\u1ED9i dung g\u00F3i trong `ng-template` v\u00E0 chi\u1EBFu l\u1EA1i b\u1EB1ng `ngTemplateOutlet`: khai\n nhi\u1EC1u `<ng-content>` kh\u00F4ng `select` \u2014 k\u1EC3 c\u1EA3 \u1EDF c\u00E1c nh\u00E1nh lo\u1EA1i tr\u1EEB nhau \u2014\n th\u00EC Angular ch\u1EC9 chi\u1EBFu v\u00E0o M\u1ED8T ch\u1ED7 v\u00E0 th\u1EF1c t\u1EBF m\u1EA5t \u1EDF m\u1ECDi nh\u00E1nh.\n-->\n<ng-template #itemContent>\n @if (icon()) {\n <ol-icon [name]=\"icon()!\" size=\"sm\" class=\"shrink-0\"></ol-icon>\n }\n @if (label()) {\n <span class=\"whitespace-nowrap\">{{ label() }}</span>\n }\n <ng-content />\n</ng-template>\n\n@if (routerLink()) {\n <a\n [routerLink]=\"routerLink()\"\n [routerLinkActive]=\"active() ? '' : routerLinkActive()\"\n [routerLinkActiveOptions]=\"routerLinkActiveOptions()\"\n [class]=\"itemClasses()\"\n [attr.aria-disabled]=\"disabled()\"\n >\n <ng-container [ngTemplateOutlet]=\"itemContent\" />\n </a>\n} @else if (href()) {\n <a [href]=\"href()\" [class]=\"itemClasses()\" [attr.aria-disabled]=\"disabled()\">\n <ng-container [ngTemplateOutlet]=\"itemContent\" />\n </a>\n} @else {\n <a [class]=\"itemClasses()\" [attr.aria-disabled]=\"disabled()\">\n <ng-container [ngTemplateOutlet]=\"itemContent\" />\n </a>\n}\n" }]
14188
+ args: [{ selector: 'ol-navbar-item', standalone: true, imports: [CommonModule, RouterModule, OlIcon], changeDetection: ChangeDetectionStrategy.OnPush, template: "<!--\n BA NH\u00C1NH, `routerLinkActive` CH\u1EC8 \u1EDF nh\u00E1nh c\u00F3 `routerLink`.\n\n B\u1EA3n c\u0169 d\u00F9ng M\u1ED8T `<a>` mang c\u1EA3 `[routerLink]`, `[routerLinkActive]` v\u00E0\n `[class]=\"itemClasses()\"`. Hai th\u1EE9 sau c\u00F9ng ghi v\u00E0o thu\u1ED9c t\u00EDnh `class`, v\u00E0\n `RouterLinkActive` ghi SAU n\u00EAn n\u00F3 th\u1EAFng: chu\u1ED7i `'active \u2026'` m\u00E0 `itemClasses()`\n th\u00EAm khi `[active]=\"true\"` b\u1ECB nu\u1ED1t s\u1EA1ch.\n\n T\u00E1ch nh\u00E1nh th\u00F4i KH\u00D4NG \u0111\u1EE7 \u2014 nh\u00E1nh `routerLink` v\u1EABn c\u00F3 c\u1EA3 hai directive tr\u00EAn\n c\u00F9ng th\u1EBB. N\u00EAn `routerLinkActive` ch\u1EC9 g\u1EAFn khi `active()` T\u1EAET: \u0111\u00E3 \u00E9p active\n b\u1EB1ng input th\u00EC kh\u00F4ng c\u00F2n g\u00EC \u0111\u1EC3 directive t\u00EDnh, hai b\u00EAn h\u1EBFt tranh nhau.\n\n H\u1ECFng im l\u1EB7ng \u2014 n\u1EC1n v\u00E0 ch\u1EEF v\u1EABn \u0111\u00FAng (ch\u00FAng n\u1EB1m trong c\u00F9ng chu\u1ED7i), ch\u1EC9 ri\u00EAng\n ch\u1EEF `active` m\u1EA5t. H\u1EADu qu\u1EA3 ch\u1EC9 l\u1ED9 ra khi r\u00EA chu\u1ED9t: \u0111o /navbar 2026-09-24, m\u1EE5c\n \u0111ang m\u1EDF NH\u1EA0T \u0110I (#e3eaff -> #f5f8ff) v\u00EC `not-[.active]:hover:bg-brand-softer`\n v\u1EABn \u00E1p d\u1EE5ng, c\u00F2n `[&.active]:hover:bg-brand-medium` th\u00EC kh\u00F4ng. Sidebar c\u00F9ng\n b\u1ED9 class nh\u01B0ng \u0110\u1EACM L\u00CAN \u0111\u00FAng (#d2dcfa) v\u00EC n\u00F3 \u0111\u00E3 t\u00E1ch nh\u00E1nh t\u1EEB tr\u01B0\u1EDBc.\n\n Unit test KH\u00D4NG b\u1EAFt \u0111\u01B0\u1EE3c: `provideRouter([])` kh\u00F4ng c\u00F3 route n\u00E0o kh\u1EDBp n\u00EAn\n directive kh\u00F4ng ch\u1EA1m v\u00E0o `class`, test xanh trong khi tr\u00ECnh duy\u1EC7t h\u1ECFng.\n\n N\u1ED9i dung g\u00F3i trong `ng-template` v\u00E0 chi\u1EBFu l\u1EA1i b\u1EB1ng `ngTemplateOutlet`: khai\n nhi\u1EC1u `<ng-content>` kh\u00F4ng `select` \u2014 k\u1EC3 c\u1EA3 \u1EDF c\u00E1c nh\u00E1nh lo\u1EA1i tr\u1EEB nhau \u2014\n th\u00EC Angular ch\u1EC9 chi\u1EBFu v\u00E0o M\u1ED8T ch\u1ED7 v\u00E0 th\u1EF1c t\u1EBF m\u1EA5t \u1EDF m\u1ECDi nh\u00E1nh.\n-->\n<ng-template #itemContent>\n @if (icon()) {\n <ol-icon [name]=\"icon()!\" size=\"sm\" class=\"shrink-0\"></ol-icon>\n }\n @if (label()) {\n <span class=\"whitespace-nowrap\">{{ label() }}</span>\n }\n <ng-content />\n</ng-template>\n\n@if (routerLink()) {\n <a\n [routerLink]=\"routerLink()\"\n [routerLinkActive]=\"active() ? '' : routerLinkActive()\"\n [routerLinkActiveOptions]=\"routerLinkActiveOptions()\"\n [class]=\"itemClasses()\"\n [attr.aria-disabled]=\"disabled()\"\n >\n <ng-container [ngTemplateOutlet]=\"itemContent\" />\n </a>\n} @else if (href()) {\n <a [href]=\"href()\" [class]=\"itemClasses()\" [attr.aria-disabled]=\"disabled()\">\n <ng-container [ngTemplateOutlet]=\"itemContent\" />\n </a>\n} @else {\n <!--\n Kh\u00F4ng li\u00EAn k\u1EBFt th\u00EC l\u00E0 N\u00DAT, kh\u00F4ng ph\u1EA3i `<a>` tr\u1EA7n. `<a>` kh\u00F4ng `href` kh\u00F4ng\n nh\u1EADn Tab v\u00E0 Enter kh\u00F4ng k\u00EDch ho\u1EA1t \u0111\u01B0\u1EE3c: m\u1EE5c d\u00F9ng \u0111\u1EC3 \u0111\u1ED5i tab t\u1EA1i ch\u1ED7 (kh\u00F4ng\n \u0111i\u1EC1u h\u01B0\u1EDBng) th\u00EC b\u00E0n ph\u00EDm kh\u00F4ng b\u1EA5m \u0111\u01B0\u1EE3c (\u0111o /navbar 2026-09-28).\n -->\n <button\n type=\"button\"\n [class]=\"itemClasses()\"\n [disabled]=\"disabled()\"\n [attr.aria-current]=\"active() ? 'page' : null\"\n >\n <ng-container [ngTemplateOutlet]=\"itemContent\" />\n </button>\n}\n" }]
12393
14189
  }], propDecorators: { routerLink: [{ type: i0.Input, args: [{ isSignal: true, alias: "routerLink", required: false }] }], routerLinkActive: [{ type: i0.Input, args: [{ isSignal: true, alias: "routerLinkActive", required: false }] }], routerLinkActiveOptions: [{ type: i0.Input, args: [{ isSignal: true, alias: "routerLinkActiveOptions", required: false }] }], href: [{ type: i0.Input, args: [{ isSignal: true, alias: "href", required: false }] }], icon: [{ type: i0.Input, args: [{ isSignal: true, alias: "icon", required: false }] }], label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }], active: [{ type: i0.Input, args: [{ isSignal: true, alias: "active", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }] } });
12394
14190
 
12395
14191
  class OlNavDropdown {
@@ -12399,8 +14195,75 @@ class OlNavDropdown {
12399
14195
  ...(ngDevMode ? [{ debugName: "icon" }] : /* istanbul ignore next */ []));
12400
14196
  alignRight = input(false, /* @ts-ignore */
12401
14197
  ...(ngDevMode ? [{ debugName: "alignRight" }] : /* istanbul ignore next */ []));
14198
+ /**
14199
+ * Đang ở một trang CON của menu này (ví dụ "Lớp học" khi đang xem 10A1).
14200
+ * Cùng hình với `ol-navbar-item` active — nền brand nhạt, chữ brand đậm —
14201
+ * để thanh điều hướng luôn chỉ ra người dùng đang ở phân hệ nào.
14202
+ */
14203
+ active = input(false, /* @ts-ignore */
14204
+ ...(ngDevMode ? [{ debugName: "active" }] : /* istanbul ignore next */ []));
14205
+ triggerClasses = computed(() => [
14206
+ 'inline-flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-subtle cursor-pointer whitespace-nowrap shrink-0',
14207
+ this.active()
14208
+ ? 'bg-brand-soft text-fg-brand font-semibold hover:bg-brand-medium'
14209
+ : 'font-medium text-body hover:text-fg-brand hover:bg-brand-softer',
14210
+ ].join(' '), /* @ts-ignore */
14211
+ ...(ngDevMode ? [{ debugName: "triggerClasses" }] : /* istanbul ignore next */ []));
14212
+ /**
14213
+ * Số cột của panel. `1` (mặc định) là menu thường; từ `2` là mega-menu.
14214
+ *
14215
+ * Mega-menu dùng cho menu có nhiều nhóm mục — thay vì một cột dài phải cuộn,
14216
+ * chia thành cột đứng cạnh nhau và người dùng quét mắt một lượt là thấy hết.
14217
+ *
14218
+ * Dưới `md` LUÔN về một cột bất kể giá trị: hai cột trên màn 390px là mỗi
14219
+ * cột 150px, chữ vỡ dòng giữa từ. Ngưỡng theo breakpoint chứ không theo bề
14220
+ * ngang panel vì panel treo tự do, không có container để đo.
14221
+ */
14222
+ columns = input(1, /* @ts-ignore */
14223
+ ...(ngDevMode ? [{ debugName: "columns" }] : /* istanbul ignore next */ []));
14224
+ /**
14225
+ * Panel rộng bằng cả navbar thay vì bám mép nút.
14226
+ *
14227
+ * Mega-menu nhiều cột mà vẫn bám mép nút thì nửa panel tràn ra ngoài màn
14228
+ * hình khi nút nằm bên phải. Trải hết chiều ngang thì luôn nằm trong khung.
14229
+ */
14230
+ fullWidth = input(false, /* @ts-ignore */
14231
+ ...(ngDevMode ? [{ debugName: "fullWidth" }] : /* istanbul ignore next */ []));
12402
14232
  isOpen = signal(false, /* @ts-ignore */
12403
14233
  ...(ngDevMode ? [{ debugName: "isOpen" }] : /* istanbul ignore next */ []));
14234
+ /**
14235
+ * Class của panel.
14236
+ *
14237
+ * Gom vào MỘT chuỗi thay vì nhiều `[class.x]` rời: hai class cùng thuộc tính
14238
+ * thì trình duyệt chọn theo thứ tự luật trong file CSS chứ không theo thứ tự
14239
+ * viết, kết quả đổi theo bản build.
14240
+ */
14241
+ panelClasses = computed(() => {
14242
+ const cols = this.columns();
14243
+ /**
14244
+ * Vỏ theo Figma `ol-dropdown` Type=Default (node 1149:39908): lề 8px bốn
14245
+ * cạnh, các dòng cách nhau 6px, bóng `shadow-lg`, bo 12px — CÙNG vỏ với
14246
+ * `ol-dropdown`. Bản trước `py-2` không lề ngang (nền mục đang chọn chạm
14247
+ * sát mép khung), các dòng dính nhau, `shadow-2xl` — lệch cả Figma lẫn
14248
+ * `ol-dropdown` (báo 2026-09-28).
14249
+ */
14250
+ const base = 'absolute mt-2 bg-neutral-primary rounded-md shadow-lg border border-default p-2 z-dropdown focus:outline-none transition-all duration-150';
14251
+ // Một cột: rộng tối thiểu 64, bám mép nút. `space-y` chứ không `flex gap`:
14252
+ // dòng là khối `block`, và không đụng `display` của vỏ.
14253
+ if (cols === 1) {
14254
+ const anchor = this.fullWidth() ? 'inset-x-0' : this.alignRight() ? 'right-0' : 'left-0';
14255
+ return `${base} space-y-1.5 min-w-64 ${anchor}`;
14256
+ }
14257
+ // Nhiều cột: lưới, nhưng dưới `md` về một cột. Hai cột trên màn 390px là
14258
+ // mỗi cột 150px — chữ vỡ dòng giữa từ.
14259
+ const grid = { 2: 'md:grid-cols-2', 3: 'md:grid-cols-3', 4: 'md:grid-cols-4' }[cols];
14260
+ const anchor = this.fullWidth() ? 'inset-x-0' : this.alignRight() ? 'right-0' : 'left-0';
14261
+ const width = this.fullWidth()
14262
+ ? ''
14263
+ : { 2: 'md:w-[32rem]', 3: 'md:w-[44rem]', 4: 'md:w-[56rem]' }[cols];
14264
+ return `${base} grid grid-cols-1 gap-x-6 gap-y-1 px-3 ${grid} ${anchor} ${width} min-w-64`;
14265
+ }, /* @ts-ignore */
14266
+ ...(ngDevMode ? [{ debugName: "panelClasses" }] : /* istanbul ignore next */ []));
12404
14267
  elementRef = inject(ElementRef);
12405
14268
  overlayContainer = inject(OlOverlayContainer);
12406
14269
  constructor() {
@@ -12431,16 +14294,184 @@ class OlNavDropdown {
12431
14294
  }
12432
14295
  }
12433
14296
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: OlNavDropdown, deps: [], target: i0.ɵɵFactoryTarget.Component });
12434
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.6", type: OlNavDropdown, isStandalone: true, selector: "ol-nav-dropdown", inputs: { label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, icon: { classPropertyName: "icon", publicName: "icon", isSignal: true, isRequired: false, transformFunction: null }, alignRight: { classPropertyName: "alignRight", publicName: "alignRight", isSignal: true, isRequired: false, transformFunction: null } }, host: { listeners: { "document:click": "handleClickOutside($event)" } }, ngImport: i0, template: "<div class=\"relative inline-block text-left\">\n <!-- Trigger -->\n <div (click)=\"toggle()\">\n <ng-content select=\"[slot=trigger], [trigger]\" />\n @if (label() || icon()) {\n <button\n type=\"button\"\n class=\"inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium rounded-lg text-body hover:text-fg-brand hover:bg-neutral-secondary-medium transition-colors focus:outline-none cursor-pointer whitespace-nowrap shrink-0\"\n [attr.aria-expanded]=\"isOpen()\"\n aria-haspopup=\"true\"\n >\n @if (icon()) {\n <ol-icon [name]=\"icon()!\" size=\"sm\" class=\"shrink-0\"></ol-icon>\n }\n <span class=\"whitespace-nowrap\">{{ label() }}</span>\n <ol-icon\n name=\"chevron-down\"\n size=\"xs\"\n class=\"transition-transform duration-200 shrink-0\"\n [class.rotate-180]=\"isOpen()\"\n ></ol-icon>\n </button>\n }\n </div>\n\n <!-- Dropdown Menu Content -->\n <div\n class=\"absolute mt-2 min-w-64 bg-neutral-primary rounded-md shadow-2xl border border-default py-2 z-dropdown focus:outline-none transition-all duration-150\"\n [class.hidden]=\"!isOpen()\"\n [class.right-0]=\"alignRight()\"\n [class.left-0]=\"!alignRight()\"\n role=\"menu\"\n >\n <ng-content />\n </div>\n</div>\n", dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "component", type: OlIcon, selector: "ol-icon", inputs: ["name", "size", "color", "variant", "strokeWidth"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
14297
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.6", type: OlNavDropdown, isStandalone: true, selector: "ol-nav-dropdown", inputs: { label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, icon: { classPropertyName: "icon", publicName: "icon", isSignal: true, isRequired: false, transformFunction: null }, alignRight: { classPropertyName: "alignRight", publicName: "alignRight", isSignal: true, isRequired: false, transformFunction: null }, active: { classPropertyName: "active", publicName: "active", isSignal: true, isRequired: false, transformFunction: null }, columns: { classPropertyName: "columns", publicName: "columns", isSignal: true, isRequired: false, transformFunction: null }, fullWidth: { classPropertyName: "fullWidth", publicName: "fullWidth", isSignal: true, isRequired: false, transformFunction: null } }, host: { listeners: { "document:click": "handleClickOutside($event)" } }, ngImport: i0, template: "<div class=\"relative inline-block text-left\">\n <!-- Trigger -->\n <div (click)=\"toggle()\">\n <ng-content select=\"[slot=trigger], [trigger]\" />\n @if (label() || icon()) {\n <button\n type=\"button\"\n [class]=\"triggerClasses()\"\n [attr.aria-expanded]=\"isOpen()\"\n [attr.aria-current]=\"active() ? 'page' : null\"\n aria-haspopup=\"true\"\n >\n @if (icon()) {\n <ol-icon [name]=\"icon()!\" size=\"sm\" class=\"shrink-0\"></ol-icon>\n }\n <span class=\"whitespace-nowrap\">{{ label() }}</span>\n <ol-icon\n name=\"chevron-down\"\n size=\"xs\"\n class=\"transition-transform duration-200 shrink-0\"\n [class.rotate-180]=\"isOpen()\"\n ></ol-icon>\n </button>\n }\n </div>\n\n <!--\n Panel. Class gom trong `panelClasses()` v\u00EC `columns` \u0111\u1ED5i c\u1EA3 c\u00E1ch x\u1EBFp, b\u1EC1\n ngang l\u1EABn c\u00E1ch neo \u2014 ba th\u1EE9 \u0111\u00F3 ph\u1EA3i quy\u1EBFt c\u00F9ng nhau, t\u00E1ch th\u00E0nh nhi\u1EC1u\n `[class.x]` r\u1EDDi th\u00EC m\u1ED7i t\u1ED5 h\u1EE3p l\u00E0 m\u1ED9t lu\u1EADt CSS ch\u1ECDi nhau.\n -->\n <div [class]=\"panelClasses()\" [class.hidden]=\"!isOpen()\" role=\"menu\">\n <ng-content />\n </div>\n</div>\n", dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "component", type: OlIcon, selector: "ol-icon", inputs: ["name", "size", "color", "variant", "strokeWidth"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
12435
14298
  }
12436
14299
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: OlNavDropdown, decorators: [{
12437
14300
  type: Component,
12438
- args: [{ selector: 'ol-nav-dropdown', standalone: true, imports: [CommonModule, OlIcon], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"relative inline-block text-left\">\n <!-- Trigger -->\n <div (click)=\"toggle()\">\n <ng-content select=\"[slot=trigger], [trigger]\" />\n @if (label() || icon()) {\n <button\n type=\"button\"\n class=\"inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium rounded-lg text-body hover:text-fg-brand hover:bg-neutral-secondary-medium transition-colors focus:outline-none cursor-pointer whitespace-nowrap shrink-0\"\n [attr.aria-expanded]=\"isOpen()\"\n aria-haspopup=\"true\"\n >\n @if (icon()) {\n <ol-icon [name]=\"icon()!\" size=\"sm\" class=\"shrink-0\"></ol-icon>\n }\n <span class=\"whitespace-nowrap\">{{ label() }}</span>\n <ol-icon\n name=\"chevron-down\"\n size=\"xs\"\n class=\"transition-transform duration-200 shrink-0\"\n [class.rotate-180]=\"isOpen()\"\n ></ol-icon>\n </button>\n }\n </div>\n\n <!-- Dropdown Menu Content -->\n <div\n class=\"absolute mt-2 min-w-64 bg-neutral-primary rounded-md shadow-2xl border border-default py-2 z-dropdown focus:outline-none transition-all duration-150\"\n [class.hidden]=\"!isOpen()\"\n [class.right-0]=\"alignRight()\"\n [class.left-0]=\"!alignRight()\"\n role=\"menu\"\n >\n <ng-content />\n </div>\n</div>\n" }]
12439
- }], ctorParameters: () => [], propDecorators: { label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }], icon: [{ type: i0.Input, args: [{ isSignal: true, alias: "icon", required: false }] }], alignRight: [{ type: i0.Input, args: [{ isSignal: true, alias: "alignRight", required: false }] }], handleClickOutside: [{
14301
+ args: [{ selector: 'ol-nav-dropdown', standalone: true, imports: [CommonModule, OlIcon], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"relative inline-block text-left\">\n <!-- Trigger -->\n <div (click)=\"toggle()\">\n <ng-content select=\"[slot=trigger], [trigger]\" />\n @if (label() || icon()) {\n <button\n type=\"button\"\n [class]=\"triggerClasses()\"\n [attr.aria-expanded]=\"isOpen()\"\n [attr.aria-current]=\"active() ? 'page' : null\"\n aria-haspopup=\"true\"\n >\n @if (icon()) {\n <ol-icon [name]=\"icon()!\" size=\"sm\" class=\"shrink-0\"></ol-icon>\n }\n <span class=\"whitespace-nowrap\">{{ label() }}</span>\n <ol-icon\n name=\"chevron-down\"\n size=\"xs\"\n class=\"transition-transform duration-200 shrink-0\"\n [class.rotate-180]=\"isOpen()\"\n ></ol-icon>\n </button>\n }\n </div>\n\n <!--\n Panel. Class gom trong `panelClasses()` v\u00EC `columns` \u0111\u1ED5i c\u1EA3 c\u00E1ch x\u1EBFp, b\u1EC1\n ngang l\u1EABn c\u00E1ch neo \u2014 ba th\u1EE9 \u0111\u00F3 ph\u1EA3i quy\u1EBFt c\u00F9ng nhau, t\u00E1ch th\u00E0nh nhi\u1EC1u\n `[class.x]` r\u1EDDi th\u00EC m\u1ED7i t\u1ED5 h\u1EE3p l\u00E0 m\u1ED9t lu\u1EADt CSS ch\u1ECDi nhau.\n -->\n <div [class]=\"panelClasses()\" [class.hidden]=\"!isOpen()\" role=\"menu\">\n <ng-content />\n </div>\n</div>\n" }]
14302
+ }], ctorParameters: () => [], propDecorators: { label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }], icon: [{ type: i0.Input, args: [{ isSignal: true, alias: "icon", required: false }] }], alignRight: [{ type: i0.Input, args: [{ isSignal: true, alias: "alignRight", required: false }] }], active: [{ type: i0.Input, args: [{ isSignal: true, alias: "active", required: false }] }], columns: [{ type: i0.Input, args: [{ isSignal: true, alias: "columns", required: false }] }], fullWidth: [{ type: i0.Input, args: [{ isSignal: true, alias: "fullWidth", required: false }] }], handleClickOutside: [{
12440
14303
  type: HostListener,
12441
14304
  args: ['document:click', ['$event']]
12442
14305
  }] } });
12443
14306
 
14307
+ /**
14308
+ * Thanh menu kiểu ứng dụng để bàn — Tệp · Sửa · Xem.
14309
+ *
14310
+ * Chưa có node Figma — dựng theo design system, dùng lại cách trình bày của
14311
+ * `ol-dropdown-item`.
14312
+ *
14313
+ * ## Khác `ol-dropdown` ở ba điểm, và đó là lý do có component riêng
14314
+ *
14315
+ * 1. **Mở một menu rồi RÊ sang menu kế là mở luôn**, không phải bấm lại. Đây
14316
+ * là hành vi người dùng đã quen từ phần mềm để bàn; thiếu nó thì thanh menu
14317
+ * chỉ là mấy cái dropdown đặt cạnh nhau.
14318
+ * 2. **Mũi tên trái/phải đi giữa các menu**, lên/xuống đi trong menu đang mở.
14319
+ * WAI-ARIA quy định `role="menubar"` phải làm vậy.
14320
+ * 3. **Một menu mở thì menu khác đóng** — không bao giờ có hai panel cùng lúc.
14321
+ *
14322
+ * ## Vì sao KHÔNG treo panel ra `<body>`
14323
+ *
14324
+ * Thanh menu thường nằm ngay dưới tiêu đề cửa sổ, không bị `overflow: hidden`
14325
+ * cắt như dropdown trong bảng. Giữ panel trong cây DOM thì Tab đi vào được tự
14326
+ * nhiên, không phải xử lý riêng như `ol-dropdown` đã phải làm.
14327
+ *
14328
+ * ## Lưu ý khi viết test
14329
+ *
14330
+ * Rê chuột giữa các menu cần bố cục thật để tính vị trí. jsdom không có, nên
14331
+ * spec khoá phần bàn phím và trạng thái mở/đóng; hành vi rê nghiệm thu trên
14332
+ * trình duyệt.
14333
+ */
14334
+ class OlMenubar {
14335
+ elementRef = inject(ElementRef);
14336
+ destroyRef = inject(DestroyRef);
14337
+ overlayContainer = inject(OlOverlayContainer);
14338
+ menus = input([], /* @ts-ignore */
14339
+ ...(ngDevMode ? [{ debugName: "menus" }] : /* istanbul ignore next */ []));
14340
+ ariaLabel = input('Thanh menu ứng dụng', /* @ts-ignore */
14341
+ ...(ngDevMode ? [{ debugName: "ariaLabel" }] : /* istanbul ignore next */ []));
14342
+ /** `id` của menu đang mở. Rỗng nghĩa là không menu nào mở. */
14343
+ openId = signal('', /* @ts-ignore */
14344
+ ...(ngDevMode ? [{ debugName: "openId" }] : /* istanbul ignore next */ []));
14345
+ openMenuId = this.openId.asReadonly();
14346
+ itemSelect = output();
14347
+ releaseEscape = null;
14348
+ constructor() {
14349
+ this.destroyRef.onDestroy(() => this.closeAll());
14350
+ }
14351
+ toggle(menu) {
14352
+ if (menu.disabled)
14353
+ return;
14354
+ this.openId() === menu.id ? this.closeAll() : this.open(menu.id);
14355
+ }
14356
+ /**
14357
+ * Rê sang menu khác khi ĐANG có menu mở thì mở luôn menu đó.
14358
+ *
14359
+ * Không mở khi chưa menu nào mở — nếu không, lướt chuột ngang thanh là bung
14360
+ * ra một panel người dùng không định mở.
14361
+ */
14362
+ onMenuHover(menu) {
14363
+ if (!this.openId() || menu.disabled)
14364
+ return;
14365
+ this.open(menu.id);
14366
+ }
14367
+ open(id) {
14368
+ this.openId.set(id);
14369
+ // Vào ngăn xếp Escape — chỉ lớp trên cùng đóng, nên menubar trong modal
14370
+ // không kéo modal đóng theo.
14371
+ // Truyền cả `contains` để ngăn xếp tự lo việc bấm ra ngoài — không tự gắn
14372
+ // `document:click` nữa. Lớp nổi nào tự nghe thì một cú bấm nền đóng luôn
14373
+ // mọi lớp; ngăn xếp biết cú bấm thuộc lớp nào nên chỉ đóng đúng cái cần.
14374
+ this.releaseEscape ??= this.overlayContainer.register(() => this.closeAll(), (target) => this.elementRef.nativeElement.contains(target));
14375
+ }
14376
+ closeAll() {
14377
+ this.openId.set('');
14378
+ this.releaseEscape?.();
14379
+ this.releaseEscape = null;
14380
+ }
14381
+ onItemClick(menu, item) {
14382
+ if (item.disabled)
14383
+ return;
14384
+ this.itemSelect.emit({ menu, item });
14385
+ this.closeAll();
14386
+ }
14387
+ /**
14388
+ * Mũi tên trái/phải đi giữa các menu — WAI-ARIA quy định cho `role="menubar"`.
14389
+ *
14390
+ * Vòng lại đầu khi tới cuối: thanh menu là một dải kín, không phải danh sách
14391
+ * có điểm dừng. Người dùng nhấn phải liên tục thì quay về menu đầu.
14392
+ *
14393
+ * Dùng `(keydown)` trần rồi tự lọc phím vì thẻ đã có `(click)` — hai listener
14394
+ * khác kiểu sự kiện trên cùng thẻ thì Angular suy `$event` theo cái đầu.
14395
+ */
14396
+ onTriggerKeydown(event, index) {
14397
+ const list = this.menus();
14398
+ let next = null;
14399
+ if (event.key === 'ArrowRight')
14400
+ next = this.nextEnabled(index, 1);
14401
+ else if (event.key === 'ArrowLeft')
14402
+ next = this.nextEnabled(index, -1);
14403
+ else if (event.key === 'Home')
14404
+ next = this.nextEnabled(-1, 1);
14405
+ else if (event.key === 'End')
14406
+ next = this.nextEnabled(list.length, -1);
14407
+ else if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
14408
+ event.preventDefault();
14409
+ this.open(list[index].id);
14410
+ return;
14411
+ }
14412
+ else
14413
+ return;
14414
+ if (next === null)
14415
+ return;
14416
+ event.preventDefault();
14417
+ const host = this.elementRef.nativeElement;
14418
+ const buttons = host.querySelectorAll('[role="menuitem"][aria-haspopup]');
14419
+ buttons[next]?.focus();
14420
+ // Đang mở menu nào thì chuyển menu luôn — giống rê chuột.
14421
+ if (this.openId())
14422
+ this.open(list[next].id);
14423
+ }
14424
+ /**
14425
+ * Menu kế tiếp **còn dùng được** theo hướng `step`, vòng lại khi tới biên.
14426
+ *
14427
+ * Phải bỏ qua menu `disabled`: nút gốc có thuộc tính `disabled` nên
14428
+ * `.focus()` trên nó KHÔNG có tác dụng nào — tiêu điểm đứng nguyên chỗ cũ và
14429
+ * người dùng tưởng bàn phím hỏng. Spec bắt đúng lỗi này khi menu cuối bị vô
14430
+ * hiệu: nhấn End là tiêu điểm rơi về `<body>`.
14431
+ *
14432
+ * Trả `null` khi KHÔNG có menu nào dùng được — lúc đó không `preventDefault`,
14433
+ * để phím về lại hành vi mặc định của trình duyệt.
14434
+ *
14435
+ * Nhận `from` ngoài dải (-1 hoặc `length`) để Home/End dùng chung một phép:
14436
+ * Home là "từ trước mục đầu, đi sang phải", End là "từ sau mục cuối, đi sang
14437
+ * trái".
14438
+ */
14439
+ nextEnabled(from, step) {
14440
+ const list = this.menus();
14441
+ for (let k = 1; k <= list.length; k++) {
14442
+ const i = (((from + step * k) % list.length) + list.length) % list.length;
14443
+ if (!list[i].disabled)
14444
+ return i;
14445
+ }
14446
+ return null;
14447
+ }
14448
+ triggerClasses(menu) {
14449
+ const active = this.openId() === menu.id;
14450
+ // Gom vào MỘT chuỗi: hai class nền cùng lúc thì trình duyệt chọn theo thứ
14451
+ // tự luật trong file CSS chứ không theo thứ tự viết.
14452
+ const state = menu.disabled
14453
+ ? 'text-body-subtle opacity-50 cursor-not-allowed'
14454
+ : active
14455
+ ? 'bg-neutral-secondary text-heading'
14456
+ : 'text-body hover:bg-neutral-secondary cursor-pointer';
14457
+ return `rounded-md px-3 py-1.5 text-sm font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-brand-subtle ${state}`;
14458
+ }
14459
+ itemClasses(item) {
14460
+ const state = item.disabled
14461
+ ? 'text-body-subtle opacity-50 cursor-not-allowed'
14462
+ : 'text-body hover:bg-neutral-secondary cursor-pointer';
14463
+ return `flex w-full items-center justify-between gap-6 rounded px-3 py-1.5 text-sm focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-brand-subtle ${state}`;
14464
+ }
14465
+ hasMenus = computed(() => this.menus().length > 0, /* @ts-ignore */
14466
+ ...(ngDevMode ? [{ debugName: "hasMenus" }] : /* istanbul ignore next */ []));
14467
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: OlMenubar, deps: [], target: i0.ɵɵFactoryTarget.Component });
14468
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.6", type: OlMenubar, isStandalone: true, selector: "ol-menubar", inputs: { menus: { classPropertyName: "menus", publicName: "menus", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { itemSelect: "itemSelect" }, host: { classAttribute: "block" }, ngImport: i0, template: "<!--\n Panel n\u1EB1m TRONG c\u00E2y DOM c\u1EE7a t\u1EEBng menu (`relative` > `absolute`), kh\u00F4ng treo ra\n `<body>`: thanh menu kh\u00F4ng b\u1ECB `overflow: hidden` c\u1EE7a khung cha c\u1EAFt, n\u00EAn Tab \u0111i\n v\u00E0o panel t\u1EF1 nhi\u00EAn theo th\u1EE9 t\u1EF1 t\u00E0i li\u1EC7u.\n-->\n@if (hasMenus()) {\n <div\n [attr.aria-label]=\"ariaLabel()\"\n class=\"flex items-center gap-0.5 rounded-lg border border-default bg-neutral-primary p-1\"\n role=\"menubar\"\n >\n @for (menu of menus(); track menu.id; let i = $index) {\n <div class=\"relative\">\n <button\n type=\"button\"\n [attr.aria-disabled]=\"menu.disabled ? 'true' : null\"\n [attr.aria-expanded]=\"openMenuId() === menu.id\"\n [class]=\"triggerClasses(menu)\"\n [disabled]=\"menu.disabled\"\n aria-haspopup=\"menu\"\n role=\"menuitem\"\n (click)=\"toggle(menu)\"\n (keydown)=\"onTriggerKeydown($event, i)\"\n (mouseenter)=\"onMenuHover(menu)\"\n >\n {{ menu.label }}\n </button>\n\n @if (openMenuId() === menu.id) {\n <div\n [attr.aria-label]=\"menu.label\"\n class=\"absolute left-0 top-full z-dropdown mt-1 min-w-52 rounded-lg border border-default bg-neutral-primary p-1 shadow-lg\"\n role=\"menu\"\n >\n @for (item of menu.items; track item.id) {\n <!--\n K\u1EBB ng\u0103n l\u00E0 ph\u1EA7n t\u1EED RI\u00CANG, kh\u00F4ng ph\u1EA3i `border-top` c\u1EE7a m\u1EE5c.\n `first:`/`last:` trong template component kh\u1EDBp m\u1ECDi th\u1EC3 hi\u1EC7n n\u00EAn\n \u0111\u01B0\u1EDDng k\u1EBB theo bi\u1EBFn th\u1EC3 s\u1EBD hi\u1EC7n sai ch\u1ED7.\n -->\n @if (item.separatorBefore) {\n <div class=\"my-1 h-px bg-default\" role=\"separator\"></div>\n }\n <button\n type=\"button\"\n [attr.aria-disabled]=\"item.disabled ? 'true' : null\"\n [class]=\"itemClasses(item)\"\n [disabled]=\"item.disabled\"\n role=\"menuitem\"\n (click)=\"onItemClick(menu, item)\"\n >\n <span>{{ item.label }}</span>\n @if (item.shortcut) {\n <span class=\"text-xs text-body-subtle\">{{ item.shortcut }}</span>\n }\n </button>\n }\n </div>\n }\n </div>\n }\n </div>\n}\n", changeDetection: i0.ChangeDetectionStrategy.OnPush });
14469
+ }
14470
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: OlMenubar, decorators: [{
14471
+ type: Component,
14472
+ args: [{ selector: 'ol-menubar', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'block' }, template: "<!--\n Panel n\u1EB1m TRONG c\u00E2y DOM c\u1EE7a t\u1EEBng menu (`relative` > `absolute`), kh\u00F4ng treo ra\n `<body>`: thanh menu kh\u00F4ng b\u1ECB `overflow: hidden` c\u1EE7a khung cha c\u1EAFt, n\u00EAn Tab \u0111i\n v\u00E0o panel t\u1EF1 nhi\u00EAn theo th\u1EE9 t\u1EF1 t\u00E0i li\u1EC7u.\n-->\n@if (hasMenus()) {\n <div\n [attr.aria-label]=\"ariaLabel()\"\n class=\"flex items-center gap-0.5 rounded-lg border border-default bg-neutral-primary p-1\"\n role=\"menubar\"\n >\n @for (menu of menus(); track menu.id; let i = $index) {\n <div class=\"relative\">\n <button\n type=\"button\"\n [attr.aria-disabled]=\"menu.disabled ? 'true' : null\"\n [attr.aria-expanded]=\"openMenuId() === menu.id\"\n [class]=\"triggerClasses(menu)\"\n [disabled]=\"menu.disabled\"\n aria-haspopup=\"menu\"\n role=\"menuitem\"\n (click)=\"toggle(menu)\"\n (keydown)=\"onTriggerKeydown($event, i)\"\n (mouseenter)=\"onMenuHover(menu)\"\n >\n {{ menu.label }}\n </button>\n\n @if (openMenuId() === menu.id) {\n <div\n [attr.aria-label]=\"menu.label\"\n class=\"absolute left-0 top-full z-dropdown mt-1 min-w-52 rounded-lg border border-default bg-neutral-primary p-1 shadow-lg\"\n role=\"menu\"\n >\n @for (item of menu.items; track item.id) {\n <!--\n K\u1EBB ng\u0103n l\u00E0 ph\u1EA7n t\u1EED RI\u00CANG, kh\u00F4ng ph\u1EA3i `border-top` c\u1EE7a m\u1EE5c.\n `first:`/`last:` trong template component kh\u1EDBp m\u1ECDi th\u1EC3 hi\u1EC7n n\u00EAn\n \u0111\u01B0\u1EDDng k\u1EBB theo bi\u1EBFn th\u1EC3 s\u1EBD hi\u1EC7n sai ch\u1ED7.\n -->\n @if (item.separatorBefore) {\n <div class=\"my-1 h-px bg-default\" role=\"separator\"></div>\n }\n <button\n type=\"button\"\n [attr.aria-disabled]=\"item.disabled ? 'true' : null\"\n [class]=\"itemClasses(item)\"\n [disabled]=\"item.disabled\"\n role=\"menuitem\"\n (click)=\"onItemClick(menu, item)\"\n >\n <span>{{ item.label }}</span>\n @if (item.shortcut) {\n <span class=\"text-xs text-body-subtle\">{{ item.shortcut }}</span>\n }\n </button>\n }\n </div>\n }\n </div>\n }\n </div>\n}\n" }]
14473
+ }], ctorParameters: () => [], propDecorators: { menus: [{ type: i0.Input, args: [{ isSignal: true, alias: "menus", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], itemSelect: [{ type: i0.Output, args: ["itemSelect"] }] } });
14474
+
12444
14475
  class OlPagination {
12445
14476
  type = input('default', /* @ts-ignore */
12446
14477
  ...(ngDevMode ? [{ debugName: "type" }] : /* istanbul ignore next */ []));
@@ -12483,6 +14514,18 @@ class OlPagination {
12483
14514
  ...(ngDevMode ? [{ debugName: "text" }] : /* istanbul ignore next */ []));
12484
14515
  showInfo = input(false, /* @ts-ignore */
12485
14516
  ...(ngDevMode ? [{ debugName: "showInfo" }] : /* istanbul ignore next */ []));
14517
+ /**
14518
+ * Thêm ô chọn số dòng mỗi trang ("10 mỗi trang") vào BẤT KỲ type nào — cạnh
14519
+ * dãy số, cạnh hai nút, cạnh ô nhập trang… Trước đây nó chỉ có ở một type
14520
+ * riêng (`with-dropdown`), muốn nó cạnh `arrows` hay `two-buttons` là không
14521
+ * được. Danh sách lấy từ `pageSizeOptions`, đổi thì phát `(pageChange)` về 1.
14522
+ */
14523
+ withDropdown = input(false, /* @ts-ignore */
14524
+ ...(ngDevMode ? [{ debugName: "withDropdown" }] : /* istanbul ignore next */ []));
14525
+ /**
14526
+ * @deprecated Dùng `withDropdown`. Bí danh: bật là thêm ô chọn cỡ trang. Khi
14527
+ * không đặt `type`, nó vẫn chọn bố cục `with-dropdown` như trước.
14528
+ */
12486
14529
  showSizeChanger = input(false, /* @ts-ignore */
12487
14530
  ...(ngDevMode ? [{ debugName: "showSizeChanger" }] : /* istanbul ignore next */ []));
12488
14531
  showQuickJumper = input(false, /* @ts-ignore */
@@ -12525,6 +14568,15 @@ class OlPagination {
12525
14568
  return 'default';
12526
14569
  }, /* @ts-ignore */
12527
14570
  ...(ngDevMode ? [{ debugName: "effectiveType" }] : /* istanbul ignore next */ []));
14571
+ /** Bố cục vẽ ra: `with-dropdown` là bí danh nên quy về `text-buttons`. */
14572
+ layoutType = computed(() => {
14573
+ const t = this.effectiveType();
14574
+ return t === 'with-dropdown' ? 'text-buttons' : t;
14575
+ }, /* @ts-ignore */
14576
+ ...(ngDevMode ? [{ debugName: "layoutType" }] : /* istanbul ignore next */ []));
14577
+ /** Có vẽ ô chọn cỡ trang không — cờ riêng, độc lập với type. */
14578
+ hasPageSizeDropdown = computed(() => this.withDropdown() || this.showSizeChanger() || this.effectiveType() === 'with-dropdown', /* @ts-ignore */
14579
+ ...(ngDevMode ? [{ debugName: "hasPageSizeDropdown" }] : /* istanbul ignore next */ []));
12528
14580
  /**
12529
14581
  * Trang đang xem — gộp `pageIndex` và bí danh `currentPage`.
12530
14582
  *
@@ -12696,14 +14748,14 @@ class OlPagination {
12696
14748
  }
12697
14749
  }
12698
14750
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: OlPagination, deps: [], target: i0.ɵɵFactoryTarget.Component });
12699
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.6", type: OlPagination, isStandalone: true, selector: "ol-pagination", inputs: { type: { classPropertyName: "type", publicName: "type", isSignal: true, isRequired: false, transformFunction: null }, variant: { classPropertyName: "variant", publicName: "variant", isSignal: true, isRequired: false, transformFunction: null }, total: { classPropertyName: "total", publicName: "total", isSignal: true, isRequired: false, transformFunction: null }, pageSize: { classPropertyName: "pageSize", publicName: "pageSize", isSignal: true, isRequired: false, transformFunction: null }, pageIndex: { classPropertyName: "pageIndex", publicName: "pageIndex", isSignal: true, isRequired: false, transformFunction: null }, currentPage: { classPropertyName: "currentPage", publicName: "currentPage", isSignal: true, isRequired: false, transformFunction: null }, totalPagesInput: { classPropertyName: "totalPagesInput", publicName: "totalPages", isSignal: true, isRequired: false, transformFunction: null }, pageSizeOptions: { classPropertyName: "pageSizeOptions", publicName: "pageSizeOptions", isSignal: true, isRequired: false, transformFunction: null }, showText: { classPropertyName: "showText", publicName: "showText", isSignal: true, isRequired: false, transformFunction: null }, text: { classPropertyName: "text", publicName: "text", isSignal: true, isRequired: false, transformFunction: null }, showInfo: { classPropertyName: "showInfo", publicName: "showInfo", isSignal: true, isRequired: false, transformFunction: null }, showSizeChanger: { classPropertyName: "showSizeChanger", publicName: "showSizeChanger", isSignal: true, isRequired: false, transformFunction: null }, showQuickJumper: { classPropertyName: "showQuickJumper", publicName: "showQuickJumper", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { pageSize: "pageSizeChange", pageIndex: "pageIndexChange", currentPage: "currentPageChange", pageChange: "pageChange" }, host: { classAttribute: "block w-full" }, ngImport: i0, template: "<div class=\"flex items-center gap-4 flex-wrap max-w-full\">\n <!-- 1. Type = Default (Figma #1149:81851) -->\n @if (effectiveType() === 'default') {\n <nav\n class=\"inline-flex -space-x-px rounded-md shadow-xs isolate select-none\"\n aria-label=\"Ph\u00E2n trang\"\n >\n <!-- Prev Button -->\n <button\n type=\"button\"\n (click)=\"prev()\"\n [disabled]=\"effectivePageIndex() <= 1\"\n class=\"relative inline-flex items-center justify-center w-9 h-9 rounded-l-md border border-default transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:bg-disabled cursor-pointer bg-neutral-primary text-body hover:bg-neutral-secondary-soft shrink-0\"\n aria-label=\"Trang tr\u01B0\u1EDBc\"\n >\n <ol-icon name=\"alt-arrow-left\" size=\"xs\"></ol-icon>\n </button>\n\n <!-- Pages Array -->\n @for (page of visiblePages(); track trackPage($index, page)) {\n @if (page === 'ellipsis') {\n <span\n class=\"relative inline-flex items-center justify-center px-3 h-9 text-sm text-fg-disabled bg-neutral-primary border border-default select-none whitespace-nowrap shrink-0\"\n aria-hidden=\"true\"\n >\n ...\n </span>\n } @else {\n <button\n type=\"button\"\n (click)=\"goToPage(page)\"\n [attr.aria-current]=\"page === effectivePageIndex() ? 'page' : null\"\n [attr.aria-label]=\"'Trang ' + page\"\n [class]=\"\n 'relative inline-flex items-center justify-center min-w-9 h-9 px-3 text-sm transition-colors cursor-pointer whitespace-nowrap shrink-0 ' +\n (page === effectivePageIndex()\n ? 'z-10 font-semibold border border-brand bg-brand text-white'\n : 'font-medium border border-default bg-neutral-primary-soft text-body hover:bg-neutral-secondary-soft')\n \"\n >\n {{ page }}\n </button>\n }\n }\n\n <!-- Next Button -->\n <button\n type=\"button\"\n (click)=\"next()\"\n [disabled]=\"effectivePageIndex() >= calculatedTotalPages()\"\n class=\"relative inline-flex items-center justify-center w-9 h-9 rounded-r-md border border-default transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:bg-disabled cursor-pointer bg-neutral-primary text-body hover:bg-neutral-secondary-soft shrink-0\"\n aria-label=\"Trang sau\"\n >\n <ol-icon name=\"alt-arrow-right\" size=\"xs\"></ol-icon>\n </button>\n </nav>\n }\n\n <!-- 2. Type = Arrows (Figma #1149:81864) -->\n @if (effectiveType() === 'arrows') {\n <nav\n class=\"inline-flex -space-x-px rounded-md shadow-xs isolate select-none\"\n aria-label=\"Ph\u00E2n trang\"\n >\n <button\n type=\"button\"\n (click)=\"prev()\"\n [disabled]=\"effectivePageIndex() <= 1\"\n class=\"relative inline-flex items-center justify-center w-9 h-9 rounded-l-md border border-default transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:bg-disabled cursor-pointer bg-neutral-primary text-body hover:bg-neutral-secondary-soft shrink-0\"\n aria-label=\"Trang tr\u01B0\u1EDBc\"\n >\n <ol-icon name=\"alt-arrow-left\" size=\"xs\"></ol-icon>\n </button>\n\n <span\n class=\"relative inline-flex items-center justify-center px-4 h-9 text-sm font-medium text-body bg-neutral-primary border border-default select-none whitespace-nowrap shrink-0\"\n >\n {{ effectivePageIndex() }} tr\u00EAn {{ calculatedTotalPages() }}\n </span>\n\n <button\n type=\"button\"\n (click)=\"next()\"\n [disabled]=\"effectivePageIndex() >= calculatedTotalPages()\"\n class=\"relative inline-flex items-center justify-center w-9 h-9 rounded-r-md border border-default transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:bg-disabled cursor-pointer bg-neutral-primary text-body hover:bg-neutral-secondary-soft shrink-0\"\n aria-label=\"Trang sau\"\n >\n <ol-icon name=\"alt-arrow-right\" size=\"xs\"></ol-icon>\n </button>\n </nav>\n }\n\n <!-- 3. Type = Dropdown & buttons (Figma #1149:81868) -->\n @if (effectiveType() === 'dropdown-buttons') {\n <div class=\"inline-flex items-center gap-3 select-none flex-wrap\">\n <!-- Dropdown button -->\n <div class=\"relative inline-flex items-center\">\n <!--\n `[ngModel]` ch\u1EE9 KH\u00D4NG ph\u1EA3i `[value]`: `[value]` tr\u00EAn <select> \u0111\u1EB7t\n thu\u1ED9c t\u00EDnh DOM NGAY, l\u00FAc c\u00E1c <option> c\u1EE7a @for ch\u01B0a t\u1ED3n t\u1EA1i, n\u00EAn tr\u00ECnh\n duy\u1EC7t ho\u00E0n nguy\u00EAn v\u1EC1 option \u0111\u1EA7u. \u0110o 2026-09-17: trang hi\u1EC7n t\u1EA1i l\u00E0 3 m\u00E0\n \u00F4 ch\u1ECDn v\u1EABn hi\u1EC7n \"01\". Build xanh, kh\u00F4ng c\u1EA3nh b\u00E1o n\u00E0o.\n -->\n <select\n [ngModel]=\"effectivePageIndex()\"\n (ngModelChange)=\"goToPage(+$event)\"\n aria-label=\"Ch\u1ECDn trang\"\n class=\"appearance-none h-9 px-3.5 py-0 bg-neutral-tertiary border border-default rounded-md text-sm font-medium text-body pr-8 shadow-xs cursor-pointer focus:outline-hidden focus:ring-1 focus:ring-brand-subtle whitespace-nowrap\"\n >\n @for (p of pageList(); track p) {\n <option [value]=\"p\">{{ p < 10 ? '0' + p : p }}</option>\n }\n </select>\n <ol-icon\n name=\"alt-arrow-down\"\n size=\"xs\"\n class=\"pointer-events-none absolute right-2.5 text-body\"\n />\n </div>\n\n <!-- Helper info -->\n <span\n class=\"inline-flex items-center h-9 px-3.5 bg-neutral-tertiary/60 rounded-md text-sm font-medium text-fg-disabled select-none whitespace-nowrap\"\n >\n c\u1EE7a {{ calculatedTotalPages() }} trang\n </span>\n\n <!-- Buttons -->\n <div class=\"inline-flex -space-x-px rounded-md shadow-xs isolate shrink-0\">\n <button\n type=\"button\"\n (click)=\"prev()\"\n [disabled]=\"effectivePageIndex() <= 1\"\n class=\"relative inline-flex items-center justify-center w-9 h-9 rounded-l-md border border-default bg-neutral-primary text-body hover:bg-neutral-secondary-soft disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer shrink-0\"\n aria-label=\"Trang tr\u01B0\u1EDBc\"\n >\n <ol-icon name=\"alt-arrow-left\" size=\"xs\"></ol-icon>\n </button>\n <button\n type=\"button\"\n (click)=\"next()\"\n [disabled]=\"effectivePageIndex() >= calculatedTotalPages()\"\n class=\"relative inline-flex items-center justify-center w-9 h-9 rounded-r-md border border-default bg-neutral-primary text-body hover:bg-neutral-secondary-soft disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer shrink-0\"\n aria-label=\"Trang sau\"\n >\n <ol-icon name=\"alt-arrow-right\" size=\"xs\"></ol-icon>\n </button>\n </div>\n </div>\n }\n\n <!-- 4. Type = Text buttons (Figma #1149:81874) -->\n @if (effectiveType() === 'text-buttons') {\n <nav\n class=\"inline-flex -space-x-px rounded-md shadow-xs isolate select-none max-w-full overflow-x-auto\"\n aria-label=\"Ph\u00E2n trang\"\n >\n <!-- Trang tr\u01B0\u1EDBc -->\n <button\n type=\"button\"\n (click)=\"prev()\"\n [disabled]=\"effectivePageIndex() <= 1\"\n class=\"relative inline-flex items-center justify-center px-3.5 h-9 rounded-l-md text-sm font-medium border border-default disabled:opacity-40 disabled:cursor-not-allowed disabled:bg-disabled cursor-pointer bg-neutral-primary text-body hover:bg-neutral-secondary-soft whitespace-nowrap shrink-0\"\n >\n Trang tr\u01B0\u1EDBc\n </button>\n\n <!-- Pages -->\n @for (page of visiblePages(); track trackPage($index, page)) {\n @if (page === 'ellipsis') {\n <span\n class=\"relative inline-flex items-center justify-center px-3 h-9 text-sm text-fg-disabled bg-neutral-primary border border-default select-none whitespace-nowrap shrink-0\"\n aria-hidden=\"true\"\n >\n ...\n </span>\n } @else {\n <button\n type=\"button\"\n (click)=\"goToPage(page)\"\n [attr.aria-current]=\"page === effectivePageIndex() ? 'page' : null\"\n [attr.aria-label]=\"'Trang ' + page\"\n [class]=\"\n 'relative inline-flex items-center justify-center min-w-9 h-9 px-3 text-sm transition-colors cursor-pointer whitespace-nowrap shrink-0 ' +\n (page === effectivePageIndex()\n ? 'z-10 font-semibold border border-brand bg-brand text-white'\n : 'font-medium border border-default bg-neutral-primary-soft text-body hover:bg-neutral-secondary-soft')\n \"\n >\n {{ page }}\n </button>\n }\n }\n\n <!-- Trang sau -->\n <button\n type=\"button\"\n (click)=\"next()\"\n [disabled]=\"effectivePageIndex() >= calculatedTotalPages()\"\n class=\"relative inline-flex items-center justify-center px-3.5 h-9 rounded-r-md text-sm font-medium border border-default disabled:opacity-40 disabled:cursor-not-allowed disabled:bg-disabled cursor-pointer bg-neutral-primary text-body hover:bg-neutral-secondary-soft whitespace-nowrap shrink-0\"\n >\n Trang sau\n </button>\n </nav>\n }\n\n <!-- 5. Type = Two buttons (Figma #1149:81887) -->\n @if (effectiveType() === 'two-buttons') {\n <div class=\"inline-flex items-center gap-3 select-none flex-wrap\">\n <button\n type=\"button\"\n (click)=\"prev()\"\n [disabled]=\"effectivePageIndex() <= 1\"\n class=\"inline-flex items-center gap-1.5 px-3.5 py-2 bg-neutral-primary rounded-md border border-default text-sm font-medium text-body shadow-2xs hover:bg-neutral-secondary-soft disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer whitespace-nowrap\"\n >\n <ol-icon name=\"arrow-left\" size=\"xs\" />\n <span>Trang tr\u01B0\u1EDBc</span>\n </button>\n\n <button\n type=\"button\"\n (click)=\"next()\"\n [disabled]=\"effectivePageIndex() >= calculatedTotalPages()\"\n class=\"inline-flex items-center gap-1.5 px-3.5 py-2 bg-neutral-primary rounded-md border border-default text-sm font-medium text-body shadow-2xs hover:bg-neutral-secondary-soft disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer whitespace-nowrap\"\n >\n <span>Trang sau</span>\n <ol-icon name=\"arrow-right\" size=\"xs\" />\n </button>\n </div>\n }\n\n <!-- 6. Type = Buttons & helper text (Figma #1149:80890) -->\n @if (effectiveType() === 'buttons-helper') {\n <div class=\"flex flex-col items-center gap-3 select-none\">\n @if (showText()) {\n <p class=\"text-sm font-medium text-body text-center whitespace-nowrap\">\n Hi\u1EC3n th\u1ECB t\u1EEB <strong class=\"text-heading font-semibold\">{{ startRecord() }}</strong> \u0111\u1EBFn\n <strong class=\"text-heading font-semibold\">{{ endRecord() }}</strong> trong\n <strong class=\"text-heading font-semibold\">{{ total() }}</strong> d\u1EEF li\u1EC7u\n </p>\n }\n <div class=\"inline-flex -space-x-px rounded-md shadow-xs isolate shrink-0\">\n <button\n type=\"button\"\n (click)=\"prev()\"\n [disabled]=\"effectivePageIndex() <= 1\"\n class=\"relative inline-flex items-center justify-center px-4 h-9 rounded-l-md text-sm font-medium border border-default bg-neutral-primary text-body hover:bg-neutral-secondary-soft disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer whitespace-nowrap\"\n >\n Tr\u01B0\u1EDBc\n </button>\n <button\n type=\"button\"\n (click)=\"next()\"\n [disabled]=\"effectivePageIndex() >= calculatedTotalPages()\"\n class=\"relative inline-flex items-center justify-center px-4 h-9 rounded-r-md text-sm font-medium border border-default bg-neutral-primary text-body hover:bg-neutral-secondary-soft disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer whitespace-nowrap\"\n >\n Sau\n </button>\n </div>\n </div>\n }\n\n <!-- 7. Type = With dropdown (Figma #1149:81895) -->\n @if (effectiveType() === 'with-dropdown') {\n <div class=\"inline-flex items-center gap-4 select-none flex-wrap\">\n <nav\n class=\"inline-flex -space-x-px rounded-md shadow-xs isolate max-w-full overflow-x-auto\"\n aria-label=\"Ph\u00E2n trang\"\n >\n <button\n type=\"button\"\n (click)=\"prev()\"\n [disabled]=\"effectivePageIndex() <= 1\"\n class=\"relative inline-flex items-center justify-center px-3.5 h-9 rounded-l-md text-sm font-medium border border-default bg-neutral-primary text-body hover:bg-neutral-secondary-soft disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer whitespace-nowrap shrink-0\"\n >\n Trang tr\u01B0\u1EDBc\n </button>\n\n @for (page of visiblePages(); track trackPage($index, page)) {\n @if (page === 'ellipsis') {\n <span\n class=\"relative inline-flex items-center justify-center px-3 h-9 text-sm text-fg-disabled bg-neutral-primary border border-default select-none whitespace-nowrap shrink-0\"\n aria-hidden=\"true\"\n >\n ...\n </span>\n } @else {\n <button\n type=\"button\"\n (click)=\"goToPage(page)\"\n [attr.aria-current]=\"page === effectivePageIndex() ? 'page' : null\"\n [attr.aria-label]=\"'Trang ' + page\"\n [attr.aria-label]=\"'Trang ' + page\"\n [class]=\"\n 'relative inline-flex items-center justify-center min-w-9 h-9 px-3 text-sm transition-colors cursor-pointer whitespace-nowrap shrink-0 ' +\n (page === effectivePageIndex()\n ? 'z-10 font-semibold border border-brand bg-brand text-white'\n : 'font-medium border border-default bg-neutral-primary-soft text-body hover:bg-neutral-secondary-soft')\n \"\n >\n {{ page }}\n </button>\n }\n }\n\n <button\n type=\"button\"\n (click)=\"next()\"\n [disabled]=\"effectivePageIndex() >= calculatedTotalPages()\"\n class=\"relative inline-flex items-center justify-center px-3.5 h-9 rounded-r-md text-sm font-medium border border-default bg-neutral-primary text-body hover:bg-neutral-secondary-soft disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer whitespace-nowrap shrink-0\"\n >\n Trang sau\n </button>\n </nav>\n\n <!-- Dropdown select size -->\n <div class=\"relative inline-flex items-center shrink-0\">\n <!-- `[ngModel]` \u2014 xem l\u00FD do \u1EDF \u00F4 ch\u1ECDn trang c\u1EE7a Type `Dropdown & buttons`. -->\n <select\n [ngModel]=\"pageSize()\"\n (ngModelChange)=\"onPageSizeChange(+$event)\"\n aria-label=\"S\u1ED1 d\u00F2ng m\u1ED7i trang\"\n class=\"appearance-none h-9 px-3.5 py-0 bg-neutral-tertiary border border-default rounded-md text-sm font-medium text-body pr-8 shadow-xs cursor-pointer focus:outline-hidden focus:ring-1 focus:ring-brand-subtle whitespace-nowrap\"\n >\n @for (opt of pageSizeOptions(); track opt) {\n <option [value]=\"opt\">{{ opt }} m\u1ED7i trang</option>\n }\n </select>\n <ol-icon\n name=\"alt-arrow-down\"\n size=\"xs\"\n class=\"pointer-events-none absolute right-2.5 text-body\"\n />\n </div>\n </div>\n }\n\n <!-- 8. Type = With input (Figma #1149:81910) -->\n @if (effectiveType() === 'with-input') {\n <div class=\"inline-flex items-center gap-4 select-none flex-wrap\">\n <nav\n class=\"inline-flex -space-x-px rounded-md shadow-xs isolate max-w-full overflow-x-auto\"\n aria-label=\"Ph\u00E2n trang\"\n >\n <button\n type=\"button\"\n (click)=\"prev()\"\n [disabled]=\"effectivePageIndex() <= 1\"\n class=\"relative inline-flex items-center justify-center px-3.5 h-9 rounded-l-md text-sm font-medium border border-default bg-neutral-primary text-body hover:bg-neutral-secondary-soft disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer whitespace-nowrap shrink-0\"\n >\n Trang tr\u01B0\u1EDBc\n </button>\n\n @for (page of visiblePages(); track trackPage($index, page)) {\n @if (page === 'ellipsis') {\n <span\n class=\"relative inline-flex items-center justify-center px-3 h-9 text-sm text-fg-disabled bg-neutral-primary border border-default select-none whitespace-nowrap shrink-0\"\n aria-hidden=\"true\"\n >\n ...\n </span>\n } @else {\n <button\n type=\"button\"\n (click)=\"goToPage(page)\"\n [attr.aria-current]=\"page === effectivePageIndex() ? 'page' : null\"\n [attr.aria-label]=\"'Trang ' + page\"\n [attr.aria-label]=\"'Trang ' + page\"\n [class]=\"\n 'relative inline-flex items-center justify-center min-w-9 h-9 px-3 text-sm transition-colors cursor-pointer whitespace-nowrap shrink-0 ' +\n (page === effectivePageIndex()\n ? 'z-10 font-semibold border border-brand bg-brand text-white'\n : 'font-medium border border-default bg-neutral-primary-soft text-body hover:bg-neutral-secondary-soft')\n \"\n >\n {{ page }}\n </button>\n }\n }\n\n <button\n type=\"button\"\n (click)=\"next()\"\n [disabled]=\"effectivePageIndex() >= calculatedTotalPages()\"\n class=\"relative inline-flex items-center justify-center px-3.5 h-9 rounded-r-md text-sm font-medium border border-default bg-neutral-primary text-body hover:bg-neutral-secondary-soft disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer whitespace-nowrap shrink-0\"\n >\n Trang sau\n </button>\n </nav>\n\n <!-- Input Jump -->\n <div class=\"flex items-center gap-2 text-sm text-body whitespace-nowrap shrink-0\">\n <span>\u0110i t\u1EDBi</span>\n <input\n type=\"number\"\n min=\"1\"\n [max]=\"calculatedTotalPages()\"\n [ngModel]=\"jumpPageValue()\"\n (ngModelChange)=\"jumpPageValue.set($event)\"\n (keydown.enter)=\"handleJump()\"\n placeholder=\"199\"\n class=\"w-16 px-2.5 py-1.5 text-center text-sm bg-neutral-tertiary border border-default rounded-md focus:outline-hidden focus:ring-1 focus:ring-brand-subtle text-heading shadow-xs\"\n />\n <span>trang</span>\n </div>\n </div>\n }\n\n <!-- 9. Type = Simple with input (Figma #1149:81928) -->\n @if (effectiveType() === 'simple-input') {\n <div class=\"inline-flex items-center gap-3 select-none flex-wrap\">\n <span class=\"text-sm font-medium text-body whitespace-nowrap\">\u0110i t\u1EDBi trang</span>\n <input\n type=\"number\"\n min=\"1\"\n [max]=\"calculatedTotalPages()\"\n [ngModel]=\"jumpPageValue()\"\n (ngModelChange)=\"jumpPageValue.set($event)\"\n (keydown.enter)=\"handleJump()\"\n placeholder=\"20\"\n class=\"w-16 px-2.5 py-1.5 text-center text-sm bg-neutral-tertiary border border-default rounded-md focus:outline-hidden focus:ring-1 focus:ring-brand-subtle text-heading shadow-xs\"\n />\n <button\n type=\"button\"\n (click)=\"handleJump()\"\n class=\"px-4 py-1.5 bg-brand hover:bg-brand-strong text-white text-sm font-medium rounded-md shadow-xs cursor-pointer transition-colors whitespace-nowrap\"\n >\n \u0110i\n </button>\n </div>\n }\n</div>\n", dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1$1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1$1.DefaultValueAccessor, selector: "input:not([type=checkbox]):not([ngNoCva])[formControlName],textarea:not([ngNoCva])[formControlName],input:not([type=checkbox]):not([ngNoCva])[formControl],textarea:not([ngNoCva])[formControl],input:not([type=checkbox]):not([ngNoCva])[ngModel],textarea:not([ngNoCva])[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1$1.NumberValueAccessor, selector: "input[type=number]:not([ngNoCva])[formControlName],input[type=number]:not([ngNoCva])[formControl],input[type=number]:not([ngNoCva])[ngModel]" }, { kind: "directive", type: i1$1.SelectControlValueAccessor, selector: "select:not([multiple]):not([ngNoCva])[formControlName],select:not([multiple]):not([ngNoCva])[formControl],select:not([multiple]):not([ngNoCva])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.MinValidator, selector: "input[type=number][min][formControlName],input[type=number][min][formControl],input[type=number][min][ngModel]", inputs: ["min"] }, { kind: "directive", type: i1$1.MaxValidator, selector: "input[type=number][max][formControlName],input[type=number][max][formControl],input[type=number][max][ngModel]", inputs: ["max"] }, { kind: "directive", type: i1$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "component", type: OlIcon, selector: "ol-icon", inputs: ["name", "size", "color", "variant", "strokeWidth"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
14751
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.6", type: OlPagination, isStandalone: true, selector: "ol-pagination", inputs: { type: { classPropertyName: "type", publicName: "type", isSignal: true, isRequired: false, transformFunction: null }, variant: { classPropertyName: "variant", publicName: "variant", isSignal: true, isRequired: false, transformFunction: null }, total: { classPropertyName: "total", publicName: "total", isSignal: true, isRequired: false, transformFunction: null }, pageSize: { classPropertyName: "pageSize", publicName: "pageSize", isSignal: true, isRequired: false, transformFunction: null }, pageIndex: { classPropertyName: "pageIndex", publicName: "pageIndex", isSignal: true, isRequired: false, transformFunction: null }, currentPage: { classPropertyName: "currentPage", publicName: "currentPage", isSignal: true, isRequired: false, transformFunction: null }, totalPagesInput: { classPropertyName: "totalPagesInput", publicName: "totalPages", isSignal: true, isRequired: false, transformFunction: null }, pageSizeOptions: { classPropertyName: "pageSizeOptions", publicName: "pageSizeOptions", isSignal: true, isRequired: false, transformFunction: null }, showText: { classPropertyName: "showText", publicName: "showText", isSignal: true, isRequired: false, transformFunction: null }, text: { classPropertyName: "text", publicName: "text", isSignal: true, isRequired: false, transformFunction: null }, showInfo: { classPropertyName: "showInfo", publicName: "showInfo", isSignal: true, isRequired: false, transformFunction: null }, withDropdown: { classPropertyName: "withDropdown", publicName: "withDropdown", isSignal: true, isRequired: false, transformFunction: null }, showSizeChanger: { classPropertyName: "showSizeChanger", publicName: "showSizeChanger", isSignal: true, isRequired: false, transformFunction: null }, showQuickJumper: { classPropertyName: "showQuickJumper", publicName: "showQuickJumper", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { pageSize: "pageSizeChange", pageIndex: "pageIndexChange", currentPage: "currentPageChange", pageChange: "pageChange" }, host: { classAttribute: "block w-full" }, ngImport: i0, template: "<div class=\"flex items-center gap-4 flex-wrap max-w-full\">\n <!-- 1. Type = Default (Figma #1149:81851) -->\n @if (layoutType() === 'default') {\n <nav\n class=\"inline-flex -space-x-px rounded-md shadow-xs isolate select-none\"\n aria-label=\"Ph\u00E2n trang\"\n >\n <!-- Prev Button -->\n <button\n type=\"button\"\n (click)=\"prev()\"\n [disabled]=\"effectivePageIndex() <= 1\"\n class=\"relative inline-flex items-center justify-center w-9 h-9 rounded-l-md border border-default transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:bg-disabled cursor-pointer bg-neutral-primary text-body hover:bg-neutral-secondary-soft shrink-0\"\n aria-label=\"Trang tr\u01B0\u1EDBc\"\n >\n <ol-icon name=\"alt-arrow-left\" size=\"xs\"></ol-icon>\n </button>\n\n <!-- Pages Array -->\n @for (page of visiblePages(); track trackPage($index, page)) {\n @if (page === 'ellipsis') {\n <span\n class=\"relative inline-flex items-center justify-center px-3 h-9 text-sm text-fg-disabled bg-neutral-primary border border-default select-none whitespace-nowrap shrink-0\"\n aria-hidden=\"true\"\n >\n ...\n </span>\n } @else {\n <button\n type=\"button\"\n (click)=\"goToPage(page)\"\n [attr.aria-current]=\"page === effectivePageIndex() ? 'page' : null\"\n [attr.aria-label]=\"'Trang ' + page\"\n [class]=\"\n 'relative inline-flex items-center justify-center min-w-9 h-9 px-3 text-sm transition-colors cursor-pointer whitespace-nowrap shrink-0 ' +\n (page === effectivePageIndex()\n ? 'z-10 font-semibold border border-brand bg-brand text-white'\n : 'font-medium border border-default bg-neutral-primary-soft text-body hover:bg-neutral-secondary-soft')\n \"\n >\n {{ page }}\n </button>\n }\n }\n\n <!-- Next Button -->\n <button\n type=\"button\"\n (click)=\"next()\"\n [disabled]=\"effectivePageIndex() >= calculatedTotalPages()\"\n class=\"relative inline-flex items-center justify-center w-9 h-9 rounded-r-md border border-default transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:bg-disabled cursor-pointer bg-neutral-primary text-body hover:bg-neutral-secondary-soft shrink-0\"\n aria-label=\"Trang sau\"\n >\n <ol-icon name=\"alt-arrow-right\" size=\"xs\"></ol-icon>\n </button>\n </nav>\n }\n\n <!-- 2. Type = Arrows (Figma #1149:81864) -->\n @if (layoutType() === 'arrows') {\n <nav\n class=\"inline-flex -space-x-px rounded-md shadow-xs isolate select-none\"\n aria-label=\"Ph\u00E2n trang\"\n >\n <button\n type=\"button\"\n (click)=\"prev()\"\n [disabled]=\"effectivePageIndex() <= 1\"\n class=\"relative inline-flex items-center justify-center w-9 h-9 rounded-l-md border border-default transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:bg-disabled cursor-pointer bg-neutral-primary text-body hover:bg-neutral-secondary-soft shrink-0\"\n aria-label=\"Trang tr\u01B0\u1EDBc\"\n >\n <ol-icon name=\"alt-arrow-left\" size=\"xs\"></ol-icon>\n </button>\n\n <span\n class=\"relative inline-flex items-center justify-center px-4 h-9 text-sm font-medium text-body bg-neutral-primary border border-default select-none whitespace-nowrap shrink-0\"\n >\n {{ effectivePageIndex() }} tr\u00EAn {{ calculatedTotalPages() }}\n </span>\n\n <button\n type=\"button\"\n (click)=\"next()\"\n [disabled]=\"effectivePageIndex() >= calculatedTotalPages()\"\n class=\"relative inline-flex items-center justify-center w-9 h-9 rounded-r-md border border-default transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:bg-disabled cursor-pointer bg-neutral-primary text-body hover:bg-neutral-secondary-soft shrink-0\"\n aria-label=\"Trang sau\"\n >\n <ol-icon name=\"alt-arrow-right\" size=\"xs\"></ol-icon>\n </button>\n </nav>\n }\n\n <!-- 3. Type = Dropdown & buttons (Figma #1149:81868) -->\n @if (layoutType() === 'dropdown-buttons') {\n <div class=\"inline-flex items-center gap-3 select-none flex-wrap\">\n <!-- Dropdown button -->\n <div class=\"relative inline-flex items-center\">\n <!--\n `[ngModel]` ch\u1EE9 KH\u00D4NG ph\u1EA3i `[value]`: `[value]` tr\u00EAn <select> \u0111\u1EB7t\n thu\u1ED9c t\u00EDnh DOM NGAY, l\u00FAc c\u00E1c <option> c\u1EE7a @for ch\u01B0a t\u1ED3n t\u1EA1i, n\u00EAn tr\u00ECnh\n duy\u1EC7t ho\u00E0n nguy\u00EAn v\u1EC1 option \u0111\u1EA7u. \u0110o 2026-09-17: trang hi\u1EC7n t\u1EA1i l\u00E0 3 m\u00E0\n \u00F4 ch\u1ECDn v\u1EABn hi\u1EC7n \"01\". Build xanh, kh\u00F4ng c\u1EA3nh b\u00E1o n\u00E0o.\n -->\n <select\n [ngModel]=\"effectivePageIndex()\"\n (ngModelChange)=\"goToPage(+$event)\"\n aria-label=\"Ch\u1ECDn trang\"\n class=\"appearance-none h-9 px-3.5 py-0 bg-neutral-tertiary border border-default rounded-md text-sm font-medium text-body pr-8 shadow-xs cursor-pointer focus:outline-hidden focus:ring-1 focus:ring-brand-subtle whitespace-nowrap\"\n >\n @for (p of pageList(); track p) {\n <option [value]=\"p\">{{ p < 10 ? '0' + p : p }}</option>\n }\n </select>\n <ol-icon\n name=\"alt-arrow-down\"\n size=\"xs\"\n class=\"pointer-events-none absolute right-2.5 text-body\"\n />\n </div>\n\n <!-- Helper info -->\n <span\n class=\"inline-flex items-center h-9 px-3.5 bg-neutral-tertiary/60 rounded-md text-sm font-medium text-fg-disabled select-none whitespace-nowrap\"\n >\n c\u1EE7a {{ calculatedTotalPages() }} trang\n </span>\n\n <!-- Buttons -->\n <div class=\"inline-flex -space-x-px rounded-md shadow-xs isolate shrink-0\">\n <button\n type=\"button\"\n (click)=\"prev()\"\n [disabled]=\"effectivePageIndex() <= 1\"\n class=\"relative inline-flex items-center justify-center w-9 h-9 rounded-l-md border border-default bg-neutral-primary text-body hover:bg-neutral-secondary-soft disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer shrink-0\"\n aria-label=\"Trang tr\u01B0\u1EDBc\"\n >\n <ol-icon name=\"alt-arrow-left\" size=\"xs\"></ol-icon>\n </button>\n <button\n type=\"button\"\n (click)=\"next()\"\n [disabled]=\"effectivePageIndex() >= calculatedTotalPages()\"\n class=\"relative inline-flex items-center justify-center w-9 h-9 rounded-r-md border border-default bg-neutral-primary text-body hover:bg-neutral-secondary-soft disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer shrink-0\"\n aria-label=\"Trang sau\"\n >\n <ol-icon name=\"alt-arrow-right\" size=\"xs\"></ol-icon>\n </button>\n </div>\n </div>\n }\n\n <!-- 4. Type = Text buttons (Figma #1149:81874) -->\n @if (layoutType() === 'text-buttons') {\n <nav\n class=\"inline-flex -space-x-px rounded-md shadow-xs isolate select-none max-w-full overflow-x-auto\"\n aria-label=\"Ph\u00E2n trang\"\n >\n <!-- Trang tr\u01B0\u1EDBc -->\n <button\n type=\"button\"\n (click)=\"prev()\"\n [disabled]=\"effectivePageIndex() <= 1\"\n class=\"relative inline-flex items-center justify-center px-3.5 h-9 rounded-l-md text-sm font-medium border border-default disabled:opacity-40 disabled:cursor-not-allowed disabled:bg-disabled cursor-pointer bg-neutral-primary text-body hover:bg-neutral-secondary-soft whitespace-nowrap shrink-0\"\n >\n Trang tr\u01B0\u1EDBc\n </button>\n\n <!-- Pages -->\n @for (page of visiblePages(); track trackPage($index, page)) {\n @if (page === 'ellipsis') {\n <span\n class=\"relative inline-flex items-center justify-center px-3 h-9 text-sm text-fg-disabled bg-neutral-primary border border-default select-none whitespace-nowrap shrink-0\"\n aria-hidden=\"true\"\n >\n ...\n </span>\n } @else {\n <button\n type=\"button\"\n (click)=\"goToPage(page)\"\n [attr.aria-current]=\"page === effectivePageIndex() ? 'page' : null\"\n [attr.aria-label]=\"'Trang ' + page\"\n [class]=\"\n 'relative inline-flex items-center justify-center min-w-9 h-9 px-3 text-sm transition-colors cursor-pointer whitespace-nowrap shrink-0 ' +\n (page === effectivePageIndex()\n ? 'z-10 font-semibold border border-brand bg-brand text-white'\n : 'font-medium border border-default bg-neutral-primary-soft text-body hover:bg-neutral-secondary-soft')\n \"\n >\n {{ page }}\n </button>\n }\n }\n\n <!-- Trang sau -->\n <button\n type=\"button\"\n (click)=\"next()\"\n [disabled]=\"effectivePageIndex() >= calculatedTotalPages()\"\n class=\"relative inline-flex items-center justify-center px-3.5 h-9 rounded-r-md text-sm font-medium border border-default disabled:opacity-40 disabled:cursor-not-allowed disabled:bg-disabled cursor-pointer bg-neutral-primary text-body hover:bg-neutral-secondary-soft whitespace-nowrap shrink-0\"\n >\n Trang sau\n </button>\n </nav>\n }\n\n <!-- 5. Type = Two buttons (Figma #1149:81887) -->\n @if (layoutType() === 'two-buttons') {\n <div class=\"inline-flex items-center gap-3 select-none flex-wrap\">\n <button\n type=\"button\"\n (click)=\"prev()\"\n [disabled]=\"effectivePageIndex() <= 1\"\n class=\"inline-flex items-center gap-1.5 px-3.5 py-2 bg-neutral-primary rounded-md border border-default text-sm font-medium text-body shadow-2xs hover:bg-neutral-secondary-soft disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer whitespace-nowrap\"\n >\n <ol-icon name=\"arrow-left\" size=\"xs\" />\n <span>Trang tr\u01B0\u1EDBc</span>\n </button>\n\n <button\n type=\"button\"\n (click)=\"next()\"\n [disabled]=\"effectivePageIndex() >= calculatedTotalPages()\"\n class=\"inline-flex items-center gap-1.5 px-3.5 py-2 bg-neutral-primary rounded-md border border-default text-sm font-medium text-body shadow-2xs hover:bg-neutral-secondary-soft disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer whitespace-nowrap\"\n >\n <span>Trang sau</span>\n <ol-icon name=\"arrow-right\" size=\"xs\" />\n </button>\n </div>\n }\n\n <!-- 6. Type = Buttons & helper text (Figma #1149:80890) -->\n @if (layoutType() === 'buttons-helper') {\n <div class=\"flex flex-col items-center gap-3 select-none\">\n @if (showText()) {\n <p class=\"text-sm font-medium text-body text-center whitespace-nowrap\">\n Hi\u1EC3n th\u1ECB t\u1EEB <strong class=\"text-heading font-semibold\">{{ startRecord() }}</strong> \u0111\u1EBFn\n <strong class=\"text-heading font-semibold\">{{ endRecord() }}</strong> trong\n <strong class=\"text-heading font-semibold\">{{ total() }}</strong> d\u1EEF li\u1EC7u\n </p>\n }\n <div class=\"inline-flex -space-x-px rounded-md shadow-xs isolate shrink-0\">\n <button\n type=\"button\"\n (click)=\"prev()\"\n [disabled]=\"effectivePageIndex() <= 1\"\n class=\"relative inline-flex items-center justify-center px-4 h-9 rounded-l-md text-sm font-medium border border-default bg-neutral-primary text-body hover:bg-neutral-secondary-soft disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer whitespace-nowrap\"\n >\n Tr\u01B0\u1EDBc\n </button>\n <button\n type=\"button\"\n (click)=\"next()\"\n [disabled]=\"effectivePageIndex() >= calculatedTotalPages()\"\n class=\"relative inline-flex items-center justify-center px-4 h-9 rounded-r-md text-sm font-medium border border-default bg-neutral-primary text-body hover:bg-neutral-secondary-soft disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer whitespace-nowrap\"\n >\n Sau\n </button>\n </div>\n </div>\n }\n\n <!-- 8. Type = With input (Figma #1149:81910) -->\n @if (layoutType() === 'with-input') {\n <div class=\"inline-flex items-center gap-4 select-none flex-wrap\">\n <nav\n class=\"inline-flex -space-x-px rounded-md shadow-xs isolate max-w-full overflow-x-auto\"\n aria-label=\"Ph\u00E2n trang\"\n >\n <button\n type=\"button\"\n (click)=\"prev()\"\n [disabled]=\"effectivePageIndex() <= 1\"\n class=\"relative inline-flex items-center justify-center px-3.5 h-9 rounded-l-md text-sm font-medium border border-default bg-neutral-primary text-body hover:bg-neutral-secondary-soft disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer whitespace-nowrap shrink-0\"\n >\n Trang tr\u01B0\u1EDBc\n </button>\n\n @for (page of visiblePages(); track trackPage($index, page)) {\n @if (page === 'ellipsis') {\n <span\n class=\"relative inline-flex items-center justify-center px-3 h-9 text-sm text-fg-disabled bg-neutral-primary border border-default select-none whitespace-nowrap shrink-0\"\n aria-hidden=\"true\"\n >\n ...\n </span>\n } @else {\n <button\n type=\"button\"\n (click)=\"goToPage(page)\"\n [attr.aria-current]=\"page === effectivePageIndex() ? 'page' : null\"\n [attr.aria-label]=\"'Trang ' + page\"\n [class]=\"\n 'relative inline-flex items-center justify-center min-w-9 h-9 px-3 text-sm transition-colors cursor-pointer whitespace-nowrap shrink-0 ' +\n (page === effectivePageIndex()\n ? 'z-10 font-semibold border border-brand bg-brand text-white'\n : 'font-medium border border-default bg-neutral-primary-soft text-body hover:bg-neutral-secondary-soft')\n \"\n >\n {{ page }}\n </button>\n }\n }\n\n <button\n type=\"button\"\n (click)=\"next()\"\n [disabled]=\"effectivePageIndex() >= calculatedTotalPages()\"\n class=\"relative inline-flex items-center justify-center px-3.5 h-9 rounded-r-md text-sm font-medium border border-default bg-neutral-primary text-body hover:bg-neutral-secondary-soft disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer whitespace-nowrap shrink-0\"\n >\n Trang sau\n </button>\n </nav>\n\n <!-- Input Jump -->\n <div class=\"flex items-center gap-2 text-sm text-body whitespace-nowrap shrink-0\">\n <span>\u0110i t\u1EDBi</span>\n <input\n type=\"number\"\n min=\"1\"\n [max]=\"calculatedTotalPages()\"\n [ngModel]=\"jumpPageValue()\"\n (ngModelChange)=\"jumpPageValue.set($event)\"\n (keydown.enter)=\"handleJump()\"\n placeholder=\"199\"\n class=\"w-16 px-2.5 py-1.5 text-center text-sm bg-neutral-tertiary border border-default rounded-md focus:outline-hidden focus:ring-1 focus:ring-brand-subtle text-heading shadow-xs\"\n />\n <span>trang</span>\n </div>\n </div>\n }\n\n <!-- 9. Type = Simple with input (Figma #1149:81928) -->\n @if (layoutType() === 'simple-input') {\n <div class=\"inline-flex items-center gap-3 select-none flex-wrap\">\n <span class=\"text-sm font-medium text-body whitespace-nowrap\">\u0110i t\u1EDBi trang</span>\n <input\n type=\"number\"\n min=\"1\"\n [max]=\"calculatedTotalPages()\"\n [ngModel]=\"jumpPageValue()\"\n (ngModelChange)=\"jumpPageValue.set($event)\"\n (keydown.enter)=\"handleJump()\"\n placeholder=\"20\"\n class=\"w-16 px-2.5 py-1.5 text-center text-sm bg-neutral-tertiary border border-default rounded-md focus:outline-hidden focus:ring-1 focus:ring-brand-subtle text-heading shadow-xs\"\n />\n <button\n type=\"button\"\n (click)=\"handleJump()\"\n class=\"px-4 py-1.5 bg-brand hover:bg-brand-strong text-white text-sm font-medium rounded-md shadow-xs cursor-pointer transition-colors whitespace-nowrap\"\n >\n \u0110i\n </button>\n </div>\n }\n @if (hasPageSizeDropdown()) {\n <!--\n \u00D4 ch\u1ECDn c\u1EE1 trang \u2014 C\u1EDC RI\u00CANG `withDropdown`, g\u1EAFn \u0111\u01B0\u1EE3c v\u00E0o m\u1ECDi type (Figma\n `With dropdown` #1149:81895 l\u00E0 n\u01A1i n\u00F3 xu\u1EA5t hi\u1EC7n \u0111\u1EA7u ti\u00EAn). Vi\u1EBFt M\u1ED8T ch\u1ED7 \u1EDF\n \u0111\u00E2y thay v\u00EC ch\u00E9p v\u00E0o t\u1EEBng type.\n -->\n <div class=\"relative inline-flex items-center shrink-0\">\n <!-- `[ngModel]` \u2014 xem l\u00FD do \u1EDF \u00F4 ch\u1ECDn trang c\u1EE7a Type `Dropdown & buttons`. -->\n <select\n [ngModel]=\"pageSize()\"\n (ngModelChange)=\"onPageSizeChange(+$event)\"\n aria-label=\"S\u1ED1 d\u00F2ng m\u1ED7i trang\"\n class=\"appearance-none h-9 px-3.5 py-0 bg-neutral-tertiary border border-default rounded-md text-sm font-medium text-body pr-8 shadow-xs cursor-pointer focus:outline-hidden focus:ring-1 focus:ring-brand-subtle whitespace-nowrap\"\n >\n @for (opt of pageSizeOptions(); track opt) {\n <option [value]=\"opt\">{{ opt }} m\u1ED7i trang</option>\n }\n </select>\n <ol-icon\n name=\"alt-arrow-down\"\n size=\"xs\"\n class=\"pointer-events-none absolute right-2.5 text-body\"\n />\n </div>\n }\n</div>\n", dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1$1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1$1.DefaultValueAccessor, selector: "input:not([type=checkbox]):not([ngNoCva])[formControlName],textarea:not([ngNoCva])[formControlName],input:not([type=checkbox]):not([ngNoCva])[formControl],textarea:not([ngNoCva])[formControl],input:not([type=checkbox]):not([ngNoCva])[ngModel],textarea:not([ngNoCva])[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1$1.NumberValueAccessor, selector: "input[type=number]:not([ngNoCva])[formControlName],input[type=number]:not([ngNoCva])[formControl],input[type=number]:not([ngNoCva])[ngModel]" }, { kind: "directive", type: i1$1.SelectControlValueAccessor, selector: "select:not([multiple]):not([ngNoCva])[formControlName],select:not([multiple]):not([ngNoCva])[formControl],select:not([multiple]):not([ngNoCva])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.MinValidator, selector: "input[type=number][min][formControlName],input[type=number][min][formControl],input[type=number][min][ngModel]", inputs: ["min"] }, { kind: "directive", type: i1$1.MaxValidator, selector: "input[type=number][max][formControlName],input[type=number][max][formControl],input[type=number][max][ngModel]", inputs: ["max"] }, { kind: "directive", type: i1$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "component", type: OlIcon, selector: "ol-icon", inputs: ["name", "size", "color", "variant", "strokeWidth"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
12700
14752
  }
12701
14753
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: OlPagination, decorators: [{
12702
14754
  type: Component,
12703
14755
  args: [{ selector: 'ol-pagination', standalone: true, imports: [CommonModule, FormsModule, OlIcon], changeDetection: ChangeDetectionStrategy.OnPush, host: {
12704
14756
  class: 'block w-full',
12705
- }, template: "<div class=\"flex items-center gap-4 flex-wrap max-w-full\">\n <!-- 1. Type = Default (Figma #1149:81851) -->\n @if (effectiveType() === 'default') {\n <nav\n class=\"inline-flex -space-x-px rounded-md shadow-xs isolate select-none\"\n aria-label=\"Ph\u00E2n trang\"\n >\n <!-- Prev Button -->\n <button\n type=\"button\"\n (click)=\"prev()\"\n [disabled]=\"effectivePageIndex() <= 1\"\n class=\"relative inline-flex items-center justify-center w-9 h-9 rounded-l-md border border-default transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:bg-disabled cursor-pointer bg-neutral-primary text-body hover:bg-neutral-secondary-soft shrink-0\"\n aria-label=\"Trang tr\u01B0\u1EDBc\"\n >\n <ol-icon name=\"alt-arrow-left\" size=\"xs\"></ol-icon>\n </button>\n\n <!-- Pages Array -->\n @for (page of visiblePages(); track trackPage($index, page)) {\n @if (page === 'ellipsis') {\n <span\n class=\"relative inline-flex items-center justify-center px-3 h-9 text-sm text-fg-disabled bg-neutral-primary border border-default select-none whitespace-nowrap shrink-0\"\n aria-hidden=\"true\"\n >\n ...\n </span>\n } @else {\n <button\n type=\"button\"\n (click)=\"goToPage(page)\"\n [attr.aria-current]=\"page === effectivePageIndex() ? 'page' : null\"\n [attr.aria-label]=\"'Trang ' + page\"\n [class]=\"\n 'relative inline-flex items-center justify-center min-w-9 h-9 px-3 text-sm transition-colors cursor-pointer whitespace-nowrap shrink-0 ' +\n (page === effectivePageIndex()\n ? 'z-10 font-semibold border border-brand bg-brand text-white'\n : 'font-medium border border-default bg-neutral-primary-soft text-body hover:bg-neutral-secondary-soft')\n \"\n >\n {{ page }}\n </button>\n }\n }\n\n <!-- Next Button -->\n <button\n type=\"button\"\n (click)=\"next()\"\n [disabled]=\"effectivePageIndex() >= calculatedTotalPages()\"\n class=\"relative inline-flex items-center justify-center w-9 h-9 rounded-r-md border border-default transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:bg-disabled cursor-pointer bg-neutral-primary text-body hover:bg-neutral-secondary-soft shrink-0\"\n aria-label=\"Trang sau\"\n >\n <ol-icon name=\"alt-arrow-right\" size=\"xs\"></ol-icon>\n </button>\n </nav>\n }\n\n <!-- 2. Type = Arrows (Figma #1149:81864) -->\n @if (effectiveType() === 'arrows') {\n <nav\n class=\"inline-flex -space-x-px rounded-md shadow-xs isolate select-none\"\n aria-label=\"Ph\u00E2n trang\"\n >\n <button\n type=\"button\"\n (click)=\"prev()\"\n [disabled]=\"effectivePageIndex() <= 1\"\n class=\"relative inline-flex items-center justify-center w-9 h-9 rounded-l-md border border-default transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:bg-disabled cursor-pointer bg-neutral-primary text-body hover:bg-neutral-secondary-soft shrink-0\"\n aria-label=\"Trang tr\u01B0\u1EDBc\"\n >\n <ol-icon name=\"alt-arrow-left\" size=\"xs\"></ol-icon>\n </button>\n\n <span\n class=\"relative inline-flex items-center justify-center px-4 h-9 text-sm font-medium text-body bg-neutral-primary border border-default select-none whitespace-nowrap shrink-0\"\n >\n {{ effectivePageIndex() }} tr\u00EAn {{ calculatedTotalPages() }}\n </span>\n\n <button\n type=\"button\"\n (click)=\"next()\"\n [disabled]=\"effectivePageIndex() >= calculatedTotalPages()\"\n class=\"relative inline-flex items-center justify-center w-9 h-9 rounded-r-md border border-default transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:bg-disabled cursor-pointer bg-neutral-primary text-body hover:bg-neutral-secondary-soft shrink-0\"\n aria-label=\"Trang sau\"\n >\n <ol-icon name=\"alt-arrow-right\" size=\"xs\"></ol-icon>\n </button>\n </nav>\n }\n\n <!-- 3. Type = Dropdown & buttons (Figma #1149:81868) -->\n @if (effectiveType() === 'dropdown-buttons') {\n <div class=\"inline-flex items-center gap-3 select-none flex-wrap\">\n <!-- Dropdown button -->\n <div class=\"relative inline-flex items-center\">\n <!--\n `[ngModel]` ch\u1EE9 KH\u00D4NG ph\u1EA3i `[value]`: `[value]` tr\u00EAn <select> \u0111\u1EB7t\n thu\u1ED9c t\u00EDnh DOM NGAY, l\u00FAc c\u00E1c <option> c\u1EE7a @for ch\u01B0a t\u1ED3n t\u1EA1i, n\u00EAn tr\u00ECnh\n duy\u1EC7t ho\u00E0n nguy\u00EAn v\u1EC1 option \u0111\u1EA7u. \u0110o 2026-09-17: trang hi\u1EC7n t\u1EA1i l\u00E0 3 m\u00E0\n \u00F4 ch\u1ECDn v\u1EABn hi\u1EC7n \"01\". Build xanh, kh\u00F4ng c\u1EA3nh b\u00E1o n\u00E0o.\n -->\n <select\n [ngModel]=\"effectivePageIndex()\"\n (ngModelChange)=\"goToPage(+$event)\"\n aria-label=\"Ch\u1ECDn trang\"\n class=\"appearance-none h-9 px-3.5 py-0 bg-neutral-tertiary border border-default rounded-md text-sm font-medium text-body pr-8 shadow-xs cursor-pointer focus:outline-hidden focus:ring-1 focus:ring-brand-subtle whitespace-nowrap\"\n >\n @for (p of pageList(); track p) {\n <option [value]=\"p\">{{ p < 10 ? '0' + p : p }}</option>\n }\n </select>\n <ol-icon\n name=\"alt-arrow-down\"\n size=\"xs\"\n class=\"pointer-events-none absolute right-2.5 text-body\"\n />\n </div>\n\n <!-- Helper info -->\n <span\n class=\"inline-flex items-center h-9 px-3.5 bg-neutral-tertiary/60 rounded-md text-sm font-medium text-fg-disabled select-none whitespace-nowrap\"\n >\n c\u1EE7a {{ calculatedTotalPages() }} trang\n </span>\n\n <!-- Buttons -->\n <div class=\"inline-flex -space-x-px rounded-md shadow-xs isolate shrink-0\">\n <button\n type=\"button\"\n (click)=\"prev()\"\n [disabled]=\"effectivePageIndex() <= 1\"\n class=\"relative inline-flex items-center justify-center w-9 h-9 rounded-l-md border border-default bg-neutral-primary text-body hover:bg-neutral-secondary-soft disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer shrink-0\"\n aria-label=\"Trang tr\u01B0\u1EDBc\"\n >\n <ol-icon name=\"alt-arrow-left\" size=\"xs\"></ol-icon>\n </button>\n <button\n type=\"button\"\n (click)=\"next()\"\n [disabled]=\"effectivePageIndex() >= calculatedTotalPages()\"\n class=\"relative inline-flex items-center justify-center w-9 h-9 rounded-r-md border border-default bg-neutral-primary text-body hover:bg-neutral-secondary-soft disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer shrink-0\"\n aria-label=\"Trang sau\"\n >\n <ol-icon name=\"alt-arrow-right\" size=\"xs\"></ol-icon>\n </button>\n </div>\n </div>\n }\n\n <!-- 4. Type = Text buttons (Figma #1149:81874) -->\n @if (effectiveType() === 'text-buttons') {\n <nav\n class=\"inline-flex -space-x-px rounded-md shadow-xs isolate select-none max-w-full overflow-x-auto\"\n aria-label=\"Ph\u00E2n trang\"\n >\n <!-- Trang tr\u01B0\u1EDBc -->\n <button\n type=\"button\"\n (click)=\"prev()\"\n [disabled]=\"effectivePageIndex() <= 1\"\n class=\"relative inline-flex items-center justify-center px-3.5 h-9 rounded-l-md text-sm font-medium border border-default disabled:opacity-40 disabled:cursor-not-allowed disabled:bg-disabled cursor-pointer bg-neutral-primary text-body hover:bg-neutral-secondary-soft whitespace-nowrap shrink-0\"\n >\n Trang tr\u01B0\u1EDBc\n </button>\n\n <!-- Pages -->\n @for (page of visiblePages(); track trackPage($index, page)) {\n @if (page === 'ellipsis') {\n <span\n class=\"relative inline-flex items-center justify-center px-3 h-9 text-sm text-fg-disabled bg-neutral-primary border border-default select-none whitespace-nowrap shrink-0\"\n aria-hidden=\"true\"\n >\n ...\n </span>\n } @else {\n <button\n type=\"button\"\n (click)=\"goToPage(page)\"\n [attr.aria-current]=\"page === effectivePageIndex() ? 'page' : null\"\n [attr.aria-label]=\"'Trang ' + page\"\n [class]=\"\n 'relative inline-flex items-center justify-center min-w-9 h-9 px-3 text-sm transition-colors cursor-pointer whitespace-nowrap shrink-0 ' +\n (page === effectivePageIndex()\n ? 'z-10 font-semibold border border-brand bg-brand text-white'\n : 'font-medium border border-default bg-neutral-primary-soft text-body hover:bg-neutral-secondary-soft')\n \"\n >\n {{ page }}\n </button>\n }\n }\n\n <!-- Trang sau -->\n <button\n type=\"button\"\n (click)=\"next()\"\n [disabled]=\"effectivePageIndex() >= calculatedTotalPages()\"\n class=\"relative inline-flex items-center justify-center px-3.5 h-9 rounded-r-md text-sm font-medium border border-default disabled:opacity-40 disabled:cursor-not-allowed disabled:bg-disabled cursor-pointer bg-neutral-primary text-body hover:bg-neutral-secondary-soft whitespace-nowrap shrink-0\"\n >\n Trang sau\n </button>\n </nav>\n }\n\n <!-- 5. Type = Two buttons (Figma #1149:81887) -->\n @if (effectiveType() === 'two-buttons') {\n <div class=\"inline-flex items-center gap-3 select-none flex-wrap\">\n <button\n type=\"button\"\n (click)=\"prev()\"\n [disabled]=\"effectivePageIndex() <= 1\"\n class=\"inline-flex items-center gap-1.5 px-3.5 py-2 bg-neutral-primary rounded-md border border-default text-sm font-medium text-body shadow-2xs hover:bg-neutral-secondary-soft disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer whitespace-nowrap\"\n >\n <ol-icon name=\"arrow-left\" size=\"xs\" />\n <span>Trang tr\u01B0\u1EDBc</span>\n </button>\n\n <button\n type=\"button\"\n (click)=\"next()\"\n [disabled]=\"effectivePageIndex() >= calculatedTotalPages()\"\n class=\"inline-flex items-center gap-1.5 px-3.5 py-2 bg-neutral-primary rounded-md border border-default text-sm font-medium text-body shadow-2xs hover:bg-neutral-secondary-soft disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer whitespace-nowrap\"\n >\n <span>Trang sau</span>\n <ol-icon name=\"arrow-right\" size=\"xs\" />\n </button>\n </div>\n }\n\n <!-- 6. Type = Buttons & helper text (Figma #1149:80890) -->\n @if (effectiveType() === 'buttons-helper') {\n <div class=\"flex flex-col items-center gap-3 select-none\">\n @if (showText()) {\n <p class=\"text-sm font-medium text-body text-center whitespace-nowrap\">\n Hi\u1EC3n th\u1ECB t\u1EEB <strong class=\"text-heading font-semibold\">{{ startRecord() }}</strong> \u0111\u1EBFn\n <strong class=\"text-heading font-semibold\">{{ endRecord() }}</strong> trong\n <strong class=\"text-heading font-semibold\">{{ total() }}</strong> d\u1EEF li\u1EC7u\n </p>\n }\n <div class=\"inline-flex -space-x-px rounded-md shadow-xs isolate shrink-0\">\n <button\n type=\"button\"\n (click)=\"prev()\"\n [disabled]=\"effectivePageIndex() <= 1\"\n class=\"relative inline-flex items-center justify-center px-4 h-9 rounded-l-md text-sm font-medium border border-default bg-neutral-primary text-body hover:bg-neutral-secondary-soft disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer whitespace-nowrap\"\n >\n Tr\u01B0\u1EDBc\n </button>\n <button\n type=\"button\"\n (click)=\"next()\"\n [disabled]=\"effectivePageIndex() >= calculatedTotalPages()\"\n class=\"relative inline-flex items-center justify-center px-4 h-9 rounded-r-md text-sm font-medium border border-default bg-neutral-primary text-body hover:bg-neutral-secondary-soft disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer whitespace-nowrap\"\n >\n Sau\n </button>\n </div>\n </div>\n }\n\n <!-- 7. Type = With dropdown (Figma #1149:81895) -->\n @if (effectiveType() === 'with-dropdown') {\n <div class=\"inline-flex items-center gap-4 select-none flex-wrap\">\n <nav\n class=\"inline-flex -space-x-px rounded-md shadow-xs isolate max-w-full overflow-x-auto\"\n aria-label=\"Ph\u00E2n trang\"\n >\n <button\n type=\"button\"\n (click)=\"prev()\"\n [disabled]=\"effectivePageIndex() <= 1\"\n class=\"relative inline-flex items-center justify-center px-3.5 h-9 rounded-l-md text-sm font-medium border border-default bg-neutral-primary text-body hover:bg-neutral-secondary-soft disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer whitespace-nowrap shrink-0\"\n >\n Trang tr\u01B0\u1EDBc\n </button>\n\n @for (page of visiblePages(); track trackPage($index, page)) {\n @if (page === 'ellipsis') {\n <span\n class=\"relative inline-flex items-center justify-center px-3 h-9 text-sm text-fg-disabled bg-neutral-primary border border-default select-none whitespace-nowrap shrink-0\"\n aria-hidden=\"true\"\n >\n ...\n </span>\n } @else {\n <button\n type=\"button\"\n (click)=\"goToPage(page)\"\n [attr.aria-current]=\"page === effectivePageIndex() ? 'page' : null\"\n [attr.aria-label]=\"'Trang ' + page\"\n [attr.aria-label]=\"'Trang ' + page\"\n [class]=\"\n 'relative inline-flex items-center justify-center min-w-9 h-9 px-3 text-sm transition-colors cursor-pointer whitespace-nowrap shrink-0 ' +\n (page === effectivePageIndex()\n ? 'z-10 font-semibold border border-brand bg-brand text-white'\n : 'font-medium border border-default bg-neutral-primary-soft text-body hover:bg-neutral-secondary-soft')\n \"\n >\n {{ page }}\n </button>\n }\n }\n\n <button\n type=\"button\"\n (click)=\"next()\"\n [disabled]=\"effectivePageIndex() >= calculatedTotalPages()\"\n class=\"relative inline-flex items-center justify-center px-3.5 h-9 rounded-r-md text-sm font-medium border border-default bg-neutral-primary text-body hover:bg-neutral-secondary-soft disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer whitespace-nowrap shrink-0\"\n >\n Trang sau\n </button>\n </nav>\n\n <!-- Dropdown select size -->\n <div class=\"relative inline-flex items-center shrink-0\">\n <!-- `[ngModel]` \u2014 xem l\u00FD do \u1EDF \u00F4 ch\u1ECDn trang c\u1EE7a Type `Dropdown & buttons`. -->\n <select\n [ngModel]=\"pageSize()\"\n (ngModelChange)=\"onPageSizeChange(+$event)\"\n aria-label=\"S\u1ED1 d\u00F2ng m\u1ED7i trang\"\n class=\"appearance-none h-9 px-3.5 py-0 bg-neutral-tertiary border border-default rounded-md text-sm font-medium text-body pr-8 shadow-xs cursor-pointer focus:outline-hidden focus:ring-1 focus:ring-brand-subtle whitespace-nowrap\"\n >\n @for (opt of pageSizeOptions(); track opt) {\n <option [value]=\"opt\">{{ opt }} m\u1ED7i trang</option>\n }\n </select>\n <ol-icon\n name=\"alt-arrow-down\"\n size=\"xs\"\n class=\"pointer-events-none absolute right-2.5 text-body\"\n />\n </div>\n </div>\n }\n\n <!-- 8. Type = With input (Figma #1149:81910) -->\n @if (effectiveType() === 'with-input') {\n <div class=\"inline-flex items-center gap-4 select-none flex-wrap\">\n <nav\n class=\"inline-flex -space-x-px rounded-md shadow-xs isolate max-w-full overflow-x-auto\"\n aria-label=\"Ph\u00E2n trang\"\n >\n <button\n type=\"button\"\n (click)=\"prev()\"\n [disabled]=\"effectivePageIndex() <= 1\"\n class=\"relative inline-flex items-center justify-center px-3.5 h-9 rounded-l-md text-sm font-medium border border-default bg-neutral-primary text-body hover:bg-neutral-secondary-soft disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer whitespace-nowrap shrink-0\"\n >\n Trang tr\u01B0\u1EDBc\n </button>\n\n @for (page of visiblePages(); track trackPage($index, page)) {\n @if (page === 'ellipsis') {\n <span\n class=\"relative inline-flex items-center justify-center px-3 h-9 text-sm text-fg-disabled bg-neutral-primary border border-default select-none whitespace-nowrap shrink-0\"\n aria-hidden=\"true\"\n >\n ...\n </span>\n } @else {\n <button\n type=\"button\"\n (click)=\"goToPage(page)\"\n [attr.aria-current]=\"page === effectivePageIndex() ? 'page' : null\"\n [attr.aria-label]=\"'Trang ' + page\"\n [attr.aria-label]=\"'Trang ' + page\"\n [class]=\"\n 'relative inline-flex items-center justify-center min-w-9 h-9 px-3 text-sm transition-colors cursor-pointer whitespace-nowrap shrink-0 ' +\n (page === effectivePageIndex()\n ? 'z-10 font-semibold border border-brand bg-brand text-white'\n : 'font-medium border border-default bg-neutral-primary-soft text-body hover:bg-neutral-secondary-soft')\n \"\n >\n {{ page }}\n </button>\n }\n }\n\n <button\n type=\"button\"\n (click)=\"next()\"\n [disabled]=\"effectivePageIndex() >= calculatedTotalPages()\"\n class=\"relative inline-flex items-center justify-center px-3.5 h-9 rounded-r-md text-sm font-medium border border-default bg-neutral-primary text-body hover:bg-neutral-secondary-soft disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer whitespace-nowrap shrink-0\"\n >\n Trang sau\n </button>\n </nav>\n\n <!-- Input Jump -->\n <div class=\"flex items-center gap-2 text-sm text-body whitespace-nowrap shrink-0\">\n <span>\u0110i t\u1EDBi</span>\n <input\n type=\"number\"\n min=\"1\"\n [max]=\"calculatedTotalPages()\"\n [ngModel]=\"jumpPageValue()\"\n (ngModelChange)=\"jumpPageValue.set($event)\"\n (keydown.enter)=\"handleJump()\"\n placeholder=\"199\"\n class=\"w-16 px-2.5 py-1.5 text-center text-sm bg-neutral-tertiary border border-default rounded-md focus:outline-hidden focus:ring-1 focus:ring-brand-subtle text-heading shadow-xs\"\n />\n <span>trang</span>\n </div>\n </div>\n }\n\n <!-- 9. Type = Simple with input (Figma #1149:81928) -->\n @if (effectiveType() === 'simple-input') {\n <div class=\"inline-flex items-center gap-3 select-none flex-wrap\">\n <span class=\"text-sm font-medium text-body whitespace-nowrap\">\u0110i t\u1EDBi trang</span>\n <input\n type=\"number\"\n min=\"1\"\n [max]=\"calculatedTotalPages()\"\n [ngModel]=\"jumpPageValue()\"\n (ngModelChange)=\"jumpPageValue.set($event)\"\n (keydown.enter)=\"handleJump()\"\n placeholder=\"20\"\n class=\"w-16 px-2.5 py-1.5 text-center text-sm bg-neutral-tertiary border border-default rounded-md focus:outline-hidden focus:ring-1 focus:ring-brand-subtle text-heading shadow-xs\"\n />\n <button\n type=\"button\"\n (click)=\"handleJump()\"\n class=\"px-4 py-1.5 bg-brand hover:bg-brand-strong text-white text-sm font-medium rounded-md shadow-xs cursor-pointer transition-colors whitespace-nowrap\"\n >\n \u0110i\n </button>\n </div>\n }\n</div>\n" }]
12706
- }], propDecorators: { type: [{ type: i0.Input, args: [{ isSignal: true, alias: "type", required: false }] }], variant: [{ type: i0.Input, args: [{ isSignal: true, alias: "variant", required: false }] }], total: [{ type: i0.Input, args: [{ isSignal: true, alias: "total", required: false }] }], pageSize: [{ type: i0.Input, args: [{ isSignal: true, alias: "pageSize", required: false }] }, { type: i0.Output, args: ["pageSizeChange"] }], pageIndex: [{ type: i0.Input, args: [{ isSignal: true, alias: "pageIndex", required: false }] }, { type: i0.Output, args: ["pageIndexChange"] }], currentPage: [{ type: i0.Input, args: [{ isSignal: true, alias: "currentPage", required: false }] }, { type: i0.Output, args: ["currentPageChange"] }], totalPagesInput: [{ type: i0.Input, args: [{ isSignal: true, alias: "totalPages", required: false }] }], pageSizeOptions: [{ type: i0.Input, args: [{ isSignal: true, alias: "pageSizeOptions", required: false }] }], showText: [{ type: i0.Input, args: [{ isSignal: true, alias: "showText", required: false }] }], text: [{ type: i0.Input, args: [{ isSignal: true, alias: "text", required: false }] }], showInfo: [{ type: i0.Input, args: [{ isSignal: true, alias: "showInfo", required: false }] }], showSizeChanger: [{ type: i0.Input, args: [{ isSignal: true, alias: "showSizeChanger", required: false }] }], showQuickJumper: [{ type: i0.Input, args: [{ isSignal: true, alias: "showQuickJumper", required: false }] }], pageChange: [{ type: i0.Output, args: ["pageChange"] }] } });
14757
+ }, template: "<div class=\"flex items-center gap-4 flex-wrap max-w-full\">\n <!-- 1. Type = Default (Figma #1149:81851) -->\n @if (layoutType() === 'default') {\n <nav\n class=\"inline-flex -space-x-px rounded-md shadow-xs isolate select-none\"\n aria-label=\"Ph\u00E2n trang\"\n >\n <!-- Prev Button -->\n <button\n type=\"button\"\n (click)=\"prev()\"\n [disabled]=\"effectivePageIndex() <= 1\"\n class=\"relative inline-flex items-center justify-center w-9 h-9 rounded-l-md border border-default transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:bg-disabled cursor-pointer bg-neutral-primary text-body hover:bg-neutral-secondary-soft shrink-0\"\n aria-label=\"Trang tr\u01B0\u1EDBc\"\n >\n <ol-icon name=\"alt-arrow-left\" size=\"xs\"></ol-icon>\n </button>\n\n <!-- Pages Array -->\n @for (page of visiblePages(); track trackPage($index, page)) {\n @if (page === 'ellipsis') {\n <span\n class=\"relative inline-flex items-center justify-center px-3 h-9 text-sm text-fg-disabled bg-neutral-primary border border-default select-none whitespace-nowrap shrink-0\"\n aria-hidden=\"true\"\n >\n ...\n </span>\n } @else {\n <button\n type=\"button\"\n (click)=\"goToPage(page)\"\n [attr.aria-current]=\"page === effectivePageIndex() ? 'page' : null\"\n [attr.aria-label]=\"'Trang ' + page\"\n [class]=\"\n 'relative inline-flex items-center justify-center min-w-9 h-9 px-3 text-sm transition-colors cursor-pointer whitespace-nowrap shrink-0 ' +\n (page === effectivePageIndex()\n ? 'z-10 font-semibold border border-brand bg-brand text-white'\n : 'font-medium border border-default bg-neutral-primary-soft text-body hover:bg-neutral-secondary-soft')\n \"\n >\n {{ page }}\n </button>\n }\n }\n\n <!-- Next Button -->\n <button\n type=\"button\"\n (click)=\"next()\"\n [disabled]=\"effectivePageIndex() >= calculatedTotalPages()\"\n class=\"relative inline-flex items-center justify-center w-9 h-9 rounded-r-md border border-default transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:bg-disabled cursor-pointer bg-neutral-primary text-body hover:bg-neutral-secondary-soft shrink-0\"\n aria-label=\"Trang sau\"\n >\n <ol-icon name=\"alt-arrow-right\" size=\"xs\"></ol-icon>\n </button>\n </nav>\n }\n\n <!-- 2. Type = Arrows (Figma #1149:81864) -->\n @if (layoutType() === 'arrows') {\n <nav\n class=\"inline-flex -space-x-px rounded-md shadow-xs isolate select-none\"\n aria-label=\"Ph\u00E2n trang\"\n >\n <button\n type=\"button\"\n (click)=\"prev()\"\n [disabled]=\"effectivePageIndex() <= 1\"\n class=\"relative inline-flex items-center justify-center w-9 h-9 rounded-l-md border border-default transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:bg-disabled cursor-pointer bg-neutral-primary text-body hover:bg-neutral-secondary-soft shrink-0\"\n aria-label=\"Trang tr\u01B0\u1EDBc\"\n >\n <ol-icon name=\"alt-arrow-left\" size=\"xs\"></ol-icon>\n </button>\n\n <span\n class=\"relative inline-flex items-center justify-center px-4 h-9 text-sm font-medium text-body bg-neutral-primary border border-default select-none whitespace-nowrap shrink-0\"\n >\n {{ effectivePageIndex() }} tr\u00EAn {{ calculatedTotalPages() }}\n </span>\n\n <button\n type=\"button\"\n (click)=\"next()\"\n [disabled]=\"effectivePageIndex() >= calculatedTotalPages()\"\n class=\"relative inline-flex items-center justify-center w-9 h-9 rounded-r-md border border-default transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:bg-disabled cursor-pointer bg-neutral-primary text-body hover:bg-neutral-secondary-soft shrink-0\"\n aria-label=\"Trang sau\"\n >\n <ol-icon name=\"alt-arrow-right\" size=\"xs\"></ol-icon>\n </button>\n </nav>\n }\n\n <!-- 3. Type = Dropdown & buttons (Figma #1149:81868) -->\n @if (layoutType() === 'dropdown-buttons') {\n <div class=\"inline-flex items-center gap-3 select-none flex-wrap\">\n <!-- Dropdown button -->\n <div class=\"relative inline-flex items-center\">\n <!--\n `[ngModel]` ch\u1EE9 KH\u00D4NG ph\u1EA3i `[value]`: `[value]` tr\u00EAn <select> \u0111\u1EB7t\n thu\u1ED9c t\u00EDnh DOM NGAY, l\u00FAc c\u00E1c <option> c\u1EE7a @for ch\u01B0a t\u1ED3n t\u1EA1i, n\u00EAn tr\u00ECnh\n duy\u1EC7t ho\u00E0n nguy\u00EAn v\u1EC1 option \u0111\u1EA7u. \u0110o 2026-09-17: trang hi\u1EC7n t\u1EA1i l\u00E0 3 m\u00E0\n \u00F4 ch\u1ECDn v\u1EABn hi\u1EC7n \"01\". Build xanh, kh\u00F4ng c\u1EA3nh b\u00E1o n\u00E0o.\n -->\n <select\n [ngModel]=\"effectivePageIndex()\"\n (ngModelChange)=\"goToPage(+$event)\"\n aria-label=\"Ch\u1ECDn trang\"\n class=\"appearance-none h-9 px-3.5 py-0 bg-neutral-tertiary border border-default rounded-md text-sm font-medium text-body pr-8 shadow-xs cursor-pointer focus:outline-hidden focus:ring-1 focus:ring-brand-subtle whitespace-nowrap\"\n >\n @for (p of pageList(); track p) {\n <option [value]=\"p\">{{ p < 10 ? '0' + p : p }}</option>\n }\n </select>\n <ol-icon\n name=\"alt-arrow-down\"\n size=\"xs\"\n class=\"pointer-events-none absolute right-2.5 text-body\"\n />\n </div>\n\n <!-- Helper info -->\n <span\n class=\"inline-flex items-center h-9 px-3.5 bg-neutral-tertiary/60 rounded-md text-sm font-medium text-fg-disabled select-none whitespace-nowrap\"\n >\n c\u1EE7a {{ calculatedTotalPages() }} trang\n </span>\n\n <!-- Buttons -->\n <div class=\"inline-flex -space-x-px rounded-md shadow-xs isolate shrink-0\">\n <button\n type=\"button\"\n (click)=\"prev()\"\n [disabled]=\"effectivePageIndex() <= 1\"\n class=\"relative inline-flex items-center justify-center w-9 h-9 rounded-l-md border border-default bg-neutral-primary text-body hover:bg-neutral-secondary-soft disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer shrink-0\"\n aria-label=\"Trang tr\u01B0\u1EDBc\"\n >\n <ol-icon name=\"alt-arrow-left\" size=\"xs\"></ol-icon>\n </button>\n <button\n type=\"button\"\n (click)=\"next()\"\n [disabled]=\"effectivePageIndex() >= calculatedTotalPages()\"\n class=\"relative inline-flex items-center justify-center w-9 h-9 rounded-r-md border border-default bg-neutral-primary text-body hover:bg-neutral-secondary-soft disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer shrink-0\"\n aria-label=\"Trang sau\"\n >\n <ol-icon name=\"alt-arrow-right\" size=\"xs\"></ol-icon>\n </button>\n </div>\n </div>\n }\n\n <!-- 4. Type = Text buttons (Figma #1149:81874) -->\n @if (layoutType() === 'text-buttons') {\n <nav\n class=\"inline-flex -space-x-px rounded-md shadow-xs isolate select-none max-w-full overflow-x-auto\"\n aria-label=\"Ph\u00E2n trang\"\n >\n <!-- Trang tr\u01B0\u1EDBc -->\n <button\n type=\"button\"\n (click)=\"prev()\"\n [disabled]=\"effectivePageIndex() <= 1\"\n class=\"relative inline-flex items-center justify-center px-3.5 h-9 rounded-l-md text-sm font-medium border border-default disabled:opacity-40 disabled:cursor-not-allowed disabled:bg-disabled cursor-pointer bg-neutral-primary text-body hover:bg-neutral-secondary-soft whitespace-nowrap shrink-0\"\n >\n Trang tr\u01B0\u1EDBc\n </button>\n\n <!-- Pages -->\n @for (page of visiblePages(); track trackPage($index, page)) {\n @if (page === 'ellipsis') {\n <span\n class=\"relative inline-flex items-center justify-center px-3 h-9 text-sm text-fg-disabled bg-neutral-primary border border-default select-none whitespace-nowrap shrink-0\"\n aria-hidden=\"true\"\n >\n ...\n </span>\n } @else {\n <button\n type=\"button\"\n (click)=\"goToPage(page)\"\n [attr.aria-current]=\"page === effectivePageIndex() ? 'page' : null\"\n [attr.aria-label]=\"'Trang ' + page\"\n [class]=\"\n 'relative inline-flex items-center justify-center min-w-9 h-9 px-3 text-sm transition-colors cursor-pointer whitespace-nowrap shrink-0 ' +\n (page === effectivePageIndex()\n ? 'z-10 font-semibold border border-brand bg-brand text-white'\n : 'font-medium border border-default bg-neutral-primary-soft text-body hover:bg-neutral-secondary-soft')\n \"\n >\n {{ page }}\n </button>\n }\n }\n\n <!-- Trang sau -->\n <button\n type=\"button\"\n (click)=\"next()\"\n [disabled]=\"effectivePageIndex() >= calculatedTotalPages()\"\n class=\"relative inline-flex items-center justify-center px-3.5 h-9 rounded-r-md text-sm font-medium border border-default disabled:opacity-40 disabled:cursor-not-allowed disabled:bg-disabled cursor-pointer bg-neutral-primary text-body hover:bg-neutral-secondary-soft whitespace-nowrap shrink-0\"\n >\n Trang sau\n </button>\n </nav>\n }\n\n <!-- 5. Type = Two buttons (Figma #1149:81887) -->\n @if (layoutType() === 'two-buttons') {\n <div class=\"inline-flex items-center gap-3 select-none flex-wrap\">\n <button\n type=\"button\"\n (click)=\"prev()\"\n [disabled]=\"effectivePageIndex() <= 1\"\n class=\"inline-flex items-center gap-1.5 px-3.5 py-2 bg-neutral-primary rounded-md border border-default text-sm font-medium text-body shadow-2xs hover:bg-neutral-secondary-soft disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer whitespace-nowrap\"\n >\n <ol-icon name=\"arrow-left\" size=\"xs\" />\n <span>Trang tr\u01B0\u1EDBc</span>\n </button>\n\n <button\n type=\"button\"\n (click)=\"next()\"\n [disabled]=\"effectivePageIndex() >= calculatedTotalPages()\"\n class=\"inline-flex items-center gap-1.5 px-3.5 py-2 bg-neutral-primary rounded-md border border-default text-sm font-medium text-body shadow-2xs hover:bg-neutral-secondary-soft disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer whitespace-nowrap\"\n >\n <span>Trang sau</span>\n <ol-icon name=\"arrow-right\" size=\"xs\" />\n </button>\n </div>\n }\n\n <!-- 6. Type = Buttons & helper text (Figma #1149:80890) -->\n @if (layoutType() === 'buttons-helper') {\n <div class=\"flex flex-col items-center gap-3 select-none\">\n @if (showText()) {\n <p class=\"text-sm font-medium text-body text-center whitespace-nowrap\">\n Hi\u1EC3n th\u1ECB t\u1EEB <strong class=\"text-heading font-semibold\">{{ startRecord() }}</strong> \u0111\u1EBFn\n <strong class=\"text-heading font-semibold\">{{ endRecord() }}</strong> trong\n <strong class=\"text-heading font-semibold\">{{ total() }}</strong> d\u1EEF li\u1EC7u\n </p>\n }\n <div class=\"inline-flex -space-x-px rounded-md shadow-xs isolate shrink-0\">\n <button\n type=\"button\"\n (click)=\"prev()\"\n [disabled]=\"effectivePageIndex() <= 1\"\n class=\"relative inline-flex items-center justify-center px-4 h-9 rounded-l-md text-sm font-medium border border-default bg-neutral-primary text-body hover:bg-neutral-secondary-soft disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer whitespace-nowrap\"\n >\n Tr\u01B0\u1EDBc\n </button>\n <button\n type=\"button\"\n (click)=\"next()\"\n [disabled]=\"effectivePageIndex() >= calculatedTotalPages()\"\n class=\"relative inline-flex items-center justify-center px-4 h-9 rounded-r-md text-sm font-medium border border-default bg-neutral-primary text-body hover:bg-neutral-secondary-soft disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer whitespace-nowrap\"\n >\n Sau\n </button>\n </div>\n </div>\n }\n\n <!-- 8. Type = With input (Figma #1149:81910) -->\n @if (layoutType() === 'with-input') {\n <div class=\"inline-flex items-center gap-4 select-none flex-wrap\">\n <nav\n class=\"inline-flex -space-x-px rounded-md shadow-xs isolate max-w-full overflow-x-auto\"\n aria-label=\"Ph\u00E2n trang\"\n >\n <button\n type=\"button\"\n (click)=\"prev()\"\n [disabled]=\"effectivePageIndex() <= 1\"\n class=\"relative inline-flex items-center justify-center px-3.5 h-9 rounded-l-md text-sm font-medium border border-default bg-neutral-primary text-body hover:bg-neutral-secondary-soft disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer whitespace-nowrap shrink-0\"\n >\n Trang tr\u01B0\u1EDBc\n </button>\n\n @for (page of visiblePages(); track trackPage($index, page)) {\n @if (page === 'ellipsis') {\n <span\n class=\"relative inline-flex items-center justify-center px-3 h-9 text-sm text-fg-disabled bg-neutral-primary border border-default select-none whitespace-nowrap shrink-0\"\n aria-hidden=\"true\"\n >\n ...\n </span>\n } @else {\n <button\n type=\"button\"\n (click)=\"goToPage(page)\"\n [attr.aria-current]=\"page === effectivePageIndex() ? 'page' : null\"\n [attr.aria-label]=\"'Trang ' + page\"\n [class]=\"\n 'relative inline-flex items-center justify-center min-w-9 h-9 px-3 text-sm transition-colors cursor-pointer whitespace-nowrap shrink-0 ' +\n (page === effectivePageIndex()\n ? 'z-10 font-semibold border border-brand bg-brand text-white'\n : 'font-medium border border-default bg-neutral-primary-soft text-body hover:bg-neutral-secondary-soft')\n \"\n >\n {{ page }}\n </button>\n }\n }\n\n <button\n type=\"button\"\n (click)=\"next()\"\n [disabled]=\"effectivePageIndex() >= calculatedTotalPages()\"\n class=\"relative inline-flex items-center justify-center px-3.5 h-9 rounded-r-md text-sm font-medium border border-default bg-neutral-primary text-body hover:bg-neutral-secondary-soft disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer whitespace-nowrap shrink-0\"\n >\n Trang sau\n </button>\n </nav>\n\n <!-- Input Jump -->\n <div class=\"flex items-center gap-2 text-sm text-body whitespace-nowrap shrink-0\">\n <span>\u0110i t\u1EDBi</span>\n <input\n type=\"number\"\n min=\"1\"\n [max]=\"calculatedTotalPages()\"\n [ngModel]=\"jumpPageValue()\"\n (ngModelChange)=\"jumpPageValue.set($event)\"\n (keydown.enter)=\"handleJump()\"\n placeholder=\"199\"\n class=\"w-16 px-2.5 py-1.5 text-center text-sm bg-neutral-tertiary border border-default rounded-md focus:outline-hidden focus:ring-1 focus:ring-brand-subtle text-heading shadow-xs\"\n />\n <span>trang</span>\n </div>\n </div>\n }\n\n <!-- 9. Type = Simple with input (Figma #1149:81928) -->\n @if (layoutType() === 'simple-input') {\n <div class=\"inline-flex items-center gap-3 select-none flex-wrap\">\n <span class=\"text-sm font-medium text-body whitespace-nowrap\">\u0110i t\u1EDBi trang</span>\n <input\n type=\"number\"\n min=\"1\"\n [max]=\"calculatedTotalPages()\"\n [ngModel]=\"jumpPageValue()\"\n (ngModelChange)=\"jumpPageValue.set($event)\"\n (keydown.enter)=\"handleJump()\"\n placeholder=\"20\"\n class=\"w-16 px-2.5 py-1.5 text-center text-sm bg-neutral-tertiary border border-default rounded-md focus:outline-hidden focus:ring-1 focus:ring-brand-subtle text-heading shadow-xs\"\n />\n <button\n type=\"button\"\n (click)=\"handleJump()\"\n class=\"px-4 py-1.5 bg-brand hover:bg-brand-strong text-white text-sm font-medium rounded-md shadow-xs cursor-pointer transition-colors whitespace-nowrap\"\n >\n \u0110i\n </button>\n </div>\n }\n @if (hasPageSizeDropdown()) {\n <!--\n \u00D4 ch\u1ECDn c\u1EE1 trang \u2014 C\u1EDC RI\u00CANG `withDropdown`, g\u1EAFn \u0111\u01B0\u1EE3c v\u00E0o m\u1ECDi type (Figma\n `With dropdown` #1149:81895 l\u00E0 n\u01A1i n\u00F3 xu\u1EA5t hi\u1EC7n \u0111\u1EA7u ti\u00EAn). Vi\u1EBFt M\u1ED8T ch\u1ED7 \u1EDF\n \u0111\u00E2y thay v\u00EC ch\u00E9p v\u00E0o t\u1EEBng type.\n -->\n <div class=\"relative inline-flex items-center shrink-0\">\n <!-- `[ngModel]` \u2014 xem l\u00FD do \u1EDF \u00F4 ch\u1ECDn trang c\u1EE7a Type `Dropdown & buttons`. -->\n <select\n [ngModel]=\"pageSize()\"\n (ngModelChange)=\"onPageSizeChange(+$event)\"\n aria-label=\"S\u1ED1 d\u00F2ng m\u1ED7i trang\"\n class=\"appearance-none h-9 px-3.5 py-0 bg-neutral-tertiary border border-default rounded-md text-sm font-medium text-body pr-8 shadow-xs cursor-pointer focus:outline-hidden focus:ring-1 focus:ring-brand-subtle whitespace-nowrap\"\n >\n @for (opt of pageSizeOptions(); track opt) {\n <option [value]=\"opt\">{{ opt }} m\u1ED7i trang</option>\n }\n </select>\n <ol-icon\n name=\"alt-arrow-down\"\n size=\"xs\"\n class=\"pointer-events-none absolute right-2.5 text-body\"\n />\n </div>\n }\n</div>\n" }]
14758
+ }], propDecorators: { type: [{ type: i0.Input, args: [{ isSignal: true, alias: "type", required: false }] }], variant: [{ type: i0.Input, args: [{ isSignal: true, alias: "variant", required: false }] }], total: [{ type: i0.Input, args: [{ isSignal: true, alias: "total", required: false }] }], pageSize: [{ type: i0.Input, args: [{ isSignal: true, alias: "pageSize", required: false }] }, { type: i0.Output, args: ["pageSizeChange"] }], pageIndex: [{ type: i0.Input, args: [{ isSignal: true, alias: "pageIndex", required: false }] }, { type: i0.Output, args: ["pageIndexChange"] }], currentPage: [{ type: i0.Input, args: [{ isSignal: true, alias: "currentPage", required: false }] }, { type: i0.Output, args: ["currentPageChange"] }], totalPagesInput: [{ type: i0.Input, args: [{ isSignal: true, alias: "totalPages", required: false }] }], pageSizeOptions: [{ type: i0.Input, args: [{ isSignal: true, alias: "pageSizeOptions", required: false }] }], showText: [{ type: i0.Input, args: [{ isSignal: true, alias: "showText", required: false }] }], text: [{ type: i0.Input, args: [{ isSignal: true, alias: "text", required: false }] }], showInfo: [{ type: i0.Input, args: [{ isSignal: true, alias: "showInfo", required: false }] }], withDropdown: [{ type: i0.Input, args: [{ isSignal: true, alias: "withDropdown", required: false }] }], showSizeChanger: [{ type: i0.Input, args: [{ isSignal: true, alias: "showSizeChanger", required: false }] }], showQuickJumper: [{ type: i0.Input, args: [{ isSignal: true, alias: "showQuickJumper", required: false }] }], pageChange: [{ type: i0.Output, args: ["pageChange"] }] } });
12707
14759
 
12708
14760
  class OlPopover {
12709
14761
  positioning = inject(OlPositioningService);
@@ -12769,6 +14821,35 @@ class OlPopover {
12769
14821
  cleanupAutoUpdate = null;
12770
14822
  /** Hàm trả panel về chỗ cũ khi đóng. */
12771
14823
  detachPanel = null;
14824
+ /**
14825
+ * Selector cho phần tử nhận được tiêu điểm.
14826
+ *
14827
+ * `tabindex="-1"` bị loại: đó là dấu hiệu tác giả cố ý đưa phần tử ra khỏi
14828
+ * đường Tab. `:disabled` cũng loại — trình duyệt không cho nó nhận tiêu điểm.
14829
+ */
14830
+ static FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
14831
+ /**
14832
+ * Phần tử nhận tiêu điểm bên trong `root`, theo thứ tự nhìn thấy.
14833
+ *
14834
+ * Lọc phần ẩn bằng `[hidden]`/`[aria-hidden]` chứ KHÔNG `offsetParent`:
14835
+ * `offsetParent` là `null` với mọi phần tử trong `position: fixed` — mà panel
14836
+ * nổi hay dùng đúng kiểu định vị đó — nên danh sách sẽ rỗng và toàn bộ xử lý
14837
+ * Tab lặng lẽ không chạy.
14838
+ */
14839
+ focusablesIn(root) {
14840
+ return [...root.querySelectorAll(OlPopover.FOCUSABLE)].filter((el) => !el.closest('[hidden], [aria-hidden="true"]'));
14841
+ }
14842
+ /**
14843
+ * Phần tử nhận tiêu điểm đứng ngay sau `element` trong thứ tự Tab của trang.
14844
+ *
14845
+ * Loại chính panel ra: nó treo cuối `<body>` nên nếu tính cả thì "phần tử sau
14846
+ * nút trigger" lại rơi vào panel vừa đóng.
14847
+ */
14848
+ nextFocusableAfter(element, panel) {
14849
+ const all = [...document.body.querySelectorAll(OlPopover.FOCUSABLE)].filter((el) => !panel.contains(el) && !el.closest('[hidden], [aria-hidden="true"]'));
14850
+ const index = all.indexOf(element);
14851
+ return index === -1 ? null : (all[index + 1] ?? null);
14852
+ }
12772
14853
  constructor() {
12773
14854
  // Định vị thật bằng floating-ui: có flip + shift nên không bị cắt ở mép
12774
14855
  // viewport. Trước đây placement được khai nhưng KHÔNG dùng — panel luôn
@@ -12805,6 +14886,63 @@ class OlPopover {
12805
14886
  // Giữ đúng vị trí khi cuộn trang hoặc đổi kích thước cửa sổ.
12806
14887
  this.cleanupAutoUpdate = this.positioning.autoUpdate(reference, floating, (coordinates) => this.positioning.applyPosition(floating, coordinates), options);
12807
14888
  });
14889
+ /**
14890
+ * Cho Tab đi vào panel, và ra đúng chỗ khi hết.
14891
+ *
14892
+ * `appendTo="body"` treo panel ra cuối `<body>`, nên trong thứ tự DOM nó
14893
+ * nằm SAU toàn bộ nội dung trang. Bàn phím đi theo thứ tự DOM chứ không
14894
+ * theo vị trí nhìn thấy — không có xử lý riêng thì Tab từ nút trigger nhảy
14895
+ * sang phần tử kế tiếp của trang và panel mở ngay trước mắt không bao giờ
14896
+ * tới được. Cùng lỗi đã đo và sửa ở `ol-dropdown`.
14897
+ *
14898
+ * Popover thường chỉ chứa chữ, nên lỗi này chưa lộ ra ở trang tài liệu (đo
14899
+ * 2026-09-25: cả 24 mẫu đều 0 phần tử nhận tiêu điểm). Nhưng hễ ai đặt một
14900
+ * nút hay liên kết vào trong, nó hỏng ngay — và hỏng lặng lẽ.
14901
+ */
14902
+ effect((onCleanup) => {
14903
+ const open = this.isOpen();
14904
+ const panel = this.panelRef()?.nativeElement;
14905
+ const trigger = this.triggerRef()?.nativeElement;
14906
+ if (!open || !panel || !trigger)
14907
+ return;
14908
+ const onTriggerKeydown = (event) => {
14909
+ if (event.key !== 'Tab' || event.shiftKey)
14910
+ return;
14911
+ const first = this.focusablesIn(panel)[0];
14912
+ if (!first)
14913
+ return;
14914
+ event.preventDefault();
14915
+ first.focus();
14916
+ };
14917
+ const onPanelKeydown = (event) => {
14918
+ if (event.key !== 'Tab')
14919
+ return;
14920
+ const items = this.focusablesIn(panel);
14921
+ const index = items.indexOf(document.activeElement);
14922
+ if (index === -1)
14923
+ return;
14924
+ // Rời mục cuối: đóng rồi đi tiếp NHƯ THỂ panel chưa mở. Không vòng về
14925
+ // đầu — bẫy vòng tròn chỉ đúng cho hộp thoại, không cho lớp nổi cạnh
14926
+ // một nút mà người dùng còn muốn đi tiếp.
14927
+ if (!event.shiftKey && index === items.length - 1) {
14928
+ event.preventDefault();
14929
+ this.close();
14930
+ (this.nextFocusableAfter(trigger, panel) ?? trigger).focus();
14931
+ return;
14932
+ }
14933
+ // Lùi khỏi mục đầu: về nút trigger, giữ panel mở.
14934
+ if (event.shiftKey && index === 0) {
14935
+ event.preventDefault();
14936
+ trigger.focus();
14937
+ }
14938
+ };
14939
+ trigger.addEventListener('keydown', onTriggerKeydown);
14940
+ panel.addEventListener('keydown', onPanelKeydown);
14941
+ onCleanup(() => {
14942
+ trigger.removeEventListener('keydown', onTriggerKeydown);
14943
+ panel.removeEventListener('keydown', onPanelKeydown);
14944
+ });
14945
+ });
12808
14946
  // Escape đóng popover
12809
14947
  /** Vào ngăn xếp Escape khi mở — chỉ lớp trên cùng đóng. */
12810
14948
  effect((onCleanup) => {
@@ -13404,6 +15542,191 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImpor
13404
15542
  }], propDecorators: { rating: [{ type: i0.Input, args: [{ isSignal: true, alias: "rating", required: false }] }], max: [{ type: i0.Input, args: [{ isSignal: true, alias: "max", required: false }] }], type: [{ type: i0.Input, args: [{ isSignal: true, alias: "type", required: false }] }], score: [{ type: i0.Input, args: [{ isSignal: true, alias: "score", required: false }] }], badgeText: [{ type: i0.Input, args: [{ isSignal: true, alias: "badgeText", required: false }] }], minLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "minLabel", required: false }] }], maxLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxLabel", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], name: [{ type: i0.Input, args: [{ isSignal: true, alias: "name", required: false }] }], value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: false }] }, { type: i0.Output, args: ["valueChange"] }] } });
13405
15543
  let uniqueRatingId = 0;
13406
15544
 
15545
+ /**
15546
+ * Hai khung chia nhau một vùng, kéo vạch giữa để đổi tỉ lệ.
15547
+ *
15548
+ * Chưa có node Figma — dựng theo design system: vạch kéo dùng `bg-default`,
15549
+ * sáng lên `bg-brand` khi rê chuột hoặc đang kéo.
15550
+ *
15551
+ * ## Vì sao tỉ lệ phần trăm chứ không pixel
15552
+ *
15553
+ * Người dùng đổi cỡ cửa sổ thì hai khung phải giãn theo. Lưu pixel thì khung
15554
+ * trái giữ nguyên còn khung phải nuốt toàn bộ phần thêm — tỉ lệ người dùng
15555
+ * chọn biến mất.
15556
+ *
15557
+ * ## Bắt chuột trên `document`, không trên vạch
15558
+ *
15559
+ * Kéo nhanh thì con trỏ rời khỏi vạch trước khi trình duyệt kịp gửi sự kiện
15560
+ * tiếp theo; nghe trên chính vạch thì kéo đứt đoạn. Nghe trên `document` lúc
15561
+ * đang kéo và gỡ ngay khi thả.
15562
+ *
15563
+ * `setPointerCapture` giải quyết đúng việc này nhưng không có trong jsdom, nên
15564
+ * spec sẽ không chạy được nhánh kéo — dùng `document` thì cả hai môi trường
15565
+ * cùng một đường mã.
15566
+ *
15567
+ * ## Bàn phím
15568
+ *
15569
+ * Vạch là `role="separator"` có `tabindex`, mũi tên đổi 2% mỗi lần, Home/End
15570
+ * về hai biên. Không có phần này thì người dùng bàn phím không đổi được tỉ lệ
15571
+ * — và một khung chia đôi không kéo được thì chỉ là hai `<div>` cạnh nhau.
15572
+ *
15573
+ * ## Lưu ý khi viết test
15574
+ *
15575
+ * jsdom không tính bố cục: `getBoundingClientRect()` trả 0 cho mọi thứ, nên
15576
+ * phép quy đổi toạ độ chuột sang phần trăm luôn ra `NaN` hoặc 0. Spec khoá
15577
+ * phần bàn phím và ràng buộc min/max (tính thuần số); việc kéo chuột nghiệm
15578
+ * thu trên trình duyệt.
15579
+ */
15580
+ class OlResizable {
15581
+ elementRef = inject(ElementRef);
15582
+ destroyRef = inject(DestroyRef);
15583
+ direction = input('horizontal', /* @ts-ignore */
15584
+ ...(ngDevMode ? [{ debugName: "direction" }] : /* istanbul ignore next */ []));
15585
+ /** Tỉ lệ khung ĐẦU, tính bằng phần trăm. Hai chiều để nơi dùng lưu lại được. */
15586
+ ratio = model(50, /* @ts-ignore */
15587
+ ...(ngDevMode ? [{ debugName: "ratio" }] : /* istanbul ignore next */ []));
15588
+ /** Chặn dưới và chặn trên, phần trăm. Khung nào cũng không nhỏ hơn `minRatio`. */
15589
+ minRatio = input(15, /* @ts-ignore */
15590
+ ...(ngDevMode ? [{ debugName: "minRatio" }] : /* istanbul ignore next */ []));
15591
+ maxRatio = input(85, /* @ts-ignore */
15592
+ ...(ngDevMode ? [{ debugName: "maxRatio" }] : /* istanbul ignore next */ []));
15593
+ /** Bước đổi mỗi lần nhấn mũi tên, phần trăm. */
15594
+ keyboardStep = input(2, /* @ts-ignore */
15595
+ ...(ngDevMode ? [{ debugName: "keyboardStep" }] : /* istanbul ignore next */ []));
15596
+ disabled = input(false, /* @ts-ignore */
15597
+ ...(ngDevMode ? [{ debugName: "disabled" }] : /* istanbul ignore next */ []));
15598
+ ariaLabel = input('Kéo để đổi tỉ lệ hai khung', /* @ts-ignore */
15599
+ ...(ngDevMode ? [{ debugName: "ariaLabel" }] : /* istanbul ignore next */ []));
15600
+ /**
15601
+ * Bắn khi thả chuột hoặc nhấn phím — KHÔNG bắn liên tục lúc đang kéo.
15602
+ *
15603
+ * Tên là `resizeEnd` chứ không `ratioChange`: `model()` đã tự sinh
15604
+ * `ratioChange` cho binding hai chiều, khai thêm output trùng tên là lỗi
15605
+ * biên dịch NG1054. Hai thứ cũng khác nghĩa — `ratioChange` bắn mỗi lần giá
15606
+ * trị đổi (kể cả từng nhịp kéo), còn `resizeEnd` chỉ bắn khi người dùng đã
15607
+ * thả tay, đúng lúc nên lưu tỉ lệ xuống máy chủ.
15608
+ */
15609
+ resizeEnd = output();
15610
+ dragging = signal(false, /* @ts-ignore */
15611
+ ...(ngDevMode ? [{ debugName: "dragging" }] : /* istanbul ignore next */ []));
15612
+ releaseDrag = null;
15613
+ constructor() {
15614
+ this.destroyRef.onDestroy(() => this.stopDrag());
15615
+ }
15616
+ /** Ép tỉ lệ vào khoảng cho phép. Tách ra để spec khoá được mà không cần DOM. */
15617
+ clamp(value) {
15618
+ return Math.min(this.maxRatio(), Math.max(this.minRatio(), Math.round(value)));
15619
+ }
15620
+ onPointerDown(event) {
15621
+ if (this.disabled())
15622
+ return;
15623
+ event.preventDefault();
15624
+ this.dragging.set(true);
15625
+ const move = (e) => this.applyFromPointer(e);
15626
+ const up = () => {
15627
+ this.stopDrag();
15628
+ this.resizeEnd.emit(this.ratio());
15629
+ };
15630
+ document.addEventListener('pointermove', move);
15631
+ document.addEventListener('pointerup', up);
15632
+ this.releaseDrag = () => {
15633
+ document.removeEventListener('pointermove', move);
15634
+ document.removeEventListener('pointerup', up);
15635
+ };
15636
+ }
15637
+ stopDrag() {
15638
+ this.dragging.set(false);
15639
+ this.releaseDrag?.();
15640
+ this.releaseDrag = null;
15641
+ }
15642
+ applyFromPointer(event) {
15643
+ const box = this.elementRef.nativeElement.getBoundingClientRect();
15644
+ const horizontal = this.direction() === 'horizontal';
15645
+ const size = horizontal ? box.width : box.height;
15646
+ if (!size)
15647
+ return;
15648
+ const offset = horizontal ? event.clientX - box.left : event.clientY - box.top;
15649
+ this.ratio.set(this.clamp((offset / size) * 100));
15650
+ }
15651
+ onKeydown(event) {
15652
+ if (this.disabled())
15653
+ return;
15654
+ const horizontal = this.direction() === 'horizontal';
15655
+ const step = this.keyboardStep();
15656
+ // Mũi tên theo ĐÚNG chiều chia: khung ngang thì trái/phải, khung dọc thì
15657
+ // lên/xuống. Nhận cả bốn phím là mời người dùng bấm nhầm chiều rồi tưởng
15658
+ // vạch hỏng.
15659
+ const map = horizontal
15660
+ ? { ArrowLeft: -step, ArrowRight: step }
15661
+ : { ArrowUp: -step, ArrowDown: step };
15662
+ let next = null;
15663
+ if (event.key in map)
15664
+ next = this.ratio() + map[event.key];
15665
+ else if (event.key === 'Home')
15666
+ next = this.minRatio();
15667
+ else if (event.key === 'End')
15668
+ next = this.maxRatio();
15669
+ if (next === null)
15670
+ return;
15671
+ event.preventDefault();
15672
+ this.ratio.set(this.clamp(next));
15673
+ this.resizeEnd.emit(this.ratio());
15674
+ }
15675
+ rootClasses = computed(() => this.direction() === 'horizontal' ? 'flex h-full w-full' : 'flex h-full w-full flex-col', /* @ts-ignore */
15676
+ ...(ngDevMode ? [{ debugName: "rootClasses" }] : /* istanbul ignore next */ []));
15677
+ /** Kích thước khung đầu — `flex-basis` phần trăm, khung sau lấp phần còn lại. */
15678
+ firstStyle = computed(() => `${this.ratio()}%`, /* @ts-ignore */
15679
+ ...(ngDevMode ? [{ debugName: "firstStyle" }] : /* istanbul ignore next */ []));
15680
+ /**
15681
+ * Vạch chia.
15682
+ *
15683
+ * ## Vùng bấm rộng hơn vạch nhìn thấy
15684
+ *
15685
+ * Vạch chỉ dày 6px thì đúng thẩm mỹ nhưng vùng bấm 6px vi phạm WCAG 2.5.8
15686
+ * (ngưỡng 24px) — đo trên trình duyệt ra `6×188px`. Nên vạch nhận
15687
+ * `w-6`/`h-6` để vùng bấm đủ 24px, còn phần MÀU nằm ở `::before` chỉ
15688
+ * 6px ở giữa. Người dùng thấy vạch mảnh, tay và con trỏ thì có 24px để bắt.
15689
+ *
15690
+ * `::before` khai trong `handleBarClasses()` và đặt qua một `<span>` con chứ
15691
+ * không dùng biến thể `before:` của Tailwind: `before:` cần nhiều class cho
15692
+ * cùng một hình và khó đọc hơn một phần tử thật.
15693
+ */
15694
+ handleClasses = computed(() => {
15695
+ const horizontal = this.direction() === 'horizontal';
15696
+ // 24px theo WCAG 2.5.8; phần màu mảnh hơn nằm trong con.
15697
+ const axis = horizontal ? 'w-6 cursor-col-resize' : 'h-6 cursor-row-resize';
15698
+ return [
15699
+ 'group flex shrink-0 items-center justify-center',
15700
+ axis,
15701
+ this.disabled() ? 'cursor-not-allowed' : '',
15702
+ 'focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-focus-ring',
15703
+ ]
15704
+ .filter(Boolean)
15705
+ .join(' ');
15706
+ }, /* @ts-ignore */
15707
+ ...(ngDevMode ? [{ debugName: "handleClasses" }] : /* istanbul ignore next */ []));
15708
+ /** Phần MÀU của vạch — mảnh 6px, nằm giữa vùng bấm 24px. */
15709
+ handleBarClasses = computed(() => {
15710
+ const horizontal = this.direction() === 'horizontal';
15711
+ const axis = horizontal ? 'h-full w-1.5' : 'h-1.5 w-full';
15712
+ const state = this.disabled()
15713
+ ? 'bg-default'
15714
+ : this.dragging()
15715
+ ? 'bg-brand'
15716
+ : 'bg-default group-hover:bg-brand';
15717
+ return ['rounded-full transition-colors', axis, state].join(' ');
15718
+ }, /* @ts-ignore */
15719
+ ...(ngDevMode ? [{ debugName: "handleBarClasses" }] : /* istanbul ignore next */ []));
15720
+ ariaOrientation = computed(() => this.direction() === 'horizontal' ? 'vertical' : 'horizontal', /* @ts-ignore */
15721
+ ...(ngDevMode ? [{ debugName: "ariaOrientation" }] : /* istanbul ignore next */ []));
15722
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: OlResizable, deps: [], target: i0.ɵɵFactoryTarget.Component });
15723
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "22.1.6", type: OlResizable, isStandalone: true, selector: "ol-resizable", inputs: { direction: { classPropertyName: "direction", publicName: "direction", isSignal: true, isRequired: false, transformFunction: null }, ratio: { classPropertyName: "ratio", publicName: "ratio", isSignal: true, isRequired: false, transformFunction: null }, minRatio: { classPropertyName: "minRatio", publicName: "minRatio", isSignal: true, isRequired: false, transformFunction: null }, maxRatio: { classPropertyName: "maxRatio", publicName: "maxRatio", isSignal: true, isRequired: false, transformFunction: null }, keyboardStep: { classPropertyName: "keyboardStep", publicName: "keyboardStep", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { ratio: "ratioChange", resizeEnd: "resizeEnd" }, host: { classAttribute: "block" }, ngImport: i0, template: "<div [class]=\"rootClasses()\">\n <!--\n `flex-basis` ph\u1EA7n tr\u0103m + `min-w-0` \u0111\u1EC3 n\u1ED9i dung d\u00E0i kh\u00F4ng \u0111\u1EA9y khung r\u1ED9ng ra.\n Thi\u1EBFu `min-w-0` th\u00EC m\u1ED9t b\u1EA3ng hay m\u1ED9t d\u00F2ng ch\u1EEF kh\u00F4ng ng\u1EAFt s\u1EBD \u00E9p khung gi\u1EEF\n nguy\u00EAn b\u1EC1 r\u1ED9ng v\u00E0 v\u1EA1ch k\u00E9o \u0111\u1EE9ng im \u2014 l\u1ED7i tr\u00F4ng nh\u01B0 k\u00E9o h\u1ECFng.\n -->\n <div class=\"min-h-0 min-w-0 overflow-auto\" [style.flex-basis]=\"firstStyle()\">\n <ng-content select=\"[first]\" />\n </div>\n\n <!--\n `role=\"separator\"` v\u1EDBi `aria-valuenow` l\u00E0 c\u00E1ch chu\u1EA9n b\u00E1o cho tr\u00ECnh \u0111\u1ECDc m\u00E0n\n h\u00ECnh bi\u1EBFt \u0111\u00E2y l\u00E0 v\u1EA1ch chia v\u00E0 \u0111ang \u1EDF \u0111\u00E2u. `aria-orientation` NG\u01AF\u1EE2C v\u1EDBi\n h\u01B0\u1EDBng chia: khung chia ngang th\u00EC v\u1EA1ch \u0111\u1EE9ng d\u1ECDc.\n\n Th\u1EBB ngo\u00E0i r\u1ED9ng 24px cho \u0111\u1EE7 ng\u01B0\u1EE1ng v\u00F9ng b\u1EA5m WCAG 2.5.8; `span` con m\u1EDBi l\u00E0\n ph\u1EA7n m\u00E0u m\u1EA3nh 6px. Xem handleClasses() trong .ts.\n -->\n <div\n [class]=\"handleClasses()\"\n role=\"separator\"\n [attr.tabindex]=\"disabled() ? null : 0\"\n [attr.aria-orientation]=\"ariaOrientation()\"\n [attr.aria-valuenow]=\"ratio()\"\n [attr.aria-valuemin]=\"minRatio()\"\n [attr.aria-valuemax]=\"maxRatio()\"\n [attr.aria-label]=\"ariaLabel()\"\n [attr.aria-disabled]=\"disabled() ? 'true' : null\"\n (pointerdown)=\"onPointerDown($event)\"\n (keydown)=\"onKeydown($event)\"\n >\n <span [class]=\"handleBarClasses()\"></span>\n </div>\n\n <div class=\"min-h-0 min-w-0 flex-1 overflow-auto\">\n <ng-content select=\"[second]\" />\n </div>\n</div>\n", changeDetection: i0.ChangeDetectionStrategy.OnPush });
15724
+ }
15725
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: OlResizable, decorators: [{
15726
+ type: Component,
15727
+ args: [{ selector: 'ol-resizable', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'block' }, template: "<div [class]=\"rootClasses()\">\n <!--\n `flex-basis` ph\u1EA7n tr\u0103m + `min-w-0` \u0111\u1EC3 n\u1ED9i dung d\u00E0i kh\u00F4ng \u0111\u1EA9y khung r\u1ED9ng ra.\n Thi\u1EBFu `min-w-0` th\u00EC m\u1ED9t b\u1EA3ng hay m\u1ED9t d\u00F2ng ch\u1EEF kh\u00F4ng ng\u1EAFt s\u1EBD \u00E9p khung gi\u1EEF\n nguy\u00EAn b\u1EC1 r\u1ED9ng v\u00E0 v\u1EA1ch k\u00E9o \u0111\u1EE9ng im \u2014 l\u1ED7i tr\u00F4ng nh\u01B0 k\u00E9o h\u1ECFng.\n -->\n <div class=\"min-h-0 min-w-0 overflow-auto\" [style.flex-basis]=\"firstStyle()\">\n <ng-content select=\"[first]\" />\n </div>\n\n <!--\n `role=\"separator\"` v\u1EDBi `aria-valuenow` l\u00E0 c\u00E1ch chu\u1EA9n b\u00E1o cho tr\u00ECnh \u0111\u1ECDc m\u00E0n\n h\u00ECnh bi\u1EBFt \u0111\u00E2y l\u00E0 v\u1EA1ch chia v\u00E0 \u0111ang \u1EDF \u0111\u00E2u. `aria-orientation` NG\u01AF\u1EE2C v\u1EDBi\n h\u01B0\u1EDBng chia: khung chia ngang th\u00EC v\u1EA1ch \u0111\u1EE9ng d\u1ECDc.\n\n Th\u1EBB ngo\u00E0i r\u1ED9ng 24px cho \u0111\u1EE7 ng\u01B0\u1EE1ng v\u00F9ng b\u1EA5m WCAG 2.5.8; `span` con m\u1EDBi l\u00E0\n ph\u1EA7n m\u00E0u m\u1EA3nh 6px. Xem handleClasses() trong .ts.\n -->\n <div\n [class]=\"handleClasses()\"\n role=\"separator\"\n [attr.tabindex]=\"disabled() ? null : 0\"\n [attr.aria-orientation]=\"ariaOrientation()\"\n [attr.aria-valuenow]=\"ratio()\"\n [attr.aria-valuemin]=\"minRatio()\"\n [attr.aria-valuemax]=\"maxRatio()\"\n [attr.aria-label]=\"ariaLabel()\"\n [attr.aria-disabled]=\"disabled() ? 'true' : null\"\n (pointerdown)=\"onPointerDown($event)\"\n (keydown)=\"onKeydown($event)\"\n >\n <span [class]=\"handleBarClasses()\"></span>\n </div>\n\n <div class=\"min-h-0 min-w-0 flex-1 overflow-auto\">\n <ng-content select=\"[second]\" />\n </div>\n</div>\n" }]
15728
+ }], ctorParameters: () => [], propDecorators: { direction: [{ type: i0.Input, args: [{ isSignal: true, alias: "direction", required: false }] }], ratio: [{ type: i0.Input, args: [{ isSignal: true, alias: "ratio", required: false }] }, { type: i0.Output, args: ["ratioChange"] }], minRatio: [{ type: i0.Input, args: [{ isSignal: true, alias: "minRatio", required: false }] }], maxRatio: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxRatio", required: false }] }], keyboardStep: [{ type: i0.Input, args: [{ isSignal: true, alias: "keyboardStep", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], resizeEnd: [{ type: i0.Output, args: ["resizeEnd"] }] } });
15729
+
13407
15730
  /**
13408
15731
  * `ol-app-nav` — menu điều hướng dọc của Ôn Luyện.
13409
15732
  *
@@ -15106,6 +17429,228 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImpor
15106
17429
  `, styles: [":host{pointer-events:none}:host>div{pointer-events:auto}\n"] }]
15107
17430
  }] });
15108
17431
 
17432
+ /**
17433
+ * Chồng nhiều icon hoặc logo lên nhau, kèm số đếm "+N".
17434
+ *
17435
+ * Chưa có node Figma — dựng theo `ol-avatar-group`, component cùng cơ chế đã
17436
+ * có trong thư viện. Khác ở chỗ: avatar là ảnh người nên luôn tròn, còn icon
17437
+ * thường là biểu tượng dịch vụ hay môn học nên mặc định bo vừa (`rounded-lg`)
17438
+ * và có nền để icon một màu vẫn tách khỏi icon bên dưới.
17439
+ *
17440
+ * ## Vòng viền là thứ làm nên hiệu ứng chồng
17441
+ *
17442
+ * Mỗi ô có `ring-2 ring-buffer` — vòng viền màu nền trang, không phải viền
17443
+ * màu. Nhờ nó hai ô chồng nhau vẫn thấy rõ ranh giới. Bỏ vòng này thì các ô
17444
+ * dính thành một khối nhoè.
17445
+ *
17446
+ * `ring-buffer` chứ không `ring-white`: ở chế độ tối nền không phải trắng, và
17447
+ * viền trắng trên nền đen là đường kẻ sáng chói quanh mỗi icon.
17448
+ *
17449
+ * ## Thứ tự chồng
17450
+ *
17451
+ * Mặc định ô ĐẦU nằm trên — giống xếp bài. `reverse` lật lại cho ô cuối nằm
17452
+ * trên, dùng khi ô cuối là thứ cần nhấn (icon "thêm", trạng thái mới nhất).
17453
+ *
17454
+ * Làm bằng `flex-row-reverse` cộng đảo thứ tự dữ liệu chứ không bằng
17455
+ * `z-index`: `z-index` cần stacking context và sẽ hỏng khi nơi gọi đặt
17456
+ * component vào một phần tử có `transform` hay `opacity`.
17457
+ */
17458
+ class OlIconStack {
17459
+ size = input('base', /* @ts-ignore */
17460
+ ...(ngDevMode ? [{ debugName: "size" }] : /* istanbul ignore next */ []));
17461
+ /** Số đếm "+N" ở cuối. `null` thì không vẽ. */
17462
+ counter = input(null, /* @ts-ignore */
17463
+ ...(ngDevMode ? [{ debugName: "counter" }] : /* istanbul ignore next */ []));
17464
+ counterAriaLabel = input('mục khác', /* @ts-ignore */
17465
+ ...(ngDevMode ? [{ debugName: "counterAriaLabel" }] : /* istanbul ignore next */ []));
17466
+ /** Lật thứ tự chồng: ô CUỐI nằm trên thay vì ô đầu. */
17467
+ reverse = input(false, /* @ts-ignore */
17468
+ ...(ngDevMode ? [{ debugName: "reverse" }] : /* istanbul ignore next */ []));
17469
+ /**
17470
+ * Bo góc của từng ô. `full` cho logo tròn, `lg` cho biểu tượng vuông.
17471
+ *
17472
+ * Áp lên CON qua biến thể `*:` của Tailwind, nên nơi gọi chỉ việc đặt
17473
+ * `<ol-icon>` hay `<img>` vào là xong.
17474
+ */
17475
+ rounded = input('lg', /* @ts-ignore */
17476
+ ...(ngDevMode ? [{ debugName: "rounded" }] : /* istanbul ignore next */ []));
17477
+ rootClasses = computed(() => {
17478
+ const gap = { sm: '-space-x-1.5', base: '-space-x-2', lg: '-space-x-2.5' }[this.size()];
17479
+ const box = { sm: '*:size-6', base: '*:size-8', lg: '*:size-10' }[this.size()];
17480
+ const radius = this.rounded() === 'full' ? '*:rounded-full' : '*:rounded-lg';
17481
+ const dir = this.reverse() ? 'flex-row-reverse space-x-reverse' : '';
17482
+ return [
17483
+ 'flex items-center',
17484
+ gap,
17485
+ dir,
17486
+ // Vòng viền màu nền trang tạo ranh giới giữa hai ô chồng nhau.
17487
+ `${box} ${radius} *:shrink-0 *:ring-2 *:ring-buffer *:bg-neutral-secondary`,
17488
+ '*:flex *:items-center *:justify-center *:text-body-subtle',
17489
+ ].join(' ');
17490
+ }, /* @ts-ignore */
17491
+ ...(ngDevMode ? [{ debugName: "rootClasses" }] : /* istanbul ignore next */ []));
17492
+ /**
17493
+ * Ô đếm "+N".
17494
+ *
17495
+ * `text-heading` chứ KHÔNG `text-body`: tổ hợp `bg-neutral-quaternary` +
17496
+ * `text-body` đo được 6.10 ở chế độ sáng nhưng chỉ 3.96 ở chế độ tối — dưới
17497
+ * ngưỡng WCAG 4.5. Đây là kết luận đã đo ở `ol-avatar-group` (BUG-016 mục 5),
17498
+ * theo tiền lệ sẵn có chứ không đoán lại.
17499
+ *
17500
+ * Chú thích để Ở ĐÂY chứ không trong template: template là một chuỗi
17501
+ * template literal, nên dấu backtick trong chú thích HTML đóng chuỗi sớm và
17502
+ * TS báo lỗi ở dòng chẳng liên quan (đã mắc khi viết file này).
17503
+ */
17504
+ counterClasses = computed(() => {
17505
+ const font = { sm: 'text-2xs', base: 'text-xs', lg: 'text-sm' }[this.size()];
17506
+ return `font-medium bg-neutral-quaternary text-heading select-none ${font}`;
17507
+ }, /* @ts-ignore */
17508
+ ...(ngDevMode ? [{ debugName: "counterClasses" }] : /* istanbul ignore next */ []));
17509
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: OlIconStack, deps: [], target: i0.ɵɵFactoryTarget.Component });
17510
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.6", type: OlIconStack, isStandalone: true, selector: "ol-icon-stack", inputs: { size: { classPropertyName: "size", publicName: "size", isSignal: true, isRequired: false, transformFunction: null }, counter: { classPropertyName: "counter", publicName: "counter", isSignal: true, isRequired: false, transformFunction: null }, counterAriaLabel: { classPropertyName: "counterAriaLabel", publicName: "counterAriaLabel", isSignal: true, isRequired: false, transformFunction: null }, reverse: { classPropertyName: "reverse", publicName: "reverse", isSignal: true, isRequired: false, transformFunction: null }, rounded: { classPropertyName: "rounded", publicName: "rounded", isSignal: true, isRequired: false, transformFunction: null } }, host: { classAttribute: "inline-block" }, ngImport: i0, template: `
17511
+ <div [class]="rootClasses()">
17512
+ <ng-content />
17513
+
17514
+ @if (counter()) {
17515
+ <!-- Xem chú thích counterClasses() về lựa chọn màu chữ. -->
17516
+ <span [class]="counterClasses()" [attr.aria-label]="counterAriaLabel()">
17517
+ {{ counter() }}
17518
+ </span>
17519
+ }
17520
+ </div>
17521
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
17522
+ }
17523
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: OlIconStack, decorators: [{
17524
+ type: Component,
17525
+ args: [{
17526
+ selector: 'ol-icon-stack',
17527
+ standalone: true,
17528
+ template: `
17529
+ <div [class]="rootClasses()">
17530
+ <ng-content />
17531
+
17532
+ @if (counter()) {
17533
+ <!-- Xem chú thích counterClasses() về lựa chọn màu chữ. -->
17534
+ <span [class]="counterClasses()" [attr.aria-label]="counterAriaLabel()">
17535
+ {{ counter() }}
17536
+ </span>
17537
+ }
17538
+ </div>
17539
+ `,
17540
+ changeDetection: ChangeDetectionStrategy.OnPush,
17541
+ host: { class: 'inline-block' },
17542
+ }]
17543
+ }], propDecorators: { size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }], counter: [{ type: i0.Input, args: [{ isSignal: true, alias: "counter", required: false }] }], counterAriaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "counterAriaLabel", required: false }] }], reverse: [{ type: i0.Input, args: [{ isSignal: true, alias: "reverse", required: false }] }], rounded: [{ type: i0.Input, args: [{ isSignal: true, alias: "rounded", required: false }] }] } });
17544
+
17545
+ /**
17546
+ * Một dòng nội dung: phần dẫn đầu · tiêu đề + mô tả · phần hành động.
17547
+ *
17548
+ * Chưa có node Figma — dựng theo design system, lấy khoảng cách và cỡ chữ từ
17549
+ * `ol-checkbox-radio-form` (component gần nhất đang có cấu trúc này).
17550
+ *
17551
+ * ## Vì sao KHÔNG mở rộng `ol-list-group-item`
17552
+ *
17553
+ * Cái đó là `<li>` và phải nằm trong `<ul>` của `ol-list-group` — HTML quy
17554
+ * định vậy. Còn dòng nội dung thì hay đứng một mình trong thẻ, trong ngăn kéo,
17555
+ * hay trong kết quả tìm kiếm. Nhồi cấu trúc vào `<li>` là buộc mọi nơi dùng
17556
+ * phải bọc thêm `<ul>` không có ý nghĩa ngữ nghĩa nào.
17557
+ *
17558
+ * Hai component dùng chung cách xếp; `ol-item` lo phần bên trong, đặt ở đâu là
17559
+ * việc của nơi gọi.
17560
+ *
17561
+ * ## Ba khe, KHÔNG phải ba input
17562
+ *
17563
+ * `[lead]` và `[actions]` là khe cắm chứ không phải input nhận tên icon: phần
17564
+ * dẫn đầu có thể là icon, avatar, ảnh bìa hay số thứ tự; phần hành động có thể
17565
+ * là nút, huy hiệu, hay một dòng chữ. Nhận tên icon thì mỗi lần cần thứ khác
17566
+ * lại phải thêm một input.
17567
+ *
17568
+ * Riêng `icon` giữ lại làm lối tắt cho ca hay gặp nhất. Nó nằm TRONG cùng khe
17569
+ * `[lead]`, sau nội dung chiếu — nơi gọi truyền cả hai thì có hai phần dẫn đầu
17570
+ * cạnh nhau, nhưng đó là ca hiếm và không đáng thêm selector CSS để chặn.
17571
+ */
17572
+ class OlItem {
17573
+ title = input('', /* @ts-ignore */
17574
+ ...(ngDevMode ? [{ debugName: "title" }] : /* istanbul ignore next */ []));
17575
+ description = input('', /* @ts-ignore */
17576
+ ...(ngDevMode ? [{ debugName: "description" }] : /* istanbul ignore next */ []));
17577
+ /** Lối tắt cho phần dẫn đầu dạng icon. Khe `[lead]` đè lên nó. */
17578
+ icon = input('', /* @ts-ignore */
17579
+ ...(ngDevMode ? [{ debugName: "icon" }] : /* istanbul ignore next */ []));
17580
+ size = input('base', /* @ts-ignore */
17581
+ ...(ngDevMode ? [{ debugName: "size" }] : /* istanbul ignore next */ []));
17582
+ /**
17583
+ * Căn phần dẫn đầu và hành động theo chiều dọc.
17584
+ *
17585
+ * `start` khi mô tả dài hai ba dòng — icon căn giữa một khối cao trông như
17586
+ * trôi lơ lửng. `center` khi chỉ có tiêu đề.
17587
+ */
17588
+ align = input('center', /* @ts-ignore */
17589
+ ...(ngDevMode ? [{ debugName: "align" }] : /* istanbul ignore next */ []));
17590
+ /** Bấm được: thêm hiệu ứng hover và con trỏ tay, phát `itemClick`. */
17591
+ interactive = input(false, /* @ts-ignore */
17592
+ ...(ngDevMode ? [{ debugName: "interactive" }] : /* istanbul ignore next */ []));
17593
+ disabled = input(false, /* @ts-ignore */
17594
+ ...(ngDevMode ? [{ debugName: "disabled" }] : /* istanbul ignore next */ []));
17595
+ /** Biến cả dòng thành liên kết. Bỏ qua khi `disabled`. */
17596
+ href = input(null, /* @ts-ignore */
17597
+ ...(ngDevMode ? [{ debugName: "href" }] : /* istanbul ignore next */ []));
17598
+ itemClick = output();
17599
+ /**
17600
+ * Enter và Space kích hoạt dòng `interactive`, như một `<button>` thật.
17601
+ *
17602
+ * Dùng `(keydown)` trần rồi tự lọc phím, KHÔNG `(keydown.enter)`: hai
17603
+ * listener khác kiểu sự kiện trên cùng một thẻ thì Angular suy `$event` theo
17604
+ * cái đầu — `(click)` đứng trước nên `$event` thành `MouseEvent` và TS báo
17605
+ * lỗi ở dòng `keydown`.
17606
+ *
17607
+ * `preventDefault` cho Space, nếu không trang cuộn xuống một màn cùng lúc.
17608
+ */
17609
+ onKeyActivate(event) {
17610
+ if (this.disabled() || !this.interactive())
17611
+ return;
17612
+ if (event.key !== 'Enter' && event.key !== ' ')
17613
+ return;
17614
+ event.preventDefault();
17615
+ this.itemClick.emit(new MouseEvent('click'));
17616
+ }
17617
+ onClick(event) {
17618
+ if (this.disabled()) {
17619
+ event.preventDefault();
17620
+ return;
17621
+ }
17622
+ if (this.interactive() || this.href())
17623
+ this.itemClick.emit(event);
17624
+ }
17625
+ rootClasses = computed(() => {
17626
+ const pad = { sm: 'px-3 py-2 gap-2.5', base: 'px-4 py-3 gap-3', lg: 'px-5 py-4 gap-4' }[this.size()];
17627
+ const align = this.align() === 'start' ? 'items-start' : 'items-center';
17628
+ // Gom vào MỘT chuỗi qua ba-ngôi thay vì nhiều `[class.x]` rời: hai class
17629
+ // cùng thuộc tính thì trình duyệt chọn theo thứ tự luật trong file CSS chứ
17630
+ // không theo thứ tự viết.
17631
+ const state = this.disabled()
17632
+ ? 'opacity-50 cursor-not-allowed'
17633
+ : this.interactive() || this.href()
17634
+ ? 'cursor-pointer hover:bg-neutral-secondary transition-colors'
17635
+ : '';
17636
+ return `flex w-full ${align} ${pad} ${state}`;
17637
+ }, /* @ts-ignore */
17638
+ ...(ngDevMode ? [{ debugName: "rootClasses" }] : /* istanbul ignore next */ []));
17639
+ titleClasses = computed(() => `font-semibold text-heading ${{ sm: 'text-xs', base: 'text-sm', lg: 'text-base' }[this.size()]}`, /* @ts-ignore */
17640
+ ...(ngDevMode ? [{ debugName: "titleClasses" }] : /* istanbul ignore next */ []));
17641
+ descriptionClasses = computed(() => `text-body-subtle ${{ sm: 'text-2xs', base: 'text-xs', lg: 'text-sm' }[this.size()]}`, /* @ts-ignore */
17642
+ ...(ngDevMode ? [{ debugName: "descriptionClasses" }] : /* istanbul ignore next */ []));
17643
+ /** Cỡ icon lối tắt, theo cỡ dòng. */
17644
+ iconSize = computed(() => ({ sm: 16, base: 20, lg: 24 })[this.size()], /* @ts-ignore */
17645
+ ...(ngDevMode ? [{ debugName: "iconSize" }] : /* istanbul ignore next */ []));
17646
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: OlItem, deps: [], target: i0.ɵɵFactoryTarget.Component });
17647
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.6", type: OlItem, isStandalone: true, selector: "ol-item", inputs: { title: { classPropertyName: "title", publicName: "title", isSignal: true, isRequired: false, transformFunction: null }, description: { classPropertyName: "description", publicName: "description", isSignal: true, isRequired: false, transformFunction: null }, icon: { classPropertyName: "icon", publicName: "icon", isSignal: true, isRequired: false, transformFunction: null }, size: { classPropertyName: "size", publicName: "size", isSignal: true, isRequired: false, transformFunction: null }, align: { classPropertyName: "align", publicName: "align", isSignal: true, isRequired: false, transformFunction: null }, interactive: { classPropertyName: "interactive", publicName: "interactive", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, href: { classPropertyName: "href", publicName: "href", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { itemClick: "itemClick" }, host: { classAttribute: "block" }, ngImport: i0, template: "<!--\n `<a>` khi c\u00F3 `href`, `<div>` khi kh\u00F4ng.\n\n \u0110\u1ED5i th\u1EBB ch\u1EE9 kh\u00F4ng ch\u1EC9 \u0111\u1ED5i m\u00E0u: \u0111\u1EC3 th\u1EBB `<a>` cho m\u1ED9t d\u00F2ng kh\u00F4ng d\u1EABn \u0111i \u0111\u00E2u th\u00EC\n b\u00E0n ph\u00EDm v\u1EABn Tab v\u00E0o v\u00E0 tr\u00ECnh \u0111\u1ECDc m\u00E0n h\u00ECnh v\u1EABn \u0111\u1ECDc l\u00E0 \"li\u00EAn k\u1EBFt\".\n-->\n@if (href() && !disabled()) {\n <a [href]=\"href()\" [class]=\"rootClasses()\" (click)=\"onClick($event)\">\n <ng-container [ngTemplateOutlet]=\"ruot\" />\n </a>\n} @else {\n <div\n [class]=\"rootClasses()\"\n [attr.aria-disabled]=\"disabled() ? 'true' : null\"\n [attr.role]=\"interactive() && !disabled() ? 'button' : null\"\n [attr.tabindex]=\"interactive() && !disabled() ? 0 : null\"\n (click)=\"onClick($event)\"\n (keydown)=\"onKeyActivate($event)\"\n >\n <ng-container [ngTemplateOutlet]=\"ruot\" />\n </div>\n}\n\n<ng-template #ruot>\n <!--\n Khe `[lead]` \u0111\u00E8 l\u00EAn l\u1ED1i t\u1EAFt `icon`: n\u01A1i g\u1ECDi truy\u1EC1n c\u1EA3 hai th\u00EC khe th\u1EAFng,\n v\u00EC n\u00F3 c\u1EE5 th\u1EC3 h\u01A1n. Kh\u00F4ng d\u00F9ng `@if` tr\u00EAn `<ng-content>` \u2014 n\u1ED9i dung chi\u1EBFu\n qua nhi\u1EC1u nh\u00E1nh lo\u1EA1i tr\u1EEB nhau v\u1EABn m\u1EA5t ru\u1ED9t (xem `ol-dropdown`).\n -->\n <span class=\"shrink-0 empty:hidden\">\n <ng-content select=\"[lead]\" />\n\n <!--\n L\u1ED1i t\u1EAFt `icon` n\u1EB1m TRONG c\u00F9ng khe `[lead]`, sau n\u1ED9i dung chi\u1EBFu.\n\n Kh\u00F4ng t\u00E1ch hai `<span>` r\u1ED3i \u1EA9n c\u00E1i sau b\u1EB1ng `:has()`: n\u01A1i g\u1ECDi truy\u1EC1n c\u1EA3\n hai th\u00EC c\u00F3 hai ph\u1EA7n d\u1EABn \u0111\u1EA7u c\u1EA1nh nhau, m\u00E0 \u0111\u00E2y l\u00E0 ca hi\u1EBFm \u2014 ng\u01B0\u1EDDi vi\u1EBFt ch\u1EC9\n d\u00F9ng m\u1ED9t trong hai. \u0110\u1EB7t chung m\u1ED9t khe th\u00EC th\u1EE9 t\u1EF1 t\u1EF1 nhi\u00EAn v\u00E0 kh\u00F4ng c\u1EA7n\n selector CSS n\u00E0o.\n -->\n @if (icon()) {\n <ol-icon [name]=\"icon()\" [size]=\"iconSize()\" class=\"text-body-subtle\" />\n }\n </span>\n\n <span class=\"min-w-0 flex-1\">\n @if (title()) {\n <span class=\"block truncate\" [class]=\"titleClasses()\">{{ title() }}</span>\n }\n @if (description()) {\n <span class=\"mt-0.5 block\" [class]=\"descriptionClasses()\">{{ description() }}</span>\n }\n <ng-content />\n </span>\n\n <span class=\"shrink-0 empty:hidden\">\n <ng-content select=\"[actions]\" />\n </span>\n</ng-template>\n", dependencies: [{ kind: "component", type: OlIcon, selector: "ol-icon", inputs: ["name", "size", "color", "variant", "strokeWidth"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
17648
+ }
17649
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: OlItem, decorators: [{
17650
+ type: Component,
17651
+ args: [{ selector: 'ol-item', standalone: true, imports: [OlIcon, NgTemplateOutlet], changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'block' }, template: "<!--\n `<a>` khi c\u00F3 `href`, `<div>` khi kh\u00F4ng.\n\n \u0110\u1ED5i th\u1EBB ch\u1EE9 kh\u00F4ng ch\u1EC9 \u0111\u1ED5i m\u00E0u: \u0111\u1EC3 th\u1EBB `<a>` cho m\u1ED9t d\u00F2ng kh\u00F4ng d\u1EABn \u0111i \u0111\u00E2u th\u00EC\n b\u00E0n ph\u00EDm v\u1EABn Tab v\u00E0o v\u00E0 tr\u00ECnh \u0111\u1ECDc m\u00E0n h\u00ECnh v\u1EABn \u0111\u1ECDc l\u00E0 \"li\u00EAn k\u1EBFt\".\n-->\n@if (href() && !disabled()) {\n <a [href]=\"href()\" [class]=\"rootClasses()\" (click)=\"onClick($event)\">\n <ng-container [ngTemplateOutlet]=\"ruot\" />\n </a>\n} @else {\n <div\n [class]=\"rootClasses()\"\n [attr.aria-disabled]=\"disabled() ? 'true' : null\"\n [attr.role]=\"interactive() && !disabled() ? 'button' : null\"\n [attr.tabindex]=\"interactive() && !disabled() ? 0 : null\"\n (click)=\"onClick($event)\"\n (keydown)=\"onKeyActivate($event)\"\n >\n <ng-container [ngTemplateOutlet]=\"ruot\" />\n </div>\n}\n\n<ng-template #ruot>\n <!--\n Khe `[lead]` \u0111\u00E8 l\u00EAn l\u1ED1i t\u1EAFt `icon`: n\u01A1i g\u1ECDi truy\u1EC1n c\u1EA3 hai th\u00EC khe th\u1EAFng,\n v\u00EC n\u00F3 c\u1EE5 th\u1EC3 h\u01A1n. Kh\u00F4ng d\u00F9ng `@if` tr\u00EAn `<ng-content>` \u2014 n\u1ED9i dung chi\u1EBFu\n qua nhi\u1EC1u nh\u00E1nh lo\u1EA1i tr\u1EEB nhau v\u1EABn m\u1EA5t ru\u1ED9t (xem `ol-dropdown`).\n -->\n <span class=\"shrink-0 empty:hidden\">\n <ng-content select=\"[lead]\" />\n\n <!--\n L\u1ED1i t\u1EAFt `icon` n\u1EB1m TRONG c\u00F9ng khe `[lead]`, sau n\u1ED9i dung chi\u1EBFu.\n\n Kh\u00F4ng t\u00E1ch hai `<span>` r\u1ED3i \u1EA9n c\u00E1i sau b\u1EB1ng `:has()`: n\u01A1i g\u1ECDi truy\u1EC1n c\u1EA3\n hai th\u00EC c\u00F3 hai ph\u1EA7n d\u1EABn \u0111\u1EA7u c\u1EA1nh nhau, m\u00E0 \u0111\u00E2y l\u00E0 ca hi\u1EBFm \u2014 ng\u01B0\u1EDDi vi\u1EBFt ch\u1EC9\n d\u00F9ng m\u1ED9t trong hai. \u0110\u1EB7t chung m\u1ED9t khe th\u00EC th\u1EE9 t\u1EF1 t\u1EF1 nhi\u00EAn v\u00E0 kh\u00F4ng c\u1EA7n\n selector CSS n\u00E0o.\n -->\n @if (icon()) {\n <ol-icon [name]=\"icon()\" [size]=\"iconSize()\" class=\"text-body-subtle\" />\n }\n </span>\n\n <span class=\"min-w-0 flex-1\">\n @if (title()) {\n <span class=\"block truncate\" [class]=\"titleClasses()\">{{ title() }}</span>\n }\n @if (description()) {\n <span class=\"mt-0.5 block\" [class]=\"descriptionClasses()\">{{ description() }}</span>\n }\n <ng-content />\n </span>\n\n <span class=\"shrink-0 empty:hidden\">\n <ng-content select=\"[actions]\" />\n </span>\n</ng-template>\n" }]
17652
+ }], propDecorators: { title: [{ type: i0.Input, args: [{ isSignal: true, alias: "title", required: false }] }], description: [{ type: i0.Input, args: [{ isSignal: true, alias: "description", required: false }] }], icon: [{ type: i0.Input, args: [{ isSignal: true, alias: "icon", required: false }] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }], align: [{ type: i0.Input, args: [{ isSignal: true, alias: "align", required: false }] }], interactive: [{ type: i0.Input, args: [{ isSignal: true, alias: "interactive", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], href: [{ type: i0.Input, args: [{ isSignal: true, alias: "href", required: false }] }], itemClick: [{ type: i0.Output, args: ["itemClick"] }] } });
17653
+
15109
17654
  class OlBottomNav {
15110
17655
  bordered = input(true, /* @ts-ignore */
15111
17656
  ...(ngDevMode ? [{ debugName: "bordered" }] : /* istanbul ignore next */ []));
@@ -15340,13 +17885,13 @@ class OlStepper {
15340
17885
  }
15341
17886
  }
15342
17887
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: OlStepper, deps: [], target: i0.ɵɵFactoryTarget.Component });
15343
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.6", type: OlStepper, isStandalone: true, selector: "ol-stepper", inputs: { currentStep: { classPropertyName: "currentStep", publicName: "currentStep", isSignal: true, isRequired: false, transformFunction: null }, variant: { classPropertyName: "variant", publicName: "variant", isSignal: true, isRequired: false, transformFunction: null }, orientation: { classPropertyName: "orientation", publicName: "orientation", isSignal: true, isRequired: false, transformFunction: null }, breakpoint: { classPropertyName: "breakpoint", publicName: "breakpoint", isSignal: true, isRequired: false, transformFunction: null }, linear: { classPropertyName: "linear", publicName: "linear", isSignal: true, isRequired: false, transformFunction: null }, clickable: { classPropertyName: "clickable", publicName: "clickable", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { currentStep: "currentStepChange", stepChange: "stepChange" }, host: { classAttribute: "block w-full" }, queries: [{ propertyName: "steps", predicate: OlStepItem, isSignal: true }], ngImport: i0, template: "<div [class]=\"isVertical() ? 'flex flex-col md:flex-row gap-8 w-full' : 'w-full'\">\n <!-- Stepper Header / Navigation Area -->\n <div [class]=\"isVertical() ? 'w-full md:w-80 shrink-0' : 'w-full mb-6'\">\n @if (isMobile()) {\n <!-- Mobile Layout -->\n <div class=\"p-4 rounded-md bg-neutral-tertiary/80 border border-default\">\n <div class=\"flex items-center justify-between mb-2\">\n <span class=\"text-xs font-semibold text-fg-brand uppercase tracking-wider\">\n B\u01B0\u1EDBc {{ currentStep() + 1 }} / {{ steps().length }}\n </span>\n @if (steps()[currentStep()]; as activeStep) {\n <span class=\"text-xs text-body-subtle font-medium\">\n {{ activeStep.title() }}\n </span>\n }\n </div>\n <!-- Progress Bar -->\n <div\n class=\"w-full bg-neutral-tertiary-medium h-2 rounded-full overflow-hidden flex gap-1 p-0.5\"\n >\n @for (step of steps(); track step.stepId(); let i = $index) {\n <div\n class=\"h-full flex-1 rounded-full transition-all duration-300\"\n [class.bg-brand]=\"i <= currentStep()\"\n [class.bg-neutral-tertiary-medium]=\"i > currentStep()\"\n ></div>\n }\n </div>\n </div>\n } @else if (variant() === 'cards') {\n <!-- Cards Variant (Vertical Stack) -->\n <div class=\"flex flex-col gap-3\" role=\"tablist\" aria-label=\"Ti\u1EBFn tr\u00ECnh c\u00E1c b\u01B0\u1EDBc\">\n @for (step of steps(); track step.stepId(); let i = $index) {\n <!--\n `<button role=\"tab\">` ch\u1EE9 KH\u00D4NG ph\u1EA3i `<div (click)>`. Khung ngo\u00E0i\n khai `role=\"tablist\"` nh\u01B0ng \u0111o 2026-09-17 b\u00EAn trong c\u00F3 **0\n `role=\"tab\"` v\u00E0 0 ph\u1EA7n t\u1EED tab t\u1EDBi \u0111\u01B0\u1EE3c** \u2014 `tablist` r\u1ED7ng vai tr\u00F2,\n v\u00E0 b\u00E0n ph\u00EDm kh\u00F4ng ch\u1ECDn n\u1ED5i b\u01B0\u1EDBc n\u00E0o. Ba bi\u1EBFn th\u1EC3 stepper kh\u00E1c \u0111\u00E3\n d\u00F9ng `<button>` s\u1EB5n; ch\u1EC9 ri\u00EAng `cards` l\u1EC7ch.\n\n `w-full` v\u00EC button kh\u00F4ng t\u1EF1 gi\u00E3n nh\u01B0 div.\n -->\n <button\n type=\"button\"\n role=\"tab\"\n [attr.aria-selected]=\"step.effectiveState() === 'active'\"\n [disabled]=\"step.effectiveState() === 'disabled'\"\n (click)=\"goToStep(i)\"\n [class]=\"\n 'w-full flex items-center justify-between p-4 rounded-md border transition-all duration-200 text-left ' +\n (step.effectiveState() === 'disabled'\n ? 'opacity-50 cursor-not-allowed bg-neutral-secondary-soft border-default text-fg-disabled'\n : step.effectiveState() === 'active'\n ? 'cursor-pointer hover:shadow-xs border-brand-subtle bg-brand-softer shadow-xs ring-2 ring-brand-subtle'\n : step.effectiveState() === 'completed'\n ? 'cursor-pointer hover:shadow-xs border-brand-subtle bg-brand-softer'\n : step.effectiveState() === 'error'\n ? 'cursor-pointer hover:shadow-xs border-danger-subtle bg-danger-soft'\n : 'cursor-pointer hover:shadow-xs border-default-medium bg-neutral-secondary-medium')\n \"\n [attr.aria-label]=\"stepAriaLabel(i)\"\n [attr.aria-current]=\"step.effectiveState() === 'active' ? 'step' : null\"\n >\n <div class=\"flex items-center gap-3 min-w-0\">\n <!-- Left Icon / Step Badge -->\n <div\n [class]=\"\n 'w-9 h-9 rounded-lg flex items-center justify-center shrink-0 font-bold text-xs transition-colors ' +\n (step.effectiveState() === 'completed'\n ? 'bg-brand-softer text-fg-brand-strong'\n : step.effectiveState() === 'active'\n ? 'bg-brand-softer text-fg-brand-strong'\n : step.effectiveState() === 'error'\n ? 'bg-danger-soft text-fg-danger-strong'\n : 'bg-neutral-tertiary text-body')\n \"\n >\n @if (step.effectiveState() === 'completed') {\n <ol-icon name=\"check\" size=\"xs\"></ol-icon>\n } @else if (step.effectiveState() === 'error') {\n <ol-icon name=\"info-circle\" size=\"xs\"></ol-icon>\n } @else if (step.icon()) {\n <ol-icon [name]=\"step.icon()!\" size=\"xs\"></ol-icon>\n } @else {\n <span>{{ i + 1 }}</span>\n }\n </div>\n\n <!-- Title & Subtitle -->\n <div class=\"min-w-0\">\n <div\n [class]=\"\n 'text-sm truncate ' +\n (step.effectiveState() === 'active'\n ? 'font-bold text-fg-brand-strong '\n : step.effectiveState() === 'completed'\n ? 'font-semibold text-heading'\n : step.effectiveState() === 'error'\n ? 'font-semibold text-fg-danger'\n : 'font-medium text-body')\n \"\n >\n {{ step.title() }}\n </div>\n @if (step.badge()) {\n <ol-badge size=\"sm\" color=\"brand\" class=\"shrink-0\">{{ step.badge() }}</ol-badge>\n }\n @if (step.effectiveSubtitle()) {\n <div class=\"text-xs text-fg-disabled truncate\">\n {{ step.effectiveSubtitle() }}\n </div>\n }\n </div>\n </div>\n\n <!-- Right Status Icon -->\n <div class=\"shrink-0 ml-3\">\n @if (step.effectiveState() === 'completed') {\n <span class=\"inline-flex items-center gap-1 text-xs font-semibold text-fg-brand\">\n <ol-icon name=\"check-circle\" size=\"xs\" color=\"brand\"></ol-icon>\n <span class=\"hidden sm:inline\">Ho\u00E0n t\u1EA5t</span>\n </span>\n } @else if (step.effectiveState() === 'active') {\n <span class=\"inline-flex items-center gap-1 text-xs font-semibold text-fg-brand\">\n <span class=\"w-2 h-2 rounded-full bg-brand animate-pulse\"></span>\n <span class=\"hidden sm:inline\">\u0110ang th\u1EF1c hi\u1EC7n</span>\n </span>\n } @else if (step.effectiveState() === 'error') {\n <span class=\"inline-flex items-center gap-1 text-xs font-semibold text-fg-danger\">\n <ol-icon name=\"info-circle\" size=\"xs\" color=\"danger\"></ol-icon>\n <span class=\"hidden sm:inline\">L\u1ED7i</span>\n </span>\n } @else {\n <ol-icon name=\"chevron-right\" size=\"xs\" class=\"text-fg-disabled\"></ol-icon>\n }\n </div>\n </button>\n }\n </div>\n } @else if (isVertical()) {\n <!-- Vertical Variant -->\n <div class=\"flex flex-col relative\" role=\"tablist\" aria-label=\"Ti\u1EBFn tr\u00ECnh c\u00E1c b\u01B0\u1EDBc\">\n @for (step of steps(); track step.stepId(); let i = $index) {\n <div class=\"flex items-start gap-4 relative pb-8 last:pb-0\">\n <!-- Connecting Vertical Line -->\n @if (i < steps().length - 1) {\n <div\n class=\"absolute left-4.25 top-9 bottom-0 w-0.5 transition-colors duration-200\"\n [class.bg-brand]=\"i < currentStep()\"\n [class.bg-neutral-tertiary-medium]=\"i >= currentStep()\"\n ></div>\n }\n\n <!-- Step Circle / Icon Shape -->\n <button\n type=\"button\"\n (click)=\"goToStep(i)\"\n [disabled]=\"\n step.effectiveState() === 'disabled' || (linear() && i > currentStep() + 1)\n \"\n [class]=\"\n 'w-9 h-9 rounded-full flex items-center justify-center font-bold text-xs transition-all duration-200 shrink-0 z-10 ' +\n (step.effectiveState() === 'disabled'\n ? 'opacity-50 cursor-not-allowed bg-neutral-tertiary text-fg-disabled border border-default'\n : step.effectiveState() === 'completed'\n ? 'cursor-pointer bg-brand-softer text-fg-brand shadow-xs'\n : step.effectiveState() === 'active'\n ? 'cursor-pointer border-2 border-brand bg-brand-softer text-fg-brand ring-4 ring-brand-subtle'\n : step.effectiveState() === 'error'\n ? 'cursor-pointer bg-danger-soft border-2 border-danger text-fg-danger-strong'\n : 'cursor-pointer bg-neutral-primary text-body border border-default-strong')\n \"\n [attr.aria-label]=\"stepAriaLabel(i)\"\n [attr.aria-current]=\"step.effectiveState() === 'active' ? 'step' : null\"\n >\n @if (step.effectiveState() === 'completed') {\n <ol-icon name=\"check\" size=\"xs\"></ol-icon>\n } @else if (step.effectiveState() === 'error') {\n <ol-icon name=\"info-circle\" size=\"xs\" color=\"danger\"></ol-icon>\n } @else if (step.icon()) {\n <ol-icon [name]=\"step.icon()!\" size=\"xs\"></ol-icon>\n } @else {\n <span>{{ i + 1 }}</span>\n }\n </button>\n\n <!-- Step Title & Subtitle -->\n <div class=\"text-left pt-1 cursor-pointer\" (click)=\"goToStep(i)\">\n <div\n [class]=\"\n 'text-sm transition-colors ' +\n (step.effectiveState() === 'active'\n ? 'font-bold text-fg-brand'\n : step.effectiveState() === 'completed'\n ? 'font-semibold text-heading'\n : step.effectiveState() === 'error'\n ? 'font-semibold text-fg-danger'\n : 'font-medium text-body-subtle')\n \"\n >\n {{ step.title() }}\n </div>\n @if (step.badge()) {\n <ol-badge size=\"sm\" color=\"brand\" class=\"shrink-0\">{{ step.badge() }}</ol-badge>\n }\n @if (step.effectiveSubtitle()) {\n <div class=\"text-xs text-fg-disabled mt-0.5\">\n {{ step.effectiveSubtitle() }}\n </div>\n }\n </div>\n </div>\n }\n </div>\n } @else {\n <!-- Horizontal Variants: Default | Only Icons | Icons & Text | With Dots -->\n <div\n class=\"flex items-center justify-between w-full relative\"\n role=\"tablist\"\n aria-label=\"Ti\u1EBFn tr\u00ECnh c\u00E1c b\u01B0\u1EDBc\"\n >\n @for (step of steps(); track step.stepId(); let i = $index) {\n <div class=\"flex items-center flex-1 last:flex-none relative\">\n <div class=\"flex items-center gap-3\">\n @if (variant() === 'with-dots') {\n <!-- With Dots Variant -->\n <button\n type=\"button\"\n (click)=\"goToStep(i)\"\n [disabled]=\"\n step.effectiveState() === 'disabled' || (linear() && i > currentStep() + 1)\n \"\n [class]=\"\n 'flex items-center justify-center transition-all duration-200 shrink-0 cursor-pointer disabled:cursor-not-allowed ' +\n (step.effectiveState() === 'disabled'\n ? 'w-3.5 h-3.5 rounded-full opacity-50 bg-neutral-primary text-fg-disabled border-2 border-default-medium'\n : step.effectiveState() === 'completed'\n ? 'w-4 h-4 rounded-full bg-brand text-white'\n : step.effectiveState() === 'active'\n ? 'w-4 h-4 rounded-full bg-neutral-primary border-2 border-brand-light ring-4 ring-brand-subtle'\n : step.effectiveState() === 'error'\n ? 'w-4 h-4 rounded-full bg-danger-surface ring-4 ring-danger-subtle'\n : 'w-3.5 h-3.5 rounded-full bg-neutral-primary border-2 border-default-medium')\n \"\n [attr.aria-label]=\"stepAriaLabel(i)\"\n [attr.aria-current]=\"step.effectiveState() === 'active' ? 'step' : null\"\n >\n @if (step.effectiveState() === 'completed') {\n <ol-icon name=\"check\" size=\"xs\" color=\"white\" class=\"scale-75\"></ol-icon>\n }\n </button>\n } @else if (variant() === 'only-icons' || variant() === 'icons-text') {\n <!-- Icon Shape Variant -->\n <button\n type=\"button\"\n (click)=\"goToStep(i)\"\n [disabled]=\"\n step.effectiveState() === 'disabled' || (linear() && i > currentStep() + 1)\n \"\n [class]=\"\n 'w-10 h-10 rounded-full flex items-center justify-center font-semibold text-sm transition-all duration-200 shrink-0 cursor-pointer disabled:cursor-not-allowed ' +\n (step.effectiveState() === 'disabled'\n ? 'opacity-50 bg-neutral-tertiary text-fg-disabled'\n : step.effectiveState() === 'completed'\n ? 'bg-brand-softer text-fg-brand shadow-xs'\n : step.effectiveState() === 'active'\n ? 'border-2 border-brand bg-brand-softer text-fg-brand ring-4 ring-brand-subtle'\n : step.effectiveState() === 'error'\n ? 'bg-danger-soft border-2 border-danger text-fg-danger-strong'\n : 'bg-neutral-tertiary text-body border border-default')\n \"\n [attr.aria-label]=\"stepAriaLabel(i)\"\n [attr.aria-current]=\"step.effectiveState() === 'active' ? 'step' : null\"\n >\n @if (step.effectiveState() === 'completed') {\n <ol-icon name=\"check\" size=\"sm\"></ol-icon>\n } @else if (step.effectiveState() === 'error') {\n <ol-icon name=\"info-circle\" size=\"sm\" color=\"danger\"></ol-icon>\n } @else if (step.icon()) {\n <ol-icon [name]=\"step.icon()!\" size=\"sm\"></ol-icon>\n } @else {\n <span>{{ i + 1 }}</span>\n }\n </button>\n } @else {\n <!-- Default Variant (Numbered Circle) -->\n <button\n type=\"button\"\n (click)=\"goToStep(i)\"\n [disabled]=\"\n step.effectiveState() === 'disabled' || (linear() && i > currentStep() + 1)\n \"\n [class]=\"\n 'w-8 h-8 rounded-full flex items-center justify-center font-bold text-xs transition-all duration-200 shrink-0 cursor-pointer disabled:cursor-not-allowed ' +\n (step.effectiveState() === 'disabled'\n ? 'opacity-50 bg-neutral-primary text-fg-disabled border border-default'\n : step.effectiveState() === 'completed'\n ? 'border-2 border-brand bg-brand-softer text-fg-brand shadow-xs'\n : step.effectiveState() === 'active'\n ? 'border-2 border-brand bg-brand-softer text-fg-brand shadow-xs'\n : step.effectiveState() === 'error'\n ? 'bg-danger-soft border-2 border-danger text-fg-danger-strong'\n : 'bg-neutral-primary text-body border border-default-strong')\n \"\n [attr.aria-label]=\"stepAriaLabel(i)\"\n [attr.aria-current]=\"step.effectiveState() === 'active' ? 'step' : null\"\n >\n @if (step.effectiveState() === 'completed') {\n <ol-icon name=\"check\" size=\"xs\"></ol-icon>\n } @else if (step.effectiveState() === 'error') {\n <ol-icon name=\"info-circle\" size=\"xs\" color=\"danger\"></ol-icon>\n } @else {\n <span>{{ i + 1 }}</span>\n }\n </button>\n }\n\n <!-- Text Title & Subtitle (unless only-icons) -->\n @if (variant() !== 'only-icons') {\n <div class=\"text-left cursor-pointer\" (click)=\"goToStep(i)\">\n <div\n [class]=\"\n 'text-sm transition-colors ' +\n (step.effectiveState() === 'active'\n ? 'font-bold text-fg-brand'\n : step.effectiveState() === 'completed'\n ? 'font-semibold text-heading'\n : step.effectiveState() === 'error'\n ? 'font-semibold text-fg-danger'\n : 'font-medium text-body-subtle')\n \"\n >\n {{ step.title() }}\n </div>\n @if (step.badge()) {\n <ol-badge size=\"sm\" color=\"brand\" class=\"shrink-0\">{{ step.badge() }}</ol-badge>\n }\n @if (step.effectiveSubtitle()) {\n <div class=\"text-xs text-fg-disabled hidden md:block\">\n {{ step.effectiveSubtitle() }}\n </div>\n }\n </div>\n }\n </div>\n\n <!-- Connecting Line (Horizontal) -->\n @if (i < steps().length - 1) {\n <div class=\"flex-1 mx-4 h-0.5 bg-neutral-tertiary-medium relative overflow-hidden\">\n <div\n class=\"h-full bg-brand transition-all duration-300\"\n [class.w-full]=\"i < currentStep()\"\n [class.w-0]=\"i >= currentStep()\"\n ></div>\n </div>\n }\n </div>\n }\n </div>\n }\n </div>\n\n <!-- Stepper Body Content Area -->\n <div [class]=\"isVertical() ? 'flex-1 min-w-0' : 'w-full'\">\n <ng-content />\n </div>\n</div>\n", dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "component", type: OlIcon, selector: "ol-icon", inputs: ["name", "size", "color", "variant", "strokeWidth"] }, { kind: "component", type: OlBadge, selector: "ol-badge", inputs: ["color", "bordered", "theme", "size", "shape", "rounded", "type", "dot", "leftIcon", "rightIcon", "avatar", "avatarText", "secondaryText", "loading", "removable", "href"], outputs: ["dismiss"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
17888
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.6", type: OlStepper, isStandalone: true, selector: "ol-stepper", inputs: { currentStep: { classPropertyName: "currentStep", publicName: "currentStep", isSignal: true, isRequired: false, transformFunction: null }, variant: { classPropertyName: "variant", publicName: "variant", isSignal: true, isRequired: false, transformFunction: null }, orientation: { classPropertyName: "orientation", publicName: "orientation", isSignal: true, isRequired: false, transformFunction: null }, breakpoint: { classPropertyName: "breakpoint", publicName: "breakpoint", isSignal: true, isRequired: false, transformFunction: null }, linear: { classPropertyName: "linear", publicName: "linear", isSignal: true, isRequired: false, transformFunction: null }, clickable: { classPropertyName: "clickable", publicName: "clickable", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { currentStep: "currentStepChange", stepChange: "stepChange" }, host: { classAttribute: "block w-full" }, queries: [{ propertyName: "steps", predicate: OlStepItem, isSignal: true }], ngImport: i0, template: "<div [class]=\"isVertical() ? 'flex flex-col md:flex-row gap-8 w-full' : 'w-full'\">\n <!-- Stepper Header / Navigation Area -->\n <div [class]=\"isVertical() ? 'w-full md:w-80 shrink-0' : 'w-full mb-6'\">\n @if (isMobile()) {\n <!-- Mobile Layout -->\n <div class=\"p-4 rounded-md bg-neutral-tertiary/80 border border-default\">\n <div class=\"flex items-center justify-between mb-2\">\n <span class=\"text-xs font-semibold text-fg-brand uppercase tracking-wider\">\n B\u01B0\u1EDBc {{ currentStep() + 1 }} / {{ steps().length }}\n </span>\n @if (steps()[currentStep()]; as activeStep) {\n <span class=\"text-xs text-body-subtle font-medium\">\n {{ activeStep.title() }}\n </span>\n }\n </div>\n <!-- Progress Bar -->\n <div\n class=\"w-full bg-neutral-tertiary-medium h-2 rounded-full overflow-hidden flex gap-1 p-0.5\"\n >\n @for (step of steps(); track step.stepId(); let i = $index) {\n <div\n class=\"h-full flex-1 rounded-full transition-all duration-300\"\n [class.bg-brand]=\"i <= currentStep()\"\n [class.bg-neutral-tertiary-medium]=\"i > currentStep()\"\n ></div>\n }\n </div>\n </div>\n } @else if (variant() === 'cards') {\n <!-- Cards Variant (Vertical Stack) -->\n <div class=\"flex flex-col gap-3\" role=\"tablist\" aria-label=\"Ti\u1EBFn tr\u00ECnh c\u00E1c b\u01B0\u1EDBc\">\n @for (step of steps(); track step.stepId(); let i = $index) {\n <!--\n `<button role=\"tab\">` ch\u1EE9 KH\u00D4NG ph\u1EA3i `<div (click)>`. Khung ngo\u00E0i\n khai `role=\"tablist\"` nh\u01B0ng \u0111o 2026-09-17 b\u00EAn trong c\u00F3 **0\n `role=\"tab\"` v\u00E0 0 ph\u1EA7n t\u1EED tab t\u1EDBi \u0111\u01B0\u1EE3c** \u2014 `tablist` r\u1ED7ng vai tr\u00F2,\n v\u00E0 b\u00E0n ph\u00EDm kh\u00F4ng ch\u1ECDn n\u1ED5i b\u01B0\u1EDBc n\u00E0o. Ba bi\u1EBFn th\u1EC3 stepper kh\u00E1c \u0111\u00E3\n d\u00F9ng `<button>` s\u1EB5n; ch\u1EC9 ri\u00EAng `cards` l\u1EC7ch.\n\n `w-full` v\u00EC button kh\u00F4ng t\u1EF1 gi\u00E3n nh\u01B0 div.\n -->\n <button\n type=\"button\"\n role=\"tab\"\n [attr.aria-selected]=\"step.effectiveState() === 'active'\"\n [disabled]=\"step.effectiveState() === 'disabled'\"\n (click)=\"goToStep(i)\"\n [class]=\"\n 'w-full flex items-center justify-between p-4 rounded-md border transition-all duration-200 text-left ' +\n (step.effectiveState() === 'disabled'\n ? 'opacity-50 cursor-not-allowed bg-neutral-secondary-soft border-default text-fg-disabled'\n : step.effectiveState() === 'active'\n ? 'cursor-pointer hover:shadow-xs border-brand-subtle bg-brand-softer shadow-xs ring-2 ring-brand-subtle'\n : step.effectiveState() === 'completed'\n ? 'cursor-pointer hover:shadow-xs border-brand-subtle bg-brand-softer'\n : step.effectiveState() === 'error'\n ? 'cursor-pointer hover:shadow-xs border-danger-subtle bg-danger-soft'\n : 'cursor-pointer hover:shadow-xs border-default-medium bg-neutral-secondary-medium')\n \"\n [attr.aria-label]=\"stepAriaLabel(i)\"\n [attr.aria-current]=\"step.effectiveState() === 'active' ? 'step' : null\"\n >\n <div class=\"flex items-center gap-3 min-w-0\">\n <!-- Left Icon / Step Badge -->\n <div\n [class]=\"\n 'w-9 h-9 rounded-lg flex items-center justify-center shrink-0 font-bold text-xs transition-colors ' +\n (step.effectiveState() === 'completed'\n ? 'bg-brand-softer text-fg-brand-strong'\n : step.effectiveState() === 'active'\n ? 'bg-brand-softer text-fg-brand-strong'\n : step.effectiveState() === 'error'\n ? 'bg-danger-soft text-fg-danger-strong'\n : 'bg-neutral-tertiary text-body')\n \"\n >\n @if (step.effectiveState() === 'completed') {\n <ol-icon name=\"check\" size=\"xs\"></ol-icon>\n } @else if (step.effectiveState() === 'error') {\n <ol-icon name=\"info-circle\" size=\"xs\"></ol-icon>\n } @else if (step.icon()) {\n <ol-icon [name]=\"step.icon()!\" size=\"xs\"></ol-icon>\n } @else {\n <span>{{ i + 1 }}</span>\n }\n </div>\n\n <!-- Title & Subtitle -->\n <div class=\"min-w-0\">\n <div\n [class]=\"\n 'text-sm truncate ' +\n (step.effectiveState() === 'active'\n ? 'font-bold text-fg-brand-strong '\n : step.effectiveState() === 'completed'\n ? 'font-semibold text-heading'\n : step.effectiveState() === 'error'\n ? 'font-semibold text-fg-danger'\n : 'font-medium text-body')\n \"\n >\n {{ step.title() }}\n </div>\n @if (step.badge()) {\n <ol-badge size=\"sm\" color=\"brand\" class=\"shrink-0\">{{ step.badge() }}</ol-badge>\n }\n @if (step.effectiveSubtitle()) {\n <div class=\"text-xs text-fg-disabled truncate\">\n {{ step.effectiveSubtitle() }}\n </div>\n }\n </div>\n </div>\n\n <!-- Right Status Icon -->\n <div class=\"shrink-0 ml-3\">\n @if (step.effectiveState() === 'completed') {\n <span class=\"inline-flex items-center gap-1 text-xs font-semibold text-fg-brand\">\n <ol-icon name=\"check-circle\" size=\"xs\" color=\"brand\"></ol-icon>\n <span class=\"hidden sm:inline\">Ho\u00E0n t\u1EA5t</span>\n </span>\n } @else if (step.effectiveState() === 'active') {\n <span class=\"inline-flex items-center gap-1 text-xs font-semibold text-fg-brand\">\n <span class=\"w-2 h-2 rounded-full bg-brand animate-pulse\"></span>\n <span class=\"hidden sm:inline\">\u0110ang th\u1EF1c hi\u1EC7n</span>\n </span>\n } @else if (step.effectiveState() === 'error') {\n <span class=\"inline-flex items-center gap-1 text-xs font-semibold text-fg-danger\">\n <ol-icon name=\"info-circle\" size=\"xs\" color=\"danger\"></ol-icon>\n <span class=\"hidden sm:inline\">L\u1ED7i</span>\n </span>\n } @else {\n <ol-icon name=\"chevron-right\" size=\"xs\" class=\"text-fg-disabled\"></ol-icon>\n }\n </div>\n </button>\n }\n </div>\n } @else if (isVertical()) {\n <!-- Vertical Variant -->\n <div class=\"flex flex-col relative\" role=\"tablist\" aria-label=\"Ti\u1EBFn tr\u00ECnh c\u00E1c b\u01B0\u1EDBc\">\n @for (step of steps(); track step.stepId(); let i = $index) {\n <div class=\"flex items-start gap-4 relative pb-8 last:pb-0\">\n <!-- Connecting Vertical Line -->\n @if (i < steps().length - 1) {\n <div\n class=\"absolute left-4.25 top-9 bottom-0 w-0.5 transition-colors duration-200\"\n [class.bg-brand]=\"i < currentStep()\"\n [class.bg-neutral-tertiary-medium]=\"i >= currentStep()\"\n ></div>\n }\n\n <!-- Step Circle / Icon Shape -->\n <button\n type=\"button\"\n (click)=\"goToStep(i)\"\n [disabled]=\"\n step.effectiveState() === 'disabled' || (linear() && i > currentStep() + 1)\n \"\n [class]=\"\n 'w-9 h-9 rounded-full flex items-center justify-center font-bold text-xs transition-all duration-200 shrink-0 z-10 ' +\n (step.effectiveState() === 'disabled'\n ? 'opacity-50 cursor-not-allowed bg-neutral-tertiary text-fg-disabled border border-default'\n : step.effectiveState() === 'completed'\n ? 'cursor-pointer bg-brand-softer text-fg-brand shadow-xs'\n : step.effectiveState() === 'active'\n ? 'cursor-pointer border-2 border-brand bg-brand-softer text-fg-brand ring-4 ring-brand-subtle'\n : step.effectiveState() === 'error'\n ? 'cursor-pointer bg-danger-soft border-2 border-danger text-fg-danger-strong'\n : 'cursor-pointer bg-neutral-primary text-body border border-default-strong')\n \"\n [attr.aria-label]=\"stepAriaLabel(i)\"\n [attr.aria-current]=\"step.effectiveState() === 'active' ? 'step' : null\"\n >\n @if (step.effectiveState() === 'completed') {\n <ol-icon name=\"check\" size=\"xs\"></ol-icon>\n } @else if (step.effectiveState() === 'error') {\n <ol-icon name=\"info-circle\" size=\"xs\" color=\"danger\"></ol-icon>\n } @else if (step.icon()) {\n <ol-icon [name]=\"step.icon()!\" size=\"xs\"></ol-icon>\n } @else {\n <span>{{ i + 1 }}</span>\n }\n </button>\n\n <!-- Step Title & Subtitle -->\n <div class=\"text-left pt-1 cursor-pointer\" (click)=\"goToStep(i)\">\n <div\n [class]=\"\n 'text-sm transition-colors ' +\n (step.effectiveState() === 'active'\n ? 'font-bold text-fg-brand'\n : step.effectiveState() === 'completed'\n ? 'font-semibold text-heading'\n : step.effectiveState() === 'error'\n ? 'font-semibold text-fg-danger'\n : 'font-medium text-body-subtle')\n \"\n >\n {{ step.title() }}\n </div>\n @if (step.badge()) {\n <ol-badge size=\"sm\" color=\"brand\" class=\"shrink-0\">{{ step.badge() }}</ol-badge>\n }\n @if (step.effectiveSubtitle()) {\n <div class=\"text-xs text-fg-disabled mt-0.5\">\n {{ step.effectiveSubtitle() }}\n </div>\n }\n </div>\n </div>\n }\n </div>\n } @else {\n <!--\n Horizontal Variants: Default | Only Icons | Icons & Text | With Dots\n\n `overflow-x-auto` tr\u00EAn khung ngo\u00E0i: `breakpoint` l\u00E0 input TH\u1EE6 C\u00D4NG m\u1EB7c\n \u0111\u1ECBnh `desktop`, kh\u00F4ng t\u1EF1 theo b\u1EC1 r\u1ED9ng m\u00E0n. N\u01A1i g\u1ECDi \u0111\u1EC3 nguy\u00EAn m\u1EB7c \u0111\u1ECBnh m\u00E0\n ng\u01B0\u1EDDi d\u00F9ng m\u1EDF tr\u00EAn \u0111i\u1EC7n tho\u1EA1i th\u00EC b\u1ED1 c\u1EE5c ngang b\u1ED1n b\u01B0\u1EDBc \u0111o \u0111\u01B0\u1EE3c\n **x=402 tr\u00EAn viewport 375** \u2014 b\u01B0\u1EDBc cu\u1ED1i n\u1EB1m h\u1EB3n ngo\u00E0i m\u00E9p ph\u1EA3i v\u00E0\n `<main>` c\u1EE7a trang (`overflow-x: hidden`) C\u1EAET M\u1EA4T n\u00F3, kh\u00F4ng cu\u1ED9n t\u1EDBi\n \u0111\u01B0\u1EE3c.\n\n Cho d\u1EA3i n\u00E0y t\u1EF1 cu\u1ED9n ngang th\u00EC b\u01B0\u1EDBc cu\u1ED1i v\u1EABn t\u1EDBi \u0111\u01B0\u1EE3c b\u1EB1ng c\u00E1ch k\u00E9o, v\u00E0\n tr\u00EAn desktop `justify-between` v\u1EABn d\u00E0n \u0111\u1EC1u nh\u01B0 c\u0169 v\u00EC n\u1ED9i dung v\u1EEBa khung.\n K\u00E8m `pb-1` \u0111\u1EC3 thanh cu\u1ED9n kh\u00F4ng che \u0111\u01B0\u1EDDng k\u1EBB n\u1ED1i gi\u1EEFa c\u00E1c b\u01B0\u1EDBc.\n -->\n <div\n class=\"flex items-center justify-between w-full relative overflow-x-auto pb-1\"\n role=\"tablist\"\n aria-label=\"Ti\u1EBFn tr\u00ECnh c\u00E1c b\u01B0\u1EDBc\"\n >\n @for (step of steps(); track step.stepId(); let i = $index) {\n <div class=\"flex items-center flex-1 last:flex-none relative\">\n <div class=\"flex items-center gap-3\">\n @if (variant() === 'with-dots') {\n <!-- With Dots Variant -->\n <button\n type=\"button\"\n (click)=\"goToStep(i)\"\n [disabled]=\"\n step.effectiveState() === 'disabled' || (linear() && i > currentStep() + 1)\n \"\n [class]=\"\n 'flex items-center justify-center transition-all duration-200 shrink-0 cursor-pointer disabled:cursor-not-allowed ' +\n (step.effectiveState() === 'disabled'\n ? 'w-3.5 h-3.5 rounded-full opacity-50 bg-neutral-primary text-fg-disabled border-2 border-default-medium'\n : step.effectiveState() === 'completed'\n ? 'w-4 h-4 rounded-full bg-brand text-white'\n : step.effectiveState() === 'active'\n ? 'w-4 h-4 rounded-full bg-neutral-primary border-2 border-brand-light ring-4 ring-brand-subtle'\n : step.effectiveState() === 'error'\n ? 'w-4 h-4 rounded-full bg-danger-surface ring-4 ring-danger-subtle'\n : 'w-3.5 h-3.5 rounded-full bg-neutral-primary border-2 border-default-medium')\n \"\n [attr.aria-label]=\"stepAriaLabel(i)\"\n [attr.aria-current]=\"step.effectiveState() === 'active' ? 'step' : null\"\n >\n @if (step.effectiveState() === 'completed') {\n <ol-icon name=\"check\" size=\"xs\" color=\"white\" class=\"scale-75\"></ol-icon>\n }\n </button>\n } @else if (variant() === 'only-icons' || variant() === 'icons-text') {\n <!-- Icon Shape Variant -->\n <button\n type=\"button\"\n (click)=\"goToStep(i)\"\n [disabled]=\"\n step.effectiveState() === 'disabled' || (linear() && i > currentStep() + 1)\n \"\n [class]=\"\n 'w-10 h-10 rounded-full flex items-center justify-center font-semibold text-sm transition-all duration-200 shrink-0 cursor-pointer disabled:cursor-not-allowed ' +\n (step.effectiveState() === 'disabled'\n ? 'opacity-50 bg-neutral-tertiary text-fg-disabled'\n : step.effectiveState() === 'completed'\n ? 'bg-brand-softer text-fg-brand shadow-xs'\n : step.effectiveState() === 'active'\n ? 'border-2 border-brand bg-brand-softer text-fg-brand ring-4 ring-brand-subtle'\n : step.effectiveState() === 'error'\n ? 'bg-danger-soft border-2 border-danger text-fg-danger-strong'\n : 'bg-neutral-tertiary text-body border border-default')\n \"\n [attr.aria-label]=\"stepAriaLabel(i)\"\n [attr.aria-current]=\"step.effectiveState() === 'active' ? 'step' : null\"\n >\n @if (step.effectiveState() === 'completed') {\n <ol-icon name=\"check\" size=\"sm\"></ol-icon>\n } @else if (step.effectiveState() === 'error') {\n <ol-icon name=\"info-circle\" size=\"sm\" color=\"danger\"></ol-icon>\n } @else if (step.icon()) {\n <ol-icon [name]=\"step.icon()!\" size=\"sm\"></ol-icon>\n } @else {\n <span>{{ i + 1 }}</span>\n }\n </button>\n } @else {\n <!-- Default Variant (Numbered Circle) -->\n <button\n type=\"button\"\n (click)=\"goToStep(i)\"\n [disabled]=\"\n step.effectiveState() === 'disabled' || (linear() && i > currentStep() + 1)\n \"\n [class]=\"\n 'w-8 h-8 rounded-full flex items-center justify-center font-bold text-xs transition-all duration-200 shrink-0 cursor-pointer disabled:cursor-not-allowed ' +\n (step.effectiveState() === 'disabled'\n ? 'opacity-50 bg-neutral-primary text-fg-disabled border border-default'\n : step.effectiveState() === 'completed'\n ? 'border-2 border-brand bg-brand-softer text-fg-brand shadow-xs'\n : step.effectiveState() === 'active'\n ? 'border-2 border-brand bg-brand-softer text-fg-brand shadow-xs'\n : step.effectiveState() === 'error'\n ? 'bg-danger-soft border-2 border-danger text-fg-danger-strong'\n : 'bg-neutral-primary text-body border border-default-strong')\n \"\n [attr.aria-label]=\"stepAriaLabel(i)\"\n [attr.aria-current]=\"step.effectiveState() === 'active' ? 'step' : null\"\n >\n @if (step.effectiveState() === 'completed') {\n <ol-icon name=\"check\" size=\"xs\"></ol-icon>\n } @else if (step.effectiveState() === 'error') {\n <ol-icon name=\"info-circle\" size=\"xs\" color=\"danger\"></ol-icon>\n } @else {\n <span>{{ i + 1 }}</span>\n }\n </button>\n }\n\n <!-- Text Title & Subtitle (unless only-icons) -->\n @if (variant() !== 'only-icons') {\n <div class=\"text-left cursor-pointer\" (click)=\"goToStep(i)\">\n <div\n [class]=\"\n 'text-sm transition-colors ' +\n (step.effectiveState() === 'active'\n ? 'font-bold text-fg-brand'\n : step.effectiveState() === 'completed'\n ? 'font-semibold text-heading'\n : step.effectiveState() === 'error'\n ? 'font-semibold text-fg-danger'\n : 'font-medium text-body-subtle')\n \"\n >\n {{ step.title() }}\n </div>\n @if (step.badge()) {\n <ol-badge size=\"sm\" color=\"brand\" class=\"shrink-0\">{{ step.badge() }}</ol-badge>\n }\n @if (step.effectiveSubtitle()) {\n <div class=\"text-xs text-fg-disabled hidden md:block\">\n {{ step.effectiveSubtitle() }}\n </div>\n }\n </div>\n }\n </div>\n\n <!-- Connecting Line (Horizontal) -->\n @if (i < steps().length - 1) {\n <div class=\"flex-1 mx-4 h-0.5 bg-neutral-tertiary-medium relative overflow-hidden\">\n <div\n class=\"h-full bg-brand transition-all duration-300\"\n [class.w-full]=\"i < currentStep()\"\n [class.w-0]=\"i >= currentStep()\"\n ></div>\n </div>\n }\n </div>\n }\n </div>\n }\n </div>\n\n <!-- Stepper Body Content Area -->\n <div [class]=\"isVertical() ? 'flex-1 min-w-0' : 'w-full'\">\n <ng-content />\n </div>\n</div>\n", dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "component", type: OlIcon, selector: "ol-icon", inputs: ["name", "size", "color", "variant", "strokeWidth"] }, { kind: "component", type: OlBadge, selector: "ol-badge", inputs: ["color", "bordered", "theme", "size", "shape", "rounded", "type", "dot", "leftIcon", "rightIcon", "avatar", "avatarText", "secondaryText", "loading", "removable", "href"], outputs: ["dismiss"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
15344
17889
  }
15345
17890
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: OlStepper, decorators: [{
15346
17891
  type: Component,
15347
17892
  args: [{ selector: 'ol-stepper', standalone: true, imports: [CommonModule, OlIcon, OlBadge], host: {
15348
17893
  class: 'block w-full',
15349
- }, changeDetection: ChangeDetectionStrategy.OnPush, template: "<div [class]=\"isVertical() ? 'flex flex-col md:flex-row gap-8 w-full' : 'w-full'\">\n <!-- Stepper Header / Navigation Area -->\n <div [class]=\"isVertical() ? 'w-full md:w-80 shrink-0' : 'w-full mb-6'\">\n @if (isMobile()) {\n <!-- Mobile Layout -->\n <div class=\"p-4 rounded-md bg-neutral-tertiary/80 border border-default\">\n <div class=\"flex items-center justify-between mb-2\">\n <span class=\"text-xs font-semibold text-fg-brand uppercase tracking-wider\">\n B\u01B0\u1EDBc {{ currentStep() + 1 }} / {{ steps().length }}\n </span>\n @if (steps()[currentStep()]; as activeStep) {\n <span class=\"text-xs text-body-subtle font-medium\">\n {{ activeStep.title() }}\n </span>\n }\n </div>\n <!-- Progress Bar -->\n <div\n class=\"w-full bg-neutral-tertiary-medium h-2 rounded-full overflow-hidden flex gap-1 p-0.5\"\n >\n @for (step of steps(); track step.stepId(); let i = $index) {\n <div\n class=\"h-full flex-1 rounded-full transition-all duration-300\"\n [class.bg-brand]=\"i <= currentStep()\"\n [class.bg-neutral-tertiary-medium]=\"i > currentStep()\"\n ></div>\n }\n </div>\n </div>\n } @else if (variant() === 'cards') {\n <!-- Cards Variant (Vertical Stack) -->\n <div class=\"flex flex-col gap-3\" role=\"tablist\" aria-label=\"Ti\u1EBFn tr\u00ECnh c\u00E1c b\u01B0\u1EDBc\">\n @for (step of steps(); track step.stepId(); let i = $index) {\n <!--\n `<button role=\"tab\">` ch\u1EE9 KH\u00D4NG ph\u1EA3i `<div (click)>`. Khung ngo\u00E0i\n khai `role=\"tablist\"` nh\u01B0ng \u0111o 2026-09-17 b\u00EAn trong c\u00F3 **0\n `role=\"tab\"` v\u00E0 0 ph\u1EA7n t\u1EED tab t\u1EDBi \u0111\u01B0\u1EE3c** \u2014 `tablist` r\u1ED7ng vai tr\u00F2,\n v\u00E0 b\u00E0n ph\u00EDm kh\u00F4ng ch\u1ECDn n\u1ED5i b\u01B0\u1EDBc n\u00E0o. Ba bi\u1EBFn th\u1EC3 stepper kh\u00E1c \u0111\u00E3\n d\u00F9ng `<button>` s\u1EB5n; ch\u1EC9 ri\u00EAng `cards` l\u1EC7ch.\n\n `w-full` v\u00EC button kh\u00F4ng t\u1EF1 gi\u00E3n nh\u01B0 div.\n -->\n <button\n type=\"button\"\n role=\"tab\"\n [attr.aria-selected]=\"step.effectiveState() === 'active'\"\n [disabled]=\"step.effectiveState() === 'disabled'\"\n (click)=\"goToStep(i)\"\n [class]=\"\n 'w-full flex items-center justify-between p-4 rounded-md border transition-all duration-200 text-left ' +\n (step.effectiveState() === 'disabled'\n ? 'opacity-50 cursor-not-allowed bg-neutral-secondary-soft border-default text-fg-disabled'\n : step.effectiveState() === 'active'\n ? 'cursor-pointer hover:shadow-xs border-brand-subtle bg-brand-softer shadow-xs ring-2 ring-brand-subtle'\n : step.effectiveState() === 'completed'\n ? 'cursor-pointer hover:shadow-xs border-brand-subtle bg-brand-softer'\n : step.effectiveState() === 'error'\n ? 'cursor-pointer hover:shadow-xs border-danger-subtle bg-danger-soft'\n : 'cursor-pointer hover:shadow-xs border-default-medium bg-neutral-secondary-medium')\n \"\n [attr.aria-label]=\"stepAriaLabel(i)\"\n [attr.aria-current]=\"step.effectiveState() === 'active' ? 'step' : null\"\n >\n <div class=\"flex items-center gap-3 min-w-0\">\n <!-- Left Icon / Step Badge -->\n <div\n [class]=\"\n 'w-9 h-9 rounded-lg flex items-center justify-center shrink-0 font-bold text-xs transition-colors ' +\n (step.effectiveState() === 'completed'\n ? 'bg-brand-softer text-fg-brand-strong'\n : step.effectiveState() === 'active'\n ? 'bg-brand-softer text-fg-brand-strong'\n : step.effectiveState() === 'error'\n ? 'bg-danger-soft text-fg-danger-strong'\n : 'bg-neutral-tertiary text-body')\n \"\n >\n @if (step.effectiveState() === 'completed') {\n <ol-icon name=\"check\" size=\"xs\"></ol-icon>\n } @else if (step.effectiveState() === 'error') {\n <ol-icon name=\"info-circle\" size=\"xs\"></ol-icon>\n } @else if (step.icon()) {\n <ol-icon [name]=\"step.icon()!\" size=\"xs\"></ol-icon>\n } @else {\n <span>{{ i + 1 }}</span>\n }\n </div>\n\n <!-- Title & Subtitle -->\n <div class=\"min-w-0\">\n <div\n [class]=\"\n 'text-sm truncate ' +\n (step.effectiveState() === 'active'\n ? 'font-bold text-fg-brand-strong '\n : step.effectiveState() === 'completed'\n ? 'font-semibold text-heading'\n : step.effectiveState() === 'error'\n ? 'font-semibold text-fg-danger'\n : 'font-medium text-body')\n \"\n >\n {{ step.title() }}\n </div>\n @if (step.badge()) {\n <ol-badge size=\"sm\" color=\"brand\" class=\"shrink-0\">{{ step.badge() }}</ol-badge>\n }\n @if (step.effectiveSubtitle()) {\n <div class=\"text-xs text-fg-disabled truncate\">\n {{ step.effectiveSubtitle() }}\n </div>\n }\n </div>\n </div>\n\n <!-- Right Status Icon -->\n <div class=\"shrink-0 ml-3\">\n @if (step.effectiveState() === 'completed') {\n <span class=\"inline-flex items-center gap-1 text-xs font-semibold text-fg-brand\">\n <ol-icon name=\"check-circle\" size=\"xs\" color=\"brand\"></ol-icon>\n <span class=\"hidden sm:inline\">Ho\u00E0n t\u1EA5t</span>\n </span>\n } @else if (step.effectiveState() === 'active') {\n <span class=\"inline-flex items-center gap-1 text-xs font-semibold text-fg-brand\">\n <span class=\"w-2 h-2 rounded-full bg-brand animate-pulse\"></span>\n <span class=\"hidden sm:inline\">\u0110ang th\u1EF1c hi\u1EC7n</span>\n </span>\n } @else if (step.effectiveState() === 'error') {\n <span class=\"inline-flex items-center gap-1 text-xs font-semibold text-fg-danger\">\n <ol-icon name=\"info-circle\" size=\"xs\" color=\"danger\"></ol-icon>\n <span class=\"hidden sm:inline\">L\u1ED7i</span>\n </span>\n } @else {\n <ol-icon name=\"chevron-right\" size=\"xs\" class=\"text-fg-disabled\"></ol-icon>\n }\n </div>\n </button>\n }\n </div>\n } @else if (isVertical()) {\n <!-- Vertical Variant -->\n <div class=\"flex flex-col relative\" role=\"tablist\" aria-label=\"Ti\u1EBFn tr\u00ECnh c\u00E1c b\u01B0\u1EDBc\">\n @for (step of steps(); track step.stepId(); let i = $index) {\n <div class=\"flex items-start gap-4 relative pb-8 last:pb-0\">\n <!-- Connecting Vertical Line -->\n @if (i < steps().length - 1) {\n <div\n class=\"absolute left-4.25 top-9 bottom-0 w-0.5 transition-colors duration-200\"\n [class.bg-brand]=\"i < currentStep()\"\n [class.bg-neutral-tertiary-medium]=\"i >= currentStep()\"\n ></div>\n }\n\n <!-- Step Circle / Icon Shape -->\n <button\n type=\"button\"\n (click)=\"goToStep(i)\"\n [disabled]=\"\n step.effectiveState() === 'disabled' || (linear() && i > currentStep() + 1)\n \"\n [class]=\"\n 'w-9 h-9 rounded-full flex items-center justify-center font-bold text-xs transition-all duration-200 shrink-0 z-10 ' +\n (step.effectiveState() === 'disabled'\n ? 'opacity-50 cursor-not-allowed bg-neutral-tertiary text-fg-disabled border border-default'\n : step.effectiveState() === 'completed'\n ? 'cursor-pointer bg-brand-softer text-fg-brand shadow-xs'\n : step.effectiveState() === 'active'\n ? 'cursor-pointer border-2 border-brand bg-brand-softer text-fg-brand ring-4 ring-brand-subtle'\n : step.effectiveState() === 'error'\n ? 'cursor-pointer bg-danger-soft border-2 border-danger text-fg-danger-strong'\n : 'cursor-pointer bg-neutral-primary text-body border border-default-strong')\n \"\n [attr.aria-label]=\"stepAriaLabel(i)\"\n [attr.aria-current]=\"step.effectiveState() === 'active' ? 'step' : null\"\n >\n @if (step.effectiveState() === 'completed') {\n <ol-icon name=\"check\" size=\"xs\"></ol-icon>\n } @else if (step.effectiveState() === 'error') {\n <ol-icon name=\"info-circle\" size=\"xs\" color=\"danger\"></ol-icon>\n } @else if (step.icon()) {\n <ol-icon [name]=\"step.icon()!\" size=\"xs\"></ol-icon>\n } @else {\n <span>{{ i + 1 }}</span>\n }\n </button>\n\n <!-- Step Title & Subtitle -->\n <div class=\"text-left pt-1 cursor-pointer\" (click)=\"goToStep(i)\">\n <div\n [class]=\"\n 'text-sm transition-colors ' +\n (step.effectiveState() === 'active'\n ? 'font-bold text-fg-brand'\n : step.effectiveState() === 'completed'\n ? 'font-semibold text-heading'\n : step.effectiveState() === 'error'\n ? 'font-semibold text-fg-danger'\n : 'font-medium text-body-subtle')\n \"\n >\n {{ step.title() }}\n </div>\n @if (step.badge()) {\n <ol-badge size=\"sm\" color=\"brand\" class=\"shrink-0\">{{ step.badge() }}</ol-badge>\n }\n @if (step.effectiveSubtitle()) {\n <div class=\"text-xs text-fg-disabled mt-0.5\">\n {{ step.effectiveSubtitle() }}\n </div>\n }\n </div>\n </div>\n }\n </div>\n } @else {\n <!-- Horizontal Variants: Default | Only Icons | Icons & Text | With Dots -->\n <div\n class=\"flex items-center justify-between w-full relative\"\n role=\"tablist\"\n aria-label=\"Ti\u1EBFn tr\u00ECnh c\u00E1c b\u01B0\u1EDBc\"\n >\n @for (step of steps(); track step.stepId(); let i = $index) {\n <div class=\"flex items-center flex-1 last:flex-none relative\">\n <div class=\"flex items-center gap-3\">\n @if (variant() === 'with-dots') {\n <!-- With Dots Variant -->\n <button\n type=\"button\"\n (click)=\"goToStep(i)\"\n [disabled]=\"\n step.effectiveState() === 'disabled' || (linear() && i > currentStep() + 1)\n \"\n [class]=\"\n 'flex items-center justify-center transition-all duration-200 shrink-0 cursor-pointer disabled:cursor-not-allowed ' +\n (step.effectiveState() === 'disabled'\n ? 'w-3.5 h-3.5 rounded-full opacity-50 bg-neutral-primary text-fg-disabled border-2 border-default-medium'\n : step.effectiveState() === 'completed'\n ? 'w-4 h-4 rounded-full bg-brand text-white'\n : step.effectiveState() === 'active'\n ? 'w-4 h-4 rounded-full bg-neutral-primary border-2 border-brand-light ring-4 ring-brand-subtle'\n : step.effectiveState() === 'error'\n ? 'w-4 h-4 rounded-full bg-danger-surface ring-4 ring-danger-subtle'\n : 'w-3.5 h-3.5 rounded-full bg-neutral-primary border-2 border-default-medium')\n \"\n [attr.aria-label]=\"stepAriaLabel(i)\"\n [attr.aria-current]=\"step.effectiveState() === 'active' ? 'step' : null\"\n >\n @if (step.effectiveState() === 'completed') {\n <ol-icon name=\"check\" size=\"xs\" color=\"white\" class=\"scale-75\"></ol-icon>\n }\n </button>\n } @else if (variant() === 'only-icons' || variant() === 'icons-text') {\n <!-- Icon Shape Variant -->\n <button\n type=\"button\"\n (click)=\"goToStep(i)\"\n [disabled]=\"\n step.effectiveState() === 'disabled' || (linear() && i > currentStep() + 1)\n \"\n [class]=\"\n 'w-10 h-10 rounded-full flex items-center justify-center font-semibold text-sm transition-all duration-200 shrink-0 cursor-pointer disabled:cursor-not-allowed ' +\n (step.effectiveState() === 'disabled'\n ? 'opacity-50 bg-neutral-tertiary text-fg-disabled'\n : step.effectiveState() === 'completed'\n ? 'bg-brand-softer text-fg-brand shadow-xs'\n : step.effectiveState() === 'active'\n ? 'border-2 border-brand bg-brand-softer text-fg-brand ring-4 ring-brand-subtle'\n : step.effectiveState() === 'error'\n ? 'bg-danger-soft border-2 border-danger text-fg-danger-strong'\n : 'bg-neutral-tertiary text-body border border-default')\n \"\n [attr.aria-label]=\"stepAriaLabel(i)\"\n [attr.aria-current]=\"step.effectiveState() === 'active' ? 'step' : null\"\n >\n @if (step.effectiveState() === 'completed') {\n <ol-icon name=\"check\" size=\"sm\"></ol-icon>\n } @else if (step.effectiveState() === 'error') {\n <ol-icon name=\"info-circle\" size=\"sm\" color=\"danger\"></ol-icon>\n } @else if (step.icon()) {\n <ol-icon [name]=\"step.icon()!\" size=\"sm\"></ol-icon>\n } @else {\n <span>{{ i + 1 }}</span>\n }\n </button>\n } @else {\n <!-- Default Variant (Numbered Circle) -->\n <button\n type=\"button\"\n (click)=\"goToStep(i)\"\n [disabled]=\"\n step.effectiveState() === 'disabled' || (linear() && i > currentStep() + 1)\n \"\n [class]=\"\n 'w-8 h-8 rounded-full flex items-center justify-center font-bold text-xs transition-all duration-200 shrink-0 cursor-pointer disabled:cursor-not-allowed ' +\n (step.effectiveState() === 'disabled'\n ? 'opacity-50 bg-neutral-primary text-fg-disabled border border-default'\n : step.effectiveState() === 'completed'\n ? 'border-2 border-brand bg-brand-softer text-fg-brand shadow-xs'\n : step.effectiveState() === 'active'\n ? 'border-2 border-brand bg-brand-softer text-fg-brand shadow-xs'\n : step.effectiveState() === 'error'\n ? 'bg-danger-soft border-2 border-danger text-fg-danger-strong'\n : 'bg-neutral-primary text-body border border-default-strong')\n \"\n [attr.aria-label]=\"stepAriaLabel(i)\"\n [attr.aria-current]=\"step.effectiveState() === 'active' ? 'step' : null\"\n >\n @if (step.effectiveState() === 'completed') {\n <ol-icon name=\"check\" size=\"xs\"></ol-icon>\n } @else if (step.effectiveState() === 'error') {\n <ol-icon name=\"info-circle\" size=\"xs\" color=\"danger\"></ol-icon>\n } @else {\n <span>{{ i + 1 }}</span>\n }\n </button>\n }\n\n <!-- Text Title & Subtitle (unless only-icons) -->\n @if (variant() !== 'only-icons') {\n <div class=\"text-left cursor-pointer\" (click)=\"goToStep(i)\">\n <div\n [class]=\"\n 'text-sm transition-colors ' +\n (step.effectiveState() === 'active'\n ? 'font-bold text-fg-brand'\n : step.effectiveState() === 'completed'\n ? 'font-semibold text-heading'\n : step.effectiveState() === 'error'\n ? 'font-semibold text-fg-danger'\n : 'font-medium text-body-subtle')\n \"\n >\n {{ step.title() }}\n </div>\n @if (step.badge()) {\n <ol-badge size=\"sm\" color=\"brand\" class=\"shrink-0\">{{ step.badge() }}</ol-badge>\n }\n @if (step.effectiveSubtitle()) {\n <div class=\"text-xs text-fg-disabled hidden md:block\">\n {{ step.effectiveSubtitle() }}\n </div>\n }\n </div>\n }\n </div>\n\n <!-- Connecting Line (Horizontal) -->\n @if (i < steps().length - 1) {\n <div class=\"flex-1 mx-4 h-0.5 bg-neutral-tertiary-medium relative overflow-hidden\">\n <div\n class=\"h-full bg-brand transition-all duration-300\"\n [class.w-full]=\"i < currentStep()\"\n [class.w-0]=\"i >= currentStep()\"\n ></div>\n </div>\n }\n </div>\n }\n </div>\n }\n </div>\n\n <!-- Stepper Body Content Area -->\n <div [class]=\"isVertical() ? 'flex-1 min-w-0' : 'w-full'\">\n <ng-content />\n </div>\n</div>\n" }]
17894
+ }, changeDetection: ChangeDetectionStrategy.OnPush, template: "<div [class]=\"isVertical() ? 'flex flex-col md:flex-row gap-8 w-full' : 'w-full'\">\n <!-- Stepper Header / Navigation Area -->\n <div [class]=\"isVertical() ? 'w-full md:w-80 shrink-0' : 'w-full mb-6'\">\n @if (isMobile()) {\n <!-- Mobile Layout -->\n <div class=\"p-4 rounded-md bg-neutral-tertiary/80 border border-default\">\n <div class=\"flex items-center justify-between mb-2\">\n <span class=\"text-xs font-semibold text-fg-brand uppercase tracking-wider\">\n B\u01B0\u1EDBc {{ currentStep() + 1 }} / {{ steps().length }}\n </span>\n @if (steps()[currentStep()]; as activeStep) {\n <span class=\"text-xs text-body-subtle font-medium\">\n {{ activeStep.title() }}\n </span>\n }\n </div>\n <!-- Progress Bar -->\n <div\n class=\"w-full bg-neutral-tertiary-medium h-2 rounded-full overflow-hidden flex gap-1 p-0.5\"\n >\n @for (step of steps(); track step.stepId(); let i = $index) {\n <div\n class=\"h-full flex-1 rounded-full transition-all duration-300\"\n [class.bg-brand]=\"i <= currentStep()\"\n [class.bg-neutral-tertiary-medium]=\"i > currentStep()\"\n ></div>\n }\n </div>\n </div>\n } @else if (variant() === 'cards') {\n <!-- Cards Variant (Vertical Stack) -->\n <div class=\"flex flex-col gap-3\" role=\"tablist\" aria-label=\"Ti\u1EBFn tr\u00ECnh c\u00E1c b\u01B0\u1EDBc\">\n @for (step of steps(); track step.stepId(); let i = $index) {\n <!--\n `<button role=\"tab\">` ch\u1EE9 KH\u00D4NG ph\u1EA3i `<div (click)>`. Khung ngo\u00E0i\n khai `role=\"tablist\"` nh\u01B0ng \u0111o 2026-09-17 b\u00EAn trong c\u00F3 **0\n `role=\"tab\"` v\u00E0 0 ph\u1EA7n t\u1EED tab t\u1EDBi \u0111\u01B0\u1EE3c** \u2014 `tablist` r\u1ED7ng vai tr\u00F2,\n v\u00E0 b\u00E0n ph\u00EDm kh\u00F4ng ch\u1ECDn n\u1ED5i b\u01B0\u1EDBc n\u00E0o. Ba bi\u1EBFn th\u1EC3 stepper kh\u00E1c \u0111\u00E3\n d\u00F9ng `<button>` s\u1EB5n; ch\u1EC9 ri\u00EAng `cards` l\u1EC7ch.\n\n `w-full` v\u00EC button kh\u00F4ng t\u1EF1 gi\u00E3n nh\u01B0 div.\n -->\n <button\n type=\"button\"\n role=\"tab\"\n [attr.aria-selected]=\"step.effectiveState() === 'active'\"\n [disabled]=\"step.effectiveState() === 'disabled'\"\n (click)=\"goToStep(i)\"\n [class]=\"\n 'w-full flex items-center justify-between p-4 rounded-md border transition-all duration-200 text-left ' +\n (step.effectiveState() === 'disabled'\n ? 'opacity-50 cursor-not-allowed bg-neutral-secondary-soft border-default text-fg-disabled'\n : step.effectiveState() === 'active'\n ? 'cursor-pointer hover:shadow-xs border-brand-subtle bg-brand-softer shadow-xs ring-2 ring-brand-subtle'\n : step.effectiveState() === 'completed'\n ? 'cursor-pointer hover:shadow-xs border-brand-subtle bg-brand-softer'\n : step.effectiveState() === 'error'\n ? 'cursor-pointer hover:shadow-xs border-danger-subtle bg-danger-soft'\n : 'cursor-pointer hover:shadow-xs border-default-medium bg-neutral-secondary-medium')\n \"\n [attr.aria-label]=\"stepAriaLabel(i)\"\n [attr.aria-current]=\"step.effectiveState() === 'active' ? 'step' : null\"\n >\n <div class=\"flex items-center gap-3 min-w-0\">\n <!-- Left Icon / Step Badge -->\n <div\n [class]=\"\n 'w-9 h-9 rounded-lg flex items-center justify-center shrink-0 font-bold text-xs transition-colors ' +\n (step.effectiveState() === 'completed'\n ? 'bg-brand-softer text-fg-brand-strong'\n : step.effectiveState() === 'active'\n ? 'bg-brand-softer text-fg-brand-strong'\n : step.effectiveState() === 'error'\n ? 'bg-danger-soft text-fg-danger-strong'\n : 'bg-neutral-tertiary text-body')\n \"\n >\n @if (step.effectiveState() === 'completed') {\n <ol-icon name=\"check\" size=\"xs\"></ol-icon>\n } @else if (step.effectiveState() === 'error') {\n <ol-icon name=\"info-circle\" size=\"xs\"></ol-icon>\n } @else if (step.icon()) {\n <ol-icon [name]=\"step.icon()!\" size=\"xs\"></ol-icon>\n } @else {\n <span>{{ i + 1 }}</span>\n }\n </div>\n\n <!-- Title & Subtitle -->\n <div class=\"min-w-0\">\n <div\n [class]=\"\n 'text-sm truncate ' +\n (step.effectiveState() === 'active'\n ? 'font-bold text-fg-brand-strong '\n : step.effectiveState() === 'completed'\n ? 'font-semibold text-heading'\n : step.effectiveState() === 'error'\n ? 'font-semibold text-fg-danger'\n : 'font-medium text-body')\n \"\n >\n {{ step.title() }}\n </div>\n @if (step.badge()) {\n <ol-badge size=\"sm\" color=\"brand\" class=\"shrink-0\">{{ step.badge() }}</ol-badge>\n }\n @if (step.effectiveSubtitle()) {\n <div class=\"text-xs text-fg-disabled truncate\">\n {{ step.effectiveSubtitle() }}\n </div>\n }\n </div>\n </div>\n\n <!-- Right Status Icon -->\n <div class=\"shrink-0 ml-3\">\n @if (step.effectiveState() === 'completed') {\n <span class=\"inline-flex items-center gap-1 text-xs font-semibold text-fg-brand\">\n <ol-icon name=\"check-circle\" size=\"xs\" color=\"brand\"></ol-icon>\n <span class=\"hidden sm:inline\">Ho\u00E0n t\u1EA5t</span>\n </span>\n } @else if (step.effectiveState() === 'active') {\n <span class=\"inline-flex items-center gap-1 text-xs font-semibold text-fg-brand\">\n <span class=\"w-2 h-2 rounded-full bg-brand animate-pulse\"></span>\n <span class=\"hidden sm:inline\">\u0110ang th\u1EF1c hi\u1EC7n</span>\n </span>\n } @else if (step.effectiveState() === 'error') {\n <span class=\"inline-flex items-center gap-1 text-xs font-semibold text-fg-danger\">\n <ol-icon name=\"info-circle\" size=\"xs\" color=\"danger\"></ol-icon>\n <span class=\"hidden sm:inline\">L\u1ED7i</span>\n </span>\n } @else {\n <ol-icon name=\"chevron-right\" size=\"xs\" class=\"text-fg-disabled\"></ol-icon>\n }\n </div>\n </button>\n }\n </div>\n } @else if (isVertical()) {\n <!-- Vertical Variant -->\n <div class=\"flex flex-col relative\" role=\"tablist\" aria-label=\"Ti\u1EBFn tr\u00ECnh c\u00E1c b\u01B0\u1EDBc\">\n @for (step of steps(); track step.stepId(); let i = $index) {\n <div class=\"flex items-start gap-4 relative pb-8 last:pb-0\">\n <!-- Connecting Vertical Line -->\n @if (i < steps().length - 1) {\n <div\n class=\"absolute left-4.25 top-9 bottom-0 w-0.5 transition-colors duration-200\"\n [class.bg-brand]=\"i < currentStep()\"\n [class.bg-neutral-tertiary-medium]=\"i >= currentStep()\"\n ></div>\n }\n\n <!-- Step Circle / Icon Shape -->\n <button\n type=\"button\"\n (click)=\"goToStep(i)\"\n [disabled]=\"\n step.effectiveState() === 'disabled' || (linear() && i > currentStep() + 1)\n \"\n [class]=\"\n 'w-9 h-9 rounded-full flex items-center justify-center font-bold text-xs transition-all duration-200 shrink-0 z-10 ' +\n (step.effectiveState() === 'disabled'\n ? 'opacity-50 cursor-not-allowed bg-neutral-tertiary text-fg-disabled border border-default'\n : step.effectiveState() === 'completed'\n ? 'cursor-pointer bg-brand-softer text-fg-brand shadow-xs'\n : step.effectiveState() === 'active'\n ? 'cursor-pointer border-2 border-brand bg-brand-softer text-fg-brand ring-4 ring-brand-subtle'\n : step.effectiveState() === 'error'\n ? 'cursor-pointer bg-danger-soft border-2 border-danger text-fg-danger-strong'\n : 'cursor-pointer bg-neutral-primary text-body border border-default-strong')\n \"\n [attr.aria-label]=\"stepAriaLabel(i)\"\n [attr.aria-current]=\"step.effectiveState() === 'active' ? 'step' : null\"\n >\n @if (step.effectiveState() === 'completed') {\n <ol-icon name=\"check\" size=\"xs\"></ol-icon>\n } @else if (step.effectiveState() === 'error') {\n <ol-icon name=\"info-circle\" size=\"xs\" color=\"danger\"></ol-icon>\n } @else if (step.icon()) {\n <ol-icon [name]=\"step.icon()!\" size=\"xs\"></ol-icon>\n } @else {\n <span>{{ i + 1 }}</span>\n }\n </button>\n\n <!-- Step Title & Subtitle -->\n <div class=\"text-left pt-1 cursor-pointer\" (click)=\"goToStep(i)\">\n <div\n [class]=\"\n 'text-sm transition-colors ' +\n (step.effectiveState() === 'active'\n ? 'font-bold text-fg-brand'\n : step.effectiveState() === 'completed'\n ? 'font-semibold text-heading'\n : step.effectiveState() === 'error'\n ? 'font-semibold text-fg-danger'\n : 'font-medium text-body-subtle')\n \"\n >\n {{ step.title() }}\n </div>\n @if (step.badge()) {\n <ol-badge size=\"sm\" color=\"brand\" class=\"shrink-0\">{{ step.badge() }}</ol-badge>\n }\n @if (step.effectiveSubtitle()) {\n <div class=\"text-xs text-fg-disabled mt-0.5\">\n {{ step.effectiveSubtitle() }}\n </div>\n }\n </div>\n </div>\n }\n </div>\n } @else {\n <!--\n Horizontal Variants: Default | Only Icons | Icons & Text | With Dots\n\n `overflow-x-auto` tr\u00EAn khung ngo\u00E0i: `breakpoint` l\u00E0 input TH\u1EE6 C\u00D4NG m\u1EB7c\n \u0111\u1ECBnh `desktop`, kh\u00F4ng t\u1EF1 theo b\u1EC1 r\u1ED9ng m\u00E0n. N\u01A1i g\u1ECDi \u0111\u1EC3 nguy\u00EAn m\u1EB7c \u0111\u1ECBnh m\u00E0\n ng\u01B0\u1EDDi d\u00F9ng m\u1EDF tr\u00EAn \u0111i\u1EC7n tho\u1EA1i th\u00EC b\u1ED1 c\u1EE5c ngang b\u1ED1n b\u01B0\u1EDBc \u0111o \u0111\u01B0\u1EE3c\n **x=402 tr\u00EAn viewport 375** \u2014 b\u01B0\u1EDBc cu\u1ED1i n\u1EB1m h\u1EB3n ngo\u00E0i m\u00E9p ph\u1EA3i v\u00E0\n `<main>` c\u1EE7a trang (`overflow-x: hidden`) C\u1EAET M\u1EA4T n\u00F3, kh\u00F4ng cu\u1ED9n t\u1EDBi\n \u0111\u01B0\u1EE3c.\n\n Cho d\u1EA3i n\u00E0y t\u1EF1 cu\u1ED9n ngang th\u00EC b\u01B0\u1EDBc cu\u1ED1i v\u1EABn t\u1EDBi \u0111\u01B0\u1EE3c b\u1EB1ng c\u00E1ch k\u00E9o, v\u00E0\n tr\u00EAn desktop `justify-between` v\u1EABn d\u00E0n \u0111\u1EC1u nh\u01B0 c\u0169 v\u00EC n\u1ED9i dung v\u1EEBa khung.\n K\u00E8m `pb-1` \u0111\u1EC3 thanh cu\u1ED9n kh\u00F4ng che \u0111\u01B0\u1EDDng k\u1EBB n\u1ED1i gi\u1EEFa c\u00E1c b\u01B0\u1EDBc.\n -->\n <div\n class=\"flex items-center justify-between w-full relative overflow-x-auto pb-1\"\n role=\"tablist\"\n aria-label=\"Ti\u1EBFn tr\u00ECnh c\u00E1c b\u01B0\u1EDBc\"\n >\n @for (step of steps(); track step.stepId(); let i = $index) {\n <div class=\"flex items-center flex-1 last:flex-none relative\">\n <div class=\"flex items-center gap-3\">\n @if (variant() === 'with-dots') {\n <!-- With Dots Variant -->\n <button\n type=\"button\"\n (click)=\"goToStep(i)\"\n [disabled]=\"\n step.effectiveState() === 'disabled' || (linear() && i > currentStep() + 1)\n \"\n [class]=\"\n 'flex items-center justify-center transition-all duration-200 shrink-0 cursor-pointer disabled:cursor-not-allowed ' +\n (step.effectiveState() === 'disabled'\n ? 'w-3.5 h-3.5 rounded-full opacity-50 bg-neutral-primary text-fg-disabled border-2 border-default-medium'\n : step.effectiveState() === 'completed'\n ? 'w-4 h-4 rounded-full bg-brand text-white'\n : step.effectiveState() === 'active'\n ? 'w-4 h-4 rounded-full bg-neutral-primary border-2 border-brand-light ring-4 ring-brand-subtle'\n : step.effectiveState() === 'error'\n ? 'w-4 h-4 rounded-full bg-danger-surface ring-4 ring-danger-subtle'\n : 'w-3.5 h-3.5 rounded-full bg-neutral-primary border-2 border-default-medium')\n \"\n [attr.aria-label]=\"stepAriaLabel(i)\"\n [attr.aria-current]=\"step.effectiveState() === 'active' ? 'step' : null\"\n >\n @if (step.effectiveState() === 'completed') {\n <ol-icon name=\"check\" size=\"xs\" color=\"white\" class=\"scale-75\"></ol-icon>\n }\n </button>\n } @else if (variant() === 'only-icons' || variant() === 'icons-text') {\n <!-- Icon Shape Variant -->\n <button\n type=\"button\"\n (click)=\"goToStep(i)\"\n [disabled]=\"\n step.effectiveState() === 'disabled' || (linear() && i > currentStep() + 1)\n \"\n [class]=\"\n 'w-10 h-10 rounded-full flex items-center justify-center font-semibold text-sm transition-all duration-200 shrink-0 cursor-pointer disabled:cursor-not-allowed ' +\n (step.effectiveState() === 'disabled'\n ? 'opacity-50 bg-neutral-tertiary text-fg-disabled'\n : step.effectiveState() === 'completed'\n ? 'bg-brand-softer text-fg-brand shadow-xs'\n : step.effectiveState() === 'active'\n ? 'border-2 border-brand bg-brand-softer text-fg-brand ring-4 ring-brand-subtle'\n : step.effectiveState() === 'error'\n ? 'bg-danger-soft border-2 border-danger text-fg-danger-strong'\n : 'bg-neutral-tertiary text-body border border-default')\n \"\n [attr.aria-label]=\"stepAriaLabel(i)\"\n [attr.aria-current]=\"step.effectiveState() === 'active' ? 'step' : null\"\n >\n @if (step.effectiveState() === 'completed') {\n <ol-icon name=\"check\" size=\"sm\"></ol-icon>\n } @else if (step.effectiveState() === 'error') {\n <ol-icon name=\"info-circle\" size=\"sm\" color=\"danger\"></ol-icon>\n } @else if (step.icon()) {\n <ol-icon [name]=\"step.icon()!\" size=\"sm\"></ol-icon>\n } @else {\n <span>{{ i + 1 }}</span>\n }\n </button>\n } @else {\n <!-- Default Variant (Numbered Circle) -->\n <button\n type=\"button\"\n (click)=\"goToStep(i)\"\n [disabled]=\"\n step.effectiveState() === 'disabled' || (linear() && i > currentStep() + 1)\n \"\n [class]=\"\n 'w-8 h-8 rounded-full flex items-center justify-center font-bold text-xs transition-all duration-200 shrink-0 cursor-pointer disabled:cursor-not-allowed ' +\n (step.effectiveState() === 'disabled'\n ? 'opacity-50 bg-neutral-primary text-fg-disabled border border-default'\n : step.effectiveState() === 'completed'\n ? 'border-2 border-brand bg-brand-softer text-fg-brand shadow-xs'\n : step.effectiveState() === 'active'\n ? 'border-2 border-brand bg-brand-softer text-fg-brand shadow-xs'\n : step.effectiveState() === 'error'\n ? 'bg-danger-soft border-2 border-danger text-fg-danger-strong'\n : 'bg-neutral-primary text-body border border-default-strong')\n \"\n [attr.aria-label]=\"stepAriaLabel(i)\"\n [attr.aria-current]=\"step.effectiveState() === 'active' ? 'step' : null\"\n >\n @if (step.effectiveState() === 'completed') {\n <ol-icon name=\"check\" size=\"xs\"></ol-icon>\n } @else if (step.effectiveState() === 'error') {\n <ol-icon name=\"info-circle\" size=\"xs\" color=\"danger\"></ol-icon>\n } @else {\n <span>{{ i + 1 }}</span>\n }\n </button>\n }\n\n <!-- Text Title & Subtitle (unless only-icons) -->\n @if (variant() !== 'only-icons') {\n <div class=\"text-left cursor-pointer\" (click)=\"goToStep(i)\">\n <div\n [class]=\"\n 'text-sm transition-colors ' +\n (step.effectiveState() === 'active'\n ? 'font-bold text-fg-brand'\n : step.effectiveState() === 'completed'\n ? 'font-semibold text-heading'\n : step.effectiveState() === 'error'\n ? 'font-semibold text-fg-danger'\n : 'font-medium text-body-subtle')\n \"\n >\n {{ step.title() }}\n </div>\n @if (step.badge()) {\n <ol-badge size=\"sm\" color=\"brand\" class=\"shrink-0\">{{ step.badge() }}</ol-badge>\n }\n @if (step.effectiveSubtitle()) {\n <div class=\"text-xs text-fg-disabled hidden md:block\">\n {{ step.effectiveSubtitle() }}\n </div>\n }\n </div>\n }\n </div>\n\n <!-- Connecting Line (Horizontal) -->\n @if (i < steps().length - 1) {\n <div class=\"flex-1 mx-4 h-0.5 bg-neutral-tertiary-medium relative overflow-hidden\">\n <div\n class=\"h-full bg-brand transition-all duration-300\"\n [class.w-full]=\"i < currentStep()\"\n [class.w-0]=\"i >= currentStep()\"\n ></div>\n </div>\n }\n </div>\n }\n </div>\n }\n </div>\n\n <!-- Stepper Body Content Area -->\n <div [class]=\"isVertical() ? 'flex-1 min-w-0' : 'w-full'\">\n <ng-content />\n </div>\n</div>\n" }]
15350
17895
  }], propDecorators: { currentStep: [{ type: i0.Input, args: [{ isSignal: true, alias: "currentStep", required: false }] }, { type: i0.Output, args: ["currentStepChange"] }], variant: [{ type: i0.Input, args: [{ isSignal: true, alias: "variant", required: false }] }], orientation: [{ type: i0.Input, args: [{ isSignal: true, alias: "orientation", required: false }] }], breakpoint: [{ type: i0.Input, args: [{ isSignal: true, alias: "breakpoint", required: false }] }], linear: [{ type: i0.Input, args: [{ isSignal: true, alias: "linear", required: false }] }], clickable: [{ type: i0.Input, args: [{ isSignal: true, alias: "clickable", required: false }] }], stepChange: [{ type: i0.Output, args: ["stepChange"] }], steps: [{ type: i0.ContentChildren, args: [i0.forwardRef(() => OlStepItem), { isSignal: true }] }] } });
15351
17896
 
15352
17897
  class OlTreeNodeItem {
@@ -16747,5 +19292,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImpor
16747
19292
  * Generated bundle index. Do not edit.
16748
19293
  */
16749
19294
 
16750
- export { CHAT_BUBBLE_STATUS, FIELD_ASTERISK, FIELD_DISABLED, FIELD_HEIGHT, FIELD_HELPER, FIELD_HELPER_GAP, FIELD_LABEL, FIELD_LABEL_GAP, FIELD_MIN_HEIGHT, FIELD_PADDING_X, FIELD_RADIUS, FIELD_STATE, FIELD_TEXT, MONTHS_FULL_EN, MONTHS_FULL_VI, MONTHS_SHORT_VI, OlAccordion, OlAccordionItem, OlAlert, OlAlertDialog, OlAppNav, OlAspectRatio, OlAutocomplete, OlAvatar, OlAvatarGroup, OlBackdrop, OlBadge, OlBanner, OlBottomNav, OlBottomNavItem, OlBreadcrumb, OlBreadcrumbItem, OlButton, OlButtonGroup, OlCard, OlCarousel, OlCarouselSlide, OlChart, OlChatBubble, OlChatContainer, OlCheckbox, OlCheckboxRadioForm, OlCheckboxRadioGroup, OlClipboard, OlCollapse, OlCommand, OlDateRangePicker, OlDatepicker, OlDatepickerDirective, OlDatepickerFooter, OlDatepickerHeader, OlDatepickerPopup, OlDatepickerStateService, OlDayView, OlDeviceMockup, OlDownloadAppButton, OlDrawer, OlDrawerFooter, OlDrawerHeader, OlDropdown, OlDropdownHeader, OlDropdownItem, OlEmpty, OlFileInput, OlFloatingLabel, OlFocusTrapService, OlFooter, OlGallery, OlGalleryItem, OlHelper, OlIcon, OlIconService, OlIconShape, OlInput, OlInputCounter, OlJumbotron, OlKbd, OlLabel, OlListGroup, OlListGroupItem, OlLoadingOverlay, OlModal, OlModalFooter, OlModalHeader, OlMonthView, OlNavDropdown, OlNavbar, OlNavbarItem, OlOtpInput, OlOverlayContainer, OlPagination, OlPopover, OlPopoverContent, OlPositioningService, OlProgress, OlProgressBarService, OlProgressBarTop, OlRadio, OlRange, OlRating, OlRichTextEditor, OlScrollArea, OlSelect, OlSeparator, OlSidebar, OlSidebarGroup, OlSidebarItem, OlSidebarSubmenu, OlSkeleton, OlSpeedDial, OlSpeedDialItem, OlSpinner, OlStepItem, OlStepper, OlTabItem, OlTable, OlTableLoading, OlTableRow, OlTabs, OlTagInput, OlTextarea, OlTimeline, OlTimelineItem, OlTimepicker, OlToast, OlToastContainer, OlToastService, OlToggle, OlToggleGroup, OlToggleInput, OlTooltip, OlTooltipDirective, OlTree, OlTreeNodeItem, OlYearView, WEEKDAYS_SHORT_EN, WEEKDAYS_SHORT_VI, boDau, cloneDate, endOfDay, fieldSizeClasses, formatDate, generateMonthMatrix, generateMonthsMatrix, generateYearsMatrix, isBetween, isDateDisabled, isSameDay, isSameMonth, isSameYear, isToday, parseDate, startOfDay, stripDiacritics };
19295
+ export { CHAT_BUBBLE_STATUS, FIELD_ASTERISK, FIELD_DISABLED, FIELD_HEIGHT, FIELD_HELPER, FIELD_HELPER_GAP, FIELD_LABEL, FIELD_LABEL_GAP, FIELD_MIN_HEIGHT, FIELD_PADDING_X, FIELD_RADIUS, FIELD_STATE, FIELD_TEXT, FILE_KIND_META, MONTHS_FULL_EN, MONTHS_FULL_VI, MONTHS_SHORT_VI, OlAccordion, OlAccordionItem, OlAlert, OlAlertDialog, OlAppNav, OlAspectRatio, OlAutocomplete, OlAvatar, OlAvatarGroup, OlBackdrop, OlBadge, OlBanner, OlBottomNav, OlBottomNavItem, OlBreadcrumb, OlBreadcrumbItem, OlButton, OlButtonGroup, OlCard, OlCarousel, OlCarouselSlide, OlChart, OlChatBubble, OlChatContainer, OlCheckbox, OlCheckboxRadioForm, OlCheckboxRadioGroup, OlClipboard, OlCollapse, OlCommand, OlDateRangePicker, OlDatepicker, OlDatepickerDirective, OlDatepickerFooter, OlDatepickerHeader, OlDatepickerPopup, OlDatepickerStateService, OlDayView, OlDeviceMockup, OlDownloadAppButton, OlDrawer, OlDrawerFooter, OlDrawerHeader, OlDropdown, OlDropdownHeader, OlDropdownItem, OlEmpty, OlFileDropzone, OlFileInput, OlFileItem, OlFileUpload, OlFloatingLabel, OlFocusTrapService, OlFooter, OlFrame, OlGallery, OlGalleryItem, OlHelper, OlIcon, OlIconService, OlIconShape, OlIconStack, OlImagePreview, OlInput, OlInputCounter, OlItem, OlJumbotron, OlKbd, OlLabel, OlListGroup, OlListGroupItem, OlLoadingOverlay, OlMenubar, OlModal, OlModalFooter, OlModalHeader, OlMonthView, OlNavDropdown, OlNavbar, OlNavbarItem, OlOtpInput, OlOverlayContainer, OlPagination, OlPopover, OlPopoverContent, OlPositioningService, OlProgress, OlProgressBarService, OlProgressBarTop, OlRadio, OlRange, OlRating, OlResizable, OlRichTextEditor, OlScrollArea, OlScrollspy, OlSegmented, OlSelect, OlSeparator, OlSidebar, OlSidebarGroup, OlSidebarItem, OlSidebarSubmenu, OlSkeleton, OlSpeedDial, OlSpeedDialItem, OlSpinner, OlStepItem, OlStepper, OlTabItem, OlTable, OlTableLoading, OlTableRow, OlTabs, OlTagInput, OlTextarea, OlTimeline, OlTimelineItem, OlTimepicker, OlToast, OlToastContainer, OlToastService, OlToggle, OlToggleGroup, OlToggleInput, OlTooltip, OlTooltipDirective, OlTree, OlTreeNodeItem, OlYearView, WEEKDAYS_SHORT_EN, WEEKDAYS_SHORT_VI, boDau, cloneDate, detectFileKind, endOfDay, fieldSizeClasses, fileExtension, filesFromClipboard, formatDate, formatFileSize, generateMonthMatrix, generateMonthsMatrix, generateYearsMatrix, isBetween, isDateDisabled, isEditableTarget, isSameDay, isSameMonth, isSameYear, isToday, matchesAccept, parseDate, pasteShortcutLabel, startOfDay, stripDiacritics, toUploadFile, validateFiles };
16751
19296
  //# sourceMappingURL=edmicro-olkit.mjs.map