@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
@@ -2,11 +2,14 @@ import {
2
2
  Component,
3
3
  Directive,
4
4
  ElementRef,
5
+ Injector,
6
+ Renderer2,
5
7
  ViewEncapsulation,
6
8
  computed,
7
9
  contentChild,
8
10
  contentChildren,
9
11
  effect,
12
+ forwardRef,
10
13
  inject,
11
14
  input,
12
15
  output,
@@ -35,6 +38,17 @@ import { type GlassShape, UiGlass, UiGlassGroup } from '../glass/glass';
35
38
  import { type GlassDesign, injectGlassDesign } from '../glass/glass-design';
36
39
  import { injectIconColor, UI_ICON_COLOR } from '../icon/icon-theme';
37
40
  import { type IconName, ICONS } from '../icon/icons';
41
+ import {
42
+ KEYBOARD_GAP,
43
+ keyboardLift,
44
+ keyboardScreen,
45
+ measureForKeyboard,
46
+ } from '../search-field/keyboard';
47
+ import {
48
+ type SearchFieldContainer,
49
+ UI_SEARCH_FIELD_CONTAINER,
50
+ UiSearchField,
51
+ } from '../search-field/search-field';
38
52
  import { UiDockState } from './dock-scroll';
39
53
 
40
54
  /** The dock's sizes and shapes in one design. Sizes are in px. */
@@ -127,6 +141,15 @@ const MINIMIZED_SIZE = 48;
127
141
  */
128
142
  const MATERIAL_HIDDEN_OFFSET = 64;
129
143
 
144
+ /**
145
+ * The search field's heights in the dock, in px: iOS 26's, as tall as the
146
+ * minimized bar beside it; Material 3's search bar; and the bar before
147
+ * iOS 26 that holds UISearchBar's 36pt field, 8pt in from its edges.
148
+ */
149
+ const GLASS_SEARCH_SIZE = MINIMIZED_SIZE;
150
+ const MATERIAL_SEARCH_HEIGHT = 56;
151
+ const FROSTED_SEARCH_HEIGHT = 52;
152
+
130
153
  /**
131
154
  * Glass shapes this close merge, in px: just under the gap between them, so
132
155
  * the bar and the action stay apart at rest, and reach for each other as the
@@ -149,6 +172,18 @@ const FRAME_TRANSITION = [
149
172
  .map((property) => `${property} ${DURATION.slow + 50}ms ${EASING.sheet}`)
150
173
  .join(', ');
151
174
 
175
+ /**
176
+ * How the dock follows the keyboard up and back down. The keyboard's height
177
+ * arrives a little after it starts to move, so it's quicker than the frames.
178
+ */
179
+ const KEYBOARD_TRANSITION = `padding-bottom ${DURATION.normal}ms ${EASING.sheet}`;
180
+
181
+ /** The action's icon, fading as the action shrinks into the search field. */
182
+ const ICON_OUT_TRANSITION = `opacity ${DURATION.fast}ms ${EASING.accelerate}`;
183
+
184
+ /** The search field's content, fading in as its glass grows. */
185
+ const SEARCH_IN_TRANSITION = `opacity ${DURATION.normal}ms ${EASING.decelerate} ${DURATION.fast}ms`;
186
+
152
187
  /** The selection capsule's slide, with a slight overshoot, and its fade. */
153
188
  const CAPSULE_TRANSITION = `transform ${DURATION.slow}ms ${EASING.springSubtle}, opacity ${DURATION.normal}ms ${EASING.standard}`;
154
189
 
@@ -202,6 +237,11 @@ const iconSvg = (icon: IconName, color: string): string =>
202
237
  const itemType = () => UiDockItem;
203
238
  const actionType = () => UiDockAction;
204
239
  const accessoryType = () => UiDockAccessory;
240
+ const searchFieldType = () => UiSearchField;
241
+ /**
242
+ * UiDock, for its own providers, which run before the class is defined.
243
+ */
244
+ const dockType = () => UiDock;
205
245
 
206
246
  /**
207
247
  * Marks the content of the dock's accessory: a bar above the dock for
@@ -255,19 +295,50 @@ export class UiDockAccessory {}
255
295
  * group, and the accessory a capsule above. It minimizes as content
256
296
  * scrolls down (see UiDockScroll): the bar shrinks to the selected item's
257
297
  * icon, and the accessory moves down between it and the action.
258
- * - `'frosted'` (iOS 15 to 25): the tab bar of that time, edge to edge on a
259
- * blur material, with the action as its last button. It doesn't minimize.
298
+ * - `'frosted'` (iOS 15 to 25): the tab bar of that time, edge to edge on
299
+ * the bars' chrome material, with the action as its last button. It
300
+ * doesn't minimize.
260
301
  * - `'material'` (Android): a Material 3 Expressive floating toolbar, with an
261
302
  * active indicator behind the selected item's icon, and the action as a
262
303
  * floating action button. Minimizing slides the toolbar away and leaves
263
304
  * the button.
305
+ *
306
+ * With a `<ui-search-field>` in it as well, the action opens a search field
307
+ * at the bottom of the screen, as iOS 26 searches on iPhone:
308
+ *
309
+ * ```html
310
+ * <ui-dock>
311
+ * <ui-dock-item route="/home" icon="home" label="Home" />
312
+ * <ui-dock-action route="/search" icon="search" label="Search" />
313
+ * <ui-search-field [(value)]="query" (submitted)="search($event)" />
314
+ * </ui-dock>
315
+ * ```
316
+ *
317
+ * With a `route`, it's the search tab: the field is open while the route is
318
+ * active. Without one, pressing the action opens it and focuses it, and
319
+ * UiDockState's `searching` says whether it's open. On glass, the action
320
+ * grows into the field across the bottom while the bar shrinks to a button
321
+ * that goes back to the last item. On Material, the action grows into a
322
+ * search bar and the toolbar slides away; the field's back arrow returns.
323
+ * Before iOS 26, the field opens in a bar above the tab bar. The field rises
324
+ * with the dock above the keyboard.
264
325
  */
265
326
  @Component({
266
327
  selector: 'ui-dock',
267
328
  imports: [LYNX_ELEMENTS, UiGlass, UiGlassGroup],
268
329
  encapsulation: ViewEncapsulation.None,
269
- providers: [{ provide: UI_ICON_COLOR, useValue: ACCESSORY_ICON_COLOR }],
270
- host: { '[class]': 'hostClass()' },
330
+ providers: [
331
+ { provide: UI_ICON_COLOR, useValue: ACCESSORY_ICON_COLOR },
332
+ // The search field in the dock draws only its content, in the dock's
333
+ // glass, and tells the dock when it's canceled.
334
+ { provide: UI_SEARCH_FIELD_CONTAINER, useExisting: forwardRef(dockType) },
335
+ ],
336
+ host: {
337
+ '[class]': 'hostClass()',
338
+ // Above the keyboard while the search field has it.
339
+ '[style.padding-bottom]': 'keyboardPadding()',
340
+ '[style.transition]': 'keyboardTransition()',
341
+ },
271
342
  // The shapes are direct children of the glass group, so they merge on iOS
272
343
  // and the web. Each one keeps its element as the dock minimizes and
273
344
  // expands: its frame moves and resizes with a CSS transition, which iOS's
@@ -279,6 +350,7 @@ export class UiDockAccessory {}
279
350
  //
280
351
  // The dock measures its width with an empty view rather than on the
281
352
  // group: Lynx for Web sends no layoutchange for a blur-view.
353
+
282
354
  template: `
283
355
  <view class="h-0 w-full" (bindlayoutchange)="measure($event)"></view>
284
356
  <blur-view
@@ -291,6 +363,7 @@ export class UiDockAccessory {}
291
363
  <blur-view
292
364
  ui-glass
293
365
  interactive
366
+ material="chrome"
294
367
  class="flex-row flex absolute overflow-hidden"
295
368
  [shape]="look().accessoryShape"
296
369
  [radius]="accessoryRadius()"
@@ -302,6 +375,7 @@ export class UiDockAccessory {}
302
375
  <blur-view
303
376
  ui-glass
304
377
  interactive
378
+ material="chrome"
305
379
  [class]="barClass()"
306
380
  [shape]="look().barShape"
307
381
  [radius]="look().radius"
@@ -316,7 +390,7 @@ export class UiDockAccessory {}
316
390
  accessibility-traits="tabbar"
317
391
  [class]="rowClass()"
318
392
  [style]="rowStyle()"
319
- [attr.accessibility-elements-hidden]="minimized() || null"
393
+ [attr.accessibility-elements-hidden]="rowHidden() || null"
320
394
  >
321
395
  <view
322
396
  class="rounded-full bg-glass-foreground absolute bottom-0 left-0 top-0"
@@ -324,17 +398,18 @@ export class UiDockAccessory {}
324
398
  ></view>
325
399
  <ng-content />
326
400
  </view>
327
- @if (minimized()) {
401
+ @if (barCollapsed()) {
328
402
  <!-- Minimized, the bar is one button that brings the dock back,
329
- showing the selected item's icon. -->
403
+ showing the selected item's icon. While the glass dock
404
+ searches, it's the button that goes back to the last item. -->
330
405
  <view
331
406
  #restore
332
407
  class="items-center flex absolute bottom-0 left-0 right-0 top-0 justify-center"
333
408
  accessibility-element="true"
334
409
  accessibility-traits="button"
335
410
  [attr.accessibility-label]="expandLabel()"
336
- [attr.accessibility-value]="selectedItem()?.label()"
337
- (bindtap)="expand()"
411
+ [attr.accessibility-value]="restoreItem()?.label()"
412
+ (bindtap)="onRestore()"
338
413
  >
339
414
  <svg
340
415
  [attr.content]="minimizedIcon()"
@@ -347,11 +422,13 @@ export class UiDockAccessory {}
347
422
  <blur-view
348
423
  ui-glass
349
424
  interactive
425
+ material="chrome"
350
426
  [class]="actionClass()"
351
427
  [shape]="look().actionShape"
352
428
  [radius]="look().radius"
353
429
  [tint]="actionTint()"
354
430
  [style]="frames().action"
431
+ [attr.accessibility-elements-hidden]="actionHidden() || null"
355
432
  >
356
433
  @if (design() === 'frosted') {
357
434
  <view
@@ -360,11 +437,56 @@ export class UiDockAccessory {}
360
437
  }
361
438
  <ng-content select="ui-dock-action" />
362
439
  </blur-view>
440
+ @if (searchField(); as field) {
441
+ <!-- The search field's own shape, after the action so it draws
442
+ over it: it grows out of the action as the search opens, and
443
+ shrinks back into it as it closes. -->
444
+ <blur-view
445
+ ui-glass
446
+ interactive
447
+ material="chrome"
448
+ class="flex-row flex absolute overflow-hidden"
449
+ [shape]="searchShape()"
450
+ [radius]="0"
451
+ [style]="frames().search"
452
+ >
453
+ @if (design() === 'frosted') {
454
+ <view
455
+ class="h-[0.5px] bg-border absolute left-0 right-0 top-0"
456
+ ></view>
457
+ }
458
+ <view
459
+ [class]="searchContentClass()"
460
+ [style]="searchContentStyle()"
461
+ [attr.accessibility-elements-hidden]="!searching() || null"
462
+ >
463
+ <ng-content select="ui-search-field" />
464
+ </view>
465
+ </blur-view>
466
+ @if (design() === 'glass' && field.cancelable()) {
467
+ <!-- iOS 26's cancel beside the field: a glass circle that grows
468
+ out of the field's end while it's in use. -->
469
+ <blur-view
470
+ ui-glass
471
+ interactive
472
+ shape="circle"
473
+ class="items-center flex absolute justify-center overflow-hidden"
474
+ accessibility-element="true"
475
+ accessibility-traits="button"
476
+ [attr.accessibility-label]="field.cancelLabel()"
477
+ [attr.accessibility-elements-hidden]="!cancelShown() || null"
478
+ [style]="frames().cancel"
479
+ (bindtap)="field.cancel()"
480
+ >
481
+ <svg [attr.content]="cancelIcon()" [style]="cancelIconStyle()" />
482
+ </blur-view>
483
+ }
484
+ }
363
485
  }
364
486
  </blur-view>
365
487
  `,
366
488
  })
367
- export class UiDock {
489
+ export class UiDock implements SearchFieldContainer {
368
490
  /**
369
491
  * What a screen reader says for the minimized dock, a button that brings
370
492
  * it back. The selected item's label follows it.
@@ -375,6 +497,9 @@ export class UiDock {
375
497
  readonly #state = inject(UiDockState);
376
498
  readonly #settings = inject(LynxAccessibilitySettings);
377
499
  readonly #resolveColor = injectIconColor();
500
+ readonly #injector = inject(Injector);
501
+ readonly #renderer = inject(Renderer2);
502
+ readonly #host = inject(ElementRef);
378
503
 
379
504
  /** The design on this platform (see GlassDesign), which the items follow. */
380
505
  readonly design = injectGlassDesign();
@@ -385,7 +510,8 @@ export class UiDock {
385
510
  protected readonly items = contentChildren(itemType());
386
511
  protected readonly action = contentChild(actionType());
387
512
  protected readonly accessory = contentChild(accessoryType());
388
- protected readonly restore = viewChild<ElementRef>('restore');
513
+ protected readonly searchField = contentChild(searchFieldType());
514
+ protected readonly restoreButton = viewChild<ElementRef>('restore');
389
515
 
390
516
  /**
391
517
  * The dock's width, from Lynx's layout. The glass bar shrinks from it when
@@ -398,20 +524,93 @@ export class UiDock {
398
524
  this.items().find((item) => item.active()),
399
525
  );
400
526
 
527
+ /**
528
+ * The item selected last, which stays while the search's route is active
529
+ * and no item is: the glass bar's button goes back to it. Kept by an
530
+ * effect rather than a linkedSignal, which would only see the items
531
+ * selected while something read it.
532
+ */
533
+ readonly #lastItem = signal<UiDockItem | undefined>(undefined);
534
+ protected readonly rememberItem = effect(() => {
535
+ const item = this.selectedItem();
536
+ if (item) this.#lastItem.set(item);
537
+ });
538
+
539
+ /**
540
+ * Whether the search field is open: with a search field and an action in
541
+ * the dock, while the action's route is active, or while UiDockState says
542
+ * so for an action without one.
543
+ */
544
+ readonly searching = computed(() => {
545
+ const action = this.action();
546
+ return (
547
+ !!this.searchField() &&
548
+ !!action &&
549
+ (action.active() || this.#state.searching())
550
+ );
551
+ });
552
+
401
553
  /**
402
554
  * Whether the dock is minimized now: as UiDockState says, in the designs
403
- * that minimize, once the dock knows the width it shrinks from.
555
+ * that minimize, once the dock knows the width it shrinks from, and not
556
+ * while it searches.
404
557
  */
405
558
  readonly minimized = computed(
406
559
  () =>
407
560
  this.#state.minimized() &&
408
561
  this.design() !== 'frosted' &&
409
- this.width() > 0,
562
+ this.width() > 0 &&
563
+ !this.searching(),
564
+ );
565
+
566
+ /** The glass dock searching: its bar shrinks to a button beside the field. */
567
+ readonly #glassSearch = computed(
568
+ () => this.searching() && this.design() === 'glass' && this.width() > 0,
569
+ );
570
+
571
+ /** Whether the bar is one button: minimized, or beside the glass search. */
572
+ protected readonly barCollapsed = computed(
573
+ () => this.minimized() || this.#glassSearch(),
574
+ );
575
+
576
+ /**
577
+ * Whether the bar's items are out of sight: in a collapsed bar, or in
578
+ * Material's toolbar while it's slid away for the search bar.
579
+ */
580
+ protected readonly rowHidden = computed(
581
+ () =>
582
+ this.barCollapsed() || (this.searching() && this.design() === 'material'),
583
+ );
584
+
585
+ /**
586
+ * The glass and Material action is the field's while it searches: it
587
+ * shrinks into the field's end, and its icon fades out on the way.
588
+ */
589
+ readonly actionHidden = computed(
590
+ () => this.searching() && this.design() !== 'frosted',
591
+ );
592
+
593
+ /** The item the collapsed bar shows, and goes back to while searching. */
594
+ protected readonly restoreItem = computed(() =>
595
+ this.#glassSearch() ? this.#lastItem() : this.selectedItem(),
596
+ );
597
+
598
+ /** Whether the field's Cancel shows, beside it on glass. */
599
+ protected readonly cancelShown = computed(
600
+ () => this.searching() && !!this.searchField()?.cancelShown(),
410
601
  );
411
602
 
412
603
  /** No sliding, shrinking or fading under Reduce Motion. */
413
604
  readonly #animate = computed(() => !this.#settings.reduceMotion());
414
605
 
606
+ /**
607
+ * How far up the keyboard reaches into the dock, from its bottom edge, in
608
+ * px: 0 while it's down, or while the search field doesn't have it.
609
+ */
610
+ readonly #keyboardOverlap = signal(0);
611
+ /** Each keyboard change's measuring, so a late answer can't win. */
612
+ #measuring = 0;
613
+
415
614
  protected readonly hostClass = computed(() =>
416
615
  cn(
417
616
  'flex-col flex absolute bottom-0 left-0 right-0',
@@ -420,6 +619,24 @@ export class UiDock {
420
619
  ),
421
620
  );
422
621
 
622
+ /**
623
+ * While the search field has the keyboard, the dock floats above it: the
624
+ * glass and Material shapes a little above it, as they float above the home
625
+ * indicator, and the field's bar before iOS 26 on top of it, with the tab
626
+ * bar under the keyboard, as Safari's bar did.
627
+ */
628
+ protected readonly keyboardPadding = computed(() => {
629
+ const overlap = this.searching() ? this.#keyboardOverlap() : 0;
630
+ if (overlap <= 0) return null;
631
+ return this.design() === 'frosted'
632
+ ? `calc(${overlap - this.look().bar}px - env(safe-area-inset-bottom))`
633
+ : px(overlap + KEYBOARD_GAP);
634
+ });
635
+
636
+ protected readonly keyboardTransition = computed(() =>
637
+ this.searchField() && this.#animate() ? KEYBOARD_TRANSITION : null,
638
+ );
639
+
423
640
  protected readonly accessoryRadius = computed(() =>
424
641
  this.design() === 'frosted' ? FROSTED_ACCESSORY_RADIUS : this.look().radius,
425
642
  );
@@ -428,20 +645,44 @@ export class UiDock {
428
645
  * Each shape's frame in the dock, bottom up, and the dock's height. The
429
646
  * shapes are positioned absolutely, so a minimized dock can move each one
430
647
  * on its own: the accessory from above the bar into the row beside it.
648
+ *
649
+ * The search field's shape and its Cancel are there only with a search
650
+ * field. Closed, they're points inside the action, which they grow out of
651
+ * as the search opens.
431
652
  */
432
653
  protected readonly frames = computed(() => {
433
654
  const look = this.look();
434
655
  const design = this.design();
435
656
  const hasAction = !!this.action();
436
657
  const minimized = this.minimized();
658
+ const searching = this.searching();
659
+ const width = this.width();
437
660
  const transition = this.#animate() ? FRAME_TRANSITION : 'none';
438
661
  const actionSpace = hasAction ? look.action + look.gap : 0;
662
+ /**
663
+ * A point, a shape with no size, `right` and `bottom` px in.
664
+ */
665
+ const point = (right: number, bottom: number) => ({
666
+ left: px(Math.max(0, width - right)),
667
+ right: px(right),
668
+ bottom: px(bottom),
669
+ height: '0px',
670
+ transition,
671
+ });
439
672
 
440
673
  if (design === 'frosted') {
441
674
  const above = look.bar + look.gap;
675
+ const strip = searching ? FROSTED_SEARCH_HEIGHT : 0;
676
+ // Only with a search field, so the dock moves as it did without one.
677
+ const searchTransition = this.searchField() ? transition : undefined;
442
678
  return {
443
679
  stage: {
444
- height: abovePx(this.accessory() ? above + look.accessory : look.bar),
680
+ height: abovePx(
681
+ this.accessory()
682
+ ? above + strip + look.accessory
683
+ : look.bar + strip,
684
+ ),
685
+ transition: searchTransition,
445
686
  },
446
687
  bar: {
447
688
  left: '0px',
@@ -458,49 +699,107 @@ export class UiDock {
458
699
  accessory: {
459
700
  left: px(FROSTED_ACCESSORY_INSET),
460
701
  right: px(FROSTED_ACCESSORY_INSET),
461
- bottom: abovePx(above),
702
+ bottom: abovePx(above + strip),
462
703
  height: px(look.accessory),
704
+ transition: searchTransition,
705
+ },
706
+ // A bar on the tab bar's material, above it.
707
+ search: {
708
+ left: '0px',
709
+ right: '0px',
710
+ bottom: abovePx(look.bar),
711
+ height: px(strip),
712
+ transition,
463
713
  },
714
+ cancel: point(0, 0),
464
715
  };
465
716
  }
466
717
 
467
- if (design === 'glass' && minimized) {
718
+ if (design === 'glass' && (minimized || this.#glassSearch())) {
468
719
  const size = MINIMIZED_SIZE;
469
720
  const besideAction = hasAction ? size + look.gap : 0;
721
+ const cancel = this.cancelShown();
722
+ const cancelSpace = cancel ? GLASS_SEARCH_SIZE + look.gap : 0;
723
+ const accessoryHeight = this.accessory() ? look.gap + look.accessory : 0;
470
724
  return {
471
- stage: { height: px(size), transition },
472
- bar: {
473
- left: '0px',
474
- right: px(this.width() - size),
475
- bottom: '0px',
476
- height: px(size),
477
- transition,
478
- },
479
- action: {
480
- right: '0px',
481
- bottom: '0px',
482
- width: px(size),
483
- height: px(size),
725
+ stage: {
726
+ height: px(searching ? size + accessoryHeight : size),
484
727
  transition,
485
728
  },
486
- accessory: {
487
- left: px(size + look.gap),
488
- right: px(besideAction),
729
+ bar: {
730
+ left: '0px',
731
+ right: px(width - size),
489
732
  bottom: '0px',
490
733
  height: px(size),
491
734
  transition,
492
735
  },
736
+ // Searching, the action shrinks into the end of the field, which
737
+ // grows out of it.
738
+ action: searching
739
+ ? {
740
+ right: px(cancelSpace + size / 2),
741
+ bottom: px(size / 2),
742
+ width: '0px',
743
+ height: '0px',
744
+ transition,
745
+ }
746
+ : {
747
+ right: '0px',
748
+ bottom: '0px',
749
+ width: px(size),
750
+ height: px(size),
751
+ transition,
752
+ },
753
+ // Minimized, the accessory moves down between the bar and the
754
+ // action; searching, it stays above the field.
755
+ accessory: searching
756
+ ? {
757
+ left: '0px',
758
+ right: '0px',
759
+ bottom: px(size + look.gap),
760
+ height: px(look.accessory),
761
+ transition,
762
+ }
763
+ : {
764
+ left: px(size + look.gap),
765
+ right: px(besideAction),
766
+ bottom: '0px',
767
+ height: px(size),
768
+ transition,
769
+ },
770
+ search: searching
771
+ ? {
772
+ left: px(size + look.gap),
773
+ right: px(cancelSpace),
774
+ bottom: '0px',
775
+ height: px(GLASS_SEARCH_SIZE),
776
+ transition,
777
+ }
778
+ : point(size / 2, size / 2),
779
+ cancel: cancel
780
+ ? {
781
+ left: px(width - GLASS_SEARCH_SIZE),
782
+ right: '0px',
783
+ bottom: '0px',
784
+ height: px(GLASS_SEARCH_SIZE),
785
+ transition,
786
+ }
787
+ : point(searching ? 0 : size / 2, size / 2),
493
788
  };
494
789
  }
495
790
 
496
- // Glass or Material, expanded; or Material minimized, which slides the
497
- // toolbar and the accessory below the screen and keeps the button.
498
- const hidden = design === 'material' && minimized;
791
+ // Glass or Material, expanded; or Material minimized or searching, which
792
+ // slides the toolbar and the accessory below the screen. Minimized, it
793
+ // keeps the button; searching, the button grows into the search bar.
794
+ const hidden = design === 'material' && (minimized || searching);
499
795
  const slide = (by: number) =>
500
796
  design === 'material' ? `translateY(${hidden ? by : 0}px)` : null;
501
797
  const withAccessory = this.accessory()
502
798
  ? look.bar + look.gap + look.accessory
503
799
  : look.bar;
800
+ const actionCenter = point(look.action / 2, look.bar / 2);
801
+ // Material's button is shorter than the toolbar, and centered on it.
802
+ const actionBottom = (look.bar - look.action) / 2;
504
803
  return {
505
804
  stage: { height: px(withAccessory), transition },
506
805
  bar: {
@@ -511,14 +810,22 @@ export class UiDock {
511
810
  transform: slide(look.bar + MATERIAL_HIDDEN_OFFSET),
512
811
  transition,
513
812
  },
514
- action: {
515
- right: '0px',
516
- // Material's button is shorter than the toolbar, and centered on it.
517
- bottom: px((look.bar - look.action) / 2),
518
- width: px(look.action),
519
- height: px(look.action),
520
- transition,
521
- },
813
+ action:
814
+ design === 'material' && searching
815
+ ? {
816
+ right: px(look.action / 2),
817
+ bottom: px(look.bar / 2),
818
+ width: '0px',
819
+ height: '0px',
820
+ transition,
821
+ }
822
+ : {
823
+ right: '0px',
824
+ bottom: px(actionBottom),
825
+ width: px(look.action),
826
+ height: px(look.action),
827
+ transition,
828
+ },
522
829
  accessory: {
523
830
  left: '0px',
524
831
  right: '0px',
@@ -527,6 +834,17 @@ export class UiDock {
527
834
  transform: slide(withAccessory + MATERIAL_HIDDEN_OFFSET),
528
835
  transition,
529
836
  },
837
+ search:
838
+ design === 'material' && searching
839
+ ? {
840
+ left: '0px',
841
+ right: '0px',
842
+ bottom: px((look.bar - MATERIAL_SEARCH_HEIGHT) / 2),
843
+ height: px(MATERIAL_SEARCH_HEIGHT),
844
+ transition,
845
+ }
846
+ : actionCenter,
847
+ cancel: actionCenter,
530
848
  };
531
849
  });
532
850
 
@@ -551,6 +869,43 @@ export class UiDock {
551
869
  this.design() === 'material' ? 'var(--primary)' : undefined,
552
870
  );
553
871
 
872
+ /**
873
+ * A capsule on glass and Material, as iOS 26's field and Material 3's
874
+ * search bar are; before iOS 26, a bar from edge to edge.
875
+ */
876
+ protected readonly searchShape = computed(() =>
877
+ this.design() === 'frosted' ? 'rounded' : 'capsule',
878
+ );
879
+
880
+ protected readonly searchContentClass = computed(() =>
881
+ cn(
882
+ 'flex-1 flex-row items-center flex',
883
+ this.design() === 'frosted' && 'px-[8px]',
884
+ ),
885
+ );
886
+
887
+ /**
888
+ * The field's content fades in once its shape has mostly grown, and out at
889
+ * once as the search closes.
890
+ */
891
+ protected readonly searchContentStyle = computed(() => {
892
+ const searching = this.searching();
893
+ return {
894
+ opacity: searching ? '1' : '0',
895
+ transition: searching && this.#animate() ? SEARCH_IN_TRANSITION : 'none',
896
+ };
897
+ });
898
+
899
+ protected readonly cancelIcon = computed(() =>
900
+ iconSvg('x', this.#resolveColor('var(--glass-foreground)')),
901
+ );
902
+
903
+ protected readonly cancelIconStyle = computed(() => ({
904
+ width: '20px',
905
+ height: '20px',
906
+ opacity: this.cancelShown() ? '1' : '0',
907
+ }));
908
+
554
909
  protected readonly rowClass = computed(() =>
555
910
  cn(
556
911
  'flex-row flex relative',
@@ -566,15 +921,15 @@ export class UiDock {
566
921
  */
567
922
  protected readonly rowStyle = computed(() => {
568
923
  const look = this.look();
569
- const minimized = this.minimized();
924
+ const hidden = this.rowHidden();
570
925
  const action = this.action() ? look.action + look.gap : 0;
571
926
  const floating = this.design() !== 'frosted' && this.width() > 0;
572
927
  return {
573
928
  margin: px(look.inset),
574
929
  width: floating ? px(this.width() - action - 2 * look.inset) : null,
575
- opacity: minimized ? '0' : '1',
930
+ opacity: hidden ? '0' : '1',
576
931
  transition: this.#animate()
577
- ? minimized
932
+ ? hidden
578
933
  ? ROW_OUT_TRANSITION
579
934
  : ROW_IN_TRANSITION
580
935
  : 'none',
@@ -605,22 +960,60 @@ export class UiDock {
605
960
  };
606
961
  });
607
962
 
608
- /** The selected item's icon, or the first item's, for the minimized bar. */
963
+ /**
964
+ * The icon of the collapsed bar's item (see restoreItem), or the first
965
+ * item's.
966
+ */
609
967
  protected readonly minimizedIcon = computed(() => {
610
- const item = this.selectedItem() ?? this.items()[0];
968
+ const item = this.restoreItem() ?? this.items()[0];
611
969
  return item
612
970
  ? iconSvg(item.icon(), this.#resolveColor('var(--primary)'))
613
971
  : '';
614
972
  });
615
973
 
616
- // The minimized bar's icon fades in as the bar shrinks around it.
974
+ // The collapsed bar's icon fades in as the bar shrinks around it.
617
975
  protected readonly restoreEntrance = effect(() => {
618
- const restore = this.restore();
976
+ const restore = this.restoreButton();
619
977
  if (restore && untracked(this.#animate)) {
620
978
  fadeIn(restore.nativeElement, { duration: DURATION.slow });
621
979
  }
622
980
  });
623
981
 
982
+ /** Whether the dock searched when it last checked, to see it close. */
983
+ #wasSearching = false;
984
+
985
+ // A search that closes, by its route or by the app, takes the keyboard
986
+ // with it.
987
+ protected readonly closeKeyboard = effect(() => {
988
+ const searching = this.searching();
989
+ if (this.#wasSearching && !searching) {
990
+ untracked(() => this.searchField()?.blur());
991
+ }
992
+ this.#wasSearching = searching;
993
+ });
994
+
995
+ // The search field's keyboard height says the keyboard moved: measure how
996
+ // far it reaches into the dock, which floats above it while it searches.
997
+ protected readonly followKeyboard = effect(() => {
998
+ const height = this.searchField()?.keyboardHeight() ?? 0;
999
+ const measuring = ++this.#measuring;
1000
+ const screen = height > 0 ? keyboardScreen(this.#injector) : null;
1001
+ if (!screen) {
1002
+ this.#keyboardOverlap.set(0);
1003
+ return;
1004
+ }
1005
+ // The dock's host sits at the bottom of the screen whatever its padding,
1006
+ // so measuring it gives the same answer however far the dock has risen.
1007
+ measureForKeyboard(this.#renderer, this.#host.nativeElement).then(
1008
+ (measured) => {
1009
+ if (measuring !== this.#measuring || !measured) return;
1010
+ this.#keyboardOverlap.set(
1011
+ keyboardLift(measured.element, measured.page, height, screen, 0),
1012
+ );
1013
+ },
1014
+ );
1015
+ });
1016
+
624
1017
  protected measure(event: Event): void {
625
1018
  this.width.set((event as LayoutChangeEvent).detail.width);
626
1019
  }
@@ -631,6 +1024,55 @@ export class UiDock {
631
1024
  expand(): void {
632
1025
  this.#state.minimized.set(false);
633
1026
  }
1027
+
1028
+ /**
1029
+ * Opens the search field and gives it the keyboard, as pressing an action
1030
+ * without a route does.
1031
+ */
1032
+ openSearch(): void {
1033
+ const field = this.searchField();
1034
+ if (!field) return;
1035
+ this.#state.searching.set(true);
1036
+ field.focus();
1037
+ }
1038
+
1039
+ /**
1040
+ * Closes the search field: goes back to the last item while the search's
1041
+ * route is active, or else closes the search UiDockState holds open.
1042
+ */
1043
+ closeSearch(): void {
1044
+ if (this.action()?.active()) {
1045
+ (this.#lastItem() ?? this.items()[0])?.navigate();
1046
+ }
1047
+ this.#state.searching.set(false);
1048
+ }
1049
+
1050
+ /**
1051
+ * Cancel closes the search, except that the search tab of iOS stays open
1052
+ * with its suggestions: there, Cancel clears the field and closes the
1053
+ * keyboard, and the bar's button goes back. Material's back arrow always
1054
+ * goes back.
1055
+ */
1056
+ searchCanceled(): void {
1057
+ if (this.design() === 'material' || !this.action()?.active()) {
1058
+ this.closeSearch();
1059
+ }
1060
+ }
1061
+
1062
+ /**
1063
+ * The action was pressed: one without a route opens the search.
1064
+ */
1065
+ actionPressed(routed: boolean): void {
1066
+ if (!routed) this.openSearch();
1067
+ }
1068
+
1069
+ protected onRestore(): void {
1070
+ if (this.searching()) {
1071
+ this.closeSearch();
1072
+ } else {
1073
+ this.expand();
1074
+ }
1075
+ }
634
1076
  }
635
1077
 
636
1078
  /**
@@ -749,6 +1191,7 @@ export class UiDockItem {
749
1191
  // The host, not an inner view, shrinks on a press: it's the whole tap
750
1192
  // target, and injecting it needs no view query.
751
1193
  readonly #host = inject(ElementRef);
1194
+ readonly #link = inject(RouterLink);
752
1195
  #pressAnim?: AnimationHandle;
753
1196
 
754
1197
  protected readonly design = this.#dock.design;
@@ -846,6 +1289,14 @@ export class UiDockItem {
846
1289
  ),
847
1290
  );
848
1291
 
1292
+ /**
1293
+ * Navigates to the item's route, with its RouterLink options, as a tap
1294
+ * does: the glass dock's collapsed bar goes back to it from the search.
1295
+ */
1296
+ navigate(): void {
1297
+ this.#link.onClick(0, false, false, false, false);
1298
+ }
1299
+
849
1300
  protected onPressStart(): void {
850
1301
  this.#pressAnim?.cancel();
851
1302
  this.#pressAnim = pressDown(this.#host.nativeElement);
@@ -871,6 +1322,8 @@ export class UiDockItem {
871
1322
  * <ui-dock-action route="/search" icon="search" label="Search" />
872
1323
  * <ui-dock-action icon="plus" label="New message" (pressed)="compose()" />
873
1324
  * ```
1325
+ *
1326
+ * With a `<ui-search-field>` in the dock, it opens the search (see UiDock).
874
1327
  */
875
1328
  @Component({
876
1329
  selector: 'ui-dock-action',
@@ -907,7 +1360,11 @@ export class UiDockItem {
907
1360
  '(bindtouchcancel)': 'onPressEnd()',
908
1361
  '(bindmouseup)': 'onPressEnd()',
909
1362
  '(bindmouseleave)': 'onPressEnd()',
910
- '(bindtap)': 'pressed.emit()',
1363
+ '(bindtap)': 'onTap()',
1364
+ // Its icon fades while the button shrinks into the search field, so it
1365
+ // doesn't show through the field's glass. The content, not the glass.
1366
+ '[style.opacity]': 'fade().opacity',
1367
+ '[style.transition]': 'fade().transition',
911
1368
  },
912
1369
  template: `<svg
913
1370
  [attr.content]="iconSvg()"
@@ -923,8 +1380,10 @@ export class UiDockAction {
923
1380
  readonly pressed = output<void>();
924
1381
 
925
1382
  readonly #dock = inject(UiDock);
1383
+ readonly #settings = inject(LynxAccessibilitySettings);
926
1384
  readonly #resolveColor = injectIconColor();
927
1385
  readonly #host = inject(ElementRef);
1386
+ readonly #link = inject(RouterLink);
928
1387
  #pressAnim?: AnimationHandle;
929
1388
 
930
1389
  /** Whether the button's route is active, as for UiDockItem.active. */
@@ -948,6 +1407,25 @@ export class UiDockAction {
948
1407
  return iconSvg(this.icon(), this.#resolveColor(color));
949
1408
  });
950
1409
 
1410
+ protected readonly fade = computed(() => {
1411
+ const hidden = this.#dock.actionHidden();
1412
+ return {
1413
+ opacity: hidden ? '0' : null,
1414
+ transition:
1415
+ hidden && !this.#settings.reduceMotion() ? ICON_OUT_TRANSITION : null,
1416
+ };
1417
+ });
1418
+
1419
+ /**
1420
+ * RouterLink navigates on the same tap through its own listener, when the
1421
+ * button has a route: one without a route opens the dock's search, if it
1422
+ * has one.
1423
+ */
1424
+ protected onTap(): void {
1425
+ this.pressed.emit();
1426
+ this.#dock.actionPressed(this.#link.urlTree !== null);
1427
+ }
1428
+
951
1429
  protected onPressStart(): void {
952
1430
  this.#pressAnim?.cancel();
953
1431
  this.#pressAnim = pressDown(this.#host.nativeElement);