@blotch/dolan 22.2.3 → 22.2.5

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 (227) hide show
  1. package/dist/commands/add.js +9 -1
  2. package/dist/commands/add.js.map +1 -1
  3. package/dist/commands/add.test.js +38 -3
  4. package/dist/commands/add.test.js.map +1 -1
  5. package/dist/commands/doctor.js +258 -21
  6. package/dist/commands/doctor.js.map +1 -1
  7. package/dist/commands/doctor.test.js +425 -11
  8. package/dist/commands/doctor.test.js.map +1 -1
  9. package/dist/commands/init.d.ts +2 -2
  10. package/dist/commands/init.js +38 -18
  11. package/dist/commands/init.js.map +1 -1
  12. package/dist/commands/init.test.js +105 -2
  13. package/dist/commands/init.test.js.map +1 -1
  14. package/dist/commands/theme.js +1 -1
  15. package/dist/commands/theme.js.map +1 -1
  16. package/dist/commands/update.d.ts +7 -0
  17. package/dist/commands/update.js +194 -50
  18. package/dist/commands/update.js.map +1 -1
  19. package/dist/commands/update.test.js +570 -3
  20. package/dist/commands/update.test.js.map +1 -1
  21. package/dist/lockfile.d.ts +17 -0
  22. package/dist/lockfile.js +56 -1
  23. package/dist/lockfile.js.map +1 -1
  24. package/dist/lockfile.test.d.ts +1 -0
  25. package/dist/lockfile.test.js +65 -0
  26. package/dist/lockfile.test.js.map +1 -0
  27. package/dist/registry.d.ts +6 -0
  28. package/dist/registry.js +119 -11
  29. package/dist/registry.js.map +1 -1
  30. package/dist/registry.test.js +61 -6
  31. package/dist/registry.test.js.map +1 -1
  32. package/dist/test-utils.d.ts +27 -0
  33. package/dist/test-utils.js +45 -1
  34. package/dist/test-utils.js.map +1 -1
  35. package/dist/testing/fire-lynx-event.d.ts +14 -0
  36. package/dist/testing/fire-lynx-event.js +19 -0
  37. package/dist/testing/fire-lynx-event.js.map +1 -0
  38. package/dist/testing/fire-lynx-event.test.d.ts +1 -0
  39. package/dist/testing/fire-lynx-event.test.js +44 -0
  40. package/dist/testing/fire-lynx-event.test.js.map +1 -0
  41. package/dist/testing/lynx-main-thread.d.ts +22 -0
  42. package/dist/testing/lynx-main-thread.js +11 -0
  43. package/dist/testing/lynx-main-thread.js.map +1 -0
  44. package/dist/testing/lynx-main-thread.test.d.ts +1 -0
  45. package/dist/testing/lynx-main-thread.test.js +24 -0
  46. package/dist/testing/lynx-main-thread.test.js.map +1 -0
  47. package/dist/testing/set-input-signal.d.ts +5 -4
  48. package/dist/testing/set-input-signal.js +5 -4
  49. package/dist/testing/set-input-signal.js.map +1 -1
  50. package/dist/ui/boolean-inputs.test.ts +76 -0
  51. package/dist/ui/components/accordion/accordion.test.ts +158 -144
  52. package/dist/ui/components/accordion/accordion.ts +19 -14
  53. package/dist/ui/components/action-sheet/action-sheet.test.ts +637 -183
  54. package/dist/ui/components/action-sheet/action-sheet.ts +339 -55
  55. package/dist/ui/components/action-sheet/index.ts +1 -0
  56. package/dist/ui/components/alert/alert.test.ts +67 -19
  57. package/dist/ui/components/alert/alert.ts +14 -16
  58. package/dist/ui/components/alert-dialog/alert-dialog.test.ts +740 -129
  59. package/dist/ui/components/alert-dialog/alert-dialog.ts +672 -58
  60. package/dist/ui/components/alert-dialog/index.ts +5 -0
  61. package/dist/ui/components/area-chart/area-chart.test.ts +81 -1
  62. package/dist/ui/components/area-chart/area-chart.ts +3 -2
  63. package/dist/ui/components/avatar/avatar.test.ts +89 -45
  64. package/dist/ui/components/badge/badge.test.ts +57 -23
  65. package/dist/ui/components/badge/badge.ts +14 -15
  66. package/dist/ui/components/bar-chart/bar-chart.test.ts +82 -1
  67. package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +1314 -273
  68. package/dist/ui/components/bottom-sheet/bottom-sheet.ts +1046 -119
  69. package/dist/ui/components/bottom-sheet/index.ts +7 -1
  70. package/dist/ui/components/button/button.test.ts +646 -2
  71. package/dist/ui/components/button/button.ts +443 -64
  72. package/dist/ui/components/button/index.ts +2 -0
  73. package/dist/ui/components/button-group/button-group.test.ts +528 -2
  74. package/dist/ui/components/button-group/button-group.ts +428 -70
  75. package/dist/ui/components/button-group/index.ts +3 -0
  76. package/dist/ui/components/candlestick-chart/candlestick-chart.test.ts +82 -1
  77. package/dist/ui/components/card/card.test.ts +97 -61
  78. package/dist/ui/components/card/card.ts +2 -1
  79. package/dist/ui/components/chart/chart-presets.test.ts +197 -1
  80. package/dist/ui/components/chart/chart-presets.ts +14 -7
  81. package/dist/ui/components/chart/chart-theme.ts +12 -0
  82. package/dist/ui/components/chart/chart.test.ts +1031 -0
  83. package/dist/ui/components/chart/chart.ts +66 -46
  84. package/dist/ui/components/checkbox/checkbox.test.ts +124 -42
  85. package/dist/ui/components/checkbox/checkbox.ts +25 -24
  86. package/dist/ui/components/collapsible/collapsible.test.ts +158 -81
  87. package/dist/ui/components/collapsible/collapsible.ts +21 -15
  88. package/dist/ui/components/context-menu/context-menu.test.ts +224 -0
  89. package/dist/ui/components/context-menu/context-menu.ts +110 -0
  90. package/dist/ui/components/context-menu/index.ts +1 -0
  91. package/dist/ui/components/dialog/dialog.test.ts +210 -152
  92. package/dist/ui/components/dialog/dialog.ts +35 -29
  93. package/dist/ui/components/dock/dock-scroll.test.ts +130 -0
  94. package/dist/ui/components/dock/dock-scroll.ts +142 -0
  95. package/dist/ui/components/dock/dock-search.test.ts +594 -0
  96. package/dist/ui/components/dock/dock.test.ts +752 -0
  97. package/dist/ui/components/dock/dock.ts +1438 -0
  98. package/dist/ui/components/dock/index.ts +2 -0
  99. package/dist/ui/components/dynamic-color/dynamic-color.test.ts +144 -0
  100. package/dist/ui/components/dynamic-color/dynamic-color.ts +146 -0
  101. package/dist/ui/components/dynamic-color/index.ts +5 -0
  102. package/dist/ui/components/empty-state/empty-state.test.ts +28 -39
  103. package/dist/ui/components/glass/concentric-radius.test.ts +13 -0
  104. package/dist/ui/components/glass/concentric-radius.ts +17 -0
  105. package/dist/ui/components/glass/glass-design.test.ts +78 -0
  106. package/dist/ui/components/glass/glass-design.ts +119 -0
  107. package/dist/ui/components/glass/glass.test.ts +376 -0
  108. package/dist/ui/components/glass/glass.ts +430 -0
  109. package/dist/ui/components/glass/index.ts +19 -0
  110. package/dist/ui/components/icon/icon-theme.test.ts +64 -0
  111. package/dist/ui/components/icon/icon-theme.ts +70 -16
  112. package/dist/ui/components/icon/icon.test.ts +52 -41
  113. package/dist/ui/components/input/input.test.ts +178 -101
  114. package/dist/ui/components/input/input.ts +17 -13
  115. package/dist/ui/components/label/label.test.ts +11 -24
  116. package/dist/ui/components/label/label.ts +8 -2
  117. package/dist/ui/components/line-chart/line-chart.test.ts +76 -1
  118. package/dist/ui/components/line-chart/line-chart.ts +3 -2
  119. package/dist/ui/components/list/list.test.ts +80 -49
  120. package/dist/ui/components/list/list.ts +5 -4
  121. package/dist/ui/components/menu/index.ts +25 -0
  122. package/dist/ui/components/menu/menu-placement.test.ts +153 -0
  123. package/dist/ui/components/menu/menu-placement.ts +159 -0
  124. package/dist/ui/components/menu/menu-popup.test.ts +636 -0
  125. package/dist/ui/components/menu/menu-popup.ts +565 -0
  126. package/dist/ui/components/menu/menu.test.ts +523 -0
  127. package/dist/ui/components/menu/menu.ts +455 -0
  128. package/dist/ui/components/nav-drawer/nav-drawer.test.ts +425 -260
  129. package/dist/ui/components/nav-drawer/nav-drawer.ts +75 -58
  130. package/dist/ui/components/navigation-bar/index.ts +1 -0
  131. package/dist/ui/components/navigation-bar/navigation-bar.test.ts +425 -0
  132. package/dist/ui/components/navigation-bar/navigation-bar.ts +411 -0
  133. package/dist/ui/components/pagination/pagination.test.ts +136 -86
  134. package/dist/ui/components/popover/index.ts +9 -0
  135. package/dist/ui/components/popover/popover-panel.test.ts +735 -0
  136. package/dist/ui/components/popover/popover-panel.ts +603 -0
  137. package/dist/ui/components/popover/popover.test.ts +286 -0
  138. package/dist/ui/components/popover/popover.ts +207 -0
  139. package/dist/ui/components/progress/progress.test.ts +102 -46
  140. package/dist/ui/components/progress/progress.ts +54 -55
  141. package/dist/ui/components/radio-group/radio-group.test.ts +128 -83
  142. package/dist/ui/components/radio-group/radio-group.ts +26 -25
  143. package/dist/ui/components/scatter-chart/scatter-chart.test.ts +74 -1
  144. package/dist/ui/components/scroll-area/scroll-area.test.ts +72 -36
  145. package/dist/ui/components/scroll-area/scroll-area.ts +4 -3
  146. package/dist/ui/components/search-field/index.ts +15 -0
  147. package/dist/ui/components/search-field/keyboard.test.ts +147 -0
  148. package/dist/ui/components/search-field/keyboard.ts +123 -0
  149. package/dist/ui/components/search-field/search-field.test.ts +549 -0
  150. package/dist/ui/components/search-field/search-field.ts +628 -0
  151. package/dist/ui/components/segmented-control/index.ts +8 -0
  152. package/dist/ui/components/segmented-control/segmented-control.test.ts +861 -0
  153. package/dist/ui/components/segmented-control/segmented-control.ts +884 -0
  154. package/dist/ui/components/select/select.test.ts +138 -78
  155. package/dist/ui/components/select/select.ts +19 -12
  156. package/dist/ui/components/separator/separator.test.ts +18 -17
  157. package/dist/ui/components/sheet/sheet.test.ts +151 -11
  158. package/dist/ui/components/sheet/sheet.ts +68 -5
  159. package/dist/ui/components/skeleton/skeleton.test.ts +24 -7
  160. package/dist/ui/components/skeleton/skeleton.ts +10 -16
  161. package/dist/ui/components/slider/index.ts +1 -0
  162. package/dist/ui/components/slider/slider.test.ts +695 -0
  163. package/dist/ui/components/slider/slider.ts +901 -0
  164. package/dist/ui/components/spinner/spinner.test.ts +64 -46
  165. package/dist/ui/components/spinner/spinner.ts +16 -19
  166. package/dist/ui/components/switch/index.ts +1 -1
  167. package/dist/ui/components/switch/switch.test.ts +534 -48
  168. package/dist/ui/components/switch/switch.ts +613 -48
  169. package/dist/ui/components/tab-bar/index.ts +1 -0
  170. package/dist/ui/components/tab-bar/tab-bar.test.ts +280 -0
  171. package/dist/ui/components/tab-bar/tab-bar.ts +230 -0
  172. package/dist/ui/components/tabs/index.ts +7 -1
  173. package/dist/ui/components/tabs/tabs.test.ts +484 -150
  174. package/dist/ui/components/tabs/tabs.ts +344 -149
  175. package/dist/ui/components/textarea/textarea.test.ts +206 -100
  176. package/dist/ui/components/textarea/textarea.ts +16 -13
  177. package/dist/ui/components/toast/toast.test.ts +434 -350
  178. package/dist/ui/components/toast/toast.ts +42 -51
  179. package/dist/ui/components/toggle/index.ts +1 -0
  180. package/dist/ui/components/toggle/toggle.test.ts +559 -146
  181. package/dist/ui/components/toggle/toggle.ts +241 -26
  182. package/dist/ui/components/toolbar/bar-scroll.test.ts +310 -0
  183. package/dist/ui/components/toolbar/bar-scroll.ts +372 -0
  184. package/dist/ui/components/toolbar/index.ts +12 -0
  185. package/dist/ui/components/toolbar/toolbar.test.ts +438 -0
  186. package/dist/ui/components/toolbar/toolbar.ts +535 -0
  187. package/dist/ui/theme/chart-colors.test.ts +8 -3
  188. package/dist/ui/theme/dark.css +38 -2
  189. package/dist/ui/theme/default.css +55 -7
  190. package/dist/ui/theme/icon-colors.test.ts +8 -3
  191. package/dist/ui/theme/rgb-channels.test.ts +84 -0
  192. package/dist/ui/theme/tailwind-build.test.ts +114 -0
  193. package/dist/ui/theme/tailwind-css.test.ts +83 -0
  194. package/dist/ui/theme/tailwind-plugin.test.ts +42 -2
  195. package/dist/ui/theme/tailwind-plugin.ts +93 -29
  196. package/dist/ui/theme/tailwind.css +113 -0
  197. package/dist/ui/utils/animate.test.ts +13 -1
  198. package/dist/utils/analyze.d.ts +19 -0
  199. package/dist/utils/analyze.js +16 -0
  200. package/dist/utils/analyze.js.map +1 -1
  201. package/dist/utils/analyze.test.js +14 -1
  202. package/dist/utils/analyze.test.js.map +1 -1
  203. package/dist/utils/installed.d.ts +25 -1
  204. package/dist/utils/installed.js +33 -0
  205. package/dist/utils/installed.js.map +1 -1
  206. package/dist/utils/installed.test.js +55 -1
  207. package/dist/utils/installed.test.js.map +1 -1
  208. package/dist/utils/resolve-paths.d.ts +4 -4
  209. package/dist/utils/resolve-paths.js +5 -7
  210. package/dist/utils/resolve-paths.js.map +1 -1
  211. package/dist/utils/resolve-paths.test.js +9 -5
  212. package/dist/utils/resolve-paths.test.js.map +1 -1
  213. package/dist/utils/tailwind.d.ts +45 -0
  214. package/dist/utils/tailwind.js +90 -0
  215. package/dist/utils/tailwind.js.map +1 -0
  216. package/dist/utils/tailwind.test.d.ts +1 -0
  217. package/dist/utils/tailwind.test.js +134 -0
  218. package/dist/utils/tailwind.test.js.map +1 -0
  219. package/package.json +9 -1
  220. package/dist/ui/components/area-chart/area-chart-render.test.ts +0 -63
  221. package/dist/ui/components/bar-chart/bar-chart-render.test.ts +0 -58
  222. package/dist/ui/components/button/button-render.test.ts +0 -132
  223. package/dist/ui/components/button-group/button-group-render.test.ts +0 -146
  224. package/dist/ui/components/candlestick-chart/candlestick-chart-render.test.ts +0 -59
  225. package/dist/ui/components/chart/chart-render.test.ts +0 -730
  226. package/dist/ui/components/line-chart/line-chart-render.test.ts +0 -83
  227. package/dist/ui/components/scatter-chart/scatter-chart-render.test.ts +0 -64
@@ -3,6 +3,7 @@ import {
3
3
  Component,
4
4
  ViewEncapsulation,
5
5
  computed,
6
+ contentChildren,
6
7
  effect,
7
8
  inject,
8
9
  input,
@@ -11,7 +12,7 @@ import {
11
12
  signal,
12
13
  viewChild,
13
14
  } from '@angular/core';
14
- import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
15
+ import { LYNX_ELEMENTS, LynxAccessibilitySettings } from '@blotch/angular-lynx';
15
16
 
16
17
  import {
17
18
  type AnimationHandle,
@@ -21,10 +22,37 @@ import {
21
22
  revealIn,
22
23
  } from '../../utils/animate';
23
24
  import { cn } from '../../utils/cn';
25
+ import {
26
+ type SegmentedHost,
27
+ segmentPlace,
28
+ UI_SEGMENTED_HOST,
29
+ UiSegmentedControl,
30
+ UiSegmentedIndicator,
31
+ } from '../segmented-control/segmented-control';
32
+
33
+ /**
34
+ * A tab list's look: shadcn's tabs, or `'segmented'` for the platform's
35
+ * segmented control (see UiTabsList).
36
+ */
37
+ export type TabsListVariant = 'default' | 'segmented';
38
+
39
+ /**
40
+ * `UiTabsTrigger`, for the list's content query. A call rather than the
41
+ * class itself: for a class, Angular's test build keeps an arrow function
42
+ * that nothing calls, which an app's coverage report would show as
43
+ * uncovered.
44
+ */
45
+ const triggerType = () => UiTabsTrigger;
46
+
47
+ /**
48
+ * The list, as the segmented control it provides reads it. Named, not
49
+ * inline: the component's metadata keeps its own copy of an inline factory,
50
+ * which nothing calls.
51
+ */
52
+ const tabsListHost = (): SegmentedHost => inject(UiTabsList).segmentedHost;
24
53
 
25
54
  @Component({
26
55
  selector: 'ui-tabs',
27
- standalone: true,
28
56
  imports: [LYNX_ELEMENTS],
29
57
  encapsulation: ViewEncapsulation.None,
30
58
  template: `
@@ -44,8 +72,10 @@ export class UiTabs {
44
72
  // When the active tab changes, we compare the old and new index in this
45
73
  // list to determine slide direction (1 = right, -1 = left). This creates
46
74
  // a spatial relationship: tabs feel like they're arranged left-to-right,
47
- // with content sliding in from the direction of the selected tab.
48
- readonly #tabOrder: string[] = [];
75
+ // with content sliding in from the direction of the selected tab. A Set
76
+ // keeps the order tabs first registered in and ignores a re-registration
77
+ // (a trigger re-created by `@if` or `@for`).
78
+ readonly #tabOrder = new Set<string>();
49
79
  #previousValue: string | undefined;
50
80
  readonly direction = signal<1 | -1>(1);
51
81
 
@@ -53,43 +83,38 @@ export class UiTabs {
53
83
  cn('flex-col flex', this.userClass()),
54
84
  );
55
85
 
56
- constructor() {
57
- // Reactively compute direction when the active value changes.
58
- // This handles both internal select() calls and external model changes.
59
- effect(() => {
60
- const currentValue = this.value();
61
- if (this.#previousValue === undefined) {
62
- this.#previousValue = currentValue;
63
- return;
64
- }
65
- // Defensive: signal dedupes same-value writes so the effect never re-runs
66
- // with an unchanged `currentValue`. Kept for safety on-device.
67
- /* v8 ignore next 1 */
68
- if (currentValue === this.#previousValue) return;
69
-
70
- const prevIndex = this.#tabOrder.indexOf(this.#previousValue);
71
- const nextIndex = this.#tabOrder.indexOf(currentValue);
72
- // The `prevIndex !== nextIndex` check is defensive — the earlier
73
- // `if (currentValue === this.#previousValue) return;` guard makes an
74
- // equal-index case unreachable in normal flow.
75
- if (
76
- prevIndex !== -1 &&
77
- nextIndex !== -1 &&
78
- /* v8 ignore next 1 */ prevIndex !== nextIndex
79
- ) {
80
- this.direction.set(nextIndex > prevIndex ? 1 : -1);
81
- }
86
+ /**
87
+ * Computes the slide direction when the active value changes, whether from
88
+ * select() or from the model being set. A field rather than a constructor:
89
+ * Angular's test build keeps a function for a constructor that nothing
90
+ * calls, which an app's coverage report would show as uncovered.
91
+ */
92
+ protected readonly trackingDirection = effect(() => {
93
+ const currentValue = this.value();
94
+ if (this.#previousValue === undefined) {
82
95
  this.#previousValue = currentValue;
83
- });
84
- }
96
+ return;
97
+ }
98
+ // The value can change and change back before the effect runs (two taps
99
+ // in one frame): the tab didn't move, so the direction stays.
100
+ if (currentValue === this.#previousValue) return;
101
+
102
+ const order = [...this.#tabOrder];
103
+ const prevIndex = order.indexOf(this.#previousValue);
104
+ const nextIndex = order.indexOf(currentValue);
105
+ // A value with no trigger has no place in the order to slide from or to.
106
+ // Two different registered values always have different indexes.
107
+ if (prevIndex !== -1 && nextIndex !== -1) {
108
+ this.direction.set(nextIndex > prevIndex ? 1 : -1);
109
+ }
110
+ this.#previousValue = currentValue;
111
+ });
85
112
 
86
113
  /**
87
114
  * Called by UiTabsTrigger to register ordering for direction detection.
88
115
  */
89
116
  registerTab(value: string): void {
90
- if (!this.#tabOrder.includes(value)) {
91
- this.#tabOrder.push(value);
92
- }
117
+ this.#tabOrder.add(value);
93
118
  }
94
119
 
95
120
  select(value: string): void {
@@ -98,52 +123,138 @@ export class UiTabs {
98
123
  }
99
124
  }
100
125
 
126
+ /**
127
+ * The row of tab triggers.
128
+ *
129
+ * `variant="segmented"` draws the platform's segmented control instead (see
130
+ * UiSegmentedControl), Apple's control for switching between closely
131
+ * related views: iOS 26's capsule with a thumb that lifts into Liquid Glass
132
+ * as it slides between the tabs (it can be dragged across them too), iOS 13
133
+ * to 18's raised thumb before iOS 26, and Material 3 Expressive's connected
134
+ * button group on Android. It fills the width it's given, and the tabs
135
+ * share it.
136
+ *
137
+ * The row is a tab bar to VoiceOver (`tabbar`), which reads each trigger as
138
+ * a tab, and the selected one as selected.
139
+ */
101
140
  @Component({
102
141
  selector: 'ui-tabs-list',
103
- standalone: true,
104
- imports: [LYNX_ELEMENTS],
142
+ imports: [LYNX_ELEMENTS, UiSegmentedIndicator],
105
143
  encapsulation: ViewEncapsulation.None,
144
+ providers: [
145
+ UiSegmentedControl,
146
+ { provide: UI_SEGMENTED_HOST, useFactory: tabsListHost },
147
+ ],
148
+ // The mouse leaving the row lets go of an indicator dragged in the web
149
+ // preview, where a drag gets no touchcancel.
106
150
  template: `
107
- <view [class]="listClass()">
151
+ <view
152
+ accessibility-traits="tabbar"
153
+ [class]="listClass()"
154
+ (bindmouseleave)="segmentedControl.cancel()"
155
+ >
156
+ @if (segmentedControl.underlay()) {
157
+ <ui-segmented-indicator />
158
+ }
108
159
  <ng-content />
109
160
  </view>
110
161
  `,
111
162
  })
112
163
  export class UiTabsList {
164
+ /**
165
+ * `'default'`, or `'segmented'` for the platform's segmented control.
166
+ */
167
+ readonly variant = input<TabsListVariant>('default');
113
168
  readonly userClass = input<string>('', { alias: 'class' });
114
169
 
115
- protected readonly listClass = computed(() =>
116
- cn(
117
- // `self-start` keeps the tab bar hugging its triggers. The parent <ui-tabs>
118
- // is a `flex flex-col` column, and in Lynx's linear/flex layout a child with
119
- // no explicit width stretches to fill the parent's cross axis — so without
120
- // it the muted pill spans the full width, leaving empty space around the
121
- // tabs. Same lever as the badge fix: Lynx has no `inline-flex`, so
122
- // align-self is how you opt a child into shrink-to-fit.
123
- 'flex-row items-center self-start rounded-lg bg-muted p-1 flex',
124
- this.userClass(),
170
+ readonly #tabs = inject(UiTabs);
171
+ protected readonly segmentedControl = inject(UiSegmentedControl);
172
+ /** The triggers in the list, in order. */
173
+ readonly triggers = contentChildren(triggerType(), { descendants: true });
174
+
175
+ /** What the segmented control reads from the list. */
176
+ readonly segmentedHost: SegmentedHost = {
177
+ segmented: computed(() => this.variant() === 'segmented'),
178
+ segmentValues: computed(() =>
179
+ this.triggers().map((trigger) => trigger.triggerValue()),
125
180
  ),
181
+ selectedValues: computed(() => [this.#tabs.value()]),
182
+ // One tab is active at a time, and tabs are never disabled.
183
+ multiple: signal(false).asReadonly(),
184
+ disabled: signal(false).asReadonly(),
185
+ select: (value) => this.#tabs.select(value),
186
+ };
187
+
188
+ protected readonly listClass = computed(() =>
189
+ this.segmentedControl.enabled()
190
+ ? // A segmented control fills the width it's given, as UIKit's does
191
+ // in a form, and its tabs share it.
192
+ cn(this.segmentedControl.trackClass(), this.userClass())
193
+ : cn(
194
+ // `self-start` keeps the tab bar hugging its triggers. The parent <ui-tabs>
195
+ // is a `flex flex-col` column, and in Lynx's linear/flex layout a child with
196
+ // no explicit width stretches to fill the parent's cross axis — so without
197
+ // it the muted pill spans the full width, leaving empty space around the
198
+ // tabs. Same lever as the badge fix: Lynx has no `inline-flex`, so
199
+ // align-self is how you opt a child into shrink-to-fit.
200
+ 'flex-row items-center self-start rounded-lg bg-muted p-1 flex',
201
+ this.userClass(),
202
+ ),
126
203
  );
127
204
  }
128
205
 
129
206
  @Component({
130
207
  selector: 'ui-tabs-trigger',
131
- standalone: true,
132
208
  imports: [LYNX_ELEMENTS],
133
209
  encapsulation: ViewEncapsulation.None,
210
+ host: {
211
+ '[class]': 'hostClass()',
212
+ // The segmented control places its indicator from each tab's width.
213
+ '(bindlayoutchange)': 'measure($event)',
214
+ },
215
+ // The label is the accessibility element, a button (a tab, in the list's
216
+ // tab bar) named by its own text. A view would need a label of its own.
217
+ // The selected tab is `selected` to a screen reader, which iOS reads:
218
+ // lynxAccessibilityTraits adds the trait to `button` on iOS, and leaves
219
+ // Android, which reads one trait, with `button`.
134
220
  template: `
135
- <view #trigger [class]="triggerClass()" (bindtap)="select()">
136
- <text [class]="textClass()"><ng-content /></text>
221
+ <view
222
+ #trigger
223
+ [class]="triggerClass()"
224
+ [style]="triggerStyle()"
225
+ (bindtap)="onTap()"
226
+ (bindtouchstart)="press($event)"
227
+ (bindtouchmove)="move($event)"
228
+ (bindtouchend)="release()"
229
+ (bindtouchcancel)="cancel()"
230
+ (bindmousedown)="press($event)"
231
+ (bindmousemove)="move($event)"
232
+ (bindmouseup)="release()"
233
+ (bindmouseleave)="leave()"
234
+ >
235
+ @if (stateLayerClass(); as stateLayer) {
236
+ <view [class]="stateLayer"></view>
237
+ }
238
+ <text
239
+ accessibility-traits="button"
240
+ [lynxAccessibilityTraits]="{ selected: isActive() }"
241
+ [class]="textClass()"
242
+ ><ng-content
243
+ /></text>
137
244
  </view>
138
245
  `,
139
246
  })
140
247
  export class UiTabsTrigger {
141
248
  readonly #tabs = inject(UiTabs);
249
+ readonly #settings = inject(LynxAccessibilitySettings);
250
+ // Both absent for a trigger outside a <ui-tabs-list>.
251
+ readonly #list = inject(UiTabsList, { optional: true });
252
+ readonly #segmented = inject(UiSegmentedControl, { optional: true });
142
253
 
143
254
  readonly triggerValue = input.required<string>({ alias: 'value' });
144
255
  readonly userClass = input<string>('', { alias: 'class' });
145
256
 
146
- readonly triggerRef = viewChild<ElementRef>('trigger');
257
+ readonly triggerRef = viewChild.required<ElementRef>('trigger');
147
258
  #anim?: AnimationHandle;
148
259
  #previousActive?: boolean;
149
260
 
@@ -151,60 +262,99 @@ export class UiTabsTrigger {
151
262
  () => this.#tabs.value() === this.triggerValue(),
152
263
  );
153
264
 
154
- constructor() {
155
- // Register this trigger with the parent for direction detection
156
- effect(() => {
157
- this.#tabs.registerTab(this.triggerValue());
158
- });
159
-
160
- // Animate trigger background on selection change
161
- effect(() => {
162
- const active = this.isActive();
163
- const el = this.triggerRef()?.nativeElement;
164
- if (!el || this.#previousActive === undefined) {
165
- this.#previousActive = active;
166
- return;
167
- }
168
- // Defensive: `isActive` computed dedupes same-value emissions so the
169
- // effect never re-runs with an unchanged `active`. Kept for safety.
170
- /* v8 ignore next 1 */
171
- if (active === this.#previousActive) return;
172
- this.#previousActive = active;
265
+ /**
266
+ * The segmented control this trigger is a segment of, or null when its
267
+ * list is the default tabs (or it has no list).
268
+ */
269
+ readonly #segment = computed(() =>
270
+ this.#segmented?.enabled() ? this.#segmented : null,
271
+ );
173
272
 
174
- this.#anim?.cancel();
273
+ /**
274
+ * A segment's host is the track's flex item, an equal share of it. Angular
275
+ * puts the app's static `class` on the host as well as on the trigger, so
276
+ * the two are merged here: `class="flex-[2]"` makes a wider tab.
277
+ */
278
+ protected readonly hostClass = computed(() => {
279
+ const segment = this.#segment();
280
+ return segment ? cn(segment.segmentHostClass, this.userClass()) : '';
281
+ });
175
282
 
176
- if (active) {
177
- // Activation: spring-based scale + opacity for a lively entrance
178
- this.#anim = el.animate(
179
- [
180
- { transform: 'scaleX(0.95)', opacity: 0.7 },
181
- { transform: 'scaleX(1)', opacity: 1 },
182
- ],
183
- {
184
- duration: DURATION.normal,
185
- easing: EASING.springSubtle,
186
- fill: 'forwards',
187
- },
188
- );
189
- } else {
190
- // Deactivation: quick shrink + fade for a graceful exit
191
- this.#anim = el.animate(
192
- [
193
- { transform: 'scaleX(1)', opacity: 1 },
194
- { transform: 'scaleX(0.97)', opacity: 0.7 },
195
- ],
196
- {
197
- duration: DURATION.fast,
198
- easing: EASING.accelerate,
199
- fill: 'forwards',
200
- },
201
- );
202
- }
203
- });
204
- }
283
+ protected readonly triggerStyle = computed(
284
+ () => this.#segment()?.segmentStyle(this.triggerValue()) ?? null,
285
+ );
286
+
287
+ protected readonly stateLayerClass = computed(
288
+ () => this.#segment()?.stateLayerClass(this.triggerValue()) ?? null,
289
+ );
290
+
291
+ /** Registers this trigger with the parent for direction detection. */
292
+ protected readonly registering = effect(() => {
293
+ this.#tabs.registerTab(this.triggerValue());
294
+ });
295
+
296
+ /** Animates the trigger's background when it's selected or deselected. */
297
+ protected readonly animatingSelection = effect(() => {
298
+ const active = this.isActive();
299
+ const el = this.triggerRef().nativeElement;
300
+ const wasActive = this.#previousActive;
301
+ this.#previousActive = active;
302
+ // The first run only records the starting state, and only a change has
303
+ // anything to animate. (`isActive` is a computed, so the effect only
304
+ // re-runs once it changes; the second check is a safeguard.) A segment
305
+ // doesn't animate itself: the segmented control's indicator moves. Under
306
+ // Reduce Motion the trigger changes at once.
307
+ if (
308
+ wasActive === undefined ||
309
+ wasActive === active ||
310
+ this.#segment() ||
311
+ this.#settings.reduceMotion()
312
+ ) {
313
+ return;
314
+ }
315
+
316
+ this.#anim?.cancel();
205
317
 
206
- protected readonly triggerClass = computed(() =>
207
- cn(
318
+ if (active) {
319
+ // Activation: spring-based scale + opacity for a lively entrance
320
+ this.#anim = el.animate(
321
+ [
322
+ { transform: 'scaleX(0.95)', opacity: 0.7 },
323
+ { transform: 'scaleX(1)', opacity: 1 },
324
+ ],
325
+ {
326
+ duration: DURATION.normal,
327
+ easing: EASING.springSubtle,
328
+ fill: 'forwards',
329
+ },
330
+ );
331
+ } else {
332
+ // Deactivation: quick shrink + fade for a graceful exit
333
+ this.#anim = el.animate(
334
+ [
335
+ { transform: 'scaleX(1)', opacity: 1 },
336
+ { transform: 'scaleX(0.97)', opacity: 0.7 },
337
+ ],
338
+ {
339
+ duration: DURATION.fast,
340
+ easing: EASING.accelerate,
341
+ fill: 'forwards',
342
+ },
343
+ );
344
+ }
345
+ });
346
+
347
+ protected readonly triggerClass = computed(() => {
348
+ const segment = this.#segment();
349
+ if (segment) {
350
+ // The list provides the segmented control, so a segment is in one.
351
+ const place = segmentPlace(this.#list!.triggers(), this);
352
+ return cn(
353
+ segment.segmentClass(this.triggerValue(), place),
354
+ this.userClass(),
355
+ );
356
+ }
357
+ return cn(
208
358
  // No `flex-1` here: now that the list shrinks to fit its content (see
209
359
  // `self-start` in UiTabsList), stretching triggers to equal width is
210
360
  // pointless, and `flex-basis: 0` growth combined with Lynx treating
@@ -214,24 +364,62 @@ export class UiTabsTrigger {
214
364
  'items-center rounded-sm px-3 py-1.5 flex justify-center',
215
365
  this.isActive() ? 'bg-background' : 'bg-transparent',
216
366
  this.userClass(),
217
- ),
218
- );
367
+ );
368
+ });
219
369
 
220
- protected readonly textClass = computed(() =>
221
- cn(
222
- 'text-sm font-medium',
223
- this.isActive() ? 'text-foreground' : 'text-muted-foreground',
224
- ),
370
+ protected readonly textClass = computed(
371
+ () =>
372
+ this.#segment()?.labelClass(this.triggerValue()) ??
373
+ cn(
374
+ 'text-sm font-medium',
375
+ this.isActive() ? 'text-foreground' : 'text-muted-foreground',
376
+ ),
225
377
  );
226
378
 
227
379
  select(): void {
228
380
  this.#tabs.select(this.triggerValue());
229
381
  }
382
+
383
+ protected measure(event: unknown): void {
384
+ this.#segmented?.measure(this.triggerValue(), event);
385
+ }
386
+
387
+ /**
388
+ * A press. A segment's touches go to the segmented control, which draws
389
+ * their feedback and moves its indicator.
390
+ */
391
+ protected press(event: unknown): void {
392
+ this.#segment()?.press(this.triggerValue(), event);
393
+ }
394
+
395
+ protected move(event: unknown): void {
396
+ this.#segment()?.move(event);
397
+ }
398
+
399
+ protected release(): void {
400
+ this.#segment()?.release();
401
+ }
402
+
403
+ protected cancel(): void {
404
+ this.#segment()?.cancel();
405
+ }
406
+
407
+ protected leave(): void {
408
+ this.#segment()?.leave(this.triggerValue());
409
+ }
410
+
411
+ /**
412
+ * A tap that ends a drag of the indicator doesn't select this tab: the
413
+ * drag chose already.
414
+ */
415
+ protected onTap(): void {
416
+ if (this.#segment()?.tap(this.triggerValue()) === false) return;
417
+ this.select();
418
+ }
230
419
  }
231
420
 
232
421
  @Component({
233
422
  selector: 'ui-tabs-content',
234
- standalone: true,
235
423
  imports: [LYNX_ELEMENTS],
236
424
  encapsulation: ViewEncapsulation.None,
237
425
  host: {
@@ -250,6 +438,10 @@ export class UiTabsTrigger {
250
438
  // makes the host lay its content out vertically when shown.
251
439
  '[style.display]': "isActive() ? 'flex' : 'none'",
252
440
  '[style.flexDirection]': "'column'",
441
+ // A hidden panel's 0×0 doesn't stop iOS drawing its text: each line is
442
+ // laid out at 0×0 too, and its glyphs spill out from the panel's corner,
443
+ // over the tab list. Clipping the hidden host to its 0×0 hides them.
444
+ '[style.overflow]': "isActive() ? null : 'hidden'",
253
445
  },
254
446
  template: `
255
447
  <view #content [class]="contentClass()">
@@ -259,11 +451,12 @@ export class UiTabsTrigger {
259
451
  })
260
452
  export class UiTabsContent {
261
453
  readonly #tabs = inject(UiTabs);
454
+ readonly #settings = inject(LynxAccessibilitySettings);
262
455
 
263
456
  readonly contentValue = input.required<string>({ alias: 'value' });
264
457
  readonly userClass = input<string>('', { alias: 'class' });
265
458
 
266
- readonly contentRef = viewChild<ElementRef>('content');
459
+ readonly contentRef = viewChild.required<ElementRef>('content');
267
460
  #anim?: AnimationHandle;
268
461
  #isFirstRender = true;
269
462
 
@@ -271,43 +464,45 @@ export class UiTabsContent {
271
464
  () => this.#tabs.value() === this.contentValue(),
272
465
  );
273
466
 
274
- constructor() {
275
- // Animate content reveal when tab becomes active.
276
- //
277
- // The panel is ALWAYS rendered and shown/hidden via the host's `display`
278
- // (see the `host` block) rather than wrapped in `@if`. On Lynx, destroying a
279
- // subtree that holds projected `<ng-content>` sends that content through the
280
- // renderer's "park on the page root" path (it must keep still-alive
281
- // projected nodes from dying), which drops inactive panels at the bottom of
282
- // the page and made re-showing them unreliable — the symptom was tab content
283
- // that vanished and never came back. Keeping every panel mounted and only
284
- // toggling `display` never destroys or re-parents anything: inactive panels
285
- // collapse to 0×0 (Lynx `display: none`) so they take no space, and the
286
- // active one simply reappears in place.
287
- effect(() => {
288
- if (!this.isActive()) return;
289
- // Defer one tick so the display flip to 'flex' has applied before we animate.
290
- setTimeout(() => {
291
- const el = this.contentRef()?.nativeElement;
292
- if (!el) return;
293
-
294
- this.#anim?.cancel();
295
-
296
- if (this.#isFirstRender) {
297
- // First render: subtle vertical reveal (no directional context yet)
298
- this.#isFirstRender = false;
299
- this.#anim = revealIn(el, { fromY: 6, duration: DURATION.normal });
300
- } else {
301
- // Subsequent renders: direction-aware horizontal slide
302
- const dir = this.#tabs.direction();
303
- this.#anim = directionalSlideIn(el, dir, {
304
- distance: 24,
305
- duration: 300,
306
- });
307
- }
308
- }, 0);
309
- });
310
- }
467
+ /**
468
+ * Animates the content's reveal when its tab becomes active.
469
+ *
470
+ * The panel is ALWAYS rendered and shown/hidden via the host's `display`
471
+ * (see the `host` block) rather than wrapped in `@if`. On Lynx, destroying a
472
+ * subtree that holds projected `<ng-content>` sends that content through the
473
+ * renderer's "park on the page root" path (it must keep still-alive
474
+ * projected nodes from dying), which drops inactive panels at the bottom of
475
+ * the page and made re-showing them unreliable — the symptom was tab content
476
+ * that vanished and never came back. Keeping every panel mounted and only
477
+ * toggling `display` never destroys or re-parents anything: inactive panels
478
+ * collapse to 0×0 (Lynx `display: none`) so they take no space, and the
479
+ * active one simply reappears in place. That also means the content view
480
+ * always exists, so `contentRef` is required.
481
+ */
482
+ protected readonly revealing = effect(() => {
483
+ if (!this.isActive()) return;
484
+ // Defer one tick so the display flip to 'flex' has applied before we animate.
485
+ setTimeout(() => {
486
+ // Under Reduce Motion the panel just appears.
487
+ if (this.#settings.reduceMotion()) return;
488
+ const el = this.contentRef().nativeElement;
489
+
490
+ this.#anim?.cancel();
491
+
492
+ if (this.#isFirstRender) {
493
+ // First render: subtle vertical reveal (no directional context yet)
494
+ this.#isFirstRender = false;
495
+ this.#anim = revealIn(el, { fromY: 6, duration: DURATION.normal });
496
+ } else {
497
+ // Subsequent renders: direction-aware horizontal slide
498
+ const dir = this.#tabs.direction();
499
+ this.#anim = directionalSlideIn(el, dir, {
500
+ distance: 24,
501
+ duration: 300,
502
+ });
503
+ }
504
+ }, 0);
505
+ });
311
506
 
312
507
  // Visibility is controlled at the host (see `host` block); the inner view is
313
508
  // always a normal flex column that fills the shown host.