@blotch/dolan 22.2.4 → 22.2.6
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.
- package/dist/commands/add.test.js +2 -0
- package/dist/commands/add.test.js.map +1 -1
- package/dist/commands/doctor.js +78 -21
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/doctor.test.js +167 -26
- package/dist/commands/doctor.test.js.map +1 -1
- package/dist/commands/init.test.js +4 -7
- package/dist/commands/init.test.js.map +1 -1
- package/dist/commands/update.d.ts +3 -1
- package/dist/commands/update.js +78 -15
- package/dist/commands/update.js.map +1 -1
- package/dist/commands/update.test.js +257 -2
- package/dist/commands/update.test.js.map +1 -1
- package/dist/lockfile.d.ts +10 -0
- package/dist/lockfile.js +20 -1
- package/dist/lockfile.js.map +1 -1
- package/dist/lockfile.test.js +43 -3
- package/dist/lockfile.test.js.map +1 -1
- package/dist/registry.js +73 -11
- package/dist/registry.js.map +1 -1
- package/dist/registry.test.js +16 -3
- package/dist/registry.test.js.map +1 -1
- package/dist/test-utils.d.ts +24 -2
- package/dist/test-utils.js +41 -2
- package/dist/test-utils.js.map +1 -1
- package/dist/ui/boolean-inputs.test.ts +76 -0
- package/dist/ui/components/action-sheet/action-sheet.test.ts +370 -1
- package/dist/ui/components/action-sheet/action-sheet.ts +306 -28
- package/dist/ui/components/action-sheet/index.ts +1 -0
- package/dist/ui/components/alert-dialog/alert-dialog.test.ts +552 -8
- package/dist/ui/components/alert-dialog/alert-dialog.ts +644 -36
- package/dist/ui/components/alert-dialog/index.ts +5 -0
- package/dist/ui/components/area-chart/area-chart.ts +3 -2
- package/dist/ui/components/badge/badge.ts +2 -1
- package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +968 -3
- package/dist/ui/components/bottom-sheet/bottom-sheet.ts +1021 -95
- package/dist/ui/components/bottom-sheet/index.ts +7 -1
- package/dist/ui/components/button/button.test.ts +36 -1
- package/dist/ui/components/button/button.ts +17 -7
- package/dist/ui/components/button-group/button-group.test.ts +53 -9
- package/dist/ui/components/button-group/button-group.ts +67 -21
- package/dist/ui/components/card/card.test.ts +16 -0
- package/dist/ui/components/card/card.ts +2 -1
- package/dist/ui/components/chart/chart-presets.ts +6 -6
- package/dist/ui/components/chart/chart.test.ts +14 -0
- package/dist/ui/components/chart/chart.ts +10 -3
- package/dist/ui/components/checkbox/checkbox.ts +2 -1
- package/dist/ui/components/collapsible/collapsible.ts +2 -1
- package/dist/ui/components/dialog/dialog.ts +6 -6
- package/dist/ui/components/dock/dock-scroll.ts +9 -0
- package/dist/ui/components/dock/dock-search.test.ts +594 -0
- package/dist/ui/components/dock/dock.test.ts +4 -1
- package/dist/ui/components/dock/dock.ts +529 -51
- package/dist/ui/components/dynamic-color/dynamic-color.test.ts +144 -0
- package/dist/ui/components/dynamic-color/dynamic-color.ts +146 -0
- package/dist/ui/components/dynamic-color/index.ts +5 -0
- package/dist/ui/components/glass/glass.test.ts +39 -1
- package/dist/ui/components/glass/glass.ts +87 -17
- package/dist/ui/components/glass/index.ts +1 -0
- package/dist/ui/components/icon/icon-theme.test.ts +28 -0
- package/dist/ui/components/icon/icon-theme.ts +38 -7
- package/dist/ui/components/input/input.ts +2 -1
- package/dist/ui/components/label/label.ts +8 -2
- package/dist/ui/components/line-chart/line-chart.ts +3 -2
- package/dist/ui/components/list/list.ts +5 -4
- package/dist/ui/components/menu/menu-popup.test.ts +18 -14
- package/dist/ui/components/menu/menu-popup.ts +24 -3
- package/dist/ui/components/menu/menu.test.ts +99 -5
- package/dist/ui/components/menu/menu.ts +74 -34
- package/dist/ui/components/nav-drawer/nav-drawer.ts +3 -2
- package/dist/ui/components/navigation-bar/index.ts +1 -0
- package/dist/ui/components/navigation-bar/navigation-bar.test.ts +425 -0
- package/dist/ui/components/navigation-bar/navigation-bar.ts +411 -0
- package/dist/ui/components/popover/index.ts +9 -0
- package/dist/ui/components/popover/popover-panel.test.ts +735 -0
- package/dist/ui/components/popover/popover-panel.ts +603 -0
- package/dist/ui/components/popover/popover.test.ts +286 -0
- package/dist/ui/components/popover/popover.ts +207 -0
- package/dist/ui/components/progress/progress.ts +2 -1
- package/dist/ui/components/radio-group/radio-group.ts +2 -1
- package/dist/ui/components/scroll-area/scroll-area.ts +4 -3
- package/dist/ui/components/search-field/index.ts +15 -0
- package/dist/ui/components/search-field/keyboard.test.ts +147 -0
- package/dist/ui/components/search-field/keyboard.ts +123 -0
- package/dist/ui/components/search-field/search-field.test.ts +549 -0
- package/dist/ui/components/search-field/search-field.ts +628 -0
- package/dist/ui/components/segmented-control/index.ts +8 -0
- package/dist/ui/components/segmented-control/segmented-control.test.ts +861 -0
- package/dist/ui/components/segmented-control/segmented-control.ts +884 -0
- package/dist/ui/components/select/select.ts +2 -1
- package/dist/ui/components/sheet/sheet.test.ts +90 -3
- package/dist/ui/components/sheet/sheet.ts +59 -4
- package/dist/ui/components/slider/index.ts +1 -0
- package/dist/ui/components/slider/slider.test.ts +695 -0
- package/dist/ui/components/slider/slider.ts +901 -0
- package/dist/ui/components/switch/index.ts +1 -1
- package/dist/ui/components/switch/switch.test.ts +464 -48
- package/dist/ui/components/switch/switch.ts +613 -43
- package/dist/ui/components/tabs/index.ts +7 -1
- package/dist/ui/components/tabs/tabs.test.ts +278 -2
- package/dist/ui/components/tabs/tabs.ts +233 -30
- package/dist/ui/components/textarea/textarea.ts +2 -1
- package/dist/ui/components/toggle/index.ts +1 -0
- package/dist/ui/components/toggle/toggle.test.ts +310 -4
- package/dist/ui/components/toggle/toggle.ts +241 -26
- package/dist/ui/components/toolbar/bar-scroll.test.ts +310 -0
- package/dist/ui/components/toolbar/bar-scroll.ts +372 -0
- package/dist/ui/components/toolbar/index.ts +12 -0
- package/dist/ui/components/toolbar/toolbar.test.ts +438 -0
- package/dist/ui/components/toolbar/toolbar.ts +535 -0
- package/dist/utils/analyze.d.ts +10 -5
- package/dist/utils/analyze.js +3 -2
- package/dist/utils/analyze.js.map +1 -1
- package/dist/utils/installed.d.ts +13 -1
- package/dist/utils/installed.js +17 -0
- package/dist/utils/installed.js.map +1 -1
- package/dist/utils/installed.test.js +39 -1
- package/dist/utils/installed.test.js.map +1 -1
- package/dist/utils/tailwind.d.ts +25 -5
- package/dist/utils/tailwind.js +53 -14
- package/dist/utils/tailwind.js.map +1 -1
- package/dist/utils/tailwind.test.js +85 -22
- package/dist/utils/tailwind.test.js.map +1 -1
- 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
|
+
}
|