@zeyos/zx 4.3.0 → 4.4.0

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 (136) hide show
  1. package/CHANGELOG.md +266 -1
  2. package/README.md +7 -3
  3. package/dist/types/components/activity-item/activity-item.d.ts +351 -0
  4. package/dist/types/components/activity-list/activity-list.d.ts +195 -0
  5. package/dist/types/components/app-sidebar/app-sidebar.d.ts +28 -0
  6. package/dist/types/components/button/button.d.ts +8 -0
  7. package/dist/types/components/calendar/calendar-model.d.ts +22 -0
  8. package/dist/types/components/calendar/calendar.d.ts +19 -0
  9. package/dist/types/components/card/card.d.ts +7 -0
  10. package/dist/types/components/card-view/card-view.d.ts +12 -4
  11. package/dist/types/components/card-view/record-card.d.ts +53 -5
  12. package/dist/types/components/code-editor/code-editor.d.ts +160 -0
  13. package/dist/types/components/code-editor/field-adapter.d.ts +5 -0
  14. package/dist/types/components/context-menu/context-menu.d.ts +18 -2
  15. package/dist/types/components/date-picker/date-picker.d.ts +8 -0
  16. package/dist/types/components/date-picker/time-picker.d.ts +7 -0
  17. package/dist/types/components/dialog/dialog.d.ts +29 -0
  18. package/dist/types/components/entity-ref/entity-ref.d.ts +331 -0
  19. package/dist/types/components/file-item/file-item.d.ts +234 -0
  20. package/dist/types/components/file-list/file-list.d.ts +171 -0
  21. package/dist/types/components/filter/filter.d.ts +91 -36
  22. package/dist/types/components/filter-panel/filter-panel.d.ts +449 -0
  23. package/dist/types/components/kanban-view/kanban-card.d.ts +138 -0
  24. package/dist/types/components/kanban-view/kanban-policy.d.ts +268 -0
  25. package/dist/types/components/kanban-view/kanban-view.d.ts +837 -26
  26. package/dist/types/components/launcher/launcher.d.ts +19 -12
  27. package/dist/types/components/list-toolbar/list-toolbar.d.ts +599 -0
  28. package/dist/types/components/menu-button/menu-button.d.ts +4 -37
  29. package/dist/types/components/message/message.d.ts +7 -0
  30. package/dist/types/components/modal/modal.d.ts +48 -0
  31. package/dist/types/components/navigation-bar/navigation-bar.d.ts +105 -21
  32. package/dist/types/components/panel/panel.d.ts +7 -0
  33. package/dist/types/components/search/search.d.ts +7 -0
  34. package/dist/types/components/select/select.d.ts +4 -0
  35. package/dist/types/components/sort-control/sort-control.d.ts +105 -0
  36. package/dist/types/components/stat-tile/stat-tile.d.ts +277 -0
  37. package/dist/types/components/tabbox/tabbox.d.ts +7 -0
  38. package/dist/types/components/table/table.d.ts +144 -0
  39. package/dist/types/components/table-view/table-view.d.ts +11 -5
  40. package/dist/types/components/timebox/timebox.d.ts +7 -0
  41. package/dist/types/components/value-list/value-list.d.ts +8 -0
  42. package/dist/types/components/view/record-view.d.ts +96 -5
  43. package/dist/types/core/component.d.ts +6 -1
  44. package/dist/types/core/icons.d.ts +17 -0
  45. package/dist/types/index.d.ts +13 -1
  46. package/dist/types/internal/app-rail.d.ts +10 -0
  47. package/dist/types/internal/menu-items.d.ts +134 -0
  48. package/dist/zx-zeyos.esm.js +651 -88
  49. package/dist/zx-zeyos.esm.min.js +8 -1
  50. package/dist/zx.css +6266 -3900
  51. package/dist/zx.esm.js +8487 -1805
  52. package/dist/zx.esm.min.js +10 -3
  53. package/dist/zx.global.js +8487 -1805
  54. package/dist/zx.global.min.js +10 -3
  55. package/dist/zx.min.css +1 -1
  56. package/docs/api.json +1811 -247
  57. package/docs/llms.md +345 -40
  58. package/docs/llms.txt +11 -7
  59. package/package.json +1 -2
  60. package/src/components/activity-item/activity-item.css +137 -0
  61. package/src/components/activity-item/activity-item.js +599 -0
  62. package/src/components/activity-list/activity-list.css +64 -0
  63. package/src/components/activity-list/activity-list.js +375 -0
  64. package/src/components/app-rail/app-rail.css +227 -19
  65. package/src/components/app-sidebar/app-sidebar.css +414 -24
  66. package/src/components/app-sidebar/app-sidebar.js +65 -5
  67. package/src/components/button/button.css +8 -0
  68. package/src/components/button/button.js +6 -1
  69. package/src/components/calendar/calendar-model.js +45 -0
  70. package/src/components/calendar/calendar.css +30 -10
  71. package/src/components/calendar/calendar.js +41 -13
  72. package/src/components/card/card.js +12 -1
  73. package/src/components/card-view/card-view.css +212 -9
  74. package/src/components/card-view/card-view.js +32 -16
  75. package/src/components/card-view/record-card.js +98 -21
  76. package/src/components/code-editor/code-editor.css +67 -0
  77. package/src/components/code-editor/code-editor.js +288 -0
  78. package/src/components/code-editor/field-adapter.js +34 -0
  79. package/src/components/context-menu/context-menu.css +80 -3
  80. package/src/components/context-menu/context-menu.js +22 -30
  81. package/src/components/date-picker/date-picker.js +22 -8
  82. package/src/components/date-picker/month-picker.js +3 -3
  83. package/src/components/date-picker/time-picker.js +20 -8
  84. package/src/components/dialog/dialog.js +88 -66
  85. package/src/components/dropdown/dropdown.js +2 -2
  86. package/src/components/entity-ref/entity-ref.css +182 -0
  87. package/src/components/entity-ref/entity-ref.js +496 -0
  88. package/src/components/file-item/file-item.css +88 -0
  89. package/src/components/file-item/file-item.js +371 -0
  90. package/src/components/file-list/file-list.css +51 -0
  91. package/src/components/file-list/file-list.js +320 -0
  92. package/src/components/filter/filter.css +27 -1
  93. package/src/components/filter/filter.js +199 -45
  94. package/src/components/filter-panel/filter-panel.css +104 -0
  95. package/src/components/filter-panel/filter-panel.js +964 -0
  96. package/src/components/kanban-view/kanban-card.js +239 -0
  97. package/src/components/kanban-view/kanban-policy.js +412 -0
  98. package/src/components/kanban-view/kanban-view.css +674 -36
  99. package/src/components/kanban-view/kanban-view.js +1762 -240
  100. package/src/components/launcher/launcher.js +59 -27
  101. package/src/components/list-toolbar/list-toolbar.css +189 -0
  102. package/src/components/list-toolbar/list-toolbar.js +851 -0
  103. package/src/components/menu-button/menu-button.css +80 -3
  104. package/src/components/menu-button/menu-button.js +21 -37
  105. package/src/components/message/message.css +14 -12
  106. package/src/components/message/message.js +13 -2
  107. package/src/components/modal/modal.css +15 -0
  108. package/src/components/modal/modal.js +141 -0
  109. package/src/components/navigation-bar/navigation-bar.css +77 -2
  110. package/src/components/navigation-bar/navigation-bar.js +171 -4
  111. package/src/components/panel/panel.js +13 -2
  112. package/src/components/search/search.js +17 -5
  113. package/src/components/select/field-adapter.js +4 -1
  114. package/src/components/select/select.js +3 -0
  115. package/src/components/sort-control/sort-control.css +17 -0
  116. package/src/components/sort-control/sort-control.js +140 -0
  117. package/src/components/stat-tile/stat-tile.css +213 -0
  118. package/src/components/stat-tile/stat-tile.js +590 -0
  119. package/src/components/tabbox/tabbox.js +12 -1
  120. package/src/components/table/table.css +51 -2
  121. package/src/components/table/table.js +298 -19
  122. package/src/components/table-view/table-view.css +8 -2
  123. package/src/components/table-view/table-view.js +21 -6
  124. package/src/components/tag-picker/tag-picker.css +3 -1
  125. package/src/components/timebox/timebox.js +18 -5
  126. package/src/components/value-list/value-list.js +22 -6
  127. package/src/components/view/record-view.css +5 -0
  128. package/src/components/view/record-view.js +100 -7
  129. package/src/core/component.js +8 -3
  130. package/src/core/icons.js +74 -5
  131. package/src/index.js +33 -3
  132. package/src/internal/app-rail.js +19 -1
  133. package/src/internal/menu-items.js +142 -0
  134. package/styles/print.css +192 -0
  135. package/styles/zx.css +11 -0
  136. package/DESIGN-SYSTEM.md +0 -182
@@ -1,4 +1,40 @@
1
+ /*
2
+ * AppSidebar — the surface an application is judged by before it is read.
3
+ *
4
+ * Everything here hangs off one spacing scale declared on the root. Item height, icon gutter,
5
+ * row gap, group gap and indentation are derived from it rather than picked per rule, which is
6
+ * what makes a density switch a real change of rhythm instead of a smaller font.
7
+ *
8
+ * Two deliberate choices worth stating:
9
+ *
10
+ * - **Labels wrap, they never truncate.** German runs 20-35% longer than English and a nav is
11
+ * where that shows first: "Belege & Rechnungen" is two lines in a 16.5rem sidebar. An ellipsis
12
+ * there hides the word that distinguishes one destination from another, so the row grows
13
+ * instead and the fixed icon column keeps the second line aligned under the first.
14
+ * - **The active state never rests on colour alone.** It is a filled pill, a marker docked to the
15
+ * sidebar's outer edge, and a heavier label — three signals, one of which survives greyscale,
16
+ * forced colours and a reduced-transparency page.
17
+ */
18
+
1
19
  .zx-app-sidebar {
20
+ /* The scale. `--zx-control-height` and `--zx-space-1..4` are density-aware, so compact
21
+ tightens the item, the gaps and the padding together while `--zx-space-5/6` hold the icon
22
+ column and the indent steady — a narrower rhythm, not a smaller one. */
23
+ --zx-app-sidebar-gutter: var(--zx-space-2);
24
+ --zx-app-sidebar-icon: var(--zx-space-6);
25
+ --zx-app-sidebar-item-gap: var(--zx-space-3);
26
+ --zx-app-sidebar-item-height: calc(var(--zx-control-height) + var(--zx-space-2));
27
+ --zx-app-sidebar-pad-x: var(--zx-space-3);
28
+ --zx-app-sidebar-row-gap: var(--zx-space-1);
29
+ --zx-app-sidebar-group-gap: var(--zx-space-4);
30
+ --zx-app-sidebar-indent: var(--zx-space-5);
31
+ --zx-app-sidebar-marker: 3px;
32
+ --zx-app-sidebar-radius: var(--zx-radius-xl);
33
+ /* One overlay expression for hover and press. Mixing the *text* colour into the surface moves
34
+ the fill away from the page in whichever direction the theme runs, so a single declaration
35
+ is correct in light and in dark instead of needing a per-theme override. */
36
+ --zx-app-sidebar-hover: color-mix(in srgb, var(--zx-color-text) 7%, transparent);
37
+ --zx-app-sidebar-press: color-mix(in srgb, var(--zx-color-text) 14%, transparent);
2
38
  display: flex;
3
39
  min-block-size: 0;
4
40
  border: 1px solid var(--zx-color-glass-border);
@@ -7,6 +43,11 @@
7
43
  color: var(--zx-color-text);
8
44
  }
9
45
 
46
+ [data-zx-density="compact"] .zx-app-sidebar,
47
+ .zx-app-sidebar[data-zx-density="compact"] {
48
+ --zx-app-sidebar-group-gap: var(--zx-space-3);
49
+ }
50
+
10
51
  .zx-app-sidebar[data-orientation="horizontal"] {
11
52
  inline-size: 100%;
12
53
  }
@@ -29,57 +70,406 @@
29
70
  .zx-app-sidebar__expanded[hidden],
30
71
  .zx-app-sidebar__rail[hidden] { display: none; }
31
72
 
73
+ /* The header carries no rule. It is a brand slot above a nav, not a boundary between two kinds
74
+ of content, and a hairline there reads as leftover scaffolding. The footer keeps its rule:
75
+ an identity control below navigation is a different region and the line is doing work. */
32
76
  .zx-app-sidebar__header {
33
77
  display: grid;
34
78
  grid-template-columns: minmax(0, 1fr) auto;
35
79
  align-items: center;
36
80
  gap: var(--zx-space-2);
37
- min-block-size: calc(var(--zx-control-height) + var(--zx-space-3));
38
- border-block-end: 1px solid var(--zx-color-border);
39
- padding: var(--zx-space-2);
81
+ min-block-size: calc(var(--zx-control-height) + var(--zx-space-4));
82
+ padding: var(--zx-space-2) var(--zx-space-3);
40
83
  }
41
84
 
42
85
  .zx-app-sidebar__header-content { min-inline-size: 0; }
43
- .zx-app-sidebar__nav { min-block-size: 0; overflow: auto; padding: var(--zx-space-2); }
86
+
87
+ /* The block padding is not decoration: the focus ring is a 3px outer glow, and a nav that padded
88
+ its scroll box by less would clip the ring off the first and last row. */
89
+ .zx-app-sidebar__nav {
90
+ min-block-size: 0;
91
+ overflow: auto;
92
+ padding: var(--zx-space-2) var(--zx-app-sidebar-gutter) var(--zx-space-3);
93
+ }
94
+
44
95
  .zx-app-sidebar__list,
45
- .zx-app-sidebar__sublist { display: grid; gap: var(--zx-space-1); margin: 0; padding: 0; list-style: none; }
46
- .zx-app-sidebar__sublist { margin-block-start: var(--zx-space-1); }
96
+ .zx-app-sidebar__sublist {
97
+ display: grid;
98
+ gap: var(--zx-app-sidebar-row-gap);
99
+ margin: 0;
100
+ padding: 0;
101
+ list-style: none;
102
+ }
103
+
104
+ /* A sidebar whose items carry no icons should not pay for an empty gutter; one that has even a
105
+ single icon keeps the column on every row so the labels stay in one line down the list. */
106
+ .zx-app-sidebar__list:not(:has(.zx-app-sidebar__item-icon)) {
107
+ --zx-app-sidebar-icon: 0px;
108
+ --zx-app-sidebar-item-gap: 0px;
109
+ }
110
+
111
+ .zx-app-sidebar__sublist { position: relative; margin-block-start: var(--zx-app-sidebar-row-gap); }
47
112
  .zx-app-sidebar__sublist[hidden] { display: none; }
48
113
 
114
+ /* The one rule in the component that carries meaning: a guide dropped from the centre of the
115
+ parent's icon that scopes the open branch to the row that opened it. */
116
+ .zx-app-sidebar__sublist::before {
117
+ content: "";
118
+ position: absolute;
119
+ inset-block: var(--zx-space-1);
120
+ inset-inline-start: calc(var(--zx-app-sidebar-pad-x) + var(--zx-app-sidebar-icon) / 2);
121
+ inline-size: 1px;
122
+ border-radius: var(--zx-radius-full);
123
+ background: var(--zx-color-border);
124
+ }
125
+
126
+ /* Groups are separated by space, not by rules: a top-level branch opens a group, and the row
127
+ after that group closes it. The first row never gets the gap. */
128
+ .zx-app-sidebar__list > .zx-app-sidebar__row:has(> .zx-app-sidebar__sublist),
129
+ .zx-app-sidebar__list > .zx-app-sidebar__row:has(> .zx-app-sidebar__sublist) + .zx-app-sidebar__row {
130
+ margin-block-start: var(--zx-app-sidebar-group-gap);
131
+ }
132
+
133
+ .zx-app-sidebar__list > .zx-app-sidebar__row:first-child { margin-block-start: 0; }
134
+
49
135
  .zx-app-sidebar__item {
136
+ position: relative;
50
137
  display: grid;
51
- grid-template-columns: auto minmax(0, 1fr) auto auto;
138
+ /* The icon track is composed here and not in a variable on the root: a custom property
139
+ substitutes its `var()`s where it is declared, so a track precomputed on the component would
140
+ ignore the list-level override below and keep an empty 36px gutter on an icon-less sidebar. */
141
+ grid-template-columns: calc(var(--zx-app-sidebar-icon) + var(--zx-app-sidebar-item-gap)) minmax(0, 1fr) auto auto;
52
142
  align-items: center;
53
- gap: var(--zx-space-2);
143
+ column-gap: 0;
54
144
  inline-size: 100%;
55
- min-block-size: var(--zx-control-height);
56
- border-color: transparent;
57
- border-radius: var(--zx-radius-md);
145
+ min-block-size: var(--zx-app-sidebar-item-height);
146
+ border: 0;
147
+ border-radius: var(--zx-app-sidebar-radius);
58
148
  background: transparent;
59
- padding-inline: var(--zx-space-3);
60
- color: inherit;
149
+ padding-block: var(--zx-space-1);
150
+ padding-inline: var(--zx-app-sidebar-pad-x);
151
+ color: var(--zx-color-text);
152
+ font-size: var(--zx-text-md);
153
+ font-weight: 500;
154
+ line-height: 1.35;
61
155
  text-align: start;
62
156
  text-decoration: none;
63
157
  }
64
158
 
65
- .zx-app-sidebar__row[data-level="2"] > .zx-app-sidebar__item { padding-inline-start: calc(var(--zx-space-3) + var(--zx-space-5)); }
66
- .zx-app-sidebar__row[data-level="3"] > .zx-app-sidebar__item { padding-inline-start: calc(var(--zx-space-3) + var(--zx-space-7)); }
67
- .zx-app-sidebar__item:hover,
68
- .zx-app-sidebar__item:focus-visible,
69
- .zx-app-sidebar__item[data-active-descendant="true"],
70
- .zx-app-sidebar__item[aria-current="page"] { background: var(--zx-color-bg-hover); color: var(--zx-color-accent); }
71
- .zx-app-sidebar__item[aria-disabled="true"] { color: var(--zx-color-text-muted); cursor: not-allowed; opacity: .65; }
72
- .zx-app-sidebar__item-icon,
73
- .zx-app-sidebar__item-disclosure { display: inline-flex; }
74
- .zx-app-sidebar__item-badge { color: var(--zx-color-text-muted); font-size: var(--zx-text-xs); }
159
+ /* A nav is a touch target before it is a dense list. Compact tightens the drawn row, and a
160
+ coarse pointer puts the hit area back to 40px whatever the density says. */
161
+ @media (pointer: coarse) {
162
+ .zx-app-sidebar__item { min-block-size: 40px; }
163
+ }
164
+
165
+ .zx-app-sidebar__row[data-level="2"] > .zx-app-sidebar__item { padding-inline-start: calc(var(--zx-app-sidebar-pad-x) + var(--zx-app-sidebar-indent)); }
166
+ .zx-app-sidebar__row[data-level="3"] > .zx-app-sidebar__item { padding-inline-start: calc(var(--zx-app-sidebar-pad-x) + 2 * var(--zx-app-sidebar-indent)); }
167
+
168
+ /* The active marker. It is anchored to the control and not to the row, because a row is the
169
+ branch *and everything open under it* — a marker measured against that would grow into a
170
+ bracket down the whole group. It sits in the nav's own gutter, docked to the sidebar's outer
171
+ edge, so every marker lines up whatever the nesting depth, and it is the signal that survives
172
+ when colour does not. A collapsed branch that merely *contains* the active route gets a short
173
+ stub of the same marker: the same vocabulary, a quieter sentence. */
174
+ .zx-app-sidebar__item::before {
175
+ content: "";
176
+ position: absolute;
177
+ inset-block: 50%;
178
+ inset-inline-start: calc(-1 * var(--zx-app-sidebar-gutter));
179
+ inline-size: var(--zx-app-sidebar-marker);
180
+ border-start-end-radius: var(--zx-radius-full);
181
+ border-end-end-radius: var(--zx-radius-full);
182
+ background: var(--zx-color-accent);
183
+ opacity: 0;
184
+ }
185
+
186
+ .zx-app-sidebar[data-side="right"] .zx-app-sidebar__item::before {
187
+ inset-inline: auto calc(-1 * var(--zx-app-sidebar-gutter));
188
+ border-radius: 0;
189
+ border-start-start-radius: var(--zx-radius-full);
190
+ border-end-start-radius: var(--zx-radius-full);
191
+ }
192
+
193
+ .zx-app-sidebar__item[aria-current="page"]::before {
194
+ inset-block: 25%;
195
+ opacity: 1;
196
+ }
197
+
198
+ .zx-app-sidebar__item[data-active-descendant="true"][aria-expanded="false"]::before {
199
+ inset-block: 38%;
200
+ opacity: .55;
201
+ }
202
+
203
+ .zx-app-sidebar__item-icon {
204
+ grid-column: 1;
205
+ display: flex;
206
+ align-items: center;
207
+ justify-content: center;
208
+ inline-size: var(--zx-app-sidebar-icon);
209
+ color: var(--zx-color-text-muted);
210
+ }
211
+
212
+ /* No nowrap and no ellipsis: the row grows instead. `hyphens` is what makes that bearable for a
213
+ German compound — where the document declares a language the browser breaks "Rechnungsausgang"
214
+ at a syllable, and `overflow-wrap` is only the last resort behind it for a word that fits
215
+ nowhere at all. */
216
+ .zx-app-sidebar__item-label {
217
+ grid-column: 2;
218
+ min-inline-size: 0;
219
+ hyphens: auto;
220
+ overflow-wrap: break-word;
221
+ }
222
+
223
+ .zx-app-sidebar__item-badge {
224
+ grid-column: 3;
225
+ margin-inline-start: var(--zx-space-2);
226
+ color: var(--zx-color-text-muted);
227
+ font-size: var(--zx-text-xs);
228
+ font-variant-numeric: tabular-nums;
229
+ line-height: 1;
230
+ }
231
+
232
+ .zx-app-sidebar__item-disclosure {
233
+ grid-column: 4;
234
+ display: inline-flex;
235
+ margin-inline-start: var(--zx-space-1);
236
+ color: var(--zx-color-text-muted);
237
+ }
238
+
239
+ .zx-app-sidebar__item:hover { background: var(--zx-app-sidebar-hover); }
240
+ .zx-app-sidebar__item:hover .zx-app-sidebar__item-icon,
241
+ .zx-app-sidebar__item:hover .zx-app-sidebar__item-disclosure { color: var(--zx-color-text); }
242
+ .zx-app-sidebar__item:active { background: var(--zx-app-sidebar-press); }
243
+
244
+ /* A branch that is closed over the active route keeps the accent in the label and the marker
245
+ stub outside it; a branch that is open says so with its chevron and needs neither. */
246
+ .zx-app-sidebar__item[data-active-descendant="true"][aria-expanded="false"] {
247
+ color: var(--zx-color-accent);
248
+ font-weight: 600;
249
+ }
250
+
251
+ .zx-app-sidebar__item[aria-current="page"] {
252
+ background: var(--zx-color-accent);
253
+ color: var(--zx-color-on-accent);
254
+ font-weight: 600;
255
+ }
256
+
257
+ /* Hover and press on the pill move the fill toward the text colour, which raises the contrast
258
+ with `--zx-color-on-accent` in both polarities instead of dropping it in one of them. */
259
+ .zx-app-sidebar__item[aria-current="page"]:hover {
260
+ background: color-mix(in srgb, var(--zx-color-text) 8%, var(--zx-color-accent));
261
+ }
262
+
263
+ .zx-app-sidebar__item[aria-current="page"]:active {
264
+ background: color-mix(in srgb, var(--zx-color-text) 16%, var(--zx-color-accent));
265
+ }
266
+
267
+ /* Focus is a ring, never a fill: a keyboard user arrowing past the active route must still be
268
+ able to tell which row is current. Ordinary rows take the shared `--zx-focus-ring` from
269
+ `base.css` untouched; only the filled pill needs help, because an accent glow around an accent
270
+ fill is a ring nobody can see. The inner line is drawn in the pill's own text colour, which the
271
+ preset contract already guarantees against that fill. */
272
+ .zx-app-sidebar__item[aria-current="page"]:focus-visible {
273
+ box-shadow: inset 0 0 0 2px var(--zx-color-on-accent), var(--zx-focus-ring);
274
+ }
275
+
276
+ .zx-app-sidebar__item[aria-current="page"] .zx-app-sidebar__item-icon,
277
+ .zx-app-sidebar__item[aria-current="page"] .zx-app-sidebar__item-badge,
278
+ .zx-app-sidebar__item[aria-current="page"] .zx-app-sidebar__item-disclosure {
279
+ color: inherit;
280
+ }
281
+
282
+ /* Unavailable, and still readable. A blanket `opacity` on the row took the label under 3:1, which
283
+ is the one thing a destination a user cannot reach still has to do: say where it would go. The
284
+ icon carries the dimming instead, and the label keeps the muted text role. */
285
+ .zx-app-sidebar__item[aria-disabled="true"] {
286
+ color: var(--zx-color-text-muted);
287
+ cursor: not-allowed;
288
+ }
289
+
290
+ .zx-app-sidebar__item[aria-disabled="true"] .zx-app-sidebar__item-icon { opacity: .55; }
291
+
292
+ .zx-app-sidebar__item[aria-disabled="true"]:hover,
293
+ .zx-app-sidebar__item[aria-disabled="true"]:active { background: transparent; }
75
294
 
76
295
  .zx-app-sidebar__footer {
77
296
  border-block-start: 1px solid var(--zx-color-border);
78
- padding: var(--zx-space-2);
297
+ padding: var(--zx-space-2) var(--zx-space-3);
79
298
  }
80
299
  .zx-app-sidebar__footer[hidden] { display: none; }
300
+
81
301
  .zx-app-sidebar__rail-header { display: grid; gap: var(--zx-space-1); }
82
302
 
303
+ /*
304
+ * The minimized rail. Only the presentation changes; the same vocabulary applies — filled pill,
305
+ * docked marker, badge that survives the loss of the label.
306
+ */
307
+ .zx-app-sidebar__rail .zx-app-rail__item[aria-current="page"] {
308
+ background: var(--zx-color-accent);
309
+ color: var(--zx-color-on-accent);
310
+ }
311
+
312
+ .zx-app-sidebar__rail .zx-app-rail__item[aria-current="page"]:hover {
313
+ background: color-mix(in srgb, var(--zx-color-text) 8%, var(--zx-color-accent));
314
+ }
315
+
316
+ .zx-app-sidebar__rail .zx-app-rail__item:not([aria-current="page"]):hover,
317
+ .zx-app-sidebar__rail .zx-app-rail__item:not([aria-current="page"])[aria-expanded="true"] {
318
+ background: var(--zx-app-sidebar-hover);
319
+ color: var(--zx-color-text);
320
+ }
321
+
322
+ .zx-app-sidebar__rail .zx-app-rail__item:not([aria-current="page"]):active {
323
+ background: var(--zx-app-sidebar-press);
324
+ }
325
+
326
+ .zx-app-sidebar__rail .zx-app-rail__item[aria-current="page"]:focus-visible {
327
+ box-shadow: inset 0 0 0 2px var(--zx-color-on-accent), var(--zx-focus-ring);
328
+ }
329
+
330
+ .zx-app-sidebar__rail .zx-app-rail__item::after {
331
+ content: "";
332
+ position: absolute;
333
+ inset-block: 22%;
334
+ inset-inline-start: calc(-1 * var(--zx-space-1));
335
+ inline-size: var(--zx-app-sidebar-marker);
336
+ border-start-end-radius: var(--zx-radius-full);
337
+ border-end-end-radius: var(--zx-radius-full);
338
+ background: var(--zx-color-accent);
339
+ opacity: 0;
340
+ }
341
+
342
+ .zx-app-sidebar[data-side="right"] .zx-app-rail__item::after {
343
+ inset-inline: auto calc(-1 * var(--zx-space-1));
344
+ border-radius: 0;
345
+ border-start-start-radius: var(--zx-radius-full);
346
+ border-end-start-radius: var(--zx-radius-full);
347
+ }
348
+
349
+ .zx-app-sidebar[data-orientation="horizontal"] .zx-app-rail__item::after {
350
+ inset-block: auto calc(-1 * var(--zx-space-1));
351
+ inset-inline: 22%;
352
+ inline-size: auto;
353
+ block-size: var(--zx-app-sidebar-marker);
354
+ border-radius: 0;
355
+ border-start-start-radius: var(--zx-radius-full);
356
+ border-start-end-radius: var(--zx-radius-full);
357
+ }
358
+
359
+ .zx-app-sidebar[data-side="bottom"] .zx-app-rail__item::after {
360
+ inset-block: calc(-1 * var(--zx-space-1)) auto;
361
+ border-radius: 0;
362
+ border-end-start-radius: var(--zx-radius-full);
363
+ border-end-end-radius: var(--zx-radius-full);
364
+ }
365
+
366
+ .zx-app-sidebar__rail .zx-app-rail__item[aria-current="page"]::after { opacity: 1; }
367
+ .zx-app-sidebar__rail .zx-app-rail__item[data-active-descendant="true"]:not([aria-current="page"])::after {
368
+ inset-block: 34%;
369
+ opacity: .55;
370
+ }
371
+
372
+ .zx-app-sidebar[data-orientation="horizontal"] .zx-app-rail__item[data-active-descendant="true"]:not([aria-current="page"])::after {
373
+ inset-block: auto calc(-1 * var(--zx-space-1));
374
+ inset-inline: 34%;
375
+ }
376
+
377
+ /*
378
+ * The count a minimized rail would otherwise lose. `data-zx-badge` is set by the component from
379
+ * the item's own badge: a number that fits stays a number, and one that does not becomes a dot,
380
+ * because a three-digit pill on a 32px target stops being a badge and starts being a label. The
381
+ * figure itself moves into the control's accessible name either way, so nothing is dropped —
382
+ * only redrawn.
383
+ */
384
+ .zx-app-sidebar__rail .zx-app-rail__item-badge {
385
+ inset-block-start: 0;
386
+ inset-inline-end: 0;
387
+ min-inline-size: var(--zx-space-4);
388
+ block-size: var(--zx-space-4);
389
+ border-radius: var(--zx-radius-full);
390
+ background: var(--zx-color-accent);
391
+ box-shadow: 0 0 0 2px var(--zx-color-bg-surface);
392
+ padding-inline: var(--zx-space-1);
393
+ color: var(--zx-color-on-accent);
394
+ font-size: var(--zx-text-xs);
395
+ font-variant-numeric: tabular-nums;
396
+ font-weight: 600;
397
+ line-height: var(--zx-space-4);
398
+ text-align: center;
399
+ }
400
+
401
+ .zx-app-sidebar__rail .zx-app-rail__item-badge[data-zx-badge="dot"] {
402
+ min-inline-size: 0;
403
+ inline-size: var(--zx-space-2);
404
+ block-size: var(--zx-space-2);
405
+ padding: 0;
406
+ font-size: 0;
407
+ line-height: 0;
408
+ }
409
+
410
+ /* On the filled pill the badge would be accent-on-accent. Invert it so the count keeps a fill
411
+ of its own without introducing a third colour. */
412
+ .zx-app-sidebar__rail .zx-app-rail__item[aria-current="page"] .zx-app-rail__item-badge {
413
+ background: var(--zx-color-on-accent);
414
+ box-shadow: none;
415
+ color: var(--zx-color-accent);
416
+ }
417
+
418
+ /*
419
+ * Motion. Everything here is decoration on a state change that is already expressed statically,
420
+ * so all of it is opt-in.
421
+ *
422
+ * The expanded tree is re-rendered wholesale on every branch toggle, which is why the disclosure
423
+ * animation is driven by `data-branch-motion` — an attribute the component sets on exactly the
424
+ * sublist it just opened, so the branches that were already open do not replay it.
425
+ */
426
+ @media (prefers-reduced-motion: no-preference) {
427
+ .zx-app-sidebar__item,
428
+ .zx-app-sidebar__rail .zx-app-rail__item {
429
+ transition: background-color var(--zx-dur-fast) var(--zx-ease),
430
+ color var(--zx-dur-fast) var(--zx-ease),
431
+ box-shadow var(--zx-dur-fast) var(--zx-ease);
432
+ }
433
+
434
+ .zx-app-sidebar__item::before,
435
+ .zx-app-sidebar__rail .zx-app-rail__item::after {
436
+ transition: opacity var(--zx-dur-base) var(--zx-ease),
437
+ inset-block var(--zx-dur-base) var(--zx-ease);
438
+ }
439
+
440
+ .zx-app-sidebar__item:active,
441
+ .zx-app-sidebar__rail .zx-app-rail__item:active { transform: scale(.985); }
442
+
443
+ .zx-app-sidebar__expanded,
444
+ .zx-app-sidebar__rail { animation: zx-app-sidebar-mode var(--zx-dur-base) var(--zx-ease); }
445
+
446
+ .zx-app-sidebar__sublist[data-branch-motion="expand"] { animation: zx-app-sidebar-branch var(--zx-dur-base) var(--zx-ease); }
447
+ }
448
+
449
+ @keyframes zx-app-sidebar-mode {
450
+ from { opacity: 0; }
451
+ to { opacity: 1; }
452
+ }
453
+
454
+ @keyframes zx-app-sidebar-branch {
455
+ from { opacity: 0; transform: translateY(-4px); }
456
+ to { opacity: 1; transform: translateY(0); }
457
+ }
458
+
459
+ /* Forced colours drop backgrounds and box-shadows, so the pill and the marker both disappear.
460
+ The system's own selected-item pair carries the state there instead. */
461
+ @media (forced-colors: active) {
462
+ .zx-app-sidebar__item[aria-current="page"],
463
+ .zx-app-sidebar__rail .zx-app-rail__item[aria-current="page"],
464
+ .zx-app-sidebar__rail .zx-app-rail__item-badge {
465
+ background: Highlight;
466
+ color: HighlightText;
467
+ }
468
+
469
+ .zx-app-sidebar__item::before,
470
+ .zx-app-sidebar__rail .zx-app-rail__item::after { background: Highlight; }
471
+ }
472
+
83
473
  @media (prefers-reduced-transparency: reduce) {
84
474
  .zx-app-sidebar {
85
475
  border-color: var(--zx-color-border);
@@ -1,5 +1,6 @@
1
1
  import { Component } from '../../core/component.js';
2
2
  import { h } from '../../core/dom.js';
3
+ import { printf } from '../../core/i18n.js';
3
4
  import { icon } from '../../core/icons.js';
4
5
  import { uid } from '../../core/util.js';
5
6
  import {
@@ -99,8 +100,10 @@ export class AppSidebar extends Component {
99
100
  setItems(items) {
100
101
  this._items = normalizeAppItems(items);
101
102
  this._reconcileExpanded();
102
- if (this._rail) this._rail.setItems(this._items);
103
- else this._renderTree();
103
+ if (this._rail) {
104
+ this._rail.setItems(this._items);
105
+ this._decorateRail();
106
+ } else this._renderTree();
104
107
  return this;
105
108
  }
106
109
 
@@ -151,7 +154,10 @@ export class AppSidebar extends Component {
151
154
  const expanded = !this._expanded.has(key);
152
155
  if (expanded) this._expanded.add(key);
153
156
  else this._expanded.delete(key);
154
- if (!this._rail) this._renderTree();
157
+ if (!this._rail) {
158
+ this._renderTree();
159
+ if (expanded) this._markBranchMotion(key);
160
+ }
155
161
  if (!silent) this.emit('branchchange', {
156
162
  item,
157
163
  id: item.id,
@@ -298,7 +304,7 @@ export class AppSidebar extends Component {
298
304
  const collapseButton = h('button', {
299
305
  class: 'zx-app-sidebar__collapse zx-icon-btn',
300
306
  type: 'button',
301
- ariaLabel: 'Minimize application sidebar'
307
+ ariaLabel: this._message('appSidebar.minimize', 'Minimize application sidebar')
302
308
  }, icon(this._side === 'right' ? 'chevron-right' : 'chevron-left'));
303
309
  collapseButton.hidden = !this.options.collapsible;
304
310
  const footer = h('footer', { class: 'zx-app-sidebar__footer' });
@@ -330,7 +336,7 @@ export class AppSidebar extends Component {
330
336
  const expandButton = h('button', {
331
337
  class: 'zx-app-sidebar__rail-toggle zx-icon-btn',
332
338
  type: 'button',
333
- ariaLabel: 'Expand application sidebar'
339
+ ariaLabel: this._message('appSidebar.expand', 'Expand application sidebar')
334
340
  }, icon(this._side === 'right' ? 'chevron-left' : 'chevron-right'));
335
341
  railHeader.append(expandButton);
336
342
  this._railExpandButton = expandButton;
@@ -356,6 +362,31 @@ export class AppSidebar extends Component {
356
362
  });
357
363
  this._listenMode(railHost, 'zx-select', (event) => event.stopPropagation());
358
364
  this._listenMode(railHost, 'zx-flyoutchange', (event) => event.stopPropagation());
365
+ this._decorateRail();
366
+ }
367
+
368
+ /**
369
+ * Keeps a minimized rail's counts readable and audible.
370
+ *
371
+ * A rail destination is icon-only, so its accessible name is the label alone and the badge the
372
+ * expanded tree showed as text is announced by nothing. Here the figure is folded back into the
373
+ * name, and `data-zx-badge` tells the stylesheet whether the count still fits the target or has
374
+ * to be drawn as a dot. Presentation only: no item, option, event, or listener is touched.
375
+ * @returns {void}
376
+ */
377
+ _decorateRail() {
378
+ const rail = this.refs.rail;
379
+ if (!rail) return;
380
+ for (const control of rail.querySelectorAll('.zx-app-rail__item[data-app-nav-value-id]')) {
381
+ const badge = control.querySelector(':scope > .zx-app-rail__item-badge');
382
+ if (!badge) continue;
383
+ const item = findItem(this._items, control.getAttribute('data-app-nav-value-id'));
384
+ if (!item || item.badge == null) continue;
385
+ const count = String(item.badge);
386
+ badge.dataset.zxBadge = count.length > 2 ? 'dot' : 'count';
387
+ control.setAttribute('aria-label',
388
+ this._message('appSidebar.itemBadge', '%1 (%2)', item.label, count));
389
+ }
359
390
  }
360
391
 
361
392
  /** @param {EventTarget} target @param {string} type @param {EventListener} listener @returns {void} */
@@ -494,6 +525,35 @@ export class AppSidebar extends Component {
494
525
  }
495
526
  }
496
527
 
528
+ /**
529
+ * Flags the sublist a branch toggle just opened so the stylesheet can animate that one branch.
530
+ *
531
+ * `_renderTree()` rebuilds every row on every toggle, so a transition declared on the sublist
532
+ * alone would replay on branches that never moved. The attribute is written after the rebuild
533
+ * and disappears with the element on the next one, which is why it needs no teardown.
534
+ * @param {string} key Branch id as a string.
535
+ * @returns {void}
536
+ */
537
+ _markBranchMotion(key) {
538
+ if (!this.refs.list) return;
539
+ const control = [...this.refs.list.querySelectorAll('[data-app-nav-id]')]
540
+ .find((candidate) => candidate.getAttribute('data-app-nav-id') === key) ?? null;
541
+ const sublist = control?.parentElement?.querySelector(':scope > .zx-app-sidebar__sublist');
542
+ if (sublist instanceof HTMLElement) sublist.dataset.branchMotion = 'expand';
543
+ }
544
+
545
+ /**
546
+ * Resolves a message through the host translator, falling back to the built-in English text.
547
+ * @param {string} key Message key.
548
+ * @param {string} fallback Built-in text, with `%1`-style placeholders.
549
+ * @param {...unknown} args Interpolation values.
550
+ * @returns {string}
551
+ */
552
+ _message(key, fallback, ...args) {
553
+ const message = this.msg(key, ...args);
554
+ return message === key ? printf(fallback, args) : message;
555
+ }
556
+
497
557
  /** @returns {void} */
498
558
  _reconcileExpanded() {
499
559
  const branchIds = new Set();
@@ -93,6 +93,14 @@
93
93
  display: inline-flex;
94
94
  }
95
95
 
96
+ .zx-btn__shortcut {
97
+ font: inherit;
98
+ font-size: var(--zx-text-xs);
99
+ opacity: .7;
100
+ margin-inline-start: var(--zx-space-1);
101
+ white-space: nowrap;
102
+ }
103
+
96
104
  .zx-btn-group > .zx-btn:not(:first-child) {
97
105
  margin-inline-start: -1px;
98
106
  border-start-start-radius: 0;
@@ -1,6 +1,8 @@
1
1
  import { h } from '../../core/dom.js';
2
2
  import { icon as createIcon } from '../../core/icons.js';
3
3
 
4
+ /** @typedef {{label:string,keys:string}} ButtonShortcut Visible hint and ARIA key combination; the host binds the action. */
5
+
4
6
  /**
5
7
  * @typedef {Object} ButtonOptions
6
8
  * @property {string} [label=''] Visible button label.
@@ -9,6 +11,7 @@ import { icon as createIcon } from '../../core/icons.js';
9
11
  * @property {'md'|'sm'} [size='md'] Control size.
10
12
  * @property {boolean} [disabled=false] Whether the button is disabled.
11
13
  * @property {string} [title] Native title text.
14
+ * @property {ButtonShortcut} [shortcut] Keyboard hint; does not register a keyboard handler.
12
15
  * @property {(event: MouseEvent) => void} [onclick] Click callback.
13
16
  */
14
17
 
@@ -32,7 +35,8 @@ export function button(opts = {}) {
32
35
  class: 'zx-btn',
33
36
  type: 'button',
34
37
  disabled: Boolean(options.disabled),
35
- title: options.title,
38
+ title: options.title ?? (options.shortcut ? `${options.label} (${options.shortcut.label})` : undefined),
39
+ ariaKeyshortcuts: options.shortcut?.keys,
36
40
  dataset: {
37
41
  kind: normalizeKind(options.kind),
38
42
  size: normalizeSize(options.size)
@@ -45,6 +49,7 @@ export function button(opts = {}) {
45
49
  if (options.label !== '') {
46
50
  element.append(h('span', { class: 'zx-btn__label' }, String(options.label)));
47
51
  }
52
+ if (options.shortcut) element.append(h('kbd', { class: 'zx-btn__shortcut', ariaHidden: 'true' }, options.shortcut.label));
48
53
  if (!options.label && options.title) element.setAttribute('aria-label', options.title);
49
54
  if (typeof options.onclick === 'function') element.onclick = options.onclick;
50
55
  return element;