@blotch/dolan 0.0.0-stage → 22.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/add.d.ts +22 -0
- package/dist/commands/add.js +132 -0
- package/dist/commands/add.js.map +1 -0
- package/dist/commands/add.test.d.ts +1 -0
- package/dist/commands/add.test.js +209 -0
- package/dist/commands/add.test.js.map +1 -0
- package/dist/commands/diff.d.ts +13 -0
- package/dist/commands/diff.js +155 -0
- package/dist/commands/diff.js.map +1 -0
- package/dist/commands/diff.test.d.ts +1 -0
- package/dist/commands/diff.test.js +327 -0
- package/dist/commands/diff.test.js.map +1 -0
- package/dist/commands/doctor.d.ts +15 -0
- package/dist/commands/doctor.js +248 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/doctor.test.d.ts +1 -0
- package/dist/commands/doctor.test.js +301 -0
- package/dist/commands/doctor.test.js.map +1 -0
- package/dist/commands/eject.d.ts +11 -0
- package/dist/commands/eject.js +55 -0
- package/dist/commands/eject.js.map +1 -0
- package/dist/commands/eject.test.d.ts +1 -0
- package/dist/commands/eject.test.js +151 -0
- package/dist/commands/eject.test.js.map +1 -0
- package/dist/commands/info.d.ts +1 -0
- package/dist/commands/info.js +73 -0
- package/dist/commands/info.js.map +1 -0
- package/dist/commands/info.test.d.ts +1 -0
- package/dist/commands/info.test.js +143 -0
- package/dist/commands/info.test.js.map +1 -0
- package/dist/commands/init.d.ts +1 -0
- package/dist/commands/init.js +98 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/init.test.d.ts +1 -0
- package/dist/commands/init.test.js +154 -0
- package/dist/commands/init.test.js.map +1 -0
- package/dist/commands/list.d.ts +16 -0
- package/dist/commands/list.js +124 -0
- package/dist/commands/list.js.map +1 -0
- package/dist/commands/list.test.d.ts +1 -0
- package/dist/commands/list.test.js +285 -0
- package/dist/commands/list.test.js.map +1 -0
- package/dist/commands/outdated.d.ts +13 -0
- package/dist/commands/outdated.js +116 -0
- package/dist/commands/outdated.js.map +1 -0
- package/dist/commands/outdated.test.d.ts +1 -0
- package/dist/commands/outdated.test.js +208 -0
- package/dist/commands/outdated.test.js.map +1 -0
- package/dist/commands/remove.d.ts +18 -0
- package/dist/commands/remove.js +161 -0
- package/dist/commands/remove.js.map +1 -0
- package/dist/commands/remove.test.d.ts +1 -0
- package/dist/commands/remove.test.js +361 -0
- package/dist/commands/remove.test.js.map +1 -0
- package/dist/commands/theme.d.ts +1 -0
- package/dist/commands/theme.js +66 -0
- package/dist/commands/theme.js.map +1 -0
- package/dist/commands/theme.test.d.ts +1 -0
- package/dist/commands/theme.test.js +133 -0
- package/dist/commands/theme.test.js.map +1 -0
- package/dist/commands/update.d.ts +31 -0
- package/dist/commands/update.js +601 -0
- package/dist/commands/update.js.map +1 -0
- package/dist/commands/update.test.d.ts +1 -0
- package/dist/commands/update.test.js +1233 -0
- package/dist/commands/update.test.js.map +1 -0
- package/dist/config.d.ts +11 -0
- package/dist/config.js +19 -0
- package/dist/config.js.map +1 -0
- package/dist/config.test.d.ts +1 -0
- package/dist/config.test.js +48 -0
- package/dist/config.test.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +89 -0
- package/dist/index.js.map +1 -0
- package/dist/lockfile.d.ts +21 -0
- package/dist/lockfile.js +39 -0
- package/dist/lockfile.js.map +1 -0
- package/dist/registry.d.ts +14 -0
- package/dist/registry.js +85 -0
- package/dist/registry.js.map +1 -0
- package/dist/registry.test.d.ts +1 -0
- package/dist/registry.test.js +95 -0
- package/dist/registry.test.js.map +1 -0
- package/dist/test-utils.d.ts +16 -0
- package/dist/test-utils.js +65 -0
- package/dist/test-utils.js.map +1 -0
- package/dist/ui/components/accordion/accordion.test.ts +194 -0
- package/dist/ui/components/accordion/accordion.ts +173 -0
- package/dist/ui/components/accordion/index.ts +6 -0
- package/dist/ui/components/action-sheet/action-sheet.test.ts +211 -0
- package/dist/ui/components/action-sheet/action-sheet.ts +353 -0
- package/dist/ui/components/action-sheet/index.ts +6 -0
- package/dist/ui/components/alert/alert.test.ts +48 -0
- package/dist/ui/components/alert/alert.ts +104 -0
- package/dist/ui/components/alert/index.ts +1 -0
- package/dist/ui/components/alert-dialog/alert-dialog.test.ts +141 -0
- package/dist/ui/components/alert-dialog/alert-dialog.ts +243 -0
- package/dist/ui/components/alert-dialog/index.ts +7 -0
- package/dist/ui/components/area-chart/area-chart-render.test.ts +121 -0
- package/dist/ui/components/area-chart/area-chart.test.ts +158 -0
- package/dist/ui/components/area-chart/area-chart.ts +349 -0
- package/dist/ui/components/area-chart/index.ts +6 -0
- package/dist/ui/components/aspect-ratio/aspect-ratio.test.ts +10 -0
- package/dist/ui/components/aspect-ratio/aspect-ratio.ts +28 -0
- package/dist/ui/components/aspect-ratio/index.ts +1 -0
- package/dist/ui/components/avatar/avatar.test.ts +69 -0
- package/dist/ui/components/avatar/avatar.ts +131 -0
- package/dist/ui/components/avatar/index.ts +1 -0
- package/dist/ui/components/badge/badge.test.ts +49 -0
- package/dist/ui/components/badge/badge.ts +97 -0
- package/dist/ui/components/badge/index.ts +1 -0
- package/dist/ui/components/bar-chart/bar-chart-render.test.ts +174 -0
- package/dist/ui/components/bar-chart/bar-chart.test.ts +73 -0
- package/dist/ui/components/bar-chart/bar-chart.ts +327 -0
- package/dist/ui/components/bar-chart/index.ts +6 -0
- package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +163 -0
- package/dist/ui/components/bottom-sheet/bottom-sheet.ts +339 -0
- package/dist/ui/components/bottom-sheet/index.ts +1 -0
- package/dist/ui/components/button/button-render.test.ts +113 -0
- package/dist/ui/components/button/button.test.ts +70 -0
- package/dist/ui/components/button/button.ts +170 -0
- package/dist/ui/components/button/index.ts +6 -0
- package/dist/ui/components/button-group/button-group-render.test.ts +146 -0
- package/dist/ui/components/button-group/button-group.test.ts +54 -0
- package/dist/ui/components/button-group/button-group.ts +209 -0
- package/dist/ui/components/button-group/index.ts +8 -0
- package/dist/ui/components/candlestick-chart/candlestick-chart-render.test.ts +174 -0
- package/dist/ui/components/candlestick-chart/candlestick-chart.test.ts +96 -0
- package/dist/ui/components/candlestick-chart/candlestick-chart.ts +355 -0
- package/dist/ui/components/candlestick-chart/index.ts +7 -0
- package/dist/ui/components/card/card.test.ts +110 -0
- package/dist/ui/components/card/card.ts +162 -0
- package/dist/ui/components/card/index.ts +8 -0
- package/dist/ui/components/cartesian-chart/cartesian-chart-render.test.ts +188 -0
- package/dist/ui/components/cartesian-chart/cartesian-chart.test.ts +549 -0
- package/dist/ui/components/cartesian-chart/cartesian-chart.ts +1140 -0
- package/dist/ui/components/cartesian-chart/index.ts +18 -0
- package/dist/ui/components/checkbox/checkbox.test.ts +59 -0
- package/dist/ui/components/checkbox/checkbox.ts +96 -0
- package/dist/ui/components/checkbox/index.ts +1 -0
- package/dist/ui/components/collapsible/collapsible.test.ts +112 -0
- package/dist/ui/components/collapsible/collapsible.ts +122 -0
- package/dist/ui/components/collapsible/index.ts +5 -0
- package/dist/ui/components/dialog/dialog.test.ts +157 -0
- package/dist/ui/components/dialog/dialog.ts +248 -0
- package/dist/ui/components/dialog/index.ts +7 -0
- package/dist/ui/components/empty-state/empty-state.test.ts +60 -0
- package/dist/ui/components/empty-state/empty-state.ts +46 -0
- package/dist/ui/components/empty-state/index.ts +1 -0
- package/dist/ui/components/icon/icon.test.ts +52 -0
- package/dist/ui/components/icon/icon.ts +54 -0
- package/dist/ui/components/icon/icons.ts +569 -0
- package/dist/ui/components/icon/index.ts +2 -0
- package/dist/ui/components/input/index.ts +1 -0
- package/dist/ui/components/input/input.test.ts +134 -0
- package/dist/ui/components/input/input.ts +189 -0
- package/dist/ui/components/label/index.ts +1 -0
- package/dist/ui/components/label/label.test.ts +38 -0
- package/dist/ui/components/label/label.ts +24 -0
- package/dist/ui/components/line-chart/index.ts +6 -0
- package/dist/ui/components/line-chart/line-chart-render.test.ts +121 -0
- package/dist/ui/components/line-chart/line-chart.test.ts +81 -0
- package/dist/ui/components/line-chart/line-chart.ts +273 -0
- package/dist/ui/components/list/index.ts +1 -0
- package/dist/ui/components/list/list.test.ts +35 -0
- package/dist/ui/components/list/list.ts +71 -0
- package/dist/ui/components/nav-drawer/index.ts +8 -0
- package/dist/ui/components/nav-drawer/nav-drawer.test.ts +267 -0
- package/dist/ui/components/nav-drawer/nav-drawer.ts +504 -0
- package/dist/ui/components/pagination/index.ts +1 -0
- package/dist/ui/components/pagination/pagination.test.ts +96 -0
- package/dist/ui/components/pagination/pagination.ts +147 -0
- package/dist/ui/components/progress/index.ts +1 -0
- package/dist/ui/components/progress/progress.test.ts +71 -0
- package/dist/ui/components/progress/progress.ts +118 -0
- package/dist/ui/components/radio-group/index.ts +1 -0
- package/dist/ui/components/radio-group/radio-group.test.ts +115 -0
- package/dist/ui/components/radio-group/radio-group.ts +129 -0
- package/dist/ui/components/scatter-chart/index.ts +6 -0
- package/dist/ui/components/scatter-chart/scatter-chart-render.test.ts +157 -0
- package/dist/ui/components/scatter-chart/scatter-chart.test.ts +57 -0
- package/dist/ui/components/scatter-chart/scatter-chart.ts +277 -0
- package/dist/ui/components/scroll-area/index.ts +1 -0
- package/dist/ui/components/scroll-area/scroll-area.test.ts +40 -0
- package/dist/ui/components/scroll-area/scroll-area.ts +73 -0
- package/dist/ui/components/select/index.ts +1 -0
- package/dist/ui/components/select/select.test.ts +107 -0
- package/dist/ui/components/select/select.ts +146 -0
- package/dist/ui/components/separator/index.ts +1 -0
- package/dist/ui/components/separator/separator.test.ts +26 -0
- package/dist/ui/components/separator/separator.ts +45 -0
- package/dist/ui/components/sheet/index.ts +7 -0
- package/dist/ui/components/sheet/sheet.test.ts +44 -0
- package/dist/ui/components/sheet/sheet.ts +114 -0
- package/dist/ui/components/skeleton/index.ts +1 -0
- package/dist/ui/components/skeleton/skeleton.test.ts +13 -0
- package/dist/ui/components/skeleton/skeleton.ts +45 -0
- package/dist/ui/components/spinner/index.ts +1 -0
- package/dist/ui/components/spinner/spinner.test.ts +39 -0
- package/dist/ui/components/spinner/spinner.ts +83 -0
- package/dist/ui/components/switch/index.ts +1 -0
- package/dist/ui/components/switch/switch.test.ts +63 -0
- package/dist/ui/components/switch/switch.ts +98 -0
- package/dist/ui/components/tabs/index.ts +1 -0
- package/dist/ui/components/tabs/tabs.test.ts +199 -0
- package/dist/ui/components/tabs/tabs.ts +317 -0
- package/dist/ui/components/textarea/index.ts +1 -0
- package/dist/ui/components/textarea/textarea.test.ts +138 -0
- package/dist/ui/components/textarea/textarea.ts +221 -0
- package/dist/ui/components/toast/index.ts +9 -0
- package/dist/ui/components/toast/toast-state.test.ts +54 -0
- package/dist/ui/components/toast/toast-state.ts +46 -0
- package/dist/ui/components/toast/toast.test.ts +342 -0
- package/dist/ui/components/toast/toast.ts +527 -0
- package/dist/ui/components/toggle/index.ts +7 -0
- package/dist/ui/components/toggle/toggle.test.ts +186 -0
- package/dist/ui/components/toggle/toggle.ts +218 -0
- package/dist/ui/components/typography/index.ts +5 -0
- package/dist/ui/components/typography/typography.test.ts +55 -0
- package/dist/ui/components/typography/typography.ts +44 -0
- package/dist/ui/theme/dark.css +41 -0
- package/dist/ui/theme/default.css +56 -0
- package/dist/ui/theme/tailwind-plugin.test.ts +124 -0
- package/dist/ui/theme/tailwind-plugin.ts +151 -0
- package/dist/ui/utils/animate.test.ts +324 -0
- package/dist/ui/utils/animate.ts +544 -0
- package/dist/ui/utils/cn.test.ts +27 -0
- package/dist/ui/utils/cn.ts +6 -0
- package/dist/utils/analyze.d.ts +32 -0
- package/dist/utils/analyze.js +109 -0
- package/dist/utils/analyze.js.map +1 -0
- package/dist/utils/analyze.test.d.ts +1 -0
- package/dist/utils/analyze.test.js +146 -0
- package/dist/utils/analyze.test.js.map +1 -0
- package/dist/utils/animate.d.ts +210 -0
- package/dist/utils/animate.js +423 -0
- package/dist/utils/animate.js.map +1 -0
- package/dist/utils/animate.test.d.ts +1 -0
- package/dist/utils/animate.test.js +365 -0
- package/dist/utils/animate.test.js.map +1 -0
- package/dist/utils/banner.d.ts +1 -0
- package/dist/utils/banner.js +15 -0
- package/dist/utils/banner.js.map +1 -0
- package/dist/utils/banner.test.d.ts +1 -0
- package/dist/utils/banner.test.js +29 -0
- package/dist/utils/banner.test.js.map +1 -0
- package/dist/utils/cn.d.ts +2 -0
- package/dist/utils/cn.js +6 -0
- package/dist/utils/cn.js.map +1 -0
- package/dist/utils/cn.test.d.ts +1 -0
- package/dist/utils/cn.test.js +21 -0
- package/dist/utils/cn.test.js.map +1 -0
- package/dist/utils/format.d.ts +31 -0
- package/dist/utils/format.js +95 -0
- package/dist/utils/format.js.map +1 -0
- package/dist/utils/format.test.d.ts +1 -0
- package/dist/utils/format.test.js +71 -0
- package/dist/utils/format.test.js.map +1 -0
- package/dist/utils/logger.d.ts +6 -0
- package/dist/utils/logger.js +16 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/logger.test.d.ts +1 -0
- package/dist/utils/logger.test.js +36 -0
- package/dist/utils/logger.test.js.map +1 -0
- package/dist/utils/resolve-paths.d.ts +12 -0
- package/dist/utils/resolve-paths.js +29 -0
- package/dist/utils/resolve-paths.js.map +1 -0
- package/dist/utils/resolve-paths.test.d.ts +1 -0
- package/dist/utils/resolve-paths.test.js +52 -0
- package/dist/utils/resolve-paths.test.js.map +1 -0
- package/package.json +56 -4
- package/README.md +0 -3
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ElementRef,
|
|
3
|
+
Component,
|
|
4
|
+
ViewEncapsulation,
|
|
5
|
+
computed,
|
|
6
|
+
effect,
|
|
7
|
+
inject,
|
|
8
|
+
input,
|
|
9
|
+
model,
|
|
10
|
+
output,
|
|
11
|
+
signal,
|
|
12
|
+
viewChild,
|
|
13
|
+
} from '@angular/core';
|
|
14
|
+
import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
type AnimationHandle,
|
|
18
|
+
DURATION,
|
|
19
|
+
EASING,
|
|
20
|
+
directionalSlideIn,
|
|
21
|
+
revealIn,
|
|
22
|
+
} from '../../utils/animate';
|
|
23
|
+
import { cn } from '../../utils/cn';
|
|
24
|
+
|
|
25
|
+
@Component({
|
|
26
|
+
selector: 'ui-tabs',
|
|
27
|
+
standalone: true,
|
|
28
|
+
imports: [LYNX_ELEMENTS],
|
|
29
|
+
encapsulation: ViewEncapsulation.None,
|
|
30
|
+
template: `
|
|
31
|
+
<view [class]="containerClass()">
|
|
32
|
+
<ng-content />
|
|
33
|
+
</view>
|
|
34
|
+
`,
|
|
35
|
+
})
|
|
36
|
+
export class UiTabs {
|
|
37
|
+
readonly value = model<string>('');
|
|
38
|
+
readonly userClass = input<string>('', { alias: 'class' });
|
|
39
|
+
|
|
40
|
+
readonly changed = output<string>();
|
|
41
|
+
|
|
42
|
+
// Direction tracking for content slide animations. Each UiTabsTrigger
|
|
43
|
+
// registers itself in order via registerTab(), building an ordered list.
|
|
44
|
+
// When the active tab changes, we compare the old and new index in this
|
|
45
|
+
// list to determine slide direction (1 = right, -1 = left). This creates
|
|
46
|
+
// a spatial relationship: tabs feel like they're arranged left-to-right,
|
|
47
|
+
// with content sliding in from the direction of the selected tab.
|
|
48
|
+
readonly #tabOrder: string[] = [];
|
|
49
|
+
#previousValue: string | undefined;
|
|
50
|
+
readonly direction = signal<1 | -1>(1);
|
|
51
|
+
|
|
52
|
+
protected readonly containerClass = computed(() =>
|
|
53
|
+
cn('flex-col flex', this.userClass()),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
constructor() {
|
|
57
|
+
// Reactively compute direction when the active value changes.
|
|
58
|
+
// This handles both internal select() calls and external model changes.
|
|
59
|
+
effect(() => {
|
|
60
|
+
const currentValue = this.value();
|
|
61
|
+
if (this.#previousValue === undefined) {
|
|
62
|
+
this.#previousValue = currentValue;
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
// Defensive: signal dedupes same-value writes so the effect never re-runs
|
|
66
|
+
// with an unchanged `currentValue`. Kept for safety on-device.
|
|
67
|
+
/* v8 ignore next 1 */
|
|
68
|
+
if (currentValue === this.#previousValue) return;
|
|
69
|
+
|
|
70
|
+
const prevIndex = this.#tabOrder.indexOf(this.#previousValue);
|
|
71
|
+
const nextIndex = this.#tabOrder.indexOf(currentValue);
|
|
72
|
+
// The `prevIndex !== nextIndex` check is defensive — the earlier
|
|
73
|
+
// `if (currentValue === this.#previousValue) return;` guard makes an
|
|
74
|
+
// equal-index case unreachable in normal flow.
|
|
75
|
+
if (
|
|
76
|
+
prevIndex !== -1 &&
|
|
77
|
+
nextIndex !== -1 &&
|
|
78
|
+
/* v8 ignore next 1 */ prevIndex !== nextIndex
|
|
79
|
+
) {
|
|
80
|
+
this.direction.set(nextIndex > prevIndex ? 1 : -1);
|
|
81
|
+
}
|
|
82
|
+
this.#previousValue = currentValue;
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Called by UiTabsTrigger to register ordering for direction detection.
|
|
88
|
+
*/
|
|
89
|
+
registerTab(value: string): void {
|
|
90
|
+
if (!this.#tabOrder.includes(value)) {
|
|
91
|
+
this.#tabOrder.push(value);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
select(value: string): void {
|
|
96
|
+
this.value.set(value);
|
|
97
|
+
this.changed.emit(value);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
@Component({
|
|
102
|
+
selector: 'ui-tabs-list',
|
|
103
|
+
standalone: true,
|
|
104
|
+
imports: [LYNX_ELEMENTS],
|
|
105
|
+
encapsulation: ViewEncapsulation.None,
|
|
106
|
+
template: `
|
|
107
|
+
<view [class]="listClass()">
|
|
108
|
+
<ng-content />
|
|
109
|
+
</view>
|
|
110
|
+
`,
|
|
111
|
+
})
|
|
112
|
+
export class UiTabsList {
|
|
113
|
+
readonly userClass = input<string>('', { alias: 'class' });
|
|
114
|
+
|
|
115
|
+
protected readonly listClass = computed(() =>
|
|
116
|
+
cn(
|
|
117
|
+
// `self-start` keeps the tab bar hugging its triggers. The parent <ui-tabs>
|
|
118
|
+
// is a `flex flex-col` column, and in Lynx's linear/flex layout a child with
|
|
119
|
+
// no explicit width stretches to fill the parent's cross axis — so without
|
|
120
|
+
// it the muted pill spans the full width, leaving empty space around the
|
|
121
|
+
// tabs. Same lever as the badge fix: Lynx has no `inline-flex`, so
|
|
122
|
+
// align-self is how you opt a child into shrink-to-fit.
|
|
123
|
+
'flex-row items-center self-start rounded-lg bg-muted p-1 flex',
|
|
124
|
+
this.userClass(),
|
|
125
|
+
),
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
@Component({
|
|
130
|
+
selector: 'ui-tabs-trigger',
|
|
131
|
+
standalone: true,
|
|
132
|
+
imports: [LYNX_ELEMENTS],
|
|
133
|
+
encapsulation: ViewEncapsulation.None,
|
|
134
|
+
template: `
|
|
135
|
+
<view #trigger [class]="triggerClass()" (bindtap)="select()">
|
|
136
|
+
<text [class]="textClass()"><ng-content /></text>
|
|
137
|
+
</view>
|
|
138
|
+
`,
|
|
139
|
+
})
|
|
140
|
+
export class UiTabsTrigger {
|
|
141
|
+
readonly #tabs = inject(UiTabs);
|
|
142
|
+
|
|
143
|
+
readonly triggerValue = input.required<string>({ alias: 'value' });
|
|
144
|
+
readonly userClass = input<string>('', { alias: 'class' });
|
|
145
|
+
|
|
146
|
+
readonly triggerRef = viewChild<ElementRef>('trigger');
|
|
147
|
+
#anim?: AnimationHandle;
|
|
148
|
+
#previousActive?: boolean;
|
|
149
|
+
|
|
150
|
+
protected readonly isActive = computed(
|
|
151
|
+
() => this.#tabs.value() === this.triggerValue(),
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
constructor() {
|
|
155
|
+
// Register this trigger with the parent for direction detection
|
|
156
|
+
effect(() => {
|
|
157
|
+
this.#tabs.registerTab(this.triggerValue());
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// Animate trigger background on selection change
|
|
161
|
+
effect(() => {
|
|
162
|
+
const active = this.isActive();
|
|
163
|
+
const el = this.triggerRef()?.nativeElement;
|
|
164
|
+
if (!el || this.#previousActive === undefined) {
|
|
165
|
+
this.#previousActive = active;
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
// Defensive: `isActive` computed dedupes same-value emissions so the
|
|
169
|
+
// effect never re-runs with an unchanged `active`. Kept for safety.
|
|
170
|
+
/* v8 ignore next 1 */
|
|
171
|
+
if (active === this.#previousActive) return;
|
|
172
|
+
this.#previousActive = active;
|
|
173
|
+
|
|
174
|
+
this.#anim?.cancel();
|
|
175
|
+
|
|
176
|
+
if (active) {
|
|
177
|
+
// Activation: spring-based scale + opacity for a lively entrance
|
|
178
|
+
this.#anim = el.animate(
|
|
179
|
+
[
|
|
180
|
+
{ transform: 'scaleX(0.95)', opacity: 0.7 },
|
|
181
|
+
{ transform: 'scaleX(1)', opacity: 1 },
|
|
182
|
+
],
|
|
183
|
+
{
|
|
184
|
+
duration: DURATION.normal,
|
|
185
|
+
easing: EASING.springSubtle,
|
|
186
|
+
fill: 'forwards',
|
|
187
|
+
},
|
|
188
|
+
);
|
|
189
|
+
} else {
|
|
190
|
+
// Deactivation: quick shrink + fade for a graceful exit
|
|
191
|
+
this.#anim = el.animate(
|
|
192
|
+
[
|
|
193
|
+
{ transform: 'scaleX(1)', opacity: 1 },
|
|
194
|
+
{ transform: 'scaleX(0.97)', opacity: 0.7 },
|
|
195
|
+
],
|
|
196
|
+
{
|
|
197
|
+
duration: DURATION.fast,
|
|
198
|
+
easing: EASING.accelerate,
|
|
199
|
+
fill: 'forwards',
|
|
200
|
+
},
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
protected readonly triggerClass = computed(() =>
|
|
207
|
+
cn(
|
|
208
|
+
// No `flex-1` here: now that the list shrinks to fit its content (see
|
|
209
|
+
// `self-start` in UiTabsList), stretching triggers to equal width is
|
|
210
|
+
// pointless, and `flex-basis: 0` growth combined with Lynx treating
|
|
211
|
+
// `min-content` as `0px` would let triggers collapse to zero width and
|
|
212
|
+
// clip their labels. Content-sized triggers match shadcn's default
|
|
213
|
+
// inline tabs.
|
|
214
|
+
'items-center rounded-sm px-3 py-1.5 flex justify-center',
|
|
215
|
+
this.isActive() ? 'bg-background' : 'bg-transparent',
|
|
216
|
+
this.userClass(),
|
|
217
|
+
),
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
protected readonly textClass = computed(() =>
|
|
221
|
+
cn(
|
|
222
|
+
'text-sm font-medium',
|
|
223
|
+
this.isActive() ? 'text-foreground' : 'text-muted-foreground',
|
|
224
|
+
),
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
select(): void {
|
|
228
|
+
this.#tabs.select(this.triggerValue());
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
@Component({
|
|
233
|
+
selector: 'ui-tabs-content',
|
|
234
|
+
standalone: true,
|
|
235
|
+
imports: [LYNX_ELEMENTS],
|
|
236
|
+
encapsulation: ViewEncapsulation.None,
|
|
237
|
+
host: {
|
|
238
|
+
// Hide the WHOLE host when inactive so it reserves no layout space.
|
|
239
|
+
//
|
|
240
|
+
// The caller writes `class="flex-1"` on <ui-tabs-content>, and Angular
|
|
241
|
+
// applies that to the host element. All three content hosts live as siblings
|
|
242
|
+
// inside the flex-col <ui-tabs>, so three flex-1 hosts each grow to fill a
|
|
243
|
+
// third of the available height — pushing each panel lower than the last
|
|
244
|
+
// (the "Done tab starts at the very bottom" stacking). Hiding only the inner
|
|
245
|
+
// view doesn't help: the flex-1 host still claims its third.
|
|
246
|
+
//
|
|
247
|
+
// Toggling the host's own `display` collapses inactive hosts to 0×0 (Lynx
|
|
248
|
+
// `display: none`) so they claim no space at all, while the single active
|
|
249
|
+
// host keeps its flex-1 and fills the tabs area. `flex-direction: column`
|
|
250
|
+
// makes the host lay its content out vertically when shown.
|
|
251
|
+
'[style.display]': "isActive() ? 'flex' : 'none'",
|
|
252
|
+
'[style.flexDirection]': "'column'",
|
|
253
|
+
},
|
|
254
|
+
template: `
|
|
255
|
+
<view #content [class]="contentClass()">
|
|
256
|
+
<ng-content />
|
|
257
|
+
</view>
|
|
258
|
+
`,
|
|
259
|
+
})
|
|
260
|
+
export class UiTabsContent {
|
|
261
|
+
readonly #tabs = inject(UiTabs);
|
|
262
|
+
|
|
263
|
+
readonly contentValue = input.required<string>({ alias: 'value' });
|
|
264
|
+
readonly userClass = input<string>('', { alias: 'class' });
|
|
265
|
+
|
|
266
|
+
readonly contentRef = viewChild<ElementRef>('content');
|
|
267
|
+
#anim?: AnimationHandle;
|
|
268
|
+
#isFirstRender = true;
|
|
269
|
+
|
|
270
|
+
protected readonly isActive = computed(
|
|
271
|
+
() => this.#tabs.value() === this.contentValue(),
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
constructor() {
|
|
275
|
+
// Animate content reveal when tab becomes active.
|
|
276
|
+
//
|
|
277
|
+
// The panel is ALWAYS rendered and shown/hidden via the host's `display`
|
|
278
|
+
// (see the `host` block) rather than wrapped in `@if`. On Lynx, destroying a
|
|
279
|
+
// subtree that holds projected `<ng-content>` sends that content through the
|
|
280
|
+
// renderer's "park on the page root" path (it must keep still-alive
|
|
281
|
+
// projected nodes from dying), which drops inactive panels at the bottom of
|
|
282
|
+
// the page and made re-showing them unreliable — the symptom was tab content
|
|
283
|
+
// that vanished and never came back. Keeping every panel mounted and only
|
|
284
|
+
// toggling `display` never destroys or re-parents anything: inactive panels
|
|
285
|
+
// collapse to 0×0 (Lynx `display: none`) so they take no space, and the
|
|
286
|
+
// active one simply reappears in place.
|
|
287
|
+
effect(() => {
|
|
288
|
+
if (!this.isActive()) return;
|
|
289
|
+
// Defer one tick so the display flip to 'flex' has applied before we animate.
|
|
290
|
+
setTimeout(() => {
|
|
291
|
+
const el = this.contentRef()?.nativeElement;
|
|
292
|
+
if (!el) return;
|
|
293
|
+
|
|
294
|
+
this.#anim?.cancel();
|
|
295
|
+
|
|
296
|
+
if (this.#isFirstRender) {
|
|
297
|
+
// First render: subtle vertical reveal (no directional context yet)
|
|
298
|
+
this.#isFirstRender = false;
|
|
299
|
+
this.#anim = revealIn(el, { fromY: 6, duration: DURATION.normal });
|
|
300
|
+
} else {
|
|
301
|
+
// Subsequent renders: direction-aware horizontal slide
|
|
302
|
+
const dir = this.#tabs.direction();
|
|
303
|
+
this.#anim = directionalSlideIn(el, dir, {
|
|
304
|
+
distance: 24,
|
|
305
|
+
duration: 300,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
}, 0);
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Visibility is controlled at the host (see `host` block); the inner view is
|
|
313
|
+
// always a normal flex column that fills the shown host.
|
|
314
|
+
protected readonly contentClass = computed(() =>
|
|
315
|
+
cn('mt-2 flex-col flex', this.userClass()),
|
|
316
|
+
);
|
|
317
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { UiTextarea } from './textarea';
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { signal } from '@angular/core';
|
|
2
|
+
import { render, waitForUpdate } from '@blotch/angular-lynx-testing-library';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { setInputSignal } from '../../../test-utils/set-input-signal';
|
|
5
|
+
import { UiTextarea } from './textarea';
|
|
6
|
+
|
|
7
|
+
describe('UiTextarea', () => {
|
|
8
|
+
it('renders with default configuration', async () => {
|
|
9
|
+
const { container } = await render(UiTextarea);
|
|
10
|
+
expect(container).toBeTruthy();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('onInput() updates the model and emits `changed`', async () => {
|
|
14
|
+
const { componentRef } = await render(UiTextarea);
|
|
15
|
+
const inst = componentRef.instance as unknown as UiTextarea & {
|
|
16
|
+
onInput: (e: { detail: { value: string } }) => void;
|
|
17
|
+
};
|
|
18
|
+
const emitted: string[] = [];
|
|
19
|
+
inst.changed.subscribe((v) => emitted.push(v));
|
|
20
|
+
inst.onInput({ detail: { value: 'hello' } });
|
|
21
|
+
expect(inst.value()).toBe('hello');
|
|
22
|
+
expect(emitted).toEqual(['hello']);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('onInput() is a no-op when disabled', async () => {
|
|
26
|
+
const { componentRef } = await render(UiTextarea);
|
|
27
|
+
const inst = componentRef.instance as unknown as UiTextarea & {
|
|
28
|
+
onInput: (e: { detail: { value: string } }) => void;
|
|
29
|
+
};
|
|
30
|
+
setInputSignal(inst.disabled, true);
|
|
31
|
+
const emitted = vi.fn();
|
|
32
|
+
inst.changed.subscribe(emitted);
|
|
33
|
+
inst.onInput({ detail: { value: 'blocked' } });
|
|
34
|
+
expect(inst.value()).toBe('');
|
|
35
|
+
expect(emitted).not.toHaveBeenCalled();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('onFocus/onBlur emit and update state', async () => {
|
|
39
|
+
const { componentRef } = await render(UiTextarea);
|
|
40
|
+
const inst = componentRef.instance as unknown as UiTextarea & {
|
|
41
|
+
onFocus: () => void;
|
|
42
|
+
onBlur: () => void;
|
|
43
|
+
};
|
|
44
|
+
const focused = vi.fn();
|
|
45
|
+
const blurred = vi.fn();
|
|
46
|
+
inst.focused.subscribe(focused);
|
|
47
|
+
inst.blurred.subscribe(blurred);
|
|
48
|
+
inst.onFocus();
|
|
49
|
+
inst.onBlur();
|
|
50
|
+
expect(focused).toHaveBeenCalledTimes(1);
|
|
51
|
+
expect(blurred).toHaveBeenCalledTimes(1);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('error input triggers the shake effect', async () => {
|
|
55
|
+
// Same pattern as UiInput: JIT + jsdom doesn't populate template-ref
|
|
56
|
+
// viewChild queries. Monkey-patch containerRef so the effect body's `el`
|
|
57
|
+
// check passes and the shake path executes.
|
|
58
|
+
const { componentRef, container } = await render(UiTextarea);
|
|
59
|
+
const inst = componentRef.instance as UiTextarea;
|
|
60
|
+
const view = container.querySelector('view') as Element;
|
|
61
|
+
(inst as unknown as { containerRef: unknown }).containerRef = signal({
|
|
62
|
+
nativeElement: view,
|
|
63
|
+
});
|
|
64
|
+
setInputSignal(inst.error, 'Too short');
|
|
65
|
+
await waitForUpdate();
|
|
66
|
+
await waitForUpdate();
|
|
67
|
+
expect(container.textContent).toContain('Too short');
|
|
68
|
+
// Clear then re-trigger to cover the cancel path.
|
|
69
|
+
setInputSignal(inst.error, '');
|
|
70
|
+
await waitForUpdate();
|
|
71
|
+
await waitForUpdate();
|
|
72
|
+
setInputSignal(inst.error, 'Retry');
|
|
73
|
+
await waitForUpdate();
|
|
74
|
+
await waitForUpdate();
|
|
75
|
+
expect(container.textContent).toContain('Retry');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('renders helper text when no error is present', async () => {
|
|
79
|
+
const { componentRef, container } = await render(UiTextarea);
|
|
80
|
+
const inst = componentRef.instance as UiTextarea;
|
|
81
|
+
setInputSignal(inst.helperText, 'Notes here');
|
|
82
|
+
await waitForUpdate();
|
|
83
|
+
expect(container.textContent).toContain('Notes here');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('renders the label when provided', async () => {
|
|
87
|
+
const { componentRef, container } = await render(UiTextarea);
|
|
88
|
+
const inst = componentRef.instance as UiTextarea;
|
|
89
|
+
setInputSignal(inst.label, 'Description');
|
|
90
|
+
await waitForUpdate();
|
|
91
|
+
expect(container.textContent).toContain('Description');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('computes a min-height wrapper style when only minLines is set', async () => {
|
|
95
|
+
// Exercises the `max != null ? ... : ''` false branch of wrapperStyle.
|
|
96
|
+
const { componentRef, container } = await render(UiTextarea);
|
|
97
|
+
const inst = componentRef.instance as UiTextarea;
|
|
98
|
+
setInputSignal(inst.minLines, 5);
|
|
99
|
+
await waitForUpdate();
|
|
100
|
+
// 5 * 20 + 20 = 120px
|
|
101
|
+
const wrapper = container.querySelector('[style*="min-height"]');
|
|
102
|
+
expect(wrapper?.getAttribute('style')).toContain('min-height: 120px');
|
|
103
|
+
expect(wrapper?.getAttribute('style')).not.toContain('max-height');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('adds max-height to wrapper style when maxLines is set', async () => {
|
|
107
|
+
const { componentRef, container } = await render(UiTextarea);
|
|
108
|
+
const inst = componentRef.instance as UiTextarea;
|
|
109
|
+
setInputSignal(inst.maxLines, 4);
|
|
110
|
+
await waitForUpdate();
|
|
111
|
+
const wrapper = container.querySelector('[style*="max-height"]');
|
|
112
|
+
expect(wrapper?.getAttribute('style')).toContain('max-height: 100px');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('makes the wrapper a stacking context so the field scrolls with it', async () => {
|
|
116
|
+
// On Lynx a z-indexed element inside a scroll-view only follows the scroll
|
|
117
|
+
// when an ancestor inside the scroll content is a stacking context. Without
|
|
118
|
+
// z-0 on the wrapper, the native field (and its placeholder) stays fixed on
|
|
119
|
+
// screen while the page scrolls.
|
|
120
|
+
const { container } = await render(UiTextarea);
|
|
121
|
+
const wrapper = container.querySelector(
|
|
122
|
+
'[class*="rounded-xl"][class*="bg-muted"]',
|
|
123
|
+
);
|
|
124
|
+
expect(wrapper?.getAttribute('class')?.split(/\s+/)).toContain('z-0');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('applies the disabled dim class when disabled', async () => {
|
|
128
|
+
// Covers the true branch of `disabled() && 'opacity-50'` in textareaWrapperClass.
|
|
129
|
+
const { componentRef, container } = await render(UiTextarea);
|
|
130
|
+
const inst = componentRef.instance as UiTextarea;
|
|
131
|
+
setInputSignal(inst.disabled, true);
|
|
132
|
+
await waitForUpdate();
|
|
133
|
+
const wrapper = container.querySelector(
|
|
134
|
+
'[class*="rounded-xl"][class*="bg-muted"]',
|
|
135
|
+
);
|
|
136
|
+
expect(wrapper?.getAttribute('class')).toContain('opacity-50');
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ElementRef,
|
|
3
|
+
Component,
|
|
4
|
+
ViewEncapsulation,
|
|
5
|
+
computed,
|
|
6
|
+
effect,
|
|
7
|
+
input,
|
|
8
|
+
model,
|
|
9
|
+
output,
|
|
10
|
+
signal,
|
|
11
|
+
viewChild,
|
|
12
|
+
} from '@angular/core';
|
|
13
|
+
import { type FormValueControl } from '@angular/forms/signals';
|
|
14
|
+
import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
|
|
15
|
+
|
|
16
|
+
import { type AnimationHandle, shake } from '../../utils/animate';
|
|
17
|
+
import { cn } from '../../utils/cn';
|
|
18
|
+
|
|
19
|
+
@Component({
|
|
20
|
+
selector: 'ui-textarea',
|
|
21
|
+
standalone: true,
|
|
22
|
+
imports: [LYNX_ELEMENTS],
|
|
23
|
+
encapsulation: ViewEncapsulation.None,
|
|
24
|
+
template: `
|
|
25
|
+
<view #container [class]="containerClass()">
|
|
26
|
+
@if (label()) {
|
|
27
|
+
<text [class]="labelClass()">{{ label() }}</text>
|
|
28
|
+
}
|
|
29
|
+
<!-- The min/max height lives on this wrapper, not the textarea: a native
|
|
30
|
+
Lynx textarea only auto-grows when it drives its container's height
|
|
31
|
+
(via height:100% below). Pinning min/max-height on the textarea itself
|
|
32
|
+
locks it to a fixed box that scrolls but never grows. -->
|
|
33
|
+
<view [class]="textareaWrapperClass()" [style]="wrapperStyle()">
|
|
34
|
+
<!-- Focus ring on its own overlay layer so it can fade (see input.ts for
|
|
35
|
+
the full rationale): box-shadow is animatable:no on Lynx, opacity is,
|
|
36
|
+
so we transition the layer's opacity. First child = painted behind
|
|
37
|
+
the textarea on Lynx (strict source order). Source order alone is NOT
|
|
38
|
+
enough on web: CSS paints a positioned (absolute) sibling above a
|
|
39
|
+
static one regardless of source order, so this transparent overlay
|
|
40
|
+
would swallow every tap — no focus, no typing. The textarea below
|
|
41
|
+
carries position:relative + z-index:1 so it sits above the overlay on
|
|
42
|
+
BOTH platforms (required on web). The wrapper's z-0 makes it the
|
|
43
|
+
stacking context for that z-index; without it Lynx stacks the native
|
|
44
|
+
textarea against an ancestor outside the scroll-view, so it stays
|
|
45
|
+
fixed on screen while the page scrolls. No pointer-events
|
|
46
|
+
(which errors the Lynx build). OUTSET only; Lynx doesn't render inset
|
|
47
|
+
box-shadows. -->
|
|
48
|
+
<view
|
|
49
|
+
class="rounded-xl opacity-0 absolute bottom-0 left-0 right-0 top-0"
|
|
50
|
+
[style.transition]="'opacity 150ms ease'"
|
|
51
|
+
[style.box-shadow]="ringShadow()"
|
|
52
|
+
[style.opacity]="ringVisible() ? 1 : 0"
|
|
53
|
+
></view>
|
|
54
|
+
<!-- [attr.disabled]="disabled() || undefined": passing 'undefined'
|
|
55
|
+
removes the attribute entirely; passing 'false' would set
|
|
56
|
+
disabled="false" which Lynx still treats as disabled. -->
|
|
57
|
+
<!-- [value] not [attr.value]: same reason as UiInput — routes through
|
|
58
|
+
LynxTextarea.ngOnChanges so value changes call invoke("setValue")
|
|
59
|
+
rather than the ineffective __SetAttribute path. -->
|
|
60
|
+
<!-- [attr.enable-scroll-bar]: only surface the scrollbar once maxLines is
|
|
61
|
+
set (i.e. the field can actually scroll). Native scrolling past
|
|
62
|
+
max-height works regardless; this just shows the indicator.
|
|
63
|
+
height:100% makes the textarea fill (and grow) the wrapper; the
|
|
64
|
+
pinned line-height keeps the wrapper's line math exact. -->
|
|
65
|
+
<!-- [attr.placeholder]: suppress the placeholder whenever a value is
|
|
66
|
+
present. The browser hides a placeholder under a value automatically,
|
|
67
|
+
but Lynx's iOS textarea renders the placeholder as a separate overlay
|
|
68
|
+
view that only hides on textViewDidChange — so an initial value set via
|
|
69
|
+
the setValue UIMethod leaves the placeholder stacked on top of it. Not
|
|
70
|
+
emitting the attribute when there's a value matches web semantics and
|
|
71
|
+
avoids the overlap on all platforms. -->
|
|
72
|
+
<textarea
|
|
73
|
+
[attr.placeholder]="value() ? null : placeholder()"
|
|
74
|
+
[value]="value()"
|
|
75
|
+
[attr.disabled]="disabled() || undefined"
|
|
76
|
+
[attr.enable-scroll-bar]="maxLines() != null ? 'true' : null"
|
|
77
|
+
class="text-sm text-foreground"
|
|
78
|
+
style="border: none; background: transparent; height: 100%; width: 100%; line-height: 20px; position: relative; z-index: 1;"
|
|
79
|
+
(bindinput)="onInput($any($event))"
|
|
80
|
+
(bindfocus)="onFocus()"
|
|
81
|
+
(bindblur)="onBlur()"
|
|
82
|
+
></textarea>
|
|
83
|
+
</view>
|
|
84
|
+
@if (error()) {
|
|
85
|
+
<text [class]="errorClass()">{{ error() }}</text>
|
|
86
|
+
} @else if (helperText()) {
|
|
87
|
+
<text [class]="helperClass()">{{ helperText() }}</text>
|
|
88
|
+
}
|
|
89
|
+
</view>
|
|
90
|
+
`,
|
|
91
|
+
})
|
|
92
|
+
export class UiTextarea implements FormValueControl<string> {
|
|
93
|
+
readonly value = model<string>('');
|
|
94
|
+
readonly label = input<string>('');
|
|
95
|
+
readonly placeholder = input<string>('');
|
|
96
|
+
readonly helperText = input<string>('');
|
|
97
|
+
readonly error = input<string>('');
|
|
98
|
+
readonly disabled = input(false);
|
|
99
|
+
readonly userClass = input<string>('', { alias: 'class' });
|
|
100
|
+
|
|
101
|
+
// SwiftUI-style lineLimit(min...max). The field opens at minLines, grows with
|
|
102
|
+
// content, then stops at maxLines and scrolls internally. maxLines defaults to
|
|
103
|
+
// undefined so the field grows unbounded (the previous behavior) unless capped.
|
|
104
|
+
readonly minLines = input(3);
|
|
105
|
+
readonly maxLines = input<number>();
|
|
106
|
+
|
|
107
|
+
readonly changed = output<string>();
|
|
108
|
+
readonly focused = output<void>();
|
|
109
|
+
readonly blurred = output<void>();
|
|
110
|
+
|
|
111
|
+
readonly containerRef = viewChild<ElementRef>('container');
|
|
112
|
+
readonly #isFocused = signal(false);
|
|
113
|
+
#shakeAnim?: AnimationHandle;
|
|
114
|
+
#previousError = '';
|
|
115
|
+
|
|
116
|
+
constructor() {
|
|
117
|
+
// Shake when an error first appears
|
|
118
|
+
effect(() => {
|
|
119
|
+
const err = this.error();
|
|
120
|
+
const el = this.containerRef()?.nativeElement;
|
|
121
|
+
if (err && !this.#previousError && el) {
|
|
122
|
+
this.#shakeAnim?.cancel();
|
|
123
|
+
this.#shakeAnim = shake(el);
|
|
124
|
+
}
|
|
125
|
+
this.#previousError = err;
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
protected readonly containerClass = computed(() =>
|
|
130
|
+
cn('flex-col gap-1.5 flex', this.userClass()),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
protected readonly labelClass = computed(() =>
|
|
134
|
+
cn('text-sm font-medium text-foreground'),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
protected readonly textareaWrapperClass = computed(() =>
|
|
138
|
+
cn(
|
|
139
|
+
// No min-height here — the minimum is now enforced by the textarea's own
|
|
140
|
+
// min-height (minLines) so callers can go below the old 3-line floor.
|
|
141
|
+
// z-0: stacking context for the textarea's z-index (see the template
|
|
142
|
+
// comment) so the native textarea scrolls with its wrapper.
|
|
143
|
+
'z-0 rounded-xl bg-muted px-3.5 py-2.5',
|
|
144
|
+
this.disabled() && 'opacity-50',
|
|
145
|
+
),
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Focus ring, drawn as an OUTSET box-shadow on a dedicated overlay layer (see
|
|
150
|
+
* the template comment, and input.ts for the full rationale). Two stacked
|
|
151
|
+
* outset shadows: a solid 1px ring hugging the edge (reads as a crisp border)
|
|
152
|
+
* and a softer 3px ring around it. Focus pairs var(--ring) with the translucent
|
|
153
|
+
* var(--ring-subtle); error uses solid var(--destructive) for both. Always set
|
|
154
|
+
* (never null) so the shadow stays painted while the layer fades out —
|
|
155
|
+
* visibility is driven by ringVisible()/opacity. On its own layer (not folded
|
|
156
|
+
* into wrapperStyle()) so the wrapper's whole-string [style] and this per-key
|
|
157
|
+
* box-shadow never collide. Colors resolve at runtime and track dark mode.
|
|
158
|
+
*/
|
|
159
|
+
protected readonly ringShadow = computed(() =>
|
|
160
|
+
this.error()
|
|
161
|
+
? '0 0 0 1px var(--destructive), 0 0 0 3px var(--destructive)'
|
|
162
|
+
: '0 0 0 1px var(--ring), 0 0 0 3px var(--ring-subtle)',
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Whether the focus ring is shown. The overlay's opacity transitions between 0
|
|
167
|
+
* and 1 on this, fading the ring in/out (opacity is animatable on Lynx;
|
|
168
|
+
* box-shadow is not). Shown while focused, or while an error is present.
|
|
169
|
+
*/
|
|
170
|
+
protected readonly ringVisible = computed(
|
|
171
|
+
() => this.#isFocused() || this.error() !== '',
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
// Sizing lives on the wrapper so the textarea (height:100%) grows it with
|
|
175
|
+
// content between min and max, then scrolls at the cap — the native textarea
|
|
176
|
+
// does not auto-grow on its own. The textarea's pinned line-height (20px, the
|
|
177
|
+
// text-sm line box) makes N lines map to exactly N*20px, independent of
|
|
178
|
+
// platform font metrics or Tailwind default drift. Lynx views are border-box,
|
|
179
|
+
// so these heights include the wrapper's py-2.5 (20px) vertical padding.
|
|
180
|
+
// maxLines is omitted when unset so the field keeps growing unbounded (the
|
|
181
|
+
// previous default).
|
|
182
|
+
protected readonly wrapperStyle = computed(() => {
|
|
183
|
+
const lineHeight = 20;
|
|
184
|
+
const verticalPadding = 20; // py-2.5 → 10px top + 10px bottom
|
|
185
|
+
const max = this.maxLines();
|
|
186
|
+
return [
|
|
187
|
+
`min-height: ${this.minLines() * lineHeight + verticalPadding}px`,
|
|
188
|
+
max != null ? `max-height: ${max * lineHeight + verticalPadding}px` : '',
|
|
189
|
+
]
|
|
190
|
+
.filter(Boolean)
|
|
191
|
+
.join('; ');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
protected readonly helperClass = computed(() =>
|
|
195
|
+
cn('text-xs text-muted-foreground'),
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
protected readonly errorClass = computed(() =>
|
|
199
|
+
cn('text-xs text-destructive'),
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
protected onFocus(): void {
|
|
203
|
+
this.#isFocused.set(true);
|
|
204
|
+
this.focused.emit();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
protected onBlur(): void {
|
|
208
|
+
this.#isFocused.set(false);
|
|
209
|
+
this.blurred.emit();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Lynx input events carry the updated value in `event.detail.value`,
|
|
214
|
+
* not in `event.target.value` as on the web — hence the custom type.
|
|
215
|
+
*/
|
|
216
|
+
protected onInput(event: { detail: { value: string } }): void {
|
|
217
|
+
if (this.disabled()) return;
|
|
218
|
+
this.value.set(event.detail.value);
|
|
219
|
+
this.changed.emit(event.detail.value);
|
|
220
|
+
}
|
|
221
|
+
}
|