@blotch/dolan 22.2.4 → 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 (124) hide show
  1. package/dist/commands/add.test.js +2 -0
  2. package/dist/commands/add.test.js.map +1 -1
  3. package/dist/commands/doctor.js +78 -21
  4. package/dist/commands/doctor.js.map +1 -1
  5. package/dist/commands/doctor.test.js +167 -26
  6. package/dist/commands/doctor.test.js.map +1 -1
  7. package/dist/commands/init.test.js +4 -7
  8. package/dist/commands/init.test.js.map +1 -1
  9. package/dist/commands/update.d.ts +3 -1
  10. package/dist/commands/update.js +78 -15
  11. package/dist/commands/update.js.map +1 -1
  12. package/dist/commands/update.test.js +257 -2
  13. package/dist/commands/update.test.js.map +1 -1
  14. package/dist/lockfile.d.ts +10 -0
  15. package/dist/lockfile.js +20 -1
  16. package/dist/lockfile.js.map +1 -1
  17. package/dist/lockfile.test.js +43 -3
  18. package/dist/lockfile.test.js.map +1 -1
  19. package/dist/registry.js +73 -11
  20. package/dist/registry.js.map +1 -1
  21. package/dist/registry.test.js +16 -3
  22. package/dist/registry.test.js.map +1 -1
  23. package/dist/test-utils.d.ts +24 -2
  24. package/dist/test-utils.js +41 -2
  25. package/dist/test-utils.js.map +1 -1
  26. package/dist/ui/boolean-inputs.test.ts +76 -0
  27. package/dist/ui/components/action-sheet/action-sheet.test.ts +370 -1
  28. package/dist/ui/components/action-sheet/action-sheet.ts +306 -28
  29. package/dist/ui/components/action-sheet/index.ts +1 -0
  30. package/dist/ui/components/alert-dialog/alert-dialog.test.ts +552 -8
  31. package/dist/ui/components/alert-dialog/alert-dialog.ts +644 -36
  32. package/dist/ui/components/alert-dialog/index.ts +5 -0
  33. package/dist/ui/components/area-chart/area-chart.ts +3 -2
  34. package/dist/ui/components/badge/badge.ts +2 -1
  35. package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +968 -3
  36. package/dist/ui/components/bottom-sheet/bottom-sheet.ts +1021 -95
  37. package/dist/ui/components/bottom-sheet/index.ts +7 -1
  38. package/dist/ui/components/button/button.test.ts +36 -1
  39. package/dist/ui/components/button/button.ts +17 -7
  40. package/dist/ui/components/button-group/button-group.test.ts +53 -9
  41. package/dist/ui/components/button-group/button-group.ts +67 -21
  42. package/dist/ui/components/card/card.test.ts +16 -0
  43. package/dist/ui/components/card/card.ts +2 -1
  44. package/dist/ui/components/chart/chart-presets.ts +6 -6
  45. package/dist/ui/components/chart/chart.test.ts +14 -0
  46. package/dist/ui/components/chart/chart.ts +10 -3
  47. package/dist/ui/components/checkbox/checkbox.ts +2 -1
  48. package/dist/ui/components/collapsible/collapsible.ts +2 -1
  49. package/dist/ui/components/dialog/dialog.ts +6 -6
  50. package/dist/ui/components/dock/dock-scroll.ts +9 -0
  51. package/dist/ui/components/dock/dock-search.test.ts +594 -0
  52. package/dist/ui/components/dock/dock.test.ts +4 -1
  53. package/dist/ui/components/dock/dock.ts +529 -51
  54. package/dist/ui/components/dynamic-color/dynamic-color.test.ts +144 -0
  55. package/dist/ui/components/dynamic-color/dynamic-color.ts +146 -0
  56. package/dist/ui/components/dynamic-color/index.ts +5 -0
  57. package/dist/ui/components/glass/glass.test.ts +39 -1
  58. package/dist/ui/components/glass/glass.ts +87 -17
  59. package/dist/ui/components/glass/index.ts +1 -0
  60. package/dist/ui/components/icon/icon-theme.test.ts +28 -0
  61. package/dist/ui/components/icon/icon-theme.ts +38 -7
  62. package/dist/ui/components/input/input.ts +2 -1
  63. package/dist/ui/components/label/label.ts +8 -2
  64. package/dist/ui/components/line-chart/line-chart.ts +3 -2
  65. package/dist/ui/components/list/list.ts +5 -4
  66. package/dist/ui/components/menu/menu-popup.test.ts +18 -14
  67. package/dist/ui/components/menu/menu-popup.ts +24 -3
  68. package/dist/ui/components/menu/menu.test.ts +99 -5
  69. package/dist/ui/components/menu/menu.ts +74 -34
  70. package/dist/ui/components/nav-drawer/nav-drawer.ts +3 -2
  71. package/dist/ui/components/navigation-bar/index.ts +1 -0
  72. package/dist/ui/components/navigation-bar/navigation-bar.test.ts +425 -0
  73. package/dist/ui/components/navigation-bar/navigation-bar.ts +411 -0
  74. package/dist/ui/components/popover/index.ts +9 -0
  75. package/dist/ui/components/popover/popover-panel.test.ts +735 -0
  76. package/dist/ui/components/popover/popover-panel.ts +603 -0
  77. package/dist/ui/components/popover/popover.test.ts +286 -0
  78. package/dist/ui/components/popover/popover.ts +207 -0
  79. package/dist/ui/components/progress/progress.ts +2 -1
  80. package/dist/ui/components/radio-group/radio-group.ts +2 -1
  81. package/dist/ui/components/scroll-area/scroll-area.ts +4 -3
  82. package/dist/ui/components/search-field/index.ts +15 -0
  83. package/dist/ui/components/search-field/keyboard.test.ts +147 -0
  84. package/dist/ui/components/search-field/keyboard.ts +123 -0
  85. package/dist/ui/components/search-field/search-field.test.ts +549 -0
  86. package/dist/ui/components/search-field/search-field.ts +628 -0
  87. package/dist/ui/components/segmented-control/index.ts +8 -0
  88. package/dist/ui/components/segmented-control/segmented-control.test.ts +861 -0
  89. package/dist/ui/components/segmented-control/segmented-control.ts +884 -0
  90. package/dist/ui/components/select/select.ts +2 -1
  91. package/dist/ui/components/sheet/sheet.test.ts +90 -3
  92. package/dist/ui/components/sheet/sheet.ts +59 -4
  93. package/dist/ui/components/slider/index.ts +1 -0
  94. package/dist/ui/components/slider/slider.test.ts +695 -0
  95. package/dist/ui/components/slider/slider.ts +901 -0
  96. package/dist/ui/components/switch/index.ts +1 -1
  97. package/dist/ui/components/switch/switch.test.ts +464 -48
  98. package/dist/ui/components/switch/switch.ts +613 -43
  99. package/dist/ui/components/tabs/index.ts +7 -1
  100. package/dist/ui/components/tabs/tabs.test.ts +278 -2
  101. package/dist/ui/components/tabs/tabs.ts +233 -30
  102. package/dist/ui/components/textarea/textarea.ts +2 -1
  103. package/dist/ui/components/toggle/index.ts +1 -0
  104. package/dist/ui/components/toggle/toggle.test.ts +310 -4
  105. package/dist/ui/components/toggle/toggle.ts +241 -26
  106. package/dist/ui/components/toolbar/bar-scroll.test.ts +310 -0
  107. package/dist/ui/components/toolbar/bar-scroll.ts +372 -0
  108. package/dist/ui/components/toolbar/index.ts +12 -0
  109. package/dist/ui/components/toolbar/toolbar.test.ts +438 -0
  110. package/dist/ui/components/toolbar/toolbar.ts +535 -0
  111. package/dist/utils/analyze.d.ts +10 -5
  112. package/dist/utils/analyze.js +3 -2
  113. package/dist/utils/analyze.js.map +1 -1
  114. package/dist/utils/installed.d.ts +13 -1
  115. package/dist/utils/installed.js +17 -0
  116. package/dist/utils/installed.js.map +1 -1
  117. package/dist/utils/installed.test.js +39 -1
  118. package/dist/utils/installed.test.js.map +1 -1
  119. package/dist/utils/tailwind.d.ts +25 -5
  120. package/dist/utils/tailwind.js +53 -14
  121. package/dist/utils/tailwind.js.map +1 -1
  122. package/dist/utils/tailwind.test.js +85 -22
  123. package/dist/utils/tailwind.test.js.map +1 -1
  124. package/package.json +1 -1
@@ -0,0 +1,628 @@
1
+ import {
2
+ Component,
3
+ ElementRef,
4
+ booleanAttribute,
5
+ InjectionToken,
6
+ Injector,
7
+ Renderer2,
8
+ type Signal,
9
+ ViewEncapsulation,
10
+ computed,
11
+ inject,
12
+ input,
13
+ model,
14
+ output,
15
+ signal,
16
+ viewChild,
17
+ } from '@angular/core';
18
+ import { NgTemplateOutlet } from '@angular/common';
19
+ import { type FormValueControl } from '@angular/forms/signals';
20
+ import { LYNX_ELEMENTS, LynxAccessibilitySettings } from '@blotch/angular-lynx';
21
+
22
+ import { DURATION, EASING } from '../../utils/animate';
23
+ import { cn } from '../../utils/cn';
24
+ import { UiGlass, UiGlassGroup } from '../glass/glass';
25
+ import { type GlassDesign, injectGlassDesign } from '../glass/glass-design';
26
+ import { injectIconColor } from '../icon/icon-theme';
27
+ import { type IconName, ICONS } from '../icon/icons';
28
+ import {
29
+ KEYBOARD_GAP,
30
+ type KeyboardHeightEvent,
31
+ keyboardHeightOf,
32
+ keyboardLift,
33
+ keyboardScreen,
34
+ measureForKeyboard,
35
+ } from './keyboard';
36
+
37
+ /**
38
+ * A bar that draws a search field's shape itself and holds the field in it,
39
+ * as the dock holds its search field in its glass. It provides this so the
40
+ * field draws only its content: glass inside the bar's glass would be glass
41
+ * on glass.
42
+ */
43
+ export type SearchFieldContainer = {
44
+ /**
45
+ * Whether the bar is searching. While it is, Material's field shows its back
46
+ * arrow, the way out of the search, even when it isn't `cancelable`.
47
+ */
48
+ readonly searching: Signal<boolean>;
49
+ /** Called after the field's cancel (or back arrow) clears and blurs it. */
50
+ searchCanceled(): void;
51
+ };
52
+
53
+ export const UI_SEARCH_FIELD_CONTAINER =
54
+ new InjectionToken<SearchFieldContainer>('UI_SEARCH_FIELD_CONTAINER');
55
+
56
+ /** A search field's sizes in one design, in px. */
57
+ type SearchFieldLook = {
58
+ /** The field's height. */
59
+ readonly height: number;
60
+ /** The space between the field's edge and its icons. */
61
+ readonly inset: number;
62
+ /** The space between the icons and the text. */
63
+ readonly gap: number;
64
+ readonly icon: number;
65
+ readonly fontSize: number;
66
+ };
67
+
68
+ const LOOKS: Readonly<Record<GlassDesign, SearchFieldLook>> = {
69
+ // iOS 26's search field at the bottom of the screen: a 48pt glass capsule,
70
+ // as tall as the minimized tab bar beside it.
71
+ glass: { height: 48, inset: 14, gap: 8, icon: 20, fontSize: 17 },
72
+ // UISearchBar's field before iOS 26: a 36pt rounded rectangle.
73
+ frosted: { height: 36, inset: 8, gap: 6, icon: 18, fontSize: 17 },
74
+ // Material 3's search bar: 56dp, a 24dp leading icon 16dp in, body large.
75
+ material: { height: 56, inset: 16, gap: 16, icon: 24, fontSize: 16 },
76
+ };
77
+
78
+ /** The corner radius of UISearchBar's field before iOS 26. */
79
+ const FROSTED_RADIUS = 10;
80
+
81
+ /**
82
+ * The space between the field and its cancel button: a little more than the
83
+ * glass group's spacing, so the two are apart at rest and merge as the
84
+ * button grows out of the field.
85
+ */
86
+ const CANCEL_GAP = 10;
87
+ const GLASS_SPACING = 8;
88
+
89
+ /**
90
+ * As wide as the Cancel button before iOS 26 can be, in px: it grows from 0
91
+ * to its label's width by its max-width, as its label's width isn't known.
92
+ */
93
+ const FROSTED_CANCEL_MAX_WIDTH = 120;
94
+
95
+ /** How the field rises above the keyboard and settles back. */
96
+ const LIFT_TRANSITION = `transform ${DURATION.normal}ms ${EASING.sheet}`;
97
+
98
+ /** The cancel button's growth beside the field, and its content's fade. */
99
+ const GROW_TRANSITION = ['width', 'max-width', 'margin-left']
100
+ .map((property) => `${property} ${DURATION.normal}ms ${EASING.sheet}`)
101
+ .join(', ');
102
+ const FADE_TRANSITION = `opacity ${DURATION.fast}ms ${EASING.standard}`;
103
+
104
+ /** What Lynx sends with an `<input>`'s `input` and `confirm` events. */
105
+ type InputEvent = { detail: { value: string } };
106
+
107
+ /**
108
+ * An icon's markup, drawn in `color`.
109
+ */
110
+ const iconSvg = (icon: IconName, color: string): string =>
111
+ // split/join, not replaceAll(), which PrimJS's ES2019 runtime lacks.
112
+ ICONS[icon].split('currentColor').join(color);
113
+
114
+ /**
115
+ * A search field: a magnifier, the text, and a button that clears it.
116
+ *
117
+ * ```html
118
+ * <ui-search-field [(value)]="query" (submitted)="search($event)" />
119
+ * <ui-search-field [(value)]="query" placeholder="Songs and artists" cancelable />
120
+ * ```
121
+ *
122
+ * The keyboard's return key reads Search and emits `submitted`. With
123
+ * `cancelable`, a Cancel button shows while the field is in use, which clears
124
+ * it, closes the keyboard and emits `canceled`. The value is a `model()`, so
125
+ * `[(value)]` and Angular's Signal Forms (`[formField]`) both work.
126
+ *
127
+ * Its form follows the platform (see GlassDesign):
128
+ *
129
+ * - `'glass'` (iOS 26 and later, the web, Android with the glass opt-in): a
130
+ * Liquid Glass capsule, for a field that floats over the content. Its cancel
131
+ * is a glass circle that grows out of it, as on iOS 26.
132
+ * - `'frosted'` (iOS 15 to 25): UISearchBar's field, a rounded rectangle on a
133
+ * gray fill, with a Cancel button that slides in beside it.
134
+ * - `'material'` (Android): Material 3's search bar, a 56dp capsule on a tonal
135
+ * surface, whose search icon turns into a back arrow that cancels.
136
+ *
137
+ * A field the keyboard would cover rises above it while it's focused. Put it
138
+ * in a `<ui-dock>` to search from the bottom of the screen, as iOS 26 does.
139
+ */
140
+ @Component({
141
+ selector: 'ui-search-field',
142
+ imports: [LYNX_ELEMENTS, NgTemplateOutlet, UiGlass, UiGlassGroup],
143
+ encapsulation: ViewEncapsulation.None,
144
+ host: { '[class]': 'hostClass()' },
145
+ template: `
146
+ <ng-template #field>
147
+ @if (showsBack()) {
148
+ <!-- Material's search icon turns into a back arrow, which cancels. -->
149
+ <view
150
+ class="shrink-0 items-center flex justify-center"
151
+ accessibility-element="true"
152
+ accessibility-traits="button"
153
+ [attr.accessibility-label]="cancelLabel()"
154
+ [style]="iconButtonStyle()"
155
+ (bindtap)="cancel()"
156
+ >
157
+ <svg [attr.content]="backIcon()" [style]="iconStyle()" />
158
+ </view>
159
+ } @else {
160
+ <svg
161
+ class="shrink-0"
162
+ [attr.content]="searchIcon()"
163
+ [style]="iconStyle()"
164
+ />
165
+ }
166
+ <!-- [value], not [attr.value]: only the setValue UI method changes a
167
+ native input's text (see LynxInput). The placeholder goes while
168
+ there's text: iOS can draw both when the text is set from code. -->
169
+ <input
170
+ #input
171
+ confirm-type="search"
172
+ accessibility-element="true"
173
+ accessibility-traits="search"
174
+ [attr.accessibility-label]="placeholder()"
175
+ [attr.placeholder]="value() ? null : placeholder()"
176
+ [attr.placeholder-color]="placeholderColor()"
177
+ [attr.disabled]="disabled() || undefined"
178
+ [value]="value()"
179
+ [class]="inputClass()"
180
+ [style.font-size.px]="look().fontSize"
181
+ (bindinput)="onInput($any($event))"
182
+ (bindconfirm)="onConfirm($any($event))"
183
+ (bindfocus)="onFocus()"
184
+ (bindblur)="onBlur()"
185
+ (bindkeyboardheightchange)="onKeyboard($any($event))"
186
+ />
187
+ @if (value() && !disabled()) {
188
+ <view
189
+ class="shrink-0 items-center flex justify-center"
190
+ accessibility-element="true"
191
+ accessibility-traits="button"
192
+ [attr.accessibility-label]="clearLabel()"
193
+ [style]="iconButtonStyle()"
194
+ (bindtap)="clear()"
195
+ >
196
+ @if (design() === 'material') {
197
+ <svg [attr.content]="clearIcon()" [style]="iconStyle()" />
198
+ } @else {
199
+ <!-- UIKit's clear button: an x cut out of a filled circle. -->
200
+ <view [class]="clearCircleClass()">
201
+ <svg
202
+ [attr.content]="clearIcon()"
203
+ style="width: 10px; height: 10px;"
204
+ />
205
+ </view>
206
+ }
207
+ </view>
208
+ }
209
+ </ng-template>
210
+
211
+ <view [class]="frameClass()" [style]="frameStyle()">
212
+ @switch (form()) {
213
+ @case ('glass') {
214
+ <blur-view
215
+ ui-glass-group
216
+ class="flex-1 flex-row items-center flex"
217
+ [spacing]="glassSpacing"
218
+ >
219
+ <blur-view
220
+ ui-glass
221
+ interactive
222
+ [class]="shapeClass()"
223
+ [style]="shapeStyle()"
224
+ >
225
+ <ng-container [ngTemplateOutlet]="field" />
226
+ </blur-view>
227
+ @if (cancelable()) {
228
+ <!-- iOS 26's cancel: a glass circle that grows out of the
229
+ field, which shrinks to make room. -->
230
+ <blur-view
231
+ ui-glass
232
+ interactive
233
+ shape="circle"
234
+ class="items-center flex justify-center overflow-hidden"
235
+ accessibility-element="true"
236
+ accessibility-traits="button"
237
+ [attr.accessibility-label]="cancelLabel()"
238
+ [attr.accessibility-elements-hidden]="!cancelShown() || null"
239
+ [style]="glassCancelStyle()"
240
+ (bindtap)="cancel()"
241
+ >
242
+ <svg [attr.content]="closeIcon()" [style]="closeIconStyle()" />
243
+ </blur-view>
244
+ }
245
+ </blur-view>
246
+ }
247
+ @case ('filled') {
248
+ <view [class]="shapeClass()" [style]="shapeStyle()">
249
+ <ng-container [ngTemplateOutlet]="field" />
250
+ </view>
251
+ @if (cancelable() && design() === 'frosted') {
252
+ <!-- UISearchBar's Cancel, which slides in beside the field. -->
253
+ <view
254
+ class="flex-row items-center flex overflow-hidden"
255
+ accessibility-element="true"
256
+ accessibility-traits="button"
257
+ [attr.accessibility-label]="cancelLabel()"
258
+ [attr.accessibility-elements-hidden]="!cancelShown() || null"
259
+ [style]="frostedCancelStyle()"
260
+ (bindtap)="cancel()"
261
+ >
262
+ <text class="text-[17px] text-primary" [text-maxline]="1">{{
263
+ cancelLabel()
264
+ }}</text>
265
+ </view>
266
+ }
267
+ }
268
+ @default {
269
+ <ng-container [ngTemplateOutlet]="field" />
270
+ }
271
+ }
272
+ </view>
273
+ `,
274
+ })
275
+ export class UiSearchField implements FormValueControl<string> {
276
+ readonly value = model<string>('');
277
+ /** What the field searches, shown while it's empty. */
278
+ readonly placeholder = input('Search');
279
+ readonly disabled = input(false, { transform: booleanAttribute });
280
+ /**
281
+ * Shows a Cancel button while the field is in use (focused, or holding
282
+ * text): the glass circle with an x of iOS 26, UISearchBar's Cancel before
283
+ * it, Material's back arrow.
284
+ */
285
+ readonly cancelable = input(false, { transform: booleanAttribute });
286
+ /** The Cancel button's label, which a screen reader reads too. */
287
+ readonly cancelLabel = input('Cancel');
288
+ /** What a screen reader says for the button that clears the text. */
289
+ readonly clearLabel = input('Clear text');
290
+ readonly userClass = input<string>('', { alias: 'class' });
291
+
292
+ /** Emits the text when the keyboard's Search key is pressed. */
293
+ readonly submitted = output<string>();
294
+ /** Emits after Cancel clears the field and closes the keyboard. */
295
+ readonly canceled = output<void>();
296
+ readonly focused = output<void>();
297
+ readonly blurred = output<void>();
298
+
299
+ readonly #container = inject(UI_SEARCH_FIELD_CONTAINER, { optional: true });
300
+ readonly #settings = inject(LynxAccessibilitySettings);
301
+ readonly #resolveColor = injectIconColor();
302
+ readonly #injector = inject(Injector);
303
+ readonly #renderer = inject(Renderer2);
304
+ readonly #host = inject(ElementRef);
305
+ readonly inputRef = viewChild.required<ElementRef>('input');
306
+
307
+ /** The design on this platform (see GlassDesign). */
308
+ readonly design = injectGlassDesign();
309
+ protected readonly look = computed(() => LOOKS[this.design()]);
310
+ protected readonly glassSpacing = GLASS_SPACING;
311
+
312
+ readonly #hasFocus = signal(false);
313
+ /** Whether the field has the keyboard's focus. */
314
+ readonly hasFocus = this.#hasFocus.asReadonly();
315
+
316
+ readonly #keyboardHeight = signal(0);
317
+ /**
318
+ * The keyboard's height in px while the field has focus, and 0 otherwise:
319
+ * a bar holding the field (see SearchFieldContainer) moves itself above the
320
+ * keyboard by it.
321
+ */
322
+ readonly keyboardHeight = this.#keyboardHeight.asReadonly();
323
+
324
+ /** How far the field has risen to clear the keyboard, in px. */
325
+ readonly #lift = signal(0);
326
+ /** Each keyboard change's measuring, so a late answer can't win. */
327
+ #measuring = 0;
328
+
329
+ /** Whether the field is in use: focused, or holding text. */
330
+ readonly #inUse = computed(() => this.#hasFocus() || this.value() !== '');
331
+
332
+ /**
333
+ * Whether the Cancel button shows: while the field is in use, when it's
334
+ * `cancelable`. A bar that draws the field's Cancel itself reads it.
335
+ */
336
+ readonly cancelShown = computed(() => this.cancelable() && this.#inUse());
337
+
338
+ readonly #animate = computed(() => !this.#settings.reduceMotion());
339
+
340
+ /**
341
+ * How the field draws itself: `'glass'` for its own glass, `'filled'` for
342
+ * its own fill, or `'content'` for only its content, in a bar that draws its
343
+ * shape (see SearchFieldContainer). A bar draws no shape before iOS 26,
344
+ * which had no glass to hold it in, so the field keeps its own fill there.
345
+ */
346
+ protected readonly form = computed(() => {
347
+ const design = this.design();
348
+ if (this.#container && design !== 'frosted') return 'content';
349
+ return design === 'glass' ? 'glass' : 'filled';
350
+ });
351
+
352
+ /**
353
+ * Material's back arrow, in place of its search icon: while the field is in
354
+ * use and `cancelable`, and always while the bar holding it searches.
355
+ */
356
+ protected readonly showsBack = computed(
357
+ () =>
358
+ this.design() === 'material' &&
359
+ (this.cancelShown() || !!this.#container?.searching()),
360
+ );
361
+
362
+ /** In a bar, the field fills the space the bar gives it. */
363
+ protected readonly hostClass = computed(() =>
364
+ cn(
365
+ 'flex-row items-center flex',
366
+ this.#container && 'flex-1 self-stretch',
367
+ this.userClass(),
368
+ ),
369
+ );
370
+
371
+ protected readonly frameClass = computed(() =>
372
+ cn(
373
+ 'flex-1 flex-row items-center flex',
374
+ this.form() === 'content' && 'self-stretch',
375
+ ),
376
+ );
377
+
378
+ protected readonly shapeClass = computed(() =>
379
+ cn(
380
+ 'flex-1 flex-row items-center flex',
381
+ this.design() === 'glass' && 'overflow-hidden',
382
+ this.design() === 'frosted' ? 'bg-muted' : 'rounded-full',
383
+ this.design() === 'material' && 'bg-secondary',
384
+ ),
385
+ );
386
+
387
+ protected readonly shapeStyle = computed(() => {
388
+ const look = this.look();
389
+ return {
390
+ height: `${look.height}px`,
391
+ 'border-radius':
392
+ this.design() === 'frosted' ? `${FROSTED_RADIUS}px` : null,
393
+ 'padding-left': `${look.inset}px`,
394
+ 'padding-right': `${look.inset}px`,
395
+ 'column-gap': `${look.gap}px`,
396
+ };
397
+ });
398
+
399
+ /**
400
+ * The field rises inside its host to clear the keyboard, so the host stays
401
+ * where the layout put it, for measuring. In a bar's shape, the content
402
+ * takes the field's padding, so its icons sit as far in as on a field of
403
+ * its own.
404
+ */
405
+ protected readonly frameStyle = computed(() => {
406
+ const lift = this.#lift();
407
+ const inset = this.form() === 'content' ? `${this.look().inset}px` : null;
408
+ return {
409
+ 'padding-left': inset,
410
+ 'padding-right': inset,
411
+ 'column-gap': this.form() === 'content' ? `${this.look().gap}px` : null,
412
+ transform: lift ? `translateY(-${lift}px)` : null,
413
+ transition: this.#animate() ? LIFT_TRANSITION : 'none',
414
+ };
415
+ });
416
+
417
+ /**
418
+ * On glass, the text takes the glass's foreground, as `ui-glass` sets it.
419
+ * The text gives up its width first as the field shrinks for its Cancel,
420
+ * and the icons keep theirs: `w-0` with `flex-1` grows it from nothing.
421
+ * (Not `min-w-0`: Lynx for Web's `<x-input>` passes its width on to the
422
+ * input inside, but not its min-width.)
423
+ */
424
+ protected readonly inputClass = computed(() =>
425
+ cn(
426
+ 'h-full w-0 flex-1',
427
+ this.design() === 'glass' ? 'text-glass-foreground' : 'text-foreground',
428
+ this.disabled() && 'opacity-50',
429
+ ),
430
+ );
431
+
432
+ /**
433
+ * The colors of the icons and the placeholder, as theme tokens: on glass,
434
+ * the glass's muted foreground, full strength under Increase Contrast, as
435
+ * `ui-glass` makes `text-glass-muted-foreground`; elsewhere the muted
436
+ * foreground of the field's fill.
437
+ */
438
+ readonly #mutedColor = computed(() => {
439
+ if (this.design() !== 'glass') return 'var(--muted-foreground)';
440
+ return this.#settings.increaseContrast()
441
+ ? 'var(--glass-foreground)'
442
+ : 'var(--glass-muted-foreground)';
443
+ });
444
+
445
+ // The icons are drawn here, rather than by nested <ui-icon>s, so their color
446
+ // is resolved to a value and written into the markup, as an SVG can't read
447
+ // a text class or a CSS variable (see icon-theme.ts). So is the
448
+ // placeholder's: an attribute can't read a CSS variable either.
449
+ protected readonly placeholderColor = computed(() =>
450
+ this.#resolveColor(this.#mutedColor()),
451
+ );
452
+
453
+ protected readonly searchIcon = computed(() =>
454
+ iconSvg('search', this.#resolveColor(this.#mutedColor())),
455
+ );
456
+
457
+ protected readonly backIcon = computed(() =>
458
+ iconSvg('arrow-left', this.#resolveColor('var(--foreground)')),
459
+ );
460
+
461
+ /**
462
+ * The clear button's x: Material's in the muted foreground, and UIKit's cut
463
+ * out of its circle, in the color of what's behind.
464
+ */
465
+ protected readonly clearIcon = computed(() =>
466
+ iconSvg(
467
+ 'x',
468
+ this.#resolveColor(
469
+ this.design() === 'material'
470
+ ? 'var(--muted-foreground)'
471
+ : 'var(--background)',
472
+ ),
473
+ ),
474
+ );
475
+
476
+ protected readonly closeIcon = computed(() =>
477
+ iconSvg('x', this.#resolveColor('var(--glass-foreground)')),
478
+ );
479
+
480
+ protected readonly clearCircleClass = computed(() =>
481
+ cn(
482
+ 'h-[18px] w-[18px] items-center rounded-full opacity-70 flex justify-center',
483
+ this.design() === 'glass'
484
+ ? 'bg-glass-muted-foreground'
485
+ : 'bg-muted-foreground',
486
+ ),
487
+ );
488
+
489
+ protected readonly iconStyle = computed(() => ({
490
+ width: `${this.look().icon}px`,
491
+ height: `${this.look().icon}px`,
492
+ }));
493
+
494
+ /**
495
+ * A button around an icon, as tall as the field and a little wider than
496
+ * the icon, so a finger finds it. Its space before the text is the gap.
497
+ */
498
+ protected readonly iconButtonStyle = computed(() => ({
499
+ height: `${this.look().height}px`,
500
+ 'min-width': `${this.look().icon + this.look().gap}px`,
501
+ }));
502
+
503
+ /**
504
+ * The glass cancel button: a circle as tall as the field, which grows out
505
+ * of the field's end while the field is in use, and back into it after.
506
+ */
507
+ protected readonly glassCancelStyle = computed(() => {
508
+ const shown = this.cancelShown();
509
+ const size = this.look().height;
510
+ return {
511
+ width: `${shown ? size : 0}px`,
512
+ height: `${size}px`,
513
+ 'margin-left': `${shown ? CANCEL_GAP : 0}px`,
514
+ transition: this.#animate() ? GROW_TRANSITION : 'none',
515
+ };
516
+ });
517
+
518
+ protected readonly closeIconStyle = computed(() => ({
519
+ width: '20px',
520
+ height: '20px',
521
+ opacity: this.cancelShown() ? '1' : '0',
522
+ transition: this.#animate() ? FADE_TRANSITION : 'none',
523
+ }));
524
+
525
+ protected readonly frostedCancelStyle = computed(() => {
526
+ const shown = this.cancelShown();
527
+ return {
528
+ 'max-width': `${shown ? FROSTED_CANCEL_MAX_WIDTH : 0}px`,
529
+ 'margin-left': `${shown ? 8 : 0}px`,
530
+ height: `${this.look().height}px`,
531
+ transition: this.#animate() ? GROW_TRANSITION : 'none',
532
+ };
533
+ });
534
+
535
+ /**
536
+ * Gives the field the keyboard's focus, which shows the keyboard.
537
+ */
538
+ focus(): void {
539
+ this.inputRef().nativeElement.invoke('focus');
540
+ }
541
+
542
+ /**
543
+ * Takes the keyboard's focus from the field, which closes the keyboard.
544
+ */
545
+ blur(): void {
546
+ this.inputRef().nativeElement.invoke('blur');
547
+ }
548
+
549
+ /**
550
+ * Clears the text, keeping the field focused as UIKit's clear button does.
551
+ */
552
+ clear(): void {
553
+ this.value.set('');
554
+ }
555
+
556
+ /**
557
+ * What Cancel does: clears the text, closes the keyboard and emits
558
+ * `canceled`.
559
+ */
560
+ cancel(): void {
561
+ this.value.set('');
562
+ this.blur();
563
+ this.canceled.emit();
564
+ this.#container?.searchCanceled();
565
+ }
566
+
567
+ protected onFocus(): void {
568
+ this.#hasFocus.set(true);
569
+ this.focused.emit();
570
+ }
571
+
572
+ protected onBlur(): void {
573
+ this.#hasFocus.set(false);
574
+ this.#keyboardHeight.set(0);
575
+ this.#settle();
576
+ this.blurred.emit();
577
+ }
578
+
579
+ /**
580
+ * Lynx input events carry the text in `event.detail.value`, not on the
581
+ * event's target as on the web.
582
+ */
583
+ protected onInput(event: InputEvent): void {
584
+ if (this.disabled()) return;
585
+ this.value.set(event.detail.value);
586
+ }
587
+
588
+ protected onConfirm(event: InputEvent): void {
589
+ this.submitted.emit(event.detail.value);
590
+ }
591
+
592
+ protected onKeyboard(event: KeyboardHeightEvent): void {
593
+ const screen = keyboardScreen(this.#injector);
594
+ if (!screen) return;
595
+ const height = keyboardHeightOf(event, screen);
596
+ this.#keyboardHeight.set(height);
597
+ // In a bar, the bar moves itself and the field with it.
598
+ if (this.#container || height <= 0) {
599
+ this.#settle();
600
+ return;
601
+ }
602
+ // The host stays where the layout put it, so measuring it gives the
603
+ // same answer however far the field has risen.
604
+ const measuring = ++this.#measuring;
605
+ measureForKeyboard(this.#renderer, this.#host.nativeElement).then(
606
+ (measured) => {
607
+ if (measuring !== this.#measuring || !measured) return;
608
+ this.#lift.set(
609
+ keyboardLift(
610
+ measured.element,
611
+ measured.page,
612
+ height,
613
+ screen,
614
+ KEYBOARD_GAP,
615
+ ),
616
+ );
617
+ },
618
+ );
619
+ }
620
+
621
+ /**
622
+ * Brings a risen field back down, past any measuring still to answer.
623
+ */
624
+ #settle(): void {
625
+ this.#measuring++;
626
+ this.#lift.set(0);
627
+ }
628
+ }
@@ -0,0 +1,8 @@
1
+ export {
2
+ segmentPlace,
3
+ UI_SEGMENTED_HOST,
4
+ UiSegmentedControl,
5
+ UiSegmentedIndicator,
6
+ type SegmentedHost,
7
+ type SegmentPlace,
8
+ } from './segmented-control';