@foxeltech/angular-ui 0.7.83 → 0.7.103

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.
Files changed (27) hide show
  1. package/components.css +40 -1
  2. package/fesm2022/foxeltech-angular-ui-chart.mjs +127 -44
  3. package/fesm2022/foxeltech-angular-ui-chart.mjs.map +1 -1
  4. package/fesm2022/foxeltech-angular-ui-flow.mjs +42 -5
  5. package/fesm2022/foxeltech-angular-ui-flow.mjs.map +1 -1
  6. package/fesm2022/foxeltech-angular-ui.mjs +559 -42
  7. package/fesm2022/foxeltech-angular-ui.mjs.map +1 -1
  8. package/package.json +3 -2
  9. package/src/lib/styles/components.css +40 -1
  10. package/src/lib/styles/utilities.css +2 -1
  11. package/src/lib/ui/components/breadcrumb/breadcrumb.component.css +262 -0
  12. package/src/lib/ui/components/breadcrumb/breadcrumb.component.html +91 -0
  13. package/src/lib/ui/components/button/button.component.html +1 -0
  14. package/src/lib/ui/components/filter-pills/filter-pills.component.html +1 -1
  15. package/src/lib/ui/components/load-status/load-status.component.css +96 -0
  16. package/src/lib/ui/components/load-status/load-status.component.html +20 -0
  17. package/src/lib/ui/components/master-detail/master-detail.component.css +63 -11
  18. package/src/lib/ui/components/master-detail/master-detail.component.html +71 -42
  19. package/src/lib/ui/components/route-skeleton/route-skeleton.component.css +1 -1
  20. package/src/lib/ui/components/segmented/segmented.component.html +3 -1
  21. package/src/lib/ui/components/severity-badges/severity-badges.component.html +1 -1
  22. package/src/lib/ui/components/stat-cards/stat-cards.component.css +18 -0
  23. package/src/lib/ui/components/stat-cards/stat-cards.component.html +3 -3
  24. package/types/foxeltech-angular-ui-chart.d.ts +56 -22
  25. package/types/foxeltech-angular-ui-flow.d.ts +7 -0
  26. package/types/foxeltech-angular-ui.d.ts +309 -8
  27. package/utilities.css +2 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foxeltech/angular-ui",
3
- "version": "0.7.83",
3
+ "version": "0.7.103",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -14,7 +14,8 @@
14
14
  "@angular/material": "^20.2.7 || ^21.0.0",
15
15
  "@angular/cdk": "^20.2.7 || ^21.0.0",
16
16
  "@ngx-translate/core": "^14.0.0",
17
- "@ngx-translate/http-loader": "^7.0.0"
17
+ "@ngx-translate/http-loader": "^7.0.0",
18
+ "gsap": "^3.12.0"
18
19
  },
19
20
  "dependencies": {
20
21
  "tslib": "^2.3.0",
@@ -361,6 +361,14 @@ input[type='password'] {
361
361
  inset 0 0 0 1px rgb(0 0 0 / 0.08),
362
362
  0 1px 2px rgb(0 0 0 / 0.12);
363
363
  }
364
+ /* Icon-only button (no label): square h-8 × w-8, no side padding, so the
365
+ glyph sits dead-centre (anh Tú 04/09). Consumers may still size it with
366
+ utilities (`w-9 h-9`) — those win in the utilities layer. */
367
+ .fx-btn--icon {
368
+ width: 2rem;
369
+ padding-left: 0;
370
+ padding-right: 0;
371
+ }
364
372
  /* Nút hành động chính theo demo (`.bi-newbtn`): màu cố định, không phải accent. */
365
373
  /* ---- Tông severity theo ĐÚNG mô hình demo (đo 03/09) -----------------
366
374
  Demo KHÔNG tô badge bằng màu rực. Nó dùng token semantic, và bậc nặng
@@ -755,6 +763,17 @@ input[type='password'] {
755
763
  max-height: calc(100vh - 315px);
756
764
  overflow: auto;
757
765
  }
766
+ /* Phones: a data table must not squeeze its columns to a few px per cell —
767
+ give it a floor and let .table-page-container scroll it sideways
768
+ (anh Tú 04/09 mobile audit: Name column collapsed to one character/line). */
769
+ @media (max-width: 719px) {
770
+ /* !important like the other .mat-mdc-table overrides above: this file is
771
+ imported into a cascade @layer, so an unlayered Material rule
772
+ (`.mat-mdc-table{min-width:100%}`) beats it whatever the specificity. */
773
+ .mat-mdc-table {
774
+ min-width: 640px !important;
775
+ }
776
+ }
758
777
  .table-cell-padding {
759
778
  padding-top: 8px !important;
760
779
  padding-bottom: 8px !important;
@@ -790,8 +809,28 @@ input[type='password'] {
790
809
  rgb(var(--gradient-primary-end))
791
810
  ) border-box;
792
811
  }
812
+ /* Skeleton block = the design's `.bi-sk` shimmer ("Loading States", 04/09):
813
+ text at 7% → 13% → 7% sweeping left→right over 1.15 s, 6px radius. One
814
+ rule for every skeleton in the product — route skeleton, table cells,
815
+ dashboard cards — so the boot splash, route and in-panel states match. */
793
816
  .skeleton-load {
794
- @apply rounded bg-track animate-pulse motion-reduce:animate-none;
817
+ display: block;
818
+ border-radius: 6px;
819
+ background: linear-gradient(
820
+ 90deg,
821
+ rgb(var(--og-text) / 0.07) 0px,
822
+ rgb(var(--og-text) / 0.13) 90px,
823
+ rgb(var(--og-text) / 0.07) 180px
824
+ );
825
+ background-size: 400px 100%;
826
+ animation: fx-shimmer 1.15s linear infinite;
827
+ }
828
+ @keyframes fx-shimmer {
829
+ 0% { background-position: -260px 0; }
830
+ 100% { background-position: 260px 0; }
831
+ }
832
+ @media (prefers-reduced-motion: reduce) {
833
+ .skeleton-load { animation: none; background: rgb(var(--og-text) / 0.08); }
795
834
  }
796
835
 
797
836
  /* ============================================================================
@@ -40,5 +40,6 @@
40
40
  }
41
41
 
42
42
  @utility tag-common {
43
- @apply flex min-w-7 px-[10px] py-[2px] leading-5 hover:opacity-70 items-center justify-center;
43
+ /* design .og-badge: 3px 10px (anh 04/09: pills read too thin at 2px) */
44
+ @apply flex min-w-7 px-[10px] py-[3px] leading-5 hover:opacity-70 items-center justify-center;
44
45
  }
@@ -0,0 +1,262 @@
1
+ /* Demo topbar breadcrumb, measured 04/09 (getComputedStyle on the preview):
2
+ nav gap 7px · plain crumb 12.5px/500 muted · chip 11.5px/600, 1px border,
3
+ radius-control, 0 1px 2px .04 shadow, padding 4px 12px · listbox 220px min,
4
+ radius-card, 0 10px 28px .14 shadow. Plain CSS on tokens so the component
5
+ does not depend on the consuming app's Tailwind scan. */
6
+ :host {
7
+ display: block;
8
+ min-width: 0;
9
+ }
10
+
11
+ .fx-crumbs {
12
+ display: flex;
13
+ align-items: center;
14
+ gap: 7px;
15
+ min-width: 0;
16
+ white-space: nowrap;
17
+ }
18
+
19
+ .fx-crumb-sep {
20
+ flex: 0 0 auto;
21
+ padding: 0 2px;
22
+ font-size: 13px;
23
+ color: rgb(var(--og-text-faint));
24
+ }
25
+
26
+ .fx-crumb-link {
27
+ flex: 0 0 auto;
28
+ max-width: 260px;
29
+ overflow: hidden;
30
+ text-overflow: ellipsis;
31
+ font-size: var(--og-fs-sm);
32
+ font-weight: 500;
33
+ letter-spacing: 0.005em;
34
+ color: rgb(var(--og-text-muted));
35
+ text-decoration: none;
36
+ transition: color 0.15s;
37
+ }
38
+ .fx-crumb-link:hover {
39
+ color: rgb(var(--og-text));
40
+ }
41
+
42
+ .fx-crumb-current {
43
+ min-width: 0;
44
+ overflow: hidden;
45
+ text-overflow: ellipsis;
46
+ font-size: var(--og-fs-sm);
47
+ font-weight: 600;
48
+ color: rgb(var(--og-text));
49
+ }
50
+
51
+ .fx-crumb-chip {
52
+ appearance: none;
53
+ display: inline-flex;
54
+ align-items: center;
55
+ gap: 8px;
56
+ min-width: 0;
57
+ max-width: 320px;
58
+ padding: 4px 12px;
59
+ line-height: 1.5;
60
+ border: 1px solid rgb(var(--og-border));
61
+ border-radius: var(--og-radius-control);
62
+ background: rgb(var(--og-surface));
63
+ font-size: var(--og-fs-xs);
64
+ font-weight: 600;
65
+ color: rgb(var(--og-text));
66
+ box-shadow: 0 1px 2px rgb(0 0 0 / 0.04);
67
+ white-space: nowrap;
68
+ cursor: pointer;
69
+ transition: box-shadow 0.12s, border-color 0.12s;
70
+ }
71
+ .fx-crumb-chip:hover,
72
+ .fx-crumb-chip[aria-expanded='true'] {
73
+ box-shadow: 0 2px 7px rgb(0 0 0 / 0.1);
74
+ }
75
+ .fx-crumb-chip--mono {
76
+ font-family: var(--og-font-mono);
77
+ }
78
+
79
+ /* ---- switcher panel ---------------------------------------------------- */
80
+ .fx-crumb-panel {
81
+ position: absolute;
82
+ top: calc(100% + 8px);
83
+ left: 0;
84
+ z-index: 60;
85
+ min-width: 240px;
86
+ max-width: min(380px, 90vw);
87
+ display: flex;
88
+ flex-direction: column;
89
+ padding: 6px;
90
+ background: rgb(var(--og-surface));
91
+ border: 1px solid rgb(var(--og-border));
92
+ border-radius: var(--og-radius-card);
93
+ box-shadow: 0 10px 28px rgb(0 0 0 / 0.14);
94
+ text-align: left;
95
+ white-space: nowrap;
96
+ animation: fx-crumb-panel-in 0.16s ease-out;
97
+ }
98
+ @keyframes fx-crumb-panel-in {
99
+ from {
100
+ opacity: 0;
101
+ transform: translateY(-4px);
102
+ }
103
+ to {
104
+ opacity: 1;
105
+ transform: none;
106
+ }
107
+ }
108
+ @media (prefers-reduced-motion: reduce) {
109
+ .fx-crumb-panel {
110
+ animation: none;
111
+ }
112
+ }
113
+
114
+ .fx-crumb-panel__title {
115
+ padding: 6px 12px 8px;
116
+ margin-bottom: 4px;
117
+ font-size: var(--og-fs-2xs);
118
+ letter-spacing: 0.02em;
119
+ color: rgb(var(--og-text-faint));
120
+ border-bottom: 1px solid rgb(var(--og-border-subtle));
121
+ }
122
+
123
+ .fx-crumb-panel__filter {
124
+ margin: 2px 6px 6px;
125
+ padding: 6px 10px;
126
+ border: 1px solid rgb(var(--og-border));
127
+ border-radius: var(--og-radius-control);
128
+ background: rgb(var(--og-surface-sunken));
129
+ color: rgb(var(--og-text));
130
+ font-family: var(--og-font-body);
131
+ font-size: var(--og-fs-xs);
132
+ outline: none;
133
+ }
134
+ .fx-crumb-panel__filter:focus {
135
+ border-color: rgb(var(--og-accent));
136
+ }
137
+
138
+ .fx-crumb-panel__opt {
139
+ appearance: none;
140
+ width: 100%;
141
+ display: flex;
142
+ align-items: center;
143
+ gap: 8px;
144
+ padding: 8px 12px;
145
+ border: 0;
146
+ border-radius: var(--og-radius-control);
147
+ background: transparent;
148
+ font-family: var(--og-font-body);
149
+ font-size: var(--og-fs-sm);
150
+ font-weight: 400;
151
+ color: rgb(var(--og-text-muted));
152
+ text-align: left;
153
+ cursor: pointer;
154
+ transition: background 0.12s;
155
+ }
156
+ .fx-crumb-panel--mono .fx-crumb-panel__opt {
157
+ font-family: var(--og-font-mono);
158
+ font-size: var(--og-fs-xs);
159
+ }
160
+ .fx-crumb-panel__opt:hover {
161
+ background: rgb(var(--og-surface-sunken));
162
+ }
163
+ .fx-crumb-panel__opt--on {
164
+ font-weight: 600;
165
+ color: rgb(var(--og-text));
166
+ }
167
+ .fx-crumb-panel__meta {
168
+ margin-left: auto;
169
+ font-family: var(--og-font-mono);
170
+ font-size: var(--og-fs-2xs);
171
+ color: rgb(var(--og-text-faint));
172
+ }
173
+ .fx-crumb-panel__opt > fx-ui-hero-icon:last-child {
174
+ margin-left: auto;
175
+ }
176
+ .fx-crumb-panel__opt > .fx-crumb-panel__meta + fx-ui-hero-icon {
177
+ margin-left: 0;
178
+ }
179
+
180
+ .fx-crumb-panel__empty {
181
+ padding: 8px 12px;
182
+ font-size: var(--og-fs-xs);
183
+ color: rgb(var(--og-text-faint));
184
+ }
185
+
186
+ .fx-crumb-panel__skeleton {
187
+ display: flex;
188
+ flex-direction: column;
189
+ gap: 8px;
190
+ padding: 8px 12px;
191
+ }
192
+ .fx-crumb-panel__skeleton span {
193
+ display: block;
194
+ height: 10px;
195
+ border-radius: 4px;
196
+ background: rgb(var(--og-border-subtle));
197
+ animation: fx-crumb-pulse 1.2s ease-in-out infinite;
198
+ }
199
+ .fx-crumb-panel__skeleton span:nth-child(1) { width: 62%; }
200
+ .fx-crumb-panel__skeleton span:nth-child(2) { width: 48%; animation-delay: 0.15s; }
201
+ .fx-crumb-panel__skeleton span:nth-child(3) { width: 55%; animation-delay: 0.3s; }
202
+ @keyframes fx-crumb-pulse {
203
+ 0%, 100% { opacity: 1; }
204
+ 50% { opacity: 0.45; }
205
+ }
206
+
207
+ .fx-crumb-panel__rule {
208
+ margin: 4px 0;
209
+ border-top: 1px solid rgb(var(--og-border-subtle));
210
+ }
211
+
212
+ .fx-crumb-panel__more {
213
+ appearance: none;
214
+ width: 100%;
215
+ display: flex;
216
+ align-items: center;
217
+ gap: 8px;
218
+ padding: 7px 12px;
219
+ border: 0;
220
+ border-radius: var(--og-radius-control);
221
+ background: transparent;
222
+ font-family: var(--og-font-body);
223
+ font-size: var(--og-fs-xs);
224
+ color: rgb(var(--og-text-muted));
225
+ text-align: left;
226
+ cursor: pointer;
227
+ }
228
+ .fx-crumb-panel__more:hover {
229
+ background: rgb(var(--og-surface-sunken));
230
+ color: rgb(var(--og-text));
231
+ }
232
+
233
+ .fx-crumb-panel__create {
234
+ appearance: none;
235
+ width: calc(100% - 12px);
236
+ margin: 4px 6px 2px;
237
+ display: flex;
238
+ align-items: center;
239
+ gap: 8px;
240
+ padding: 9px 12px;
241
+ border: 0;
242
+ border-radius: var(--og-radius-control);
243
+ background: rgb(var(--og-accent-soft-bg));
244
+ color: rgb(var(--og-accent-soft-fg));
245
+ font-family: var(--og-font-body);
246
+ font-size: var(--og-fs-sm);
247
+ font-weight: 600;
248
+ text-align: left;
249
+ cursor: pointer;
250
+ transition: background 0.15s, color 0.15s;
251
+ }
252
+ .fx-crumb-panel__create:hover {
253
+ background: rgb(var(--og-accent));
254
+ color: rgb(var(--og-accent-contrast));
255
+ }
256
+
257
+ /* Text utilities used in the template — kept local so the chip renders the
258
+ same in every consumer regardless of its Tailwind scan. */
259
+ .text-accent { color: rgb(var(--og-accent)); }
260
+ .text-faint { color: rgb(var(--og-text-faint)); }
261
+ .text-strong { color: rgb(var(--og-text)); }
262
+ .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
@@ -0,0 +1,91 @@
1
+ <nav aria-label="Breadcrumb" class="fx-crumbs">
2
+ @for (crumb of crumbs(); track $index; let last = $last; let i = $index) {
3
+ @if (i > 0) {
4
+ <span aria-hidden="true" class="fx-crumb-sep">/</span>
5
+ }
6
+ @if (isChip(crumb)) {
7
+ <span class="relative inline-flex min-w-0">
8
+ <button
9
+ type="button"
10
+ class="fx-crumb-chip"
11
+ [class.fx-crumb-chip--mono]="crumb.mono"
12
+ [attr.aria-haspopup]="crumb.switcher ? 'listbox' : null"
13
+ [attr.aria-expanded]="crumb.switcher ? openIndex() === i : null"
14
+ [attr.title]="crumb.switcher?.title || crumb.label"
15
+ (click)="onChipClick(crumb, i, $event)"
16
+ >
17
+ @if (crumb.icon) {
18
+ <fx-ui-hero-icon [icon]="crumb.icon" [size]="13" class="flex shrink-0 text-accent" />
19
+ }
20
+ <span class="truncate">{{ crumb.label }}</span>
21
+ @if (crumb.switcher) {
22
+ <fx-ui-hero-icon icon="chevron-down" [size]="10" class="flex shrink-0 text-faint" />
23
+ }
24
+ </button>
25
+
26
+ @if (crumb.switcher; as sw) {
27
+ @if (openIndex() === i) {
28
+ <div role="listbox" class="fx-crumb-panel" [class.fx-crumb-panel--mono]="sw.mono">
29
+ <div class="fx-crumb-panel__title">{{ sw.title }}</div>
30
+ @if (sw.filterPlaceholder) {
31
+ <input
32
+ type="search"
33
+ class="fx-crumb-panel__filter"
34
+ [placeholder]="sw.filterPlaceholder"
35
+ [attr.aria-label]="sw.filterPlaceholder"
36
+ [value]="keyword()"
37
+ (input)="onFilter(crumb, $any($event.target).value)"
38
+ (click)="$event.stopPropagation()"
39
+ />
40
+ }
41
+ @if (loading() && options().length === 0) {
42
+ <div class="fx-crumb-panel__skeleton" aria-busy="true">
43
+ <span></span><span></span><span></span>
44
+ </div>
45
+ } @else if (options().length === 0) {
46
+ <div class="fx-crumb-panel__empty">{{ sw.emptyLabel || '—' }}</div>
47
+ }
48
+ @for (opt of options(); track opt.value) {
49
+ <button
50
+ type="button"
51
+ role="option"
52
+ class="fx-crumb-panel__opt"
53
+ [class.fx-crumb-panel__opt--on]="opt.value === sw.selected"
54
+ [attr.aria-selected]="opt.value === sw.selected"
55
+ (click)="pick(opt, $event)"
56
+ >
57
+ <span class="truncate">{{ opt.label }}</span>
58
+ @if (opt.meta) {
59
+ <span class="fx-crumb-panel__meta">{{ opt.meta }}</span>
60
+ }
61
+ @if (opt.value === sw.selected) {
62
+ <fx-ui-hero-icon icon="check" [size]="13" class="flex shrink-0 text-strong" />
63
+ }
64
+ </button>
65
+ }
66
+ @if (sw.viewAll || sw.create) {
67
+ <div class="fx-crumb-panel__rule"></div>
68
+ }
69
+ @if (sw.viewAll; as more) {
70
+ <button type="button" class="fx-crumb-panel__more" (click)="go($event, more.url)">
71
+ <fx-ui-hero-icon icon="ellipsis-horizontal" [size]="13" class="flex shrink-0 text-faint" />
72
+ {{ more.label }}
73
+ </button>
74
+ }
75
+ @if (sw.create; as create) {
76
+ <button type="button" class="fx-crumb-panel__create" (click)="go($event, create.url)">
77
+ <fx-ui-hero-icon icon="plus" [size]="14" class="flex shrink-0" />
78
+ {{ create.label }}
79
+ </button>
80
+ }
81
+ </div>
82
+ }
83
+ }
84
+ </span>
85
+ } @else if (!last && crumb.url) {
86
+ <a class="fx-crumb-link" [href]="crumb.url" (click)="go($event, crumb.url)">{{ crumb.label }}</a>
87
+ } @else {
88
+ <span class="fx-crumb-current">{{ crumb.label }}</span>
89
+ }
90
+ }
91
+ </nav>
@@ -4,6 +4,7 @@
4
4
  [attr.aria-busy]="loading() || null"
5
5
  [class]="class()"
6
6
  [ngClass]="{
7
+ 'fx-btn--icon': !!icon && !label,
7
8
  'btn-default': buttonVariant() === 'default',
8
9
  'btn-primary': buttonVariant() === 'primary',
9
10
  'btn-success': buttonVariant() === 'success',
@@ -3,7 +3,7 @@
3
3
  <button
4
4
  type="button"
5
5
  [attr.aria-pressed]="isActive(option)"
6
- class="flex items-center gap-small px-semi py-xs rounded-full border txt-utility-caption font-bold cursor-pointer transition-colors"
6
+ class="flex items-center gap-small px-[11px] py-[4px] rounded-full border txt-utility-caption font-bold cursor-pointer transition-colors"
7
7
  [ngClass]="pillClass(option)"
8
8
  (click)="valueChange.emit(option.value)"
9
9
  >
@@ -0,0 +1,96 @@
1
+ :host {
2
+ display: inline-flex;
3
+ align-items: center;
4
+ gap: 8px;
5
+ min-width: 0;
6
+ }
7
+
8
+ .fx-load-status {
9
+ display: inline-flex;
10
+ align-items: center;
11
+ gap: 7px;
12
+ min-width: 0;
13
+ font-family: var(--og-font-mono);
14
+ font-size: var(--og-fs-2xs);
15
+ color: rgb(var(--og-text-muted));
16
+ white-space: nowrap;
17
+ }
18
+ .fx-load-status--idle {
19
+ color: rgb(var(--og-text-faint));
20
+ }
21
+ .fx-load-dot {
22
+ color: rgb(var(--og-positive));
23
+ }
24
+ .truncate {
25
+ overflow: hidden;
26
+ text-overflow: ellipsis;
27
+ white-space: nowrap;
28
+ min-width: 0;
29
+ }
30
+
31
+ .fx-load-spinner {
32
+ display: inline-block;
33
+ flex: none;
34
+ width: 11px;
35
+ height: 11px;
36
+ border-radius: 50%;
37
+ border: 2px solid rgb(var(--og-text) / 0.14);
38
+ border-top-color: rgb(var(--og-accent));
39
+ animation: fx-load-spin 0.8s linear infinite;
40
+ }
41
+ @keyframes fx-load-spin {
42
+ to {
43
+ transform: rotate(360deg);
44
+ }
45
+ }
46
+
47
+ .fx-load-reload {
48
+ appearance: none;
49
+ display: inline-flex;
50
+ align-items: center;
51
+ justify-content: center;
52
+ width: 28px;
53
+ height: 28px;
54
+ padding: 0;
55
+ border: 0;
56
+ border-radius: var(--og-radius-control);
57
+ background: transparent;
58
+ color: rgb(var(--og-text-muted));
59
+ cursor: pointer;
60
+ transition: background 0.12s, color 0.12s;
61
+ }
62
+ .fx-load-reload:hover {
63
+ background: rgb(var(--og-surface-sunken));
64
+ color: rgb(var(--og-text));
65
+ }
66
+
67
+ /* 2px indeterminate bar along the top edge of the positioned ancestor */
68
+ .fx-load-bar {
69
+ position: absolute;
70
+ left: 0;
71
+ right: 0;
72
+ top: 0;
73
+ height: 2px;
74
+ overflow: hidden;
75
+ background: rgb(var(--og-accent) / 0.18);
76
+ pointer-events: none;
77
+ }
78
+ .fx-load-bar__run {
79
+ width: 38%;
80
+ height: 100%;
81
+ border-radius: 2px;
82
+ background: rgb(var(--og-accent));
83
+ animation: fx-load-indet 1.15s cubic-bezier(0.4, 0, 0.2, 1) infinite;
84
+ }
85
+ @keyframes fx-load-indet {
86
+ 0% { transform: translateX(-100%); }
87
+ 60% { transform: translateX(160%); }
88
+ 100% { transform: translateX(160%); }
89
+ }
90
+
91
+ @media (prefers-reduced-motion: reduce) {
92
+ .fx-load-spinner,
93
+ .fx-load-bar__run {
94
+ animation: none;
95
+ }
96
+ }
@@ -0,0 +1,20 @@
1
+ @if (bar() && status.loading()) {
2
+ <div class="fx-load-bar" aria-hidden="true"><div class="fx-load-bar__run"></div></div>
3
+ }
4
+ <div class="fx-load-status" role="status" aria-live="polite" [class.fx-load-status--idle]="!status.loading()">
5
+ @if (status.loading()) {
6
+ <span class="fx-load-spinner" aria-hidden="true"></span>
7
+ <span class="truncate">{{ loadingText() }}</span>
8
+ } @else {
9
+ <span class="fx-load-dot" aria-hidden="true">●</span>
10
+ <span class="truncate">{{ idleText() }}</span>
11
+ }
12
+ </div>
13
+ @if (showReload()) {
14
+ <button type="button" class="fx-load-reload" [attr.aria-label]="reloadLabel()" [title]="reloadLabel()" (click)="onReload()">
15
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
16
+ <path d="M21 12a9 9 0 1 1-3.1-6.8" />
17
+ <path d="M21 3v6h-6" />
18
+ </svg>
19
+ </button>
20
+ }
@@ -28,6 +28,29 @@
28
28
  container-name: fx-md;
29
29
  }
30
30
 
31
+ /* The frame — one card around all three panes (design 11/14/17); the panes
32
+ themselves are flat and separated by hairlines. Shadow = demo .og-panel. */
33
+ .fx-md-row {
34
+ background: rgb(var(--og-surface));
35
+ border: 1px solid rgb(var(--border-default));
36
+ border-radius: 0.5rem;
37
+ overflow: hidden;
38
+ box-shadow:
39
+ 0 1px 2px rgb(0 0 0 / 0.04),
40
+ 0 3px 8px rgb(0 0 0 / 0.05);
41
+ }
42
+ /* Stacked (narrow): hairline BELOW each pane; rail toggle is a horizontal strip. */
43
+ .fx-md-facets,
44
+ .fx-md-list {
45
+ border-bottom: 1px solid rgb(var(--og-border-subtle));
46
+ }
47
+ .fx-md-rail-toggle {
48
+ width: 100%;
49
+ padding: 10px 12px;
50
+ flex-direction: row;
51
+ justify-content: flex-start;
52
+ }
53
+
31
54
  /* Mặc định: xếp dọc, mỗi cụm full width, chiều cao tự nhiên.
32
55
  KHÔNG ép chiều cao khi xếp dọc — sẽ sinh ba hộp cuộn tí hon lồng trong
33
56
  trang đang cuộn, tệ hơn hẳn. */
@@ -37,15 +60,13 @@
37
60
  }
38
61
 
39
62
  /*
40
- * Ngưỡng 968px = ĐÚNG tổng bề rộng tối thiểu dùng được của ba cụm:
41
- * facets 200 + list 320 + detail 400 + 2 gap (~48) = 968px
42
- * Dưới ngưỡng thì thật sự không đủ chỗ xếp dọc đúng, không phải lỗi.
43
- *
44
- * ⚠️ Đừng làm tròn lên 1000px: viewport 1280 (rất phổ biến) cho hàng đúng
45
- * 987px — trượt ngưỡng 1000 chỉ vì 13px dù thừa sức xếp 3 cụm ở mức sàn
46
- * (đo được: facets 200 / list 320 / detail 419).
63
+ * Ngưỡng 720px = `md` của lib (anh 04/09: mọi ngưỡng layout theo `md`,
64
+ * không còn `lg` 1080). Từ 0.7.85 khung MỘT mảnh, rail thu gọn còn 40px,
65
+ * nên 720 vẫn đủ: rail 40 + list 300 + detail 360. Rail mở ở bề rộng
66
+ * hẹp thì list nhường chỗ (min 260) — người dùng tự đóng rail nếu cần.
67
+ * Dưới 720 thật sự không đủ chỗ xếp dọc đúng, không phải lỗi.
47
68
  */
48
- @container fx-md (min-width: 968px) {
69
+ @container fx-md (min-width: 720px) {
49
70
  .fx-md-row {
50
71
  flex-direction: row;
51
72
  height: var(--fx-md-pane-h, calc(100vh - 300px));
@@ -55,22 +76,47 @@
55
76
  /* Panes CO GIÃN: facets/list nhường chỗ để cụm detail luôn có sàn 400px.
56
77
  `--fx-md-list-width` (input `listWidth`) giờ là bề rộng MONG MUỐN kiêm
57
78
  tối đa của cụm list, không còn là con số cứng. */
79
+ /* Hairline to the RIGHT of rail and list instead of below. */
80
+ .fx-md-facets,
81
+ .fx-md-list {
82
+ border-bottom: 0;
83
+ border-right: 1px solid rgb(var(--og-border-subtle));
84
+ }
85
+
86
+ /* Rail: 40px strip when collapsed (» + vertical label), 230px when open. */
58
87
  .fx-md-facets {
88
+ flex: 0 0 40px;
89
+ width: 40px;
90
+ min-width: 40px;
91
+ max-width: 40px;
92
+ }
93
+ .fx-md-facets--open {
59
94
  flex: 0 1 230px;
95
+ width: auto;
60
96
  min-width: 200px;
61
97
  max-width: 260px;
62
98
  }
99
+ .fx-md-rail-toggle {
100
+ height: 100%;
101
+ padding: 12px 0;
102
+ flex-direction: column;
103
+ justify-content: flex-start;
104
+ }
105
+ .fx-md-rail-label {
106
+ writing-mode: vertical-rl;
107
+ transform: rotate(180deg);
108
+ }
63
109
 
64
110
  .fx-md-list {
65
111
  width: auto;
66
112
  flex: 0 1 var(--fx-md-list-width, 420px);
67
- min-width: 320px;
113
+ min-width: 260px;
68
114
  max-width: var(--fx-md-list-width, 420px);
69
115
  }
70
116
 
71
117
  .fx-md-detail {
72
- flex: 1 1 400px;
73
- min-width: 400px;
118
+ flex: 1 1 360px;
119
+ min-width: 360px;
74
120
  }
75
121
  }
76
122
 
@@ -83,3 +129,9 @@
83
129
  overflow: hidden;
84
130
  clip-path: inset(50%);
85
131
  }
132
+
133
+ /* Material paginator inside the list pane keeps the pane's horizontal rhythm. */
134
+ .fx-md-paginator {
135
+ padding: 0 12px;
136
+ border-top: 1px solid rgb(var(--og-border-subtle));
137
+ }