@joanroucoux/cairn-ui 0.2.0 → 0.3.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.
@@ -1,9 +1,202 @@
1
1
  import * as i0 from '@angular/core';
2
- import { input, computed, Component, booleanAttribute, inject, ElementRef, DestroyRef, InjectionToken, output, effect, contentChild, afterRenderEffect, forwardRef, Directive, model, viewChildren } from '@angular/core';
2
+ import { InjectionToken, signal, input, inject, LOCALE_ID, booleanAttribute, computed, Component, output, ElementRef, DestroyRef, effect, contentChild, afterRenderEffect, forwardRef, Directive, contentChildren, model, viewChildren } from '@angular/core';
3
+
4
+ const MINUS = '−';
5
+ const EM_DASH = '—';
6
+ const BULLETS = '••••';
7
+ /** Formats a number or an amount for the locale, with a missing value as an em dash and masking behind four bullets. */
8
+ function formatAmount(value, options, masked = false) {
9
+ if (value === null || value === undefined || Number.isNaN(value)) {
10
+ return EM_DASH;
11
+ }
12
+ const digits = options.fractionDigits ?? 2;
13
+ const format = new Intl.NumberFormat(options.locale, {
14
+ ...(options.currency ? { style: 'currency', currency: options.currency } : {}),
15
+ minimumFractionDigits: digits,
16
+ maximumFractionDigits: digits,
17
+ });
18
+ const magnitude = Math.abs(value);
19
+ const parts = format.formatToParts(magnitude);
20
+ if (masked) {
21
+ let replaced = false;
22
+ return parts
23
+ .map((part) => {
24
+ if (['integer', 'group', 'decimal', 'fraction'].includes(part.type)) {
25
+ if (replaced) {
26
+ return '';
27
+ }
28
+ replaced = true;
29
+ return BULLETS;
30
+ }
31
+ return part.value;
32
+ })
33
+ .join('');
34
+ }
35
+ const text = parts.map((part) => part.value).join('');
36
+ const isZero = Number(format.format(magnitude).replace(/[^\d]/g, '')) === 0;
37
+ if (!options.signed || isZero) {
38
+ return value < 0 && !isZero ? `${MINUS}${text}` : text;
39
+ }
40
+ return `${value > 0 ? '+' : MINUS}${text}`;
41
+ }
42
+
43
+ /** App-wide "amounts are hidden" state. Defaults to never masked; the app overrides it. */
44
+ const UI_AMOUNT_MASKED = new InjectionToken('UI_AMOUNT_MASKED', {
45
+ providedIn: 'root',
46
+ factory: () => signal(false),
47
+ });
48
+ /** Available figure styles. `AmountNumeric` is derived from this tuple. */
49
+ const AMOUNT_NUMERICS = ['tabular', 'proportional'];
50
+ /** Available masking behaviors. `AmountMasking` is derived from this tuple. */
51
+ const AMOUNT_MASKINGS = ['dots', 'hide'];
52
+ /**
53
+ * A number or an amount of money, formatted for the locale and masked when the app hides amounts.
54
+ *
55
+ * @example
56
+ * <ui-amount [value]="total" currency="EUR" numeric="proportional" />
57
+ * <ui-amount [value]="dayChange" currency="EUR" signed whenMasked="hide" />
58
+ */
59
+ class UiAmount {
60
+ value = input(/* @ts-ignore */
61
+ ...(ngDevMode ? [undefined, { debugName: "value" }] : /* istanbul ignore next */ []));
62
+ currency = input(/* @ts-ignore */
63
+ ...(ngDevMode ? [undefined, { debugName: "currency" }] : /* istanbul ignore next */ []));
64
+ locale = input(inject(LOCALE_ID), /* @ts-ignore */
65
+ ...(ngDevMode ? [{ debugName: "locale" }] : /* istanbul ignore next */ []));
66
+ signed = input(false, { ...(ngDevMode ? { debugName: "signed" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
67
+ fractionDigits = input(2, /* @ts-ignore */
68
+ ...(ngDevMode ? [{ debugName: "fractionDigits" }] : /* istanbul ignore next */ []));
69
+ numeric = input('tabular', /* @ts-ignore */
70
+ ...(ngDevMode ? [{ debugName: "numeric" }] : /* istanbul ignore next */ []));
71
+ whenMasked = input('dots', /* @ts-ignore */
72
+ ...(ngDevMode ? [{ debugName: "whenMasked" }] : /* istanbul ignore next */ []));
73
+ #masked = inject(UI_AMOUNT_MASKED);
74
+ hidden = computed(() => this.#masked() && this.whenMasked() === 'hide', /* @ts-ignore */
75
+ ...(ngDevMode ? [{ debugName: "hidden" }] : /* istanbul ignore next */ []));
76
+ text = computed(() => formatAmount(this.value(), {
77
+ locale: this.locale(),
78
+ currency: this.currency(),
79
+ signed: this.signed(),
80
+ fractionDigits: this.fractionDigits(),
81
+ }, this.#masked()), /* @ts-ignore */
82
+ ...(ngDevMode ? [{ debugName: "text" }] : /* istanbul ignore next */ []));
83
+ classes = computed(() => {
84
+ const missing = this.value() === null || this.value() === undefined;
85
+ return [
86
+ 'whitespace-nowrap',
87
+ this.numeric() === 'tabular' ? 'tabular-nums' : '',
88
+ missing ? 'text-(--subtle-foreground)' : '',
89
+ ]
90
+ .filter(Boolean)
91
+ .join(' ');
92
+ }, /* @ts-ignore */
93
+ ...(ngDevMode ? [{ debugName: "classes" }] : /* istanbul ignore next */ []));
94
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: UiAmount, deps: [], target: i0.ɵɵFactoryTarget.Component });
95
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "22.1.7", type: UiAmount, isStandalone: true, selector: "ui-amount", inputs: { value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: false, transformFunction: null }, currency: { classPropertyName: "currency", publicName: "currency", isSignal: true, isRequired: false, transformFunction: null }, locale: { classPropertyName: "locale", publicName: "locale", isSignal: true, isRequired: false, transformFunction: null }, signed: { classPropertyName: "signed", publicName: "signed", isSignal: true, isRequired: false, transformFunction: null }, fractionDigits: { classPropertyName: "fractionDigits", publicName: "fractionDigits", isSignal: true, isRequired: false, transformFunction: null }, numeric: { classPropertyName: "numeric", publicName: "numeric", isSignal: true, isRequired: false, transformFunction: null }, whenMasked: { classPropertyName: "whenMasked", publicName: "whenMasked", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "class": "classes()", "attr.hidden": "hidden() || null" } }, ngImport: i0, template: '{{ text() }}', isInline: true });
96
+ }
97
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: UiAmount, decorators: [{
98
+ type: Component,
99
+ args: [{
100
+ selector: 'ui-amount',
101
+ template: '{{ text() }}',
102
+ host: {
103
+ '[class]': 'classes()',
104
+ '[attr.hidden]': 'hidden() || null',
105
+ },
106
+ }]
107
+ }], propDecorators: { value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: false }] }], currency: [{ type: i0.Input, args: [{ isSignal: true, alias: "currency", required: false }] }], locale: [{ type: i0.Input, args: [{ isSignal: true, alias: "locale", required: false }] }], signed: [{ type: i0.Input, args: [{ isSignal: true, alias: "signed", required: false }] }], fractionDigits: [{ type: i0.Input, args: [{ isSignal: true, alias: "fractionDigits", required: false }] }], numeric: [{ type: i0.Input, args: [{ isSignal: true, alias: "numeric", required: false }] }], whenMasked: [{ type: i0.Input, args: [{ isSignal: true, alias: "whenMasked", required: false }] }] } });
108
+
109
+ /** Available async block states. `AsyncState` is derived from this tuple. */
110
+ const ASYNC_STATES = ['loading', 'error', 'empty', 'ready'];
111
+ const RETRY_BUTTON_CLASSES = 'mt-2 relative inline-flex items-center justify-center gap-2 rounded-control font-medium whitespace-nowrap cursor-pointer select-none touch-manipulation transition-[transform,background-color,opacity] duration-(--duration-press) ease-out active:scale-(--press-scale) focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--ring) min-h-(--row-min) px-4 text-label bg-(--card) text-(--foreground) shadow-[inset_0_0_0_1px_var(--border)] hover:bg-(--glow)';
112
+ /**
113
+ * The three states of a block that depends on a service call, next to the ready content.
114
+ *
115
+ * @example
116
+ * <ui-async [state]="totalState()" errorTitle="Total could not be loaded" errorMessage="The server did not answer." retryLabel="Retry" (retry)="reload()">
117
+ * <span asyncLoading><ui-skeleton shape="figure" /></span>
118
+ * <span asyncEmpty>Add a holding to see your total.</span>
119
+ * <span>{{ total() }}</span>
120
+ * </ui-async>
121
+ */
122
+ class UiAsync {
123
+ state = input.required(/* @ts-ignore */
124
+ ...(ngDevMode ? [{ debugName: "state" }] : /* istanbul ignore next */ []));
125
+ errorTitle = input(/* @ts-ignore */
126
+ ...(ngDevMode ? [undefined, { debugName: "errorTitle" }] : /* istanbul ignore next */ []));
127
+ errorMessage = input(/* @ts-ignore */
128
+ ...(ngDevMode ? [undefined, { debugName: "errorMessage" }] : /* istanbul ignore next */ []));
129
+ retryLabel = input(/* @ts-ignore */
130
+ ...(ngDevMode ? [undefined, { debugName: "retryLabel" }] : /* istanbul ignore next */ []));
131
+ retry = output();
132
+ retryButtonClasses = RETRY_BUTTON_CLASSES;
133
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: UiAsync, deps: [], target: i0.ɵɵFactoryTarget.Component });
134
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.7", type: UiAsync, isStandalone: true, selector: "ui-async", inputs: { state: { classPropertyName: "state", publicName: "state", isSignal: true, isRequired: true, transformFunction: null }, errorTitle: { classPropertyName: "errorTitle", publicName: "errorTitle", isSignal: true, isRequired: false, transformFunction: null }, errorMessage: { classPropertyName: "errorMessage", publicName: "errorMessage", isSignal: true, isRequired: false, transformFunction: null }, retryLabel: { classPropertyName: "retryLabel", publicName: "retryLabel", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { retry: "retry" }, host: { properties: { "attr.aria-busy": "state() === 'loading' || null" } }, ngImport: i0, template: `
135
+ @switch (state()) {
136
+ @case ('loading') {
137
+ <ng-content select="[asyncLoading]" />
138
+ }
139
+ @case ('error') {
140
+ <div
141
+ aria-live="polite"
142
+ class="rounded-control flex flex-col items-start gap-1 bg-(--elevated) p-(--inset-card)"
143
+ role="alert"
144
+ >
145
+ <span class="text-body font-medium">{{ errorTitle() }}</span>
146
+ <span class="text-label text-(--muted-foreground)">{{ errorMessage() }}</span>
147
+ <button type="button" [class]="retryButtonClasses" (click)="retry.emit()">
148
+ {{ retryLabel() }}
149
+ </button>
150
+ </div>
151
+ }
152
+ @case ('empty') {
153
+ <ng-content select="[asyncEmpty]" />
154
+ }
155
+ @default {
156
+ <ng-content />
157
+ }
158
+ }
159
+ `, isInline: true });
160
+ }
161
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: UiAsync, decorators: [{
162
+ type: Component,
163
+ args: [{
164
+ selector: 'ui-async',
165
+ template: `
166
+ @switch (state()) {
167
+ @case ('loading') {
168
+ <ng-content select="[asyncLoading]" />
169
+ }
170
+ @case ('error') {
171
+ <div
172
+ aria-live="polite"
173
+ class="rounded-control flex flex-col items-start gap-1 bg-(--elevated) p-(--inset-card)"
174
+ role="alert"
175
+ >
176
+ <span class="text-body font-medium">{{ errorTitle() }}</span>
177
+ <span class="text-label text-(--muted-foreground)">{{ errorMessage() }}</span>
178
+ <button type="button" [class]="retryButtonClasses" (click)="retry.emit()">
179
+ {{ retryLabel() }}
180
+ </button>
181
+ </div>
182
+ }
183
+ @case ('empty') {
184
+ <ng-content select="[asyncEmpty]" />
185
+ }
186
+ @default {
187
+ <ng-content />
188
+ }
189
+ }
190
+ `,
191
+ host: {
192
+ '[attr.aria-busy]': "state() === 'loading' || null",
193
+ },
194
+ }]
195
+ }], propDecorators: { state: [{ type: i0.Input, args: [{ isSignal: true, alias: "state", required: true }] }], errorTitle: [{ type: i0.Input, args: [{ isSignal: true, alias: "errorTitle", required: false }] }], errorMessage: [{ type: i0.Input, args: [{ isSignal: true, alias: "errorMessage", required: false }] }], retryLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "retryLabel", required: false }] }], retry: [{ type: i0.Output, args: ["retry"] }] } });
3
196
 
4
197
  /** Available avatar sizes. `AvatarSize` is derived from this tuple. */
5
198
  const AVATAR_SIZES = ['sm', 'md'];
6
- const BASE_CLASSES$4 = 'inline-flex items-center justify-center rounded-pill border border-(--primary) bg-(--soft) font-semibold tabular-nums text-(--primary)';
199
+ const BASE_CLASSES$6 = 'inline-flex items-center justify-center rounded-pill border border-(--primary) bg-(--soft) font-semibold tabular-nums text-(--primary)';
7
200
  const SIZE_CLASSES$1 = {
8
201
  sm: 'size-8 text-caption',
9
202
  md: 'size-11 text-label',
@@ -21,7 +214,7 @@ class UiAvatar {
21
214
  ...(ngDevMode ? [{ debugName: "label" }] : /* istanbul ignore next */ []));
22
215
  size = input('md', /* @ts-ignore */
23
216
  ...(ngDevMode ? [{ debugName: "size" }] : /* istanbul ignore next */ []));
24
- classes = computed(() => `${BASE_CLASSES$4} ${SIZE_CLASSES$1[this.size()]}`, /* @ts-ignore */
217
+ classes = computed(() => `${BASE_CLASSES$6} ${SIZE_CLASSES$1[this.size()]}`, /* @ts-ignore */
25
218
  ...(ngDevMode ? [{ debugName: "classes" }] : /* istanbul ignore next */ []));
26
219
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: UiAvatar, deps: [], target: i0.ɵɵFactoryTarget.Component });
27
220
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "22.1.7", type: UiAvatar, isStandalone: true, selector: "ui-avatar", inputs: { initials: { classPropertyName: "initials", publicName: "initials", isSignal: true, isRequired: true, transformFunction: null }, label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: true, transformFunction: null }, size: { classPropertyName: "size", publicName: "size", isSignal: true, isRequired: false, transformFunction: null } }, host: { attributes: { "role": "img" }, properties: { "attr.aria-label": "label()", "class": "classes()" } }, ngImport: i0, template: '<span aria-hidden="true">{{ initials() }}</span>', isInline: true });
@@ -41,7 +234,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImpor
41
234
 
42
235
  /** Available badge variants. `BadgeVariant` is derived from this tuple. */
43
236
  const BADGE_VARIANTS = ['neutral', 'outline'];
44
- const BASE_CLASSES$3 = 'inline-flex h-6 items-center rounded-pill px-2.5 text-caption font-medium';
237
+ const BASE_CLASSES$5 = 'inline-flex h-6 items-center rounded-pill px-2.5 text-caption font-medium';
45
238
  const VARIANT_CLASSES$2 = {
46
239
  neutral: 'bg-(--muted) text-(--foreground)',
47
240
  outline: 'shadow-[inset_0_0_0_1px_var(--border)] text-(--foreground)',
@@ -55,7 +248,7 @@ const VARIANT_CLASSES$2 = {
55
248
  class UiBadge {
56
249
  variant = input('neutral', /* @ts-ignore */
57
250
  ...(ngDevMode ? [{ debugName: "variant" }] : /* istanbul ignore next */ []));
58
- classes = computed(() => `${BASE_CLASSES$3} ${VARIANT_CLASSES$2[this.variant()]}`, /* @ts-ignore */
251
+ classes = computed(() => `${BASE_CLASSES$5} ${VARIANT_CLASSES$2[this.variant()]}`, /* @ts-ignore */
59
252
  ...(ngDevMode ? [{ debugName: "classes" }] : /* istanbul ignore next */ []));
60
253
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: UiBadge, deps: [], target: i0.ɵɵFactoryTarget.Component });
61
254
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "22.1.7", type: UiBadge, isStandalone: true, selector: "ui-badge", inputs: { variant: { classPropertyName: "variant", publicName: "variant", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "class": "classes()" } }, ngImport: i0, template: '<ng-content />', isInline: true });
@@ -75,9 +268,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImpor
75
268
  const BUTTON_VARIANTS = ['primary', 'outline', 'ghost', 'destructive'];
76
269
  /** Available button sizes. `md` is the touch target Cairn asks for everywhere. */
77
270
  const BUTTON_SIZES = ['md', 'lg', 'xl', 'icon'];
78
- const BASE_CLASSES$2 =
79
- // Tailwind's preflight gives buttons cursor: default; the hand cursor is what users expect here.
80
- 'relative inline-flex items-center justify-center gap-2 rounded-control font-medium whitespace-nowrap cursor-pointer select-none touch-manipulation transition-[transform,background-color,opacity] duration-(--duration-press) ease-out active:scale-(--press-scale) focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--ring) disabled:pointer-events-none disabled:opacity-40 aria-disabled:not-aria-busy:opacity-40';
271
+ const BASE_CLASSES$4 = 'relative inline-flex items-center justify-center gap-2 rounded-control font-medium whitespace-nowrap cursor-pointer select-none touch-manipulation transition-[transform,background-color,opacity] duration-(--duration-press) ease-out active:scale-(--press-scale) focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--ring) disabled:pointer-events-none disabled:opacity-40 aria-disabled:not-aria-busy:opacity-40';
81
272
  const VARIANT_CLASSES$1 = {
82
273
  primary: 'bg-(--primary) text-(--primary-foreground) hover:opacity-90',
83
274
  outline: 'bg-(--card) text-(--foreground) shadow-[inset_0_0_0_1px_var(--border)] hover:bg-(--glow)',
@@ -91,16 +282,7 @@ const SIZE_CLASSES = {
91
282
  icon: 'size-(--row-min) p-0',
92
283
  };
93
284
  /**
94
- * Styled native button. Applied as an attribute so the host keeps every native
95
- * button behavior (type, disabled, form submission, accessibility) for free.
96
- *
97
- * Also applies to an anchor, for the one case where the action really is a navigation:
98
- * a download the browser has to perform itself.
99
- *
100
- * `size="icon"` has no visible text: give it an `aria-label`.
101
- *
102
- * A `loading` button shows a spinner before its label and swallows clicks, so a slow first
103
- * response cannot turn into a double submission.
285
+ * Styled native button or anchor, applied as an attribute so the host keeps every native behavior.
104
286
  *
105
287
  * @example
106
288
  * <button ui-button variant="destructive">Delete</button>
@@ -112,12 +294,9 @@ class UiButton {
112
294
  size = input('md', /* @ts-ignore */
113
295
  ...(ngDevMode ? [{ debugName: "size" }] : /* istanbul ignore next */ []));
114
296
  loading = input(false, { ...(ngDevMode ? { debugName: "loading" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
115
- classes = computed(() => `${BASE_CLASSES$2} ${VARIANT_CLASSES$1[this.variant()]} ${SIZE_CLASSES[this.size()]}${this.loading() ? ' pointer-events-none opacity-70' : ''}`, /* @ts-ignore */
297
+ classes = computed(() => `${BASE_CLASSES$4} ${VARIANT_CLASSES$1[this.variant()]} ${SIZE_CLASSES[this.size()]}${this.loading() ? ' pointer-events-none opacity-70' : ''}`, /* @ts-ignore */
116
298
  ...(ngDevMode ? [{ debugName: "classes" }] : /* istanbul ignore next */ []));
117
299
  constructor() {
118
- // A host `(click)` binding runs after the template's own listener, so a loading button would
119
- // still fire the consumer's handler once before this guard got a say. Capturing from the
120
- // constructor puts this listener first regardless of binding order.
121
300
  const host = inject(ElementRef).nativeElement;
122
301
  const guard = (event) => {
123
302
  if (this.loading()) {
@@ -164,7 +343,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImpor
164
343
  const CARD_VARIANTS = ['default', 'elevated'];
165
344
  /** Available card paddings. `CardPadding` is derived from this tuple. */
166
345
  const CARD_PADDINGS = ['none', 'sm', 'md'];
167
- const BASE_CLASSES$1 = 'block rounded-container border';
346
+ const BASE_CLASSES$3 = 'block rounded-container border';
168
347
  const VARIANT_CLASSES = {
169
348
  default: 'bg-(--card) border-(--border) text-(--card-foreground)',
170
349
  elevated: 'bg-(--elevated) border-(--hairline) text-(--foreground)',
@@ -185,7 +364,7 @@ class UiCard {
185
364
  ...(ngDevMode ? [{ debugName: "variant" }] : /* istanbul ignore next */ []));
186
365
  padding = input('md', /* @ts-ignore */
187
366
  ...(ngDevMode ? [{ debugName: "padding" }] : /* istanbul ignore next */ []));
188
- classes = computed(() => `${BASE_CLASSES$1} ${VARIANT_CLASSES[this.variant()]} ${PADDING_CLASSES[this.padding()]}`, /* @ts-ignore */
367
+ classes = computed(() => `${BASE_CLASSES$3} ${VARIANT_CLASSES[this.variant()]} ${PADDING_CLASSES[this.padding()]}`, /* @ts-ignore */
189
368
  ...(ngDevMode ? [{ debugName: "classes" }] : /* istanbul ignore next */ []));
190
369
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: UiCard, deps: [], target: i0.ɵɵFactoryTarget.Component });
191
370
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "22.1.7", type: UiCard, isStandalone: true, selector: "ui-card", inputs: { variant: { classPropertyName: "variant", publicName: "variant", isSignal: true, isRequired: false, transformFunction: null }, padding: { classPropertyName: "padding", publicName: "padding", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "class": "classes()" } }, ngImport: i0, template: '<ng-content />', isInline: true });
@@ -213,7 +392,7 @@ const UI_CONTROL = new InjectionToken('UI_CONTROL');
213
392
 
214
393
  /** Available delta emphases. `DeltaEmphasis` is derived from this tuple. */
215
394
  const DELTA_EMPHASES = ['text', 'pill'];
216
- const BASE_CLASSES = 'inline-flex items-center gap-1 text-label font-medium tabular-nums';
395
+ const BASE_CLASSES$2 = 'inline-flex items-center gap-1 text-label font-medium tabular-nums';
217
396
  const PILL_CLASSES = 'h-6 rounded-pill px-2';
218
397
  const TEXT_CLASSES = {
219
398
  positive: 'text-(--positive)',
@@ -221,9 +400,6 @@ const TEXT_CLASSES = {
221
400
  neutral: 'text-(--muted-foreground)',
222
401
  unknown: 'text-(--subtle-foreground)',
223
402
  };
224
- // The handoff's `Cairn Composants.dc.html` uses --soft here, but --positive text on --soft only
225
- // reaches 4.33:1 (axe flags it): --soft is a touch darker than --background, and --positive sits
226
- // right at the edge of AA at 4.77:1 against --background alone. --muted keeps the chip legible.
227
403
  const PILL_BACKGROUND_CLASS = 'bg-(--muted)';
228
404
  /**
229
405
  * Signed amount whose sign carries meaning: a gain, a loss, or an unknown.
@@ -261,7 +437,7 @@ class UiDelta {
261
437
  classes = computed(() => {
262
438
  const sign = this.sign();
263
439
  const background = this.emphasis() === 'pill' ? `${PILL_CLASSES} ${PILL_BACKGROUND_CLASS}` : 'bg-transparent';
264
- return `${BASE_CLASSES} ${TEXT_CLASSES[sign]} ${background}`;
440
+ return `${BASE_CLASSES$2} ${TEXT_CLASSES[sign]} ${background}`;
265
441
  }, /* @ts-ignore */
266
442
  ...(ngDevMode ? [{ debugName: "classes" }] : /* istanbul ignore next */ []));
267
443
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: UiDelta, deps: [], target: i0.ɵɵFactoryTarget.Component });
@@ -296,15 +472,15 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImpor
296
472
  const DIALOG_WIDTHS = ['md', 'lg'];
297
473
  const WIDTH_CLASSES = {
298
474
  md: 'max-w-md',
299
- lg: 'max-w-lg',
475
+ lg: 'max-w-[560px]',
300
476
  };
301
- const nextId$1 = (() => {
477
+ const nextId$2 = (() => {
302
478
  let count = 0;
303
479
  return () => `ui-dialog-${++count}`;
304
480
  })();
305
481
  /**
306
- * Modal dialog on the native <dialog>: focus trap, focus return, Escape and the top layer
307
- * come from the platform. Two projection slots - the body, and [dialogActions] for the footer.
482
+ * Modal dialog on the native <dialog>: a sheet rising from the bottom under `64rem`, centered
483
+ * above it. Two projection slots - the body, and [dialogActions] for the footer.
308
484
  *
309
485
  * @example
310
486
  * <ui-dialog heading="Delete this holding?" [open]="confirming()" (dismissed)="confirming.set(false)">
@@ -319,12 +495,11 @@ class UiDialog {
319
495
  ...(ngDevMode ? [undefined, { debugName: "description" }] : /* istanbul ignore next */ []));
320
496
  width = input('lg', /* @ts-ignore */
321
497
  ...(ngDevMode ? [{ debugName: "width" }] : /* istanbul ignore next */ []));
322
- open = input(false, /* @ts-ignore */
323
- ...(ngDevMode ? [{ debugName: "open" }] : /* istanbul ignore next */ []));
498
+ open = input(false, { ...(ngDevMode ? { debugName: "open" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
324
499
  dismissed = output();
325
- headingId = nextId$1();
500
+ headingId = nextId$2();
326
501
  descriptionId = `${this.headingId}-description`;
327
- classes = computed(() => `w-full m-auto ${WIDTH_CLASSES[this.width()]} rounded-container border border-(--border) bg-(--card) p-6 text-(--foreground) backdrop:bg-black/60`, /* @ts-ignore */
502
+ classes = computed(() => `flex w-full flex-col m-auto mt-auto max-h-[calc(100dvh-2rem)] max-lg:mb-0 max-lg:max-w-none max-lg:rounded-b-none rounded-container ${WIDTH_CLASSES[this.width()]} border border-(--border) bg-(--card) text-(--foreground) backdrop:bg-black/60`, /* @ts-ignore */
328
503
  ...(ngDevMode ? [{ debugName: "classes" }] : /* istanbul ignore next */ []));
329
504
  #host = inject(ElementRef);
330
505
  constructor() {
@@ -353,54 +528,73 @@ class UiDialog {
353
528
  [class]="classes()"
354
529
  (close)="onNativeClose()"
355
530
  >
356
- <h2 class="text-title font-semibold" [id]="headingId">{{ heading() }}</h2>
531
+ <div aria-hidden="true" class="flex h-7 items-center justify-center lg:hidden" data-dialog-handle>
532
+ <span class="rounded-pill h-1.5 w-9 bg-(--border)"></span>
533
+ </div>
357
534
 
358
- @if (description()) {
359
- <p class="text-label mt-2 text-(--muted-foreground)" [id]="descriptionId">{{ description() }}</p>
360
- }
535
+ <div class="px-6 pt-1 lg:pt-6">
536
+ <h2 class="text-title font-semibold" [id]="headingId">{{ heading() }}</h2>
361
537
 
362
- <!-- empty:hidden keeps a body-less dialog from carrying the body's top margin. -->
363
- <div class="mt-4 flex flex-col gap-4 empty:hidden">
364
- <ng-content />
538
+ @if (description()) {
539
+ <p class="text-label mt-2 text-(--muted-foreground)" [id]="descriptionId">{{ description() }}</p>
540
+ }
541
+ </div>
542
+
543
+ <div class="min-h-0 flex-1 overflow-y-auto px-6" data-dialog-body tabindex="0">
544
+ <!-- empty:hidden keeps a body-less dialog from carrying the body's top margin. -->
545
+ <div class="mt-4 flex flex-col gap-4 empty:hidden">
546
+ <ng-content />
547
+ </div>
365
548
  </div>
366
549
 
367
- <div class="mt-6 flex justify-end gap-3">
550
+ <div
551
+ class="flex justify-end gap-3 px-6 pt-6 pb-6 max-lg:pb-[calc(1rem+env(safe-area-inset-bottom))]"
552
+ data-dialog-footer
553
+ >
368
554
  <ng-content select="[dialogActions]" />
369
555
  </div>
370
556
  </dialog>
371
- `, isInline: true });
557
+ `, isInline: true, styles: ["dialog{transition:opacity var(--duration-base) var(--ease-sheet),transform var(--duration-base) var(--ease-sheet),overlay var(--duration-base) allow-discrete,display var(--duration-base) allow-discrete;opacity:1;transform:translateY(0)}dialog:not([open]){opacity:0;transform:translateY(100%);transition-duration:var(--duration-exit)}@starting-style{dialog[open]{opacity:0;transform:translateY(100%)}}@media(min-width:64rem){dialog{transform:scale(1)}dialog:not([open]){transform:scale(var(--enter-scale))}@starting-style{dialog[open]{transform:scale(var(--enter-scale))}}}dialog::backdrop{transition:opacity var(--duration-base) var(--ease-out);opacity:1}dialog:not([open])::backdrop{opacity:0;transition-duration:var(--duration-exit)}@starting-style{dialog[open]::backdrop{opacity:0}}\n"] });
372
558
  }
373
559
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: UiDialog, decorators: [{
374
560
  type: Component,
375
- args: [{
376
- selector: 'ui-dialog',
377
- template: `
561
+ args: [{ selector: 'ui-dialog', template: `
378
562
  <dialog
379
563
  [attr.aria-describedby]="description() ? descriptionId : null"
380
564
  [attr.aria-labelledby]="headingId"
381
565
  [class]="classes()"
382
566
  (close)="onNativeClose()"
383
567
  >
384
- <h2 class="text-title font-semibold" [id]="headingId">{{ heading() }}</h2>
568
+ <div aria-hidden="true" class="flex h-7 items-center justify-center lg:hidden" data-dialog-handle>
569
+ <span class="rounded-pill h-1.5 w-9 bg-(--border)"></span>
570
+ </div>
385
571
 
386
- @if (description()) {
387
- <p class="text-label mt-2 text-(--muted-foreground)" [id]="descriptionId">{{ description() }}</p>
388
- }
572
+ <div class="px-6 pt-1 lg:pt-6">
573
+ <h2 class="text-title font-semibold" [id]="headingId">{{ heading() }}</h2>
389
574
 
390
- <!-- empty:hidden keeps a body-less dialog from carrying the body's top margin. -->
391
- <div class="mt-4 flex flex-col gap-4 empty:hidden">
392
- <ng-content />
575
+ @if (description()) {
576
+ <p class="text-label mt-2 text-(--muted-foreground)" [id]="descriptionId">{{ description() }}</p>
577
+ }
393
578
  </div>
394
579
 
395
- <div class="mt-6 flex justify-end gap-3">
580
+ <div class="min-h-0 flex-1 overflow-y-auto px-6" data-dialog-body tabindex="0">
581
+ <!-- empty:hidden keeps a body-less dialog from carrying the body's top margin. -->
582
+ <div class="mt-4 flex flex-col gap-4 empty:hidden">
583
+ <ng-content />
584
+ </div>
585
+ </div>
586
+
587
+ <div
588
+ class="flex justify-end gap-3 px-6 pt-6 pb-6 max-lg:pb-[calc(1rem+env(safe-area-inset-bottom))]"
589
+ data-dialog-footer
590
+ >
396
591
  <ng-content select="[dialogActions]" />
397
592
  </div>
398
593
  </dialog>
399
- `,
400
- }]
594
+ `, styles: ["dialog{transition:opacity var(--duration-base) var(--ease-sheet),transform var(--duration-base) var(--ease-sheet),overlay var(--duration-base) allow-discrete,display var(--duration-base) allow-discrete;opacity:1;transform:translateY(0)}dialog:not([open]){opacity:0;transform:translateY(100%);transition-duration:var(--duration-exit)}@starting-style{dialog[open]{opacity:0;transform:translateY(100%)}}@media(min-width:64rem){dialog{transform:scale(1)}dialog:not([open]){transform:scale(var(--enter-scale))}@starting-style{dialog[open]{transform:scale(var(--enter-scale))}}}dialog::backdrop{transition:opacity var(--duration-base) var(--ease-out);opacity:1}dialog:not([open])::backdrop{opacity:0;transition-duration:var(--duration-exit)}@starting-style{dialog[open]::backdrop{opacity:0}}\n"] }]
401
595
  }], ctorParameters: () => [], propDecorators: { heading: [{ type: i0.Input, args: [{ isSignal: true, alias: "heading", required: true }] }], description: [{ type: i0.Input, args: [{ isSignal: true, alias: "description", required: false }] }], width: [{ type: i0.Input, args: [{ isSignal: true, alias: "width", required: false }] }], open: [{ type: i0.Input, args: [{ isSignal: true, alias: "open", required: false }] }], dismissed: [{ type: i0.Output, args: ["dismissed"] }] } });
402
596
 
403
- const nextId = (() => {
597
+ const nextId$1 = (() => {
404
598
  let count = 0;
405
599
  return () => `ui-field-${++count}`;
406
600
  })();
@@ -434,10 +628,9 @@ class UiField {
434
628
  ...(ngDevMode ? [undefined, { debugName: "hint" }] : /* istanbul ignore next */ []));
435
629
  error = input(/* @ts-ignore */
436
630
  ...(ngDevMode ? [undefined, { debugName: "error" }] : /* istanbul ignore next */ []));
437
- /** Unit shown inside the control's right edge, such as `EUR` or `parts`. Not part of the control's value. */
438
631
  unit = input(/* @ts-ignore */
439
632
  ...(ngDevMode ? [undefined, { debugName: "unit" }] : /* istanbul ignore next */ []));
440
- #id = nextId();
633
+ #id = nextId$1();
441
634
  hintId = `${this.#id}-hint`;
442
635
  errorId = `${this.#id}-error`;
443
636
  #host = inject(ElementRef);
@@ -608,6 +801,192 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImpor
608
801
  }]
609
802
  }], propDecorators: { errors: [{ type: i0.Input, args: [{ isSignal: true, alias: "errors", required: false }] }], touched: [{ type: i0.Input, args: [{ isSignal: true, alias: "touched", required: false }] }] } });
610
803
 
804
+ const MENU_CLASSES = 'm-0 min-w-44 p-1 rounded-container bg-(--elevated) shadow-[0_8px_24px_rgb(0_0_0/0.16)] text-(--foreground) transition-[opacity,transform,overlay,display] transition-discrete duration-(--duration-fast) ease-out starting:open:opacity-0 starting:open:scale-(--enter-scale) opacity-0 scale-(--enter-scale) open:opacity-100 open:scale-100';
805
+ const ITEM_CLASSES = 'flex w-full items-center gap-2 min-h-(--row-min) px-3 rounded-control text-label hover:bg-(--soft) focus-visible:bg-(--soft) outline-none';
806
+ const nextId = (() => {
807
+ let count = 0;
808
+ return () => `ui-menu-${++count}`;
809
+ })();
810
+ /**
811
+ * One action of a `ui-menu`; `destructive` paints it in `--negative`.
812
+ *
813
+ * @example
814
+ * <button uiMenuItem destructive (click)="remove()">Delete the line</button>
815
+ */
816
+ class UiMenuItem {
817
+ destructive = input(false, { ...(ngDevMode ? { debugName: "destructive" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
818
+ #host = inject(ElementRef).nativeElement;
819
+ #menu = inject(UiMenu);
820
+ classes = computed(() => `${ITEM_CLASSES}${this.destructive() ? ' text-(--negative)' : ''}`, /* @ts-ignore */
821
+ ...(ngDevMode ? [{ debugName: "classes" }] : /* istanbul ignore next */ []));
822
+ focus() {
823
+ this.#host.focus();
824
+ }
825
+ hasFocus() {
826
+ return this.#host === document.activeElement;
827
+ }
828
+ onClick() {
829
+ this.#menu.close();
830
+ }
831
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: UiMenuItem, deps: [], target: i0.ɵɵFactoryTarget.Directive });
832
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.1.7", type: UiMenuItem, isStandalone: true, selector: "button[uiMenuItem]", inputs: { destructive: { classPropertyName: "destructive", publicName: "destructive", isSignal: true, isRequired: false, transformFunction: null } }, host: { attributes: { "role": "menuitem", "tabindex": "-1" }, listeners: { "click": "onClick()" }, properties: { "class": "classes()" } }, ngImport: i0 });
833
+ }
834
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: UiMenuItem, decorators: [{
835
+ type: Directive,
836
+ args: [{
837
+ selector: 'button[uiMenuItem]',
838
+ host: {
839
+ role: 'menuitem',
840
+ tabindex: '-1',
841
+ '[class]': 'classes()',
842
+ '(click)': 'onClick()',
843
+ },
844
+ }]
845
+ }], propDecorators: { destructive: [{ type: i0.Input, args: [{ isSignal: true, alias: "destructive", required: false }] }] } });
846
+ /**
847
+ * Secondary actions menu, opened from its trigger through the native Popover API.
848
+ *
849
+ * @example
850
+ * <button ui-button size="icon" aria-label="More" [uiMenuTrigger]="menu">...</button>
851
+ * <ui-menu #menu label="Line actions">
852
+ * <button uiMenuItem (click)="edit()">Edit</button>
853
+ * <button uiMenuItem destructive (click)="remove()">Delete the line</button>
854
+ * </ui-menu>
855
+ */
856
+ class UiMenu {
857
+ label = input.required(/* @ts-ignore */
858
+ ...(ngDevMode ? [{ debugName: "label" }] : /* istanbul ignore next */ []));
859
+ menuId = nextId();
860
+ isOpen = signal(false, /* @ts-ignore */
861
+ ...(ngDevMode ? [{ debugName: "isOpen" }] : /* istanbul ignore next */ []));
862
+ classes = MENU_CLASSES;
863
+ items = contentChildren(UiMenuItem, /* @ts-ignore */
864
+ ...(ngDevMode ? [{ debugName: "items" }] : /* istanbul ignore next */ []));
865
+ #host = inject(ElementRef).nativeElement;
866
+ #trigger = null;
867
+ open(trigger) {
868
+ this.#trigger = trigger;
869
+ this.isOpen.set(true);
870
+ if (typeof this.#host.showPopover === 'function') {
871
+ this.#host.showPopover();
872
+ }
873
+ this.#position(trigger);
874
+ this.items()[0]?.focus();
875
+ }
876
+ close() {
877
+ const wasOpen = this.isOpen();
878
+ this.#applyClosed();
879
+ if (wasOpen && typeof this.#host.hidePopover === 'function') {
880
+ this.#host.hidePopover();
881
+ }
882
+ }
883
+ #applyClosed() {
884
+ this.isOpen.set(false);
885
+ this.#trigger?.focus();
886
+ this.#trigger = null;
887
+ }
888
+ onToggle(event) {
889
+ if (event.newState === 'closed') {
890
+ this.#applyClosed();
891
+ }
892
+ }
893
+ onKeydown(event) {
894
+ if (event.key === 'Tab' || event.key === 'Escape') {
895
+ event.preventDefault();
896
+ this.close();
897
+ return;
898
+ }
899
+ const items = this.items();
900
+ const currentIndex = items.findIndex((item) => item.hasFocus());
901
+ const lastIndex = items.length - 1;
902
+ let nextIndex;
903
+ switch (event.key) {
904
+ case 'ArrowDown':
905
+ nextIndex = currentIndex >= lastIndex ? 0 : currentIndex + 1;
906
+ break;
907
+ case 'ArrowUp':
908
+ nextIndex = currentIndex <= 0 ? lastIndex : currentIndex - 1;
909
+ break;
910
+ case 'Home':
911
+ nextIndex = 0;
912
+ break;
913
+ case 'End':
914
+ nextIndex = lastIndex;
915
+ break;
916
+ }
917
+ if (nextIndex === undefined) {
918
+ return;
919
+ }
920
+ event.preventDefault();
921
+ items[nextIndex]?.focus();
922
+ }
923
+ #position(trigger) {
924
+ const margin = 8;
925
+ const triggerRect = trigger.getBoundingClientRect();
926
+ const menuRect = this.#host.getBoundingClientRect();
927
+ const fitsBelow = window.innerHeight - triggerRect.bottom >= menuRect.height + margin;
928
+ const top = fitsBelow ? triggerRect.bottom + 4 : Math.max(margin, triggerRect.top - menuRect.height - 4);
929
+ const left = Math.min(Math.max(triggerRect.right - menuRect.width, margin), window.innerWidth - menuRect.width - margin);
930
+ this.#host.style.position = 'fixed';
931
+ this.#host.style.top = `${Math.min(top, window.innerHeight - menuRect.height - margin)}px`;
932
+ this.#host.style.left = `${left}px`;
933
+ this.#host.style.transformOrigin = fitsBelow ? 'top right' : 'bottom right';
934
+ }
935
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: UiMenu, deps: [], target: i0.ɵɵFactoryTarget.Component });
936
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "22.1.7", type: UiMenu, isStandalone: true, selector: "ui-menu", inputs: { label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: true, transformFunction: null } }, host: { attributes: { "role": "menu", "popover": "auto" }, listeners: { "toggle": "onToggle($event)", "keydown": "onKeydown($event)" }, properties: { "id": "menuId", "class": "classes", "attr.aria-label": "label()" } }, queries: [{ propertyName: "items", predicate: UiMenuItem, isSignal: true }], ngImport: i0, template: '<ng-content />', isInline: true });
937
+ }
938
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: UiMenu, decorators: [{
939
+ type: Component,
940
+ args: [{
941
+ selector: 'ui-menu',
942
+ template: '<ng-content />',
943
+ host: {
944
+ role: 'menu',
945
+ popover: 'auto',
946
+ '[id]': 'menuId',
947
+ '[class]': 'classes',
948
+ '[attr.aria-label]': 'label()',
949
+ '(toggle)': 'onToggle($event)',
950
+ '(keydown)': 'onKeydown($event)',
951
+ },
952
+ }]
953
+ }], propDecorators: { label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: true }] }], items: [{ type: i0.ContentChildren, args: [i0.forwardRef(() => UiMenuItem), { isSignal: true }] }] } });
954
+ /**
955
+ * Opens a `ui-menu` from a trigger element and wires `aria-haspopup`, `aria-expanded` and
956
+ * `aria-controls` on it.
957
+ *
958
+ * @example
959
+ * <button ui-button size="icon" aria-label="More" [uiMenuTrigger]="menu">...</button>
960
+ */
961
+ class UiMenuTrigger {
962
+ uiMenuTrigger = input.required(/* @ts-ignore */
963
+ ...(ngDevMode ? [{ debugName: "uiMenuTrigger" }] : /* istanbul ignore next */ []));
964
+ #host = inject(ElementRef).nativeElement;
965
+ toggle() {
966
+ const menu = this.uiMenuTrigger();
967
+ if (menu.isOpen()) {
968
+ menu.close();
969
+ }
970
+ else {
971
+ menu.open(this.#host);
972
+ }
973
+ }
974
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: UiMenuTrigger, deps: [], target: i0.ɵɵFactoryTarget.Directive });
975
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.1.7", type: UiMenuTrigger, isStandalone: true, selector: "[uiMenuTrigger]", inputs: { uiMenuTrigger: { classPropertyName: "uiMenuTrigger", publicName: "uiMenuTrigger", isSignal: true, isRequired: true, transformFunction: null } }, host: { attributes: { "aria-haspopup": "menu" }, listeners: { "click": "toggle()" }, properties: { "attr.aria-expanded": "uiMenuTrigger().isOpen()", "attr.aria-controls": "uiMenuTrigger().menuId" } }, ngImport: i0 });
976
+ }
977
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: UiMenuTrigger, decorators: [{
978
+ type: Directive,
979
+ args: [{
980
+ selector: '[uiMenuTrigger]',
981
+ host: {
982
+ 'aria-haspopup': 'menu',
983
+ '[attr.aria-expanded]': 'uiMenuTrigger().isOpen()',
984
+ '[attr.aria-controls]': 'uiMenuTrigger().menuId',
985
+ '(click)': 'toggle()',
986
+ },
987
+ }]
988
+ }], propDecorators: { uiMenuTrigger: [{ type: i0.Input, args: [{ isSignal: true, alias: "uiMenuTrigger", required: true }] }] } });
989
+
611
990
  /** Ramp step used to paint the fill. `MeterTone` is derived from this tuple. */
612
991
  const METER_TONES = [1, 2, 3, 4, 5, 6];
613
992
  const TONE_CLASSES = {
@@ -673,11 +1052,66 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImpor
673
1052
  }]
674
1053
  }], propDecorators: { value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: true }] }], label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: true }] }], valueText: [{ type: i0.Input, args: [{ isSignal: true, alias: "valueText", required: true }] }], tone: [{ type: i0.Input, args: [{ isSignal: true, alias: "tone", required: false }] }] } });
675
1054
 
676
- const OPTION_CLASSES =
677
- // The visible track stays the handoff's 36px; the hit area still reaches --row-min (44px touch,
678
- // 40px pointer: fine) through an ::after enlarged only vertically, centered on the option, and
679
- // constrained to its own horizontal bounds so it never reaches into a neighboring option.
680
- "relative z-10 min-h-9 flex-1 cursor-pointer text-label font-medium transition-colors after:absolute after:inset-x-0 after:top-1/2 after:h-(--row-min) after:-translate-y-1/2 after:content-[''] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--ring)";
1055
+ const BASE_CLASSES$1 = 'flex items-center gap-3 h-10 px-3 rounded-control text-label font-medium text-(--muted-foreground) transition-colors duration-(--duration-press) hover:bg-(--glow) hover:text-(--foreground) focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-(--ring)';
1056
+ const ACTIVE_CLASSES = 'bg-(--soft) text-(--foreground)';
1057
+ /**
1058
+ * One destination of the sidebar navigation (Portfolio, Holdings, Allocation, Accounts).
1059
+ *
1060
+ * @example
1061
+ * <a ui-nav-item [active]="url === '/'" routerLink="/">
1062
+ * <lucide-icon navIcon name="house" />
1063
+ * Portfolio
1064
+ * </a>
1065
+ */
1066
+ class UiNavItem {
1067
+ active = input(false, { ...(ngDevMode ? { debugName: "active" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
1068
+ classes = computed(() => `${BASE_CLASSES$1}${this.active() ? ` ${ACTIVE_CLASSES}` : ''}`, /* @ts-ignore */
1069
+ ...(ngDevMode ? [{ debugName: "classes" }] : /* istanbul ignore next */ []));
1070
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: UiNavItem, deps: [], target: i0.ɵɵFactoryTarget.Component });
1071
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "22.1.7", type: UiNavItem, isStandalone: true, selector: "a[ui-nav-item]", inputs: { active: { classPropertyName: "active", publicName: "active", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "class": "classes()", "attr.aria-current": "active() ? 'page' : null" } }, ngImport: i0, template: '<ng-content select="[navIcon]" /><ng-content />', isInline: true });
1072
+ }
1073
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: UiNavItem, decorators: [{
1074
+ type: Component,
1075
+ args: [{
1076
+ selector: 'a[ui-nav-item]',
1077
+ template: '<ng-content select="[navIcon]" /><ng-content />',
1078
+ host: {
1079
+ '[class]': 'classes()',
1080
+ '[attr.aria-current]': "active() ? 'page' : null",
1081
+ },
1082
+ }]
1083
+ }], propDecorators: { active: [{ type: i0.Input, args: [{ isSignal: true, alias: "active", required: false }] }] } });
1084
+
1085
+ const BASE_CLASSES = 'flex w-full items-center gap-2.5 min-h-14 px-2.5 py-1.5 rounded-control text-left select-none touch-manipulation transition-colors duration-(--duration-press) hover:bg-(--glow) active:bg-(--soft) focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-(--ring)';
1086
+ const SELECTED_CLASSES$1 = 'bg-(--soft)';
1087
+ /**
1088
+ * Clickable row of a list: a holding, an envelope, an account.
1089
+ *
1090
+ * @example
1091
+ * <a ui-row [selected]="holding.id === openId()" [routerLink]="['/holdings', holding.id]">
1092
+ * {{ holding.name }}
1093
+ * </a>
1094
+ */
1095
+ class UiRow {
1096
+ selected = input(false, { ...(ngDevMode ? { debugName: "selected" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
1097
+ classes = computed(() => `${BASE_CLASSES}${this.selected() ? ` ${SELECTED_CLASSES$1}` : ''}`, /* @ts-ignore */
1098
+ ...(ngDevMode ? [{ debugName: "classes" }] : /* istanbul ignore next */ []));
1099
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: UiRow, deps: [], target: i0.ɵɵFactoryTarget.Component });
1100
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "22.1.7", type: UiRow, isStandalone: true, selector: "a[ui-row], button[ui-row]", inputs: { selected: { classPropertyName: "selected", publicName: "selected", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "class": "classes()", "attr.aria-current": "selected() ? 'true' : null" } }, ngImport: i0, template: '<ng-content />', isInline: true });
1101
+ }
1102
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: UiRow, decorators: [{
1103
+ type: Component,
1104
+ args: [{
1105
+ selector: 'a[ui-row], button[ui-row]',
1106
+ template: '<ng-content />',
1107
+ host: {
1108
+ '[class]': 'classes()',
1109
+ '[attr.aria-current]': "selected() ? 'true' : null",
1110
+ },
1111
+ }]
1112
+ }], propDecorators: { selected: [{ type: i0.Input, args: [{ isSignal: true, alias: "selected", required: false }] }] } });
1113
+
1114
+ const OPTION_CLASSES = "relative z-10 min-h-9 flex-1 cursor-pointer px-3 text-label font-medium transition-colors after:absolute after:inset-x-0 after:top-1/2 after:h-(--row-min) after:-translate-y-1/2 after:content-[''] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--ring)";
681
1115
  const SELECTED_CLASSES = 'text-(--foreground)';
682
1116
  const UNSELECTED_CLASSES = 'text-(--muted-foreground) hover:text-(--foreground)';
683
1117
  /**
@@ -696,7 +1130,6 @@ class UiSegmented {
696
1130
  ...(ngDevMode ? [{ debugName: "value" }] : /* istanbul ignore next */ []));
697
1131
  radios = viewChildren('radio', /* @ts-ignore */
698
1132
  ...(ngDevMode ? [{ debugName: "radios" }] : /* istanbul ignore next */ []));
699
- // 1-based so 0 (falsy) reliably means "no match", letting @if skip the thumb entirely.
700
1133
  thumbIndex = computed(() => {
701
1134
  const index = this.options().findIndex((option) => option.value === this.value());
702
1135
  return index === -1 ? 0 : index + 1;
@@ -864,16 +1297,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImpor
864
1297
  /** Available skeleton shapes. `SkeletonShape` is derived from this tuple. */
865
1298
  const SKELETON_SHAPES = ['text', 'figure', 'row', 'chart', 'ring'];
866
1299
  const LAST_LINE_WIDTH = '62%';
867
- // Declared here, not in tokens.css (a pure token sheet holds no element styles or keyframes).
868
1300
  const PULSE_ANIMATION = 'cairn-pulse var(--pulse-duration) var(--ease-out) infinite alternate';
869
1301
  const BAR_CLASSES = 'block rounded-control bg-(--muted)';
870
1302
  /**
871
- * Decorative placeholder for content that is still loading, at the shape of the content it stands
872
- * in for. Deliberately hidden from assistive technology: the calling screen keeps its own
873
- * `role="status"` sentence.
874
- *
875
- * Pulses opacity from 1 to 0.45 and back over `--pulse-duration`, which `tokens.css` sets to `0ms`
876
- * under `prefers-reduced-motion: reduce`: the pulse stops there with no branching in this component.
1303
+ * Decorative, pulsing placeholder for content that is still loading, at the shape of the content
1304
+ * it stands in for.
877
1305
  *
878
1306
  * @example
879
1307
  * <ui-skeleton shape="figure" />
@@ -1011,6 +1439,29 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImpor
1011
1439
  }]
1012
1440
  }], propDecorators: { label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: true }] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }] } });
1013
1441
 
1442
+ const CLASSES = "relative shrink-0 w-[51px] h-[31px] appearance-none cursor-pointer rounded-pill bg-(--muted) checked:bg-(--primary) transition-colors duration-(--duration-fast) ease-out disabled:pointer-events-none disabled:opacity-40 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--ring) before:absolute before:inset-x-0 before:top-1/2 before:-translate-y-1/2 before:h-(--row-min) before:content-[''] after:absolute after:top-0.5 after:left-0.5 after:size-[27px] after:rounded-pill after:bg-(--card) after:shadow-[0_1px_3px_rgb(0_0_0/0.2)] after:transition-transform after:duration-(--duration-fast) after:ease-out after:content-[''] checked:after:translate-x-[20px]";
1443
+ /**
1444
+ * Native checkbox styled as an iOS-style switch, exposed to assistive technology as `role="switch"`.
1445
+ *
1446
+ * @example
1447
+ * <input type="checkbox" uiSwitch [checked]="hidden()" (change)="toggle()" aria-label="Hide amounts" />
1448
+ */
1449
+ class UiSwitch {
1450
+ classes = CLASSES;
1451
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: UiSwitch, deps: [], target: i0.ɵɵFactoryTarget.Directive });
1452
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.1.7", type: UiSwitch, isStandalone: true, selector: "input[type=checkbox][uiSwitch]", host: { attributes: { "role": "switch" }, properties: { "class": "classes" } }, ngImport: i0 });
1453
+ }
1454
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: UiSwitch, decorators: [{
1455
+ type: Directive,
1456
+ args: [{
1457
+ selector: 'input[type=checkbox][uiSwitch]',
1458
+ host: {
1459
+ role: 'switch',
1460
+ '[class]': 'classes',
1461
+ },
1462
+ }]
1463
+ }] });
1464
+
1014
1465
  /** Breakpoints a secondary column can be held back until. */
1015
1466
  const CELL_BREAKPOINTS = ['md', 'lg'];
1016
1467
  const FROM_CLASSES = {
@@ -1105,6 +1556,61 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImpor
1105
1556
  }]
1106
1557
  }], propDecorators: { numeric: [{ type: i0.Input, args: [{ isSignal: true, alias: "numeric", required: false }] }], from: [{ type: i0.Input, args: [{ isSignal: true, alias: "from", required: false }] }] } });
1107
1558
 
1559
+ const TAB_CLASSES = 'flex h-[52px] flex-col items-center justify-center gap-0.5 touch-manipulation select-none text-(--muted-foreground) focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-(--ring)';
1560
+ const ACTIVE_TAB_CLASSES = 'font-semibold text-(--foreground)';
1561
+ /**
1562
+ * Bottom navigation bar for iPhone: 52px of tabs plus the home indicator's safe area.
1563
+ *
1564
+ * @example
1565
+ * <nav ui-tab-bar>
1566
+ * <a ui-tab [active]="url === '/'" routerLink="/"><lucide-icon tabIcon name="house" />Portfolio</a>
1567
+ * </nav>
1568
+ */
1569
+ class UiTabBar {
1570
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: UiTabBar, deps: [], target: i0.ɵɵFactoryTarget.Component });
1571
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "22.1.7", type: UiTabBar, isStandalone: true, selector: "nav[ui-tab-bar]", host: { classAttribute: "grid grid-flow-col auto-cols-fr items-stretch bg-(--background)/90 backdrop-blur [@media(prefers-reduced-transparency:reduce)]:bg-(--background) shadow-[inset_0_1px_0_var(--hairline)] pb-[env(safe-area-inset-bottom)]" }, ngImport: i0, template: '<ng-content />', isInline: true });
1572
+ }
1573
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: UiTabBar, decorators: [{
1574
+ type: Component,
1575
+ args: [{
1576
+ selector: 'nav[ui-tab-bar]',
1577
+ template: '<ng-content />',
1578
+ host: {
1579
+ class: 'grid grid-flow-col auto-cols-fr items-stretch bg-(--background)/90 backdrop-blur [@media(prefers-reduced-transparency:reduce)]:bg-(--background) shadow-[inset_0_1px_0_var(--hairline)] pb-[env(safe-area-inset-bottom)]',
1580
+ },
1581
+ }]
1582
+ }] });
1583
+ /**
1584
+ * One destination of `ui-tab-bar`.
1585
+ *
1586
+ * @example
1587
+ * <a ui-tab active routerLink="/"><lucide-icon tabIcon name="house" />Portfolio</a>
1588
+ */
1589
+ class UiTab {
1590
+ active = input(false, { ...(ngDevMode ? { debugName: "active" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
1591
+ classes = computed(() => `${TAB_CLASSES}${this.active() ? ` ${ACTIVE_TAB_CLASSES}` : ''}`, /* @ts-ignore */
1592
+ ...(ngDevMode ? [{ debugName: "classes" }] : /* istanbul ignore next */ []));
1593
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: UiTab, deps: [], target: i0.ɵɵFactoryTarget.Component });
1594
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "22.1.7", type: UiTab, isStandalone: true, selector: "a[ui-tab]", inputs: { active: { classPropertyName: "active", publicName: "active", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "class": "classes()", "attr.aria-current": "active() ? 'page' : null" } }, ngImport: i0, template: `
1595
+ <span class="size-6" data-tab-icon><ng-content select="[tabIcon]" /></span>
1596
+ <span class="text-caption" data-tab-label><ng-content /></span>
1597
+ `, isInline: true });
1598
+ }
1599
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: UiTab, decorators: [{
1600
+ type: Component,
1601
+ args: [{
1602
+ selector: 'a[ui-tab]',
1603
+ template: `
1604
+ <span class="size-6" data-tab-icon><ng-content select="[tabIcon]" /></span>
1605
+ <span class="text-caption" data-tab-label><ng-content /></span>
1606
+ `,
1607
+ host: {
1608
+ '[class]': 'classes()',
1609
+ '[attr.aria-current]': "active() ? 'page' : null",
1610
+ },
1611
+ }]
1612
+ }], propDecorators: { active: [{ type: i0.Input, args: [{ isSignal: true, alias: "active", required: false }] }] } });
1613
+
1108
1614
  /*
1109
1615
  * Public API surface of the ui library.
1110
1616
  */
@@ -1113,5 +1619,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImpor
1113
1619
  * Generated bundle index. Do not edit.
1114
1620
  */
1115
1621
 
1116
- export { AVATAR_SIZES, BADGE_VARIANTS, BUTTON_SIZES, BUTTON_VARIANTS, CARD_PADDINGS, CARD_VARIANTS, CELL_BREAKPOINTS, CONTROL_SIZES, DELTA_EMPHASES, DIALOG_WIDTHS, METER_TONES, STAT_SIZES, UiAvatar, UiBadge, UiButton, UiCard, UiDelta, UiDialog, UiField, UiInput, UiMeter, UiSegmented, UiSelect, UiSkeleton, UiStat, UiTable, UiTd, UiTextarea, UiTh, UiTr };
1622
+ export { AMOUNT_MASKINGS, AMOUNT_NUMERICS, ASYNC_STATES, AVATAR_SIZES, BADGE_VARIANTS, BUTTON_SIZES, BUTTON_VARIANTS, CARD_PADDINGS, CARD_VARIANTS, CELL_BREAKPOINTS, CONTROL_SIZES, DELTA_EMPHASES, DIALOG_WIDTHS, METER_TONES, STAT_SIZES, UI_AMOUNT_MASKED, UiAmount, UiAsync, UiAvatar, UiBadge, UiButton, UiCard, UiDelta, UiDialog, UiField, UiInput, UiMenu, UiMenuItem, UiMenuTrigger, UiMeter, UiNavItem, UiRow, UiSegmented, UiSelect, UiSkeleton, UiStat, UiSwitch, UiTab, UiTabBar, UiTable, UiTd, UiTextarea, UiTh, UiTr, formatAmount };
1117
1623
  //# sourceMappingURL=joanroucoux-cairn-ui.mjs.map