@nanahoshi/mona-ui 0.13.0 → 0.14.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,6 +1,6 @@
1
1
  import { NgTemplateOutlet } from '@angular/common';
2
2
  import * as i0 from '@angular/core';
3
- import { inject, TemplateRef, Directive, computed, contentChild, input, output, model, Component } from '@angular/core';
3
+ import { inject, TemplateRef, Directive, DestroyRef, ElementRef, computed, signal, contentChild, viewChildren, input, output, model, afterRenderEffect, afterNextRender, Component } from '@angular/core';
4
4
  import { themeControlSurfaceClasses, createElementControlId } from '@nanahoshi/mona-ui/internal';
5
5
  import { twMerge } from 'tailwind-merge';
6
6
  import { cva } from 'class-variance-authority';
@@ -27,7 +27,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.6", ngImpor
27
27
  }] });
28
28
 
29
29
  const segmentedContainerThemeVariants = cva(`
30
- inline-flex items-center gap-0.5
30
+ relative inline-flex items-center gap-0.5
31
31
  ${themeControlSurfaceClasses}
32
32
  border border-input-border
33
33
  p-1
@@ -56,11 +56,11 @@ const segmentedOptionThemeVariants = cva(`
56
56
  data-[disabled='true']:cursor-not-allowed
57
57
  data-[disabled='true']:opacity-50
58
58
  data-[disabled='true']:text-disabled-foreground
59
- data-[selected='true']:bg-primary data-[selected='true']:text-primary-foreground
60
- data-[selected='true']:shadow-sm data-[selected='true']:ring-1 data-[selected='true']:ring-selected-border
59
+ data-[selected='true']:text-primary-foreground
61
60
  data-[selected='false']:text-muted-foreground
62
61
  data-[selected='false']:hover:bg-hover data-[selected='false']:hover:text-foreground
63
62
  data-[disabled='true']:data-[selected='false']:text-disabled-foreground
63
+ data-[selected='true']:transition-none
64
64
  `, {
65
65
  variants: {
66
66
  rounded: {
@@ -81,6 +81,30 @@ const segmentedOptionThemeVariants = cva(`
81
81
  size: "medium"
82
82
  }
83
83
  });
84
+ const segmentedIndicatorThemeVariants = cva(`
85
+ pointer-events-none absolute top-0 left-0
86
+ bg-primary shadow-sm ring-1 ring-selected-border
87
+ data-[disabled='true']:opacity-50
88
+ motion-reduce:transition-none
89
+ `, {
90
+ variants: {
91
+ animate: {
92
+ true: "transition-[transform,width,height] duration-(--mona-motion-fast) ease-out",
93
+ false: "transition-none"
94
+ },
95
+ rounded: {
96
+ none: "rounded-none",
97
+ small: "rounded-xs",
98
+ medium: "rounded-sm",
99
+ large: "rounded-md",
100
+ full: "rounded-full"
101
+ }
102
+ },
103
+ defaultVariants: {
104
+ animate: true,
105
+ rounded: "medium"
106
+ }
107
+ });
84
108
  const segmentedInputThemeVariants = cva(`
85
109
  peer absolute inset-0 z-10 h-full w-full
86
110
  cursor-pointer appearance-none opacity-0
@@ -89,13 +113,37 @@ const segmentedInputThemeVariants = cva(`
89
113
  `);
90
114
 
91
115
  class SegmentedComponent {
116
+ #destroyRef;
117
+ #hostElementRef;
118
+ #lastAnimatedIndex;
119
+ #lastOptions;
120
+ #lastSize;
121
+ #resizeObserver;
92
122
  constructor() {
123
+ this.#destroyRef = inject(DestroyRef);
124
+ this.#hostElementRef = inject(ElementRef);
125
+ this.#lastAnimatedIndex = null;
126
+ this.#lastOptions = null;
127
+ this.#lastSize = null;
128
+ this.#resizeObserver = null;
93
129
  this.containerClasses = computed(() => {
94
130
  const classes = segmentedContainerThemeVariants({ rounded: this.rounded() });
95
131
  return twMerge(classes, this.userClass());
96
132
  }, /* @ts-ignore */
97
133
  ...(ngDevMode ? [{ debugName: "containerClasses" }] : /* istanbul ignore next */ []));
98
134
  this.groupName = createElementControlId();
135
+ this.indicatorClasses = computed(() => {
136
+ const animate = this.animate() && this.indicatorTransitionEnabled();
137
+ const rounded = this.rounded();
138
+ return segmentedIndicatorThemeVariants({ animate, rounded });
139
+ }, /* @ts-ignore */
140
+ ...(ngDevMode ? [{ debugName: "indicatorClasses" }] : /* istanbul ignore next */ []));
141
+ this.indicatorGeometry = signal(null, /* @ts-ignore */
142
+ ...(ngDevMode ? [{ debugName: "indicatorGeometry" }] : /* istanbul ignore next */ []));
143
+ this.indicatorInitialized = signal(false, /* @ts-ignore */
144
+ ...(ngDevMode ? [{ debugName: "indicatorInitialized" }] : /* istanbul ignore next */ []));
145
+ this.indicatorTransitionEnabled = signal(false, /* @ts-ignore */
146
+ ...(ngDevMode ? [{ debugName: "indicatorTransitionEnabled" }] : /* istanbul ignore next */ []));
99
147
  this.inputClasses = computed(() => segmentedInputThemeVariants(), /* @ts-ignore */
100
148
  ...(ngDevMode ? [{ debugName: "inputClasses" }] : /* istanbul ignore next */ []));
101
149
  this.invalidState = computed(() => this.touched() && this.invalid(), /* @ts-ignore */
@@ -107,6 +155,29 @@ class SegmentedComponent {
107
155
  return segmentedOptionThemeVariants({ rounded, size });
108
156
  }, /* @ts-ignore */
109
157
  ...(ngDevMode ? [{ debugName: "optionClasses" }] : /* istanbul ignore next */ []));
158
+ this.optionElements = viewChildren("optionElement", /* @ts-ignore */
159
+ ...(ngDevMode ? [{ debugName: "optionElements" }] : /* istanbul ignore next */ []));
160
+ this.selectedIndex = computed(() => {
161
+ const value = this.value();
162
+ return this.options().findIndex(option => option.value === value);
163
+ }, /* @ts-ignore */
164
+ ...(ngDevMode ? [{ debugName: "selectedIndex" }] : /* istanbul ignore next */ []));
165
+ this.selectedOptionDisabled = computed(() => {
166
+ const selectedIndex = this.selectedIndex();
167
+ if (selectedIndex === -1) {
168
+ return false;
169
+ }
170
+ const option = this.options()[selectedIndex];
171
+ return this.disabled() || !!option?.disabled;
172
+ }, /* @ts-ignore */
173
+ ...(ngDevMode ? [{ debugName: "selectedOptionDisabled" }] : /* istanbul ignore next */ []));
174
+ /**
175
+ * @description Controls whether the selection indicator animates between selected options.
176
+ * When false, selection changes are applied immediately without transition.
177
+ * @default true
178
+ */
179
+ this.animate = input(true, /* @ts-ignore */
180
+ ...(ngDevMode ? [{ debugName: "animate" }] : /* istanbul ignore next */ []));
110
181
  /**
111
182
  * @description Accessible name for the radio group. Provide either `aria-label` or `aria-labelledby`.
112
183
  * @default null
@@ -170,6 +241,30 @@ class SegmentedComponent {
170
241
  */
171
242
  this.value = model(null, /* @ts-ignore */
172
243
  ...(ngDevMode ? [{ debugName: "value" }] : /* istanbul ignore next */ []));
244
+ afterRenderEffect({
245
+ read: () => {
246
+ this.selectedIndex();
247
+ this.optionElements();
248
+ this.rounded();
249
+ this.animate();
250
+ const size = this.size();
251
+ const options = this.options();
252
+ const isSizeChange = this.#lastSize !== null && this.#lastSize !== size;
253
+ const isOptionsChange = this.#lastOptions !== null && this.#lastOptions !== options;
254
+ const suppressTransition = isSizeChange || isOptionsChange;
255
+ this.#lastSize = size;
256
+ this.#lastOptions = options;
257
+ this.updateIndicatorGeometry({ suppressTransition });
258
+ }
259
+ });
260
+ afterNextRender({
261
+ read: () => {
262
+ this.setupResizeObserver();
263
+ }
264
+ });
265
+ this.#destroyRef.onDestroy(() => {
266
+ this.#resizeObserver?.disconnect();
267
+ });
173
268
  }
174
269
  createOptionContext(option, index) {
175
270
  return {
@@ -190,8 +285,57 @@ class SegmentedComponent {
190
285
  this.value.set(option.value);
191
286
  this.touch.emit();
192
287
  }
288
+ setupResizeObserver() {
289
+ if (typeof ResizeObserver === "undefined") {
290
+ return;
291
+ }
292
+ this.#resizeObserver = new ResizeObserver(() => {
293
+ this.updateIndicatorGeometry({ suppressTransition: true });
294
+ });
295
+ this.#resizeObserver.observe(this.#hostElementRef.nativeElement);
296
+ }
297
+ updateIndicatorGeometry(options) {
298
+ const selectedIndex = this.selectedIndex();
299
+ if (selectedIndex === -1) {
300
+ this.#lastAnimatedIndex = null;
301
+ this.indicatorGeometry.set(null);
302
+ this.indicatorTransitionEnabled.set(false);
303
+ return;
304
+ }
305
+ const optionElements = this.optionElements();
306
+ const selectedElementRef = optionElements[selectedIndex];
307
+ if (!selectedElementRef?.nativeElement) {
308
+ this.#lastAnimatedIndex = null;
309
+ this.indicatorGeometry.set(null);
310
+ this.indicatorTransitionEnabled.set(false);
311
+ return;
312
+ }
313
+ const host = this.#hostElementRef.nativeElement;
314
+ const option = selectedElementRef.nativeElement;
315
+ const hostRect = host.getBoundingClientRect();
316
+ const optionRect = option.getBoundingClientRect();
317
+ const x = optionRect.left - hostRect.left - (host.clientLeft || 0);
318
+ const y = optionRect.top - hostRect.top - (host.clientTop || 0);
319
+ const width = optionRect.width;
320
+ const height = optionRect.height;
321
+ const shouldAnimate = !options?.suppressTransition &&
322
+ this.animate() &&
323
+ this.indicatorInitialized() &&
324
+ this.#lastAnimatedIndex !== null &&
325
+ this.#lastAnimatedIndex !== selectedIndex;
326
+ this.indicatorTransitionEnabled.set(shouldAnimate);
327
+ this.indicatorGeometry.set({
328
+ height,
329
+ transform: `translate3d(${x}px, ${y}px, 0)`,
330
+ width
331
+ });
332
+ this.#lastAnimatedIndex = selectedIndex;
333
+ if (!this.indicatorInitialized()) {
334
+ this.indicatorInitialized.set(true);
335
+ }
336
+ }
193
337
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.6", ngImport: i0, type: SegmentedComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
194
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.6", type: SegmentedComponent, isStandalone: true, selector: "mona-segmented", inputs: { ariaLabel: { classPropertyName: "ariaLabel", publicName: "aria-label", isSignal: true, isRequired: false, transformFunction: null }, ariaLabelledBy: { classPropertyName: "ariaLabelledBy", publicName: "aria-labelledby", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, invalid: { classPropertyName: "invalid", publicName: "invalid", isSignal: true, isRequired: false, transformFunction: null }, options: { classPropertyName: "options", publicName: "options", isSignal: true, isRequired: true, transformFunction: null }, rounded: { classPropertyName: "rounded", publicName: "rounded", isSignal: true, isRequired: false, transformFunction: null }, size: { classPropertyName: "size", publicName: "size", isSignal: true, isRequired: false, transformFunction: null }, touched: { classPropertyName: "touched", publicName: "touched", isSignal: true, isRequired: false, transformFunction: null }, userClass: { classPropertyName: "userClass", publicName: "class", isSignal: true, isRequired: false, transformFunction: null }, value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { touch: "touch", value: "valueChange" }, host: { properties: { "attr.aria-disabled": "disabled() || undefined", "attr.aria-invalid": "invalidState() ? 'true' : null", "attr.aria-label": "ariaLabel()", "attr.aria-labelledby": "ariaLabelledBy()", "attr.data-disabled": "disabled()", "attr.data-invalid": "invalidState() || null", "attr.role": "'radiogroup'", "class": "containerClasses()" } }, queries: [{ propertyName: "itemTemplate", first: true, predicate: SegmentedItemTemplateDirective, descendants: true, read: TemplateRef, isSignal: true }], ngImport: i0, template: "@for (option of options(); track option.value) {\n @let selected = option.value === value();\n @let optionDisabled = disabled() || option.disabled;\n\n <label [class]=\"optionClasses()\" [attr.data-selected]=\"selected\" [attr.data-disabled]=\"optionDisabled\">\n <input\n type=\"radio\"\n [name]=\"groupName\"\n [value]=\"option.value\"\n [checked]=\"selected\"\n [disabled]=\"optionDisabled\"\n [class]=\"inputClasses()\"\n (change)=\"onOptionChange(option)\"\n (blur)=\"onOptionBlur()\" />\n\n <span\n class=\"pointer-events-none flex flex-1 items-center justify-center gap-1\n [border-radius:inherit]\n peer-focus-visible:ring-2\n peer-focus-visible:ring-focus-indicator/35\">\n @if (itemTemplate(); as template) {\n <ng-container\n [ngTemplateOutlet]=\"template\"\n [ngTemplateOutletContext]=\"createOptionContext(option, $index)\"></ng-container>\n } @else {\n {{ option.label }}\n }\n </span>\n </label>\n}\n", dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }] }); }
338
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.6", type: SegmentedComponent, isStandalone: true, selector: "mona-segmented", inputs: { animate: { classPropertyName: "animate", publicName: "animate", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "aria-label", isSignal: true, isRequired: false, transformFunction: null }, ariaLabelledBy: { classPropertyName: "ariaLabelledBy", publicName: "aria-labelledby", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, invalid: { classPropertyName: "invalid", publicName: "invalid", isSignal: true, isRequired: false, transformFunction: null }, options: { classPropertyName: "options", publicName: "options", isSignal: true, isRequired: true, transformFunction: null }, rounded: { classPropertyName: "rounded", publicName: "rounded", isSignal: true, isRequired: false, transformFunction: null }, size: { classPropertyName: "size", publicName: "size", isSignal: true, isRequired: false, transformFunction: null }, touched: { classPropertyName: "touched", publicName: "touched", isSignal: true, isRequired: false, transformFunction: null }, userClass: { classPropertyName: "userClass", publicName: "class", isSignal: true, isRequired: false, transformFunction: null }, value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { touch: "touch", value: "valueChange" }, host: { properties: { "attr.aria-disabled": "disabled() || undefined", "attr.aria-invalid": "invalidState() ? 'true' : null", "attr.aria-label": "ariaLabel()", "attr.aria-labelledby": "ariaLabelledBy()", "attr.data-disabled": "disabled()", "attr.data-invalid": "invalidState() || null", "attr.role": "'radiogroup'", "class": "containerClasses()" } }, queries: [{ propertyName: "itemTemplate", first: true, predicate: SegmentedItemTemplateDirective, descendants: true, read: TemplateRef, isSignal: true }], viewQueries: [{ propertyName: "optionElements", predicate: ["optionElement"], descendants: true, isSignal: true }], ngImport: i0, template: "@if (indicatorGeometry(); as geometry) {\n <div\n aria-hidden=\"true\"\n [class]=\"indicatorClasses()\"\n [attr.data-disabled]=\"selectedOptionDisabled() ? 'true' : null\"\n [style.transform]=\"geometry.transform\"\n [style.width.px]=\"geometry.width\"\n [style.height.px]=\"geometry.height\"></div>\n}\n\n@for (option of options(); track option.value) {\n @let selected = option.value === value();\n @let optionDisabled = disabled() || option.disabled;\n\n <label\n #optionElement\n [class]=\"optionClasses()\"\n [attr.data-selected]=\"selected\"\n [attr.data-disabled]=\"optionDisabled\">\n <input\n type=\"radio\"\n [name]=\"groupName\"\n [value]=\"option.value\"\n [checked]=\"selected\"\n [disabled]=\"optionDisabled\"\n [class]=\"inputClasses()\"\n (change)=\"onOptionChange(option)\"\n (blur)=\"onOptionBlur()\" />\n\n <span\n class=\"pointer-events-none flex flex-1 items-center justify-center gap-1\n [border-radius:inherit]\n peer-focus-visible:ring-2\n peer-focus-visible:ring-focus-indicator/35\">\n @if (itemTemplate(); as template) {\n <ng-container\n [ngTemplateOutlet]=\"template\"\n [ngTemplateOutletContext]=\"createOptionContext(option, $index)\"></ng-container>\n } @else {\n {{ option.label }}\n }\n </span>\n </label>\n}\n\n", dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }] }); }
195
339
  }
196
340
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.6", ngImport: i0, type: SegmentedComponent, decorators: [{
197
341
  type: Component,
@@ -204,8 +348,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.6", ngImpor
204
348
  "[attr.data-invalid]": "invalidState() || null",
205
349
  "[attr.role]": "'radiogroup'",
206
350
  "[class]": "containerClasses()"
207
- }, template: "@for (option of options(); track option.value) {\n @let selected = option.value === value();\n @let optionDisabled = disabled() || option.disabled;\n\n <label [class]=\"optionClasses()\" [attr.data-selected]=\"selected\" [attr.data-disabled]=\"optionDisabled\">\n <input\n type=\"radio\"\n [name]=\"groupName\"\n [value]=\"option.value\"\n [checked]=\"selected\"\n [disabled]=\"optionDisabled\"\n [class]=\"inputClasses()\"\n (change)=\"onOptionChange(option)\"\n (blur)=\"onOptionBlur()\" />\n\n <span\n class=\"pointer-events-none flex flex-1 items-center justify-center gap-1\n [border-radius:inherit]\n peer-focus-visible:ring-2\n peer-focus-visible:ring-focus-indicator/35\">\n @if (itemTemplate(); as template) {\n <ng-container\n [ngTemplateOutlet]=\"template\"\n [ngTemplateOutletContext]=\"createOptionContext(option, $index)\"></ng-container>\n } @else {\n {{ option.label }}\n }\n </span>\n </label>\n}\n" }]
208
- }], propDecorators: { itemTemplate: [{ type: i0.ContentChild, args: [i0.forwardRef(() => SegmentedItemTemplateDirective), { ...{ read: TemplateRef }, isSignal: true }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "aria-label", required: false }] }], ariaLabelledBy: [{ type: i0.Input, args: [{ isSignal: true, alias: "aria-labelledby", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], invalid: [{ type: i0.Input, args: [{ isSignal: true, alias: "invalid", required: false }] }], options: [{ type: i0.Input, args: [{ isSignal: true, alias: "options", required: true }] }], rounded: [{ type: i0.Input, args: [{ isSignal: true, alias: "rounded", required: false }] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }], touch: [{ type: i0.Output, args: ["touch"] }], touched: [{ type: i0.Input, args: [{ isSignal: true, alias: "touched", required: false }] }], userClass: [{ type: i0.Input, args: [{ isSignal: true, alias: "class", required: false }] }], value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: false }] }, { type: i0.Output, args: ["valueChange"] }] } });
351
+ }, template: "@if (indicatorGeometry(); as geometry) {\n <div\n aria-hidden=\"true\"\n [class]=\"indicatorClasses()\"\n [attr.data-disabled]=\"selectedOptionDisabled() ? 'true' : null\"\n [style.transform]=\"geometry.transform\"\n [style.width.px]=\"geometry.width\"\n [style.height.px]=\"geometry.height\"></div>\n}\n\n@for (option of options(); track option.value) {\n @let selected = option.value === value();\n @let optionDisabled = disabled() || option.disabled;\n\n <label\n #optionElement\n [class]=\"optionClasses()\"\n [attr.data-selected]=\"selected\"\n [attr.data-disabled]=\"optionDisabled\">\n <input\n type=\"radio\"\n [name]=\"groupName\"\n [value]=\"option.value\"\n [checked]=\"selected\"\n [disabled]=\"optionDisabled\"\n [class]=\"inputClasses()\"\n (change)=\"onOptionChange(option)\"\n (blur)=\"onOptionBlur()\" />\n\n <span\n class=\"pointer-events-none flex flex-1 items-center justify-center gap-1\n [border-radius:inherit]\n peer-focus-visible:ring-2\n peer-focus-visible:ring-focus-indicator/35\">\n @if (itemTemplate(); as template) {\n <ng-container\n [ngTemplateOutlet]=\"template\"\n [ngTemplateOutletContext]=\"createOptionContext(option, $index)\"></ng-container>\n } @else {\n {{ option.label }}\n }\n </span>\n </label>\n}\n\n" }]
352
+ }], ctorParameters: () => [], propDecorators: { itemTemplate: [{ type: i0.ContentChild, args: [i0.forwardRef(() => SegmentedItemTemplateDirective), { ...{ read: TemplateRef }, isSignal: true }] }], optionElements: [{ type: i0.ViewChildren, args: ["optionElement", { isSignal: true }] }], animate: [{ type: i0.Input, args: [{ isSignal: true, alias: "animate", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "aria-label", required: false }] }], ariaLabelledBy: [{ type: i0.Input, args: [{ isSignal: true, alias: "aria-labelledby", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], invalid: [{ type: i0.Input, args: [{ isSignal: true, alias: "invalid", required: false }] }], options: [{ type: i0.Input, args: [{ isSignal: true, alias: "options", required: true }] }], rounded: [{ type: i0.Input, args: [{ isSignal: true, alias: "rounded", required: false }] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }], touch: [{ type: i0.Output, args: ["touch"] }], touched: [{ type: i0.Input, args: [{ isSignal: true, alias: "touched", required: false }] }], userClass: [{ type: i0.Input, args: [{ isSignal: true, alias: "class", required: false }] }], value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: false }] }, { type: i0.Output, args: ["valueChange"] }] } });
209
353
 
210
354
  /*
211
355
  * Public API Surface of @nanahoshi/mona-ui/segmented
@@ -0,0 +1,580 @@
1
+ import * as i0 from '@angular/core';
2
+ import { computed, input, Component, output, inject, ApplicationRef, DOCUMENT, Injector, RendererFactory2, createComponent, Injectable } from '@angular/core';
3
+ import { LucideLoader, LucideDynamicIcon } from '@lucide/angular';
4
+ import { classInputToClass } from '@nanahoshi/mona-ui/common';
5
+ import { twMerge } from 'tailwind-merge';
6
+ import { cva } from 'class-variance-authority';
7
+ import { Subject, defer, finalize } from 'rxjs';
8
+ import { v4 } from 'uuid';
9
+
10
+ const spinnerThemeVariants = cva("inline-flex items-center justify-center flex-none aspect-square relative text-current select-none", {
11
+ variants: {
12
+ size: {
13
+ small: "w-3 h-3",
14
+ medium: "w-4 h-4",
15
+ large: "w-6 h-6"
16
+ }
17
+ },
18
+ defaultVariants: {
19
+ size: "medium"
20
+ }
21
+ });
22
+
23
+ /**
24
+ * @description
25
+ * An indeterminate loading indicator primitive for communicating ongoing activity.
26
+ * Supports multiple appearances, standard sizes, and accessible status announcements.
27
+ */
28
+ class SpinnerComponent {
29
+ constructor() {
30
+ this.baseClass = computed(() => {
31
+ const variantClass = spinnerThemeVariants({ size: this.size() });
32
+ return twMerge(variantClass, this.userClass());
33
+ }, /* @ts-ignore */
34
+ ...(ngDevMode ? [{ debugName: "baseClass" }] : /* istanbul ignore next */ []));
35
+ this.loaderIcon = LucideLoader;
36
+ this.pixelSize = computed(() => {
37
+ switch (this.size()) {
38
+ case "small":
39
+ return 12;
40
+ case "large":
41
+ return 24;
42
+ case "medium":
43
+ default:
44
+ return 16;
45
+ }
46
+ }, /* @ts-ignore */
47
+ ...(ngDevMode ? [{ debugName: "pixelSize" }] : /* istanbul ignore next */ []));
48
+ /**
49
+ * @description Visual appearance of the spinner animation.
50
+ * @default "default"
51
+ */
52
+ this.appearance = input("default", /* @ts-ignore */
53
+ ...(ngDevMode ? [{ debugName: "appearance" }] : /* istanbul ignore next */ []));
54
+ /**
55
+ * @description Accessible text announced by assistive technology when the spinner is not decorative.
56
+ * @default "Loading"
57
+ */
58
+ this.ariaLabel = input("Loading", { ...(ngDevMode ? { debugName: "ariaLabel" } : /* istanbul ignore next */ {}), alias: "aria-label" });
59
+ /**
60
+ * @description When `true`, removes the `status` role and hides the spinner from assistive technology with `aria-hidden="true"`.
61
+ * Useful when a parent element already manages loading semantics (such as `aria-busy="true"` on a button).
62
+ * @default false
63
+ */
64
+ this.decorative = input(false, /* @ts-ignore */
65
+ ...(ngDevMode ? [{ debugName: "decorative" }] : /* istanbul ignore next */ []));
66
+ /**
67
+ * @description Size preset controlling the dimensions of the indicator.
68
+ * @default "medium"
69
+ */
70
+ this.size = input("medium", /* @ts-ignore */
71
+ ...(ngDevMode ? [{ debugName: "size" }] : /* istanbul ignore next */ []));
72
+ /**
73
+ * @description Additional CSS classes merged onto the host element via `tailwind-merge`.
74
+ * @default ""
75
+ */
76
+ this.userClass = input("", { ...(ngDevMode ? { debugName: "userClass" } : /* istanbul ignore next */ {}), alias: "class",
77
+ transform: value => classInputToClass(value) });
78
+ }
79
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.6", ngImport: i0, type: SpinnerComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
80
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.6", type: SpinnerComponent, isStandalone: true, selector: "mona-spinner", inputs: { appearance: { classPropertyName: "appearance", publicName: "appearance", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "aria-label", isSignal: true, isRequired: false, transformFunction: null }, decorative: { classPropertyName: "decorative", publicName: "decorative", isSignal: true, isRequired: false, transformFunction: null }, size: { classPropertyName: "size", publicName: "size", isSignal: true, isRequired: false, transformFunction: null }, userClass: { classPropertyName: "userClass", publicName: "class", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "attr.aria-hidden": "decorative() ? 'true' : null", "attr.aria-label": "decorative() ? null : (ariaLabel() || 'Loading')", "attr.role": "decorative() ? null : 'status'", "class": "baseClass()" } }, ngImport: i0, template: "@switch (appearance()) {\n @case (\"default\") {\n <svg [lucideIcon]=\"loaderIcon\" [size]=\"pixelSize()\" class=\"animate-[spin_1.5s_linear_infinite] motion-reduce:animate-none\"></svg>\n }\n @case (\"pulsing\") {\n <span class=\"mona-spinner-pulsing-container\">\n <span data-segment class=\"mona-spinner-pulsing-segment mona-spinner-pulsing-1\"></span>\n <span data-segment class=\"mona-spinner-pulsing-segment mona-spinner-pulsing-2\"></span>\n </span>\n }\n @case (\"pulsing-triad\") {\n <span class=\"mona-spinner-pulsing-triad-container\">\n <span data-segment class=\"mona-spinner-pulsing-triad-segment mona-spinner-pulsing-triad-1\"></span>\n <span data-segment class=\"mona-spinner-pulsing-triad-segment mona-spinner-pulsing-triad-2\"></span>\n <span data-segment class=\"mona-spinner-pulsing-triad-segment mona-spinner-pulsing-triad-3\"></span>\n </span>\n }\n @case (\"pulsing-ring\") {\n <span class=\"mona-spinner-pulsing-ring-container\">\n <span data-segment class=\"mona-spinner-pulsing-ring-segment mona-spinner-pulsing-ring-1\"></span>\n <span data-segment class=\"mona-spinner-pulsing-ring-segment mona-spinner-pulsing-ring-2\"></span>\n <span data-segment class=\"mona-spinner-pulsing-ring-segment mona-spinner-pulsing-ring-3\"></span>\n <span data-segment class=\"mona-spinner-pulsing-ring-segment mona-spinner-pulsing-ring-4\"></span>\n <span data-segment class=\"mona-spinner-pulsing-ring-segment mona-spinner-pulsing-ring-5\"></span>\n <span data-segment class=\"mona-spinner-pulsing-ring-segment mona-spinner-pulsing-ring-6\"></span>\n <span data-segment class=\"mona-spinner-pulsing-ring-segment mona-spinner-pulsing-ring-7\"></span>\n <span data-segment class=\"mona-spinner-pulsing-ring-segment mona-spinner-pulsing-ring-8\"></span>\n </span>\n }\n @case (\"converging-spinner\") {\n <span class=\"mona-spinner-converge-container\">\n <span data-segment class=\"mona-spinner-converge-segment mona-spinner-converge-1\"></span>\n <span data-segment class=\"mona-spinner-converge-segment mona-spinner-converge-2\"></span>\n <span data-segment class=\"mona-spinner-converge-segment mona-spinner-converge-3\"></span>\n <span data-segment class=\"mona-spinner-converge-segment mona-spinner-converge-4\"></span>\n </span>\n }\n}\n", styles: [":host{display:inline-flex;vertical-align:middle}.mona-spinner-pulsing-container{position:relative;width:100%;height:100%;display:flex;align-items:center;justify-content:center}.mona-spinner-pulsing-segment{position:absolute;inset:0;border-radius:9999px;background-color:currentColor;opacity:.6;animation:mona-spinner-pulse 1.2s cubic-bezier(0,.2,.8,1) infinite}.mona-spinner-pulsing-2{animation-delay:-.6s}@keyframes mona-spinner-pulse{0%{transform:scale(.1);opacity:.9}50%{transform:scale(.6);opacity:.5}to{transform:scale(1);opacity:0}}.mona-spinner-pulsing-triad-container{position:relative;width:100%;height:100%}.mona-spinner-pulsing-triad-segment{position:absolute;width:32%;height:32%;border-radius:9999px;background-color:currentColor;animation:mona-spinner-dot-pulse 1.4s ease-in-out infinite}.mona-spinner-pulsing-triad-1{top:0;left:34%;animation-delay:-.933s}.mona-spinner-pulsing-triad-2{bottom:0;left:0;animation-delay:-.467s}.mona-spinner-pulsing-triad-3{bottom:0;right:0;animation-delay:0s}@keyframes mona-spinner-dot-pulse{0%,to{transform:scale(.4);opacity:.4}50%{transform:scale(1);opacity:1}}.mona-spinner-pulsing-ring-container{position:relative;width:100%;height:100%}.mona-spinner-pulsing-ring-segment{position:absolute;width:22%;height:22%;border-radius:9999px;background-color:currentColor;animation:mona-spinner-dot-pulse 1.6s ease-in-out infinite}.mona-spinner-pulsing-ring-1{top:5%;left:39%;animation-delay:0s}.mona-spinner-pulsing-ring-2{top:14.96%;left:63.04%;animation-delay:-.2s}.mona-spinner-pulsing-ring-3{top:39%;left:73%;animation-delay:-.4s}.mona-spinner-pulsing-ring-4{top:63.04%;left:63.04%;animation-delay:-.6s}.mona-spinner-pulsing-ring-5{top:73%;left:39%;animation-delay:-.8s}.mona-spinner-pulsing-ring-6{top:63.04%;left:14.96%;animation-delay:-1s}.mona-spinner-pulsing-ring-7{top:39%;left:5%;animation-delay:-1.2s}.mona-spinner-pulsing-ring-8{top:14.96%;left:14.96%;animation-delay:-1.4s}.mona-spinner-converge-container{position:relative;width:100%;height:100%;animation:mona-spinner-rotate 1.5s linear infinite}.mona-spinner-converge-segment{position:absolute;width:32%;height:32%;border-radius:9999px;background-color:currentColor;animation:mona-spinner-converge 1.5s ease-in-out infinite}.mona-spinner-converge-1{top:0;left:0}.mona-spinner-converge-2{top:0;right:0}.mona-spinner-converge-3{bottom:0;right:0}.mona-spinner-converge-4{bottom:0;left:0}@keyframes mona-spinner-rotate{0%{transform:rotate(0)}to{transform:rotate(360deg)}}@keyframes mona-spinner-converge{0%,to{transform:scale(1)}50%{transform:scale(.4)}}@media(prefers-reduced-motion:reduce){.mona-spinner-pulsing-segment,.mona-spinner-pulsing-triad-segment,.mona-spinner-pulsing-ring-segment,.mona-spinner-converge-container,.mona-spinner-converge-segment{animation:none!important}.mona-spinner-pulsing-segment{opacity:.6;transform:scale(.85)}.mona-spinner-pulsing-2{opacity:.3;transform:scale(.5)}.mona-spinner-pulsing-triad-segment,.mona-spinner-pulsing-ring-segment{opacity:.7;transform:scale(.8)}}\n"], dependencies: [{ kind: "component", type: LucideDynamicIcon, selector: "svg[lucideIcon]", inputs: ["lucideIcon"] }] }); }
81
+ }
82
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.6", ngImport: i0, type: SpinnerComponent, decorators: [{
83
+ type: Component,
84
+ args: [{ selector: "mona-spinner", imports: [LucideDynamicIcon], host: {
85
+ "[attr.aria-hidden]": "decorative() ? 'true' : null",
86
+ "[attr.aria-label]": "decorative() ? null : (ariaLabel() || 'Loading')",
87
+ "[attr.role]": "decorative() ? null : 'status'",
88
+ "[class]": "baseClass()"
89
+ }, template: "@switch (appearance()) {\n @case (\"default\") {\n <svg [lucideIcon]=\"loaderIcon\" [size]=\"pixelSize()\" class=\"animate-[spin_1.5s_linear_infinite] motion-reduce:animate-none\"></svg>\n }\n @case (\"pulsing\") {\n <span class=\"mona-spinner-pulsing-container\">\n <span data-segment class=\"mona-spinner-pulsing-segment mona-spinner-pulsing-1\"></span>\n <span data-segment class=\"mona-spinner-pulsing-segment mona-spinner-pulsing-2\"></span>\n </span>\n }\n @case (\"pulsing-triad\") {\n <span class=\"mona-spinner-pulsing-triad-container\">\n <span data-segment class=\"mona-spinner-pulsing-triad-segment mona-spinner-pulsing-triad-1\"></span>\n <span data-segment class=\"mona-spinner-pulsing-triad-segment mona-spinner-pulsing-triad-2\"></span>\n <span data-segment class=\"mona-spinner-pulsing-triad-segment mona-spinner-pulsing-triad-3\"></span>\n </span>\n }\n @case (\"pulsing-ring\") {\n <span class=\"mona-spinner-pulsing-ring-container\">\n <span data-segment class=\"mona-spinner-pulsing-ring-segment mona-spinner-pulsing-ring-1\"></span>\n <span data-segment class=\"mona-spinner-pulsing-ring-segment mona-spinner-pulsing-ring-2\"></span>\n <span data-segment class=\"mona-spinner-pulsing-ring-segment mona-spinner-pulsing-ring-3\"></span>\n <span data-segment class=\"mona-spinner-pulsing-ring-segment mona-spinner-pulsing-ring-4\"></span>\n <span data-segment class=\"mona-spinner-pulsing-ring-segment mona-spinner-pulsing-ring-5\"></span>\n <span data-segment class=\"mona-spinner-pulsing-ring-segment mona-spinner-pulsing-ring-6\"></span>\n <span data-segment class=\"mona-spinner-pulsing-ring-segment mona-spinner-pulsing-ring-7\"></span>\n <span data-segment class=\"mona-spinner-pulsing-ring-segment mona-spinner-pulsing-ring-8\"></span>\n </span>\n }\n @case (\"converging-spinner\") {\n <span class=\"mona-spinner-converge-container\">\n <span data-segment class=\"mona-spinner-converge-segment mona-spinner-converge-1\"></span>\n <span data-segment class=\"mona-spinner-converge-segment mona-spinner-converge-2\"></span>\n <span data-segment class=\"mona-spinner-converge-segment mona-spinner-converge-3\"></span>\n <span data-segment class=\"mona-spinner-converge-segment mona-spinner-converge-4\"></span>\n </span>\n }\n}\n", styles: [":host{display:inline-flex;vertical-align:middle}.mona-spinner-pulsing-container{position:relative;width:100%;height:100%;display:flex;align-items:center;justify-content:center}.mona-spinner-pulsing-segment{position:absolute;inset:0;border-radius:9999px;background-color:currentColor;opacity:.6;animation:mona-spinner-pulse 1.2s cubic-bezier(0,.2,.8,1) infinite}.mona-spinner-pulsing-2{animation-delay:-.6s}@keyframes mona-spinner-pulse{0%{transform:scale(.1);opacity:.9}50%{transform:scale(.6);opacity:.5}to{transform:scale(1);opacity:0}}.mona-spinner-pulsing-triad-container{position:relative;width:100%;height:100%}.mona-spinner-pulsing-triad-segment{position:absolute;width:32%;height:32%;border-radius:9999px;background-color:currentColor;animation:mona-spinner-dot-pulse 1.4s ease-in-out infinite}.mona-spinner-pulsing-triad-1{top:0;left:34%;animation-delay:-.933s}.mona-spinner-pulsing-triad-2{bottom:0;left:0;animation-delay:-.467s}.mona-spinner-pulsing-triad-3{bottom:0;right:0;animation-delay:0s}@keyframes mona-spinner-dot-pulse{0%,to{transform:scale(.4);opacity:.4}50%{transform:scale(1);opacity:1}}.mona-spinner-pulsing-ring-container{position:relative;width:100%;height:100%}.mona-spinner-pulsing-ring-segment{position:absolute;width:22%;height:22%;border-radius:9999px;background-color:currentColor;animation:mona-spinner-dot-pulse 1.6s ease-in-out infinite}.mona-spinner-pulsing-ring-1{top:5%;left:39%;animation-delay:0s}.mona-spinner-pulsing-ring-2{top:14.96%;left:63.04%;animation-delay:-.2s}.mona-spinner-pulsing-ring-3{top:39%;left:73%;animation-delay:-.4s}.mona-spinner-pulsing-ring-4{top:63.04%;left:63.04%;animation-delay:-.6s}.mona-spinner-pulsing-ring-5{top:73%;left:39%;animation-delay:-.8s}.mona-spinner-pulsing-ring-6{top:63.04%;left:14.96%;animation-delay:-1s}.mona-spinner-pulsing-ring-7{top:39%;left:5%;animation-delay:-1.2s}.mona-spinner-pulsing-ring-8{top:14.96%;left:14.96%;animation-delay:-1.4s}.mona-spinner-converge-container{position:relative;width:100%;height:100%;animation:mona-spinner-rotate 1.5s linear infinite}.mona-spinner-converge-segment{position:absolute;width:32%;height:32%;border-radius:9999px;background-color:currentColor;animation:mona-spinner-converge 1.5s ease-in-out infinite}.mona-spinner-converge-1{top:0;left:0}.mona-spinner-converge-2{top:0;right:0}.mona-spinner-converge-3{bottom:0;right:0}.mona-spinner-converge-4{bottom:0;left:0}@keyframes mona-spinner-rotate{0%{transform:rotate(0)}to{transform:rotate(360deg)}}@keyframes mona-spinner-converge{0%,to{transform:scale(1)}50%{transform:scale(.4)}}@media(prefers-reduced-motion:reduce){.mona-spinner-pulsing-segment,.mona-spinner-pulsing-triad-segment,.mona-spinner-pulsing-ring-segment,.mona-spinner-converge-container,.mona-spinner-converge-segment{animation:none!important}.mona-spinner-pulsing-segment{opacity:.6;transform:scale(.85)}.mona-spinner-pulsing-2{opacity:.3;transform:scale(.5)}.mona-spinner-pulsing-triad-segment,.mona-spinner-pulsing-ring-segment{opacity:.7;transform:scale(.8)}}\n"] }]
90
+ }], propDecorators: { appearance: [{ type: i0.Input, args: [{ isSignal: true, alias: "appearance", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "aria-label", required: false }] }], decorative: [{ type: i0.Input, args: [{ isSignal: true, alias: "decorative", required: false }] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }], userClass: [{ type: i0.Input, args: [{ isSignal: true, alias: "class", required: false }] }] } });
91
+
92
+ const spinnerOverlayThemeVariants = cva(`
93
+ isolate flex items-center justify-center
94
+ pointer-events-auto select-none
95
+ bg-background/60 backdrop-blur-xs
96
+ text-foreground
97
+ `, {
98
+ variants: {
99
+ fullPage: {
100
+ true: "fixed inset-0 z-50",
101
+ false: "absolute inset-0 z-1"
102
+ }
103
+ },
104
+ defaultVariants: {
105
+ fullPage: false
106
+ }
107
+ });
108
+
109
+ /**
110
+ * @description
111
+ * Internal overlay component managed exclusively by `SpinnerService`.
112
+ * Hosts a decorative `SpinnerComponent`, status message, and optional cancellation control.
113
+ */
114
+ class SpinnerOverlayComponent {
115
+ constructor() {
116
+ this.baseClass = computed(() => {
117
+ return spinnerOverlayThemeVariants({ fullPage: this.fullPage() });
118
+ }, /* @ts-ignore */
119
+ ...(ngDevMode ? [{ debugName: "baseClass" }] : /* istanbul ignore next */ []));
120
+ this.effectiveZIndex = computed(() => {
121
+ const customZIndex = this.zIndex();
122
+ if (customZIndex !== undefined) {
123
+ return customZIndex;
124
+ }
125
+ return this.fullPage() ? 50 : 1;
126
+ }, /* @ts-ignore */
127
+ ...(ngDevMode ? [{ debugName: "effectiveZIndex" }] : /* istanbul ignore next */ []));
128
+ /**
129
+ * @description Visual appearance of the nested spinner animation.
130
+ * @default "default"
131
+ */
132
+ this.appearance = input("default", /* @ts-ignore */
133
+ ...(ngDevMode ? [{ debugName: "appearance" }] : /* istanbul ignore next */ []));
134
+ /**
135
+ * @description Emits when the consumer clicks the cancellation action button.
136
+ */
137
+ this.cancel = output();
138
+ /**
139
+ * @description Whether the cancellation button is displayed in the overlay.
140
+ * @default false
141
+ */
142
+ this.cancellable = input(false, /* @ts-ignore */
143
+ ...(ngDevMode ? [{ debugName: "cancellable" }] : /* istanbul ignore next */ []));
144
+ /**
145
+ * @description Label text displayed inside the cancellation button.
146
+ * @default "Cancel"
147
+ */
148
+ this.cancelText = input("Cancel", /* @ts-ignore */
149
+ ...(ngDevMode ? [{ debugName: "cancelText" }] : /* istanbul ignore next */ []));
150
+ /**
151
+ * @description Whether the overlay is fixed across the viewport or absolute within a target container.
152
+ * @default false
153
+ */
154
+ this.fullPage = input(false, /* @ts-ignore */
155
+ ...(ngDevMode ? [{ debugName: "fullPage" }] : /* istanbul ignore next */ []));
156
+ /**
157
+ * @description Size preset passed to the nested spinner.
158
+ * @default "medium"
159
+ */
160
+ this.size = input("medium", /* @ts-ignore */
161
+ ...(ngDevMode ? [{ debugName: "size" }] : /* istanbul ignore next */ []));
162
+ /**
163
+ * @description Status text displayed below the spinner indicator.
164
+ * @default undefined
165
+ */
166
+ this.text = input(undefined, /* @ts-ignore */
167
+ ...(ngDevMode ? [{ debugName: "text" }] : /* istanbul ignore next */ []));
168
+ /**
169
+ * @description Custom z-index value for unusual stacking contexts.
170
+ * @default undefined
171
+ */
172
+ this.zIndex = input(undefined, /* @ts-ignore */
173
+ ...(ngDevMode ? [{ debugName: "zIndex" }] : /* istanbul ignore next */ []));
174
+ }
175
+ onCancel() {
176
+ this.cancel.emit();
177
+ }
178
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.6", ngImport: i0, type: SpinnerOverlayComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
179
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.6", type: SpinnerOverlayComponent, isStandalone: true, selector: "mona-spinner-overlay", inputs: { appearance: { classPropertyName: "appearance", publicName: "appearance", isSignal: true, isRequired: false, transformFunction: null }, cancellable: { classPropertyName: "cancellable", publicName: "cancellable", isSignal: true, isRequired: false, transformFunction: null }, cancelText: { classPropertyName: "cancelText", publicName: "cancelText", isSignal: true, isRequired: false, transformFunction: null }, fullPage: { classPropertyName: "fullPage", publicName: "fullPage", isSignal: true, isRequired: false, transformFunction: null }, size: { classPropertyName: "size", publicName: "size", isSignal: true, isRequired: false, transformFunction: null }, text: { classPropertyName: "text", publicName: "text", isSignal: true, isRequired: false, transformFunction: null }, zIndex: { classPropertyName: "zIndex", publicName: "zIndex", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { cancel: "cancel" }, host: { properties: { "class": "baseClass()", "style.z-index": "effectiveZIndex()" } }, ngImport: i0, template: "<div class=\"flex flex-col items-center justify-center gap-2.5 p-4 text-sm font-medium text-foreground text-center\" role=\"status\" aria-live=\"polite\">\n <mona-spinner [appearance]=\"appearance()\" [size]=\"size()\" [decorative]=\"true\"></mona-spinner>\n\n @if (text()) {\n <span class=\"max-w-xs truncate\">{{ text() }}</span>\n } @else {\n <span class=\"sr-only\">Loading</span>\n }\n\n @if (cancellable()) {\n <button\n type=\"button\"\n (click)=\"onCancel()\"\n class=\"mt-1 inline-flex items-center justify-center rounded-md border border-border bg-background px-2.5 py-1 text-xs font-medium text-foreground shadow-xs hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring cursor-pointer transition-colors\"\n >\n {{ cancelText() }}\n </button>\n }\n</div>\n", dependencies: [{ kind: "component", type: SpinnerComponent, selector: "mona-spinner", inputs: ["appearance", "aria-label", "decorative", "size", "class"] }] }); }
180
+ }
181
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.6", ngImport: i0, type: SpinnerOverlayComponent, decorators: [{
182
+ type: Component,
183
+ args: [{ selector: "mona-spinner-overlay", imports: [SpinnerComponent], host: {
184
+ "[class]": "baseClass()",
185
+ "[style.z-index]": "effectiveZIndex()"
186
+ }, template: "<div class=\"flex flex-col items-center justify-center gap-2.5 p-4 text-sm font-medium text-foreground text-center\" role=\"status\" aria-live=\"polite\">\n <mona-spinner [appearance]=\"appearance()\" [size]=\"size()\" [decorative]=\"true\"></mona-spinner>\n\n @if (text()) {\n <span class=\"max-w-xs truncate\">{{ text() }}</span>\n } @else {\n <span class=\"sr-only\">Loading</span>\n }\n\n @if (cancellable()) {\n <button\n type=\"button\"\n (click)=\"onCancel()\"\n class=\"mt-1 inline-flex items-center justify-center rounded-md border border-border bg-background px-2.5 py-1 text-xs font-medium text-foreground shadow-xs hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring cursor-pointer transition-colors\"\n >\n {{ cancelText() }}\n </button>\n }\n</div>\n" }]
187
+ }], propDecorators: { appearance: [{ type: i0.Input, args: [{ isSignal: true, alias: "appearance", required: false }] }], cancel: [{ type: i0.Output, args: ["cancel"] }], cancellable: [{ type: i0.Input, args: [{ isSignal: true, alias: "cancellable", required: false }] }], cancelText: [{ type: i0.Input, args: [{ isSignal: true, alias: "cancelText", required: false }] }], fullPage: [{ type: i0.Input, args: [{ isSignal: true, alias: "fullPage", required: false }] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }], text: [{ type: i0.Input, args: [{ isSignal: true, alias: "text", required: false }] }], zIndex: [{ type: i0.Input, args: [{ isSignal: true, alias: "zIndex", required: false }] }] } });
188
+
189
+ class SpinnerService {
190
+ #appRef = inject(ApplicationRef);
191
+ #document = inject(DOCUMENT);
192
+ #injector = inject(Injector);
193
+ #renderer = inject(RendererFactory2).createRenderer(null, null);
194
+ #requests = new Map();
195
+ #targetStates = new Set();
196
+ #targets = new WeakMap();
197
+ /**
198
+ * Hides and destroys the spinner associated with the given `SpinnerRef` or string `id`.
199
+ */
200
+ hide(refOrId) {
201
+ const id = typeof refOrId === "string" ? refOrId : refOrId.id;
202
+ this.#closeRequest(id);
203
+ }
204
+ /**
205
+ * Tears down every active/pending spinner request and physical overlay so
206
+ * nothing is left behind if the service outlives its consumers.
207
+ */
208
+ ngOnDestroy() {
209
+ for (const request of this.#requests.values()) {
210
+ if (request.delayTimer != null) {
211
+ clearTimeout(request.delayTimer);
212
+ request.delayTimer = null;
213
+ }
214
+ if (!request.cancellationHandled) {
215
+ request.cancelledSubject.complete();
216
+ }
217
+ }
218
+ this.#requests.clear();
219
+ for (const targetState of this.#targetStates) {
220
+ if (targetState.minDurationTimer != null) {
221
+ clearTimeout(targetState.minDurationTimer);
222
+ targetState.minDurationTimer = null;
223
+ }
224
+ targetState.cancelSubscription?.unsubscribe();
225
+ targetState.cancelSubscription = null;
226
+ if (targetState.componentRef != null) {
227
+ this.#appRef.detachView(targetState.componentRef.hostView);
228
+ targetState.componentRef.destroy();
229
+ targetState.componentRef = null;
230
+ }
231
+ }
232
+ this.#targetStates.clear();
233
+ }
234
+ /**
235
+ * Displays a spinner loading overlay according to the provided options.
236
+ * Returns a `SpinnerRef` handle for lifecycle management and updates.
237
+ */
238
+ show(options) {
239
+ const id = options?.id ?? `spinner-${v4()}`;
240
+ // If an existing request with the same ID exists, close it cleanly first
241
+ if (this.#requests.has(id)) {
242
+ this.#closeRequest(id);
243
+ }
244
+ const targets = this.#normalizeTargets(options?.target);
245
+ const isFullPage = !options?.target || targets.length === 0;
246
+ const resolvedTargets = isFullPage ? [this.#document.body] : targets;
247
+ const cancellableOpt = options?.cancellable;
248
+ const isCancellable = !!cancellableOpt;
249
+ const cancelText = typeof cancellableOpt === "object" && cancellableOpt.text ? cancellableOpt.text : "Cancel";
250
+ const onCancel = typeof cancellableOpt === "object" ? cancellableOpt.onCancel : undefined;
251
+ const cancelledSubject = new Subject();
252
+ const request = {
253
+ activatedAt: Date.now(),
254
+ appearance: options?.appearance ?? "default",
255
+ cancellationHandled: false,
256
+ cancellable: isCancellable,
257
+ cancelText,
258
+ delayTimer: null,
259
+ id,
260
+ isFullPage,
261
+ onCancel,
262
+ options: options ?? {},
263
+ registeredAt: Date.now(),
264
+ size: options?.size ?? "medium",
265
+ status: "PENDING",
266
+ cancelledSubject,
267
+ targets: resolvedTargets,
268
+ text: options?.text
269
+ };
270
+ this.#requests.set(id, request);
271
+ // Register request against all targets and set aria-busy. Targets that already
272
+ // have a visible overlay activate immediately; idle targets respect `delay`.
273
+ const immediateTargets = [];
274
+ const delayedTargets = [];
275
+ for (const target of resolvedTargets) {
276
+ const targetState = this.#getOrCreateTargetState(target, isFullPage);
277
+ targetState.pendingRequestIds.add(id);
278
+ if (targetState.componentRef != null) {
279
+ immediateTargets.push(target);
280
+ }
281
+ else {
282
+ delayedTargets.push(target);
283
+ }
284
+ }
285
+ if (immediateTargets.length > 0) {
286
+ this.#activateRequestOnTargets(request, immediateTargets);
287
+ }
288
+ const delay = options?.delay ?? 0;
289
+ if (delayedTargets.length > 0) {
290
+ if (delay > 0) {
291
+ request.delayTimer = setTimeout(() => {
292
+ request.delayTimer = null;
293
+ if (request.status !== "CLOSED") {
294
+ this.#activateRequestOnTargets(request, delayedTargets);
295
+ }
296
+ }, delay);
297
+ }
298
+ else {
299
+ this.#activateRequestOnTargets(request, delayedTargets);
300
+ }
301
+ }
302
+ return {
303
+ id,
304
+ cancelled$: cancelledSubject.asObservable(),
305
+ close: () => this.#closeRequest(id),
306
+ update: update => this.#updateRequest(id, update)
307
+ };
308
+ }
309
+ /**
310
+ * Wraps an Observable operation with a Spinner lifecycle.
311
+ * Shows the spinner on subscription and closes it when the observable completes, errors, or is unsubscribed.
312
+ */
313
+ track(source, options) {
314
+ return defer(() => {
315
+ const ref = this.show(options);
316
+ return source.pipe(finalize(() => ref.close()));
317
+ });
318
+ }
319
+ #activateRequestOnTargets(request, targets) {
320
+ if (request.status === "CLOSED") {
321
+ return;
322
+ }
323
+ request.status = "VISIBLE";
324
+ request.activatedAt = Date.now();
325
+ for (const target of targets) {
326
+ const targetState = this.#getOrCreateTargetState(target, request.isFullPage);
327
+ targetState.activeRequestIds.add(request.id);
328
+ this.#updateTargetOverlay(targetState);
329
+ }
330
+ }
331
+ #cancelRequest(requestId) {
332
+ const request = this.#requests.get(requestId);
333
+ if (!request || request.cancellationHandled || request.status === "CLOSED") {
334
+ return;
335
+ }
336
+ request.cancellationHandled = true;
337
+ request.cancelledSubject.next();
338
+ request.cancelledSubject.complete();
339
+ try {
340
+ if (request.onCancel) {
341
+ request.onCancel();
342
+ }
343
+ }
344
+ finally {
345
+ this.#closeRequest(requestId);
346
+ }
347
+ }
348
+ #closeRequest(requestId) {
349
+ const request = this.#requests.get(requestId);
350
+ if (!request || request.status === "CLOSED") {
351
+ return;
352
+ }
353
+ request.status = "CLOSED";
354
+ if (request.delayTimer != null) {
355
+ clearTimeout(request.delayTimer);
356
+ request.delayTimer = null;
357
+ }
358
+ if (!request.cancellationHandled) {
359
+ request.cancelledSubject.complete();
360
+ }
361
+ this.#requests.delete(requestId);
362
+ for (const target of request.targets) {
363
+ const targetState = this.#targets.get(target);
364
+ if (!targetState) {
365
+ continue;
366
+ }
367
+ targetState.pendingRequestIds.delete(requestId);
368
+ targetState.activeRequestIds.delete(requestId);
369
+ if (targetState.activeRequestIds.size > 0) {
370
+ // Other active requests remain; recompute winning presentation
371
+ this.#updateTargetOverlay(targetState);
372
+ }
373
+ else if (targetState.pendingRequestIds.size === 0) {
374
+ // No active or pending requests remain on this target
375
+ const minDuration = request.options.minimumVisibleDuration ?? 0;
376
+ if (minDuration > 0 && targetState.visibleSince != null && targetState.componentRef != null) {
377
+ const elapsed = Date.now() - targetState.visibleSince;
378
+ const remaining = minDuration - elapsed;
379
+ if (remaining > 0) {
380
+ if (targetState.minDurationTimer != null) {
381
+ clearTimeout(targetState.minDurationTimer);
382
+ }
383
+ targetState.minDurationTimer = setTimeout(() => {
384
+ targetState.minDurationTimer = null;
385
+ if (targetState.activeRequestIds.size === 0 && targetState.pendingRequestIds.size === 0) {
386
+ this.#teardownTarget(targetState);
387
+ }
388
+ }, remaining);
389
+ }
390
+ else {
391
+ this.#teardownTarget(targetState);
392
+ }
393
+ }
394
+ else {
395
+ this.#teardownTarget(targetState);
396
+ }
397
+ }
398
+ }
399
+ }
400
+ #getOrCreateTargetState(target, isFullPage) {
401
+ let state = this.#targets.get(target);
402
+ if (!state) {
403
+ const originalAriaBusy = target.getAttribute("aria-busy");
404
+ const originalInlinePosition = target.style.position;
405
+ state = {
406
+ activeRequestIds: new Set(),
407
+ cancelSubscription: null,
408
+ changedPosition: false,
409
+ componentRef: null,
410
+ isFullPage,
411
+ minDurationTimer: null,
412
+ originalAriaBusy,
413
+ originalInlinePosition,
414
+ pendingRequestIds: new Set(),
415
+ target
416
+ };
417
+ this.#targets.set(target, state);
418
+ this.#targetStates.add(state);
419
+ }
420
+ // Apply aria-busy immediately while logically busy
421
+ this.#renderer.setAttribute(target, "aria-busy", "true");
422
+ // If a delayed teardown was pending, cancel it since new activity arrived
423
+ if (state.minDurationTimer != null) {
424
+ clearTimeout(state.minDurationTimer);
425
+ state.minDurationTimer = null;
426
+ }
427
+ return state;
428
+ }
429
+ #getWinningRequestForTarget(targetState) {
430
+ let winningRequest = null;
431
+ for (const requestId of targetState.activeRequestIds) {
432
+ const req = this.#requests.get(requestId);
433
+ if (req && req.status === "VISIBLE") {
434
+ if (!winningRequest || req.activatedAt >= winningRequest.activatedAt) {
435
+ winningRequest = req;
436
+ }
437
+ }
438
+ }
439
+ return winningRequest;
440
+ }
441
+ #normalizeTargets(target) {
442
+ if (!target) {
443
+ return [];
444
+ }
445
+ const rawList = Array.isArray(target) ? target : [target];
446
+ const result = [];
447
+ for (const item of rawList) {
448
+ if (item instanceof HTMLElement) {
449
+ result.push(item);
450
+ }
451
+ else if (item && typeof item === "object" && "nativeElement" in item && item.nativeElement instanceof HTMLElement) {
452
+ result.push(item.nativeElement);
453
+ }
454
+ }
455
+ return result;
456
+ }
457
+ #teardownTarget(targetState) {
458
+ if (targetState.minDurationTimer != null) {
459
+ clearTimeout(targetState.minDurationTimer);
460
+ targetState.minDurationTimer = null;
461
+ }
462
+ targetState.cancelSubscription?.unsubscribe();
463
+ targetState.cancelSubscription = null;
464
+ if (targetState.componentRef != null) {
465
+ this.#appRef.detachView(targetState.componentRef.hostView);
466
+ targetState.componentRef.destroy();
467
+ targetState.componentRef = null;
468
+ }
469
+ targetState.visibleSince = undefined;
470
+ // Restore aria-busy attribute exactly
471
+ if (targetState.originalAriaBusy === null) {
472
+ this.#renderer.removeAttribute(targetState.target, "aria-busy");
473
+ }
474
+ else {
475
+ this.#renderer.setAttribute(targetState.target, "aria-busy", targetState.originalAriaBusy);
476
+ }
477
+ // Restore target positioning safely
478
+ if (targetState.changedPosition) {
479
+ if (targetState.target.style.position === "relative") {
480
+ this.#renderer.setStyle(targetState.target, "position", targetState.originalInlinePosition || null);
481
+ }
482
+ targetState.changedPosition = false;
483
+ }
484
+ }
485
+ #updateRequest(requestId, update) {
486
+ const request = this.#requests.get(requestId);
487
+ if (!request || request.status === "CLOSED") {
488
+ return;
489
+ }
490
+ if (update.appearance !== undefined) {
491
+ request.appearance = update.appearance;
492
+ }
493
+ if (update.size !== undefined) {
494
+ request.size = update.size;
495
+ }
496
+ if (update.text !== undefined) {
497
+ request.text = update.text;
498
+ }
499
+ for (const target of request.targets) {
500
+ const targetState = this.#targets.get(target);
501
+ if (targetState) {
502
+ this.#updateTargetOverlay(targetState);
503
+ }
504
+ }
505
+ }
506
+ #updateTargetOverlay(targetState) {
507
+ const winner = this.#getWinningRequestForTarget(targetState);
508
+ if (!winner) {
509
+ return;
510
+ }
511
+ if (!targetState.componentRef) {
512
+ // Need containing block for absolute local positioning
513
+ if (!targetState.isFullPage) {
514
+ const defaultView = this.#document.defaultView;
515
+ const computedPosition = defaultView ? defaultView.getComputedStyle(targetState.target).position : "static";
516
+ if (computedPosition === "static") {
517
+ this.#renderer.setStyle(targetState.target, "position", "relative");
518
+ targetState.changedPosition = true;
519
+ }
520
+ }
521
+ const compRef = createComponent(SpinnerOverlayComponent, {
522
+ environmentInjector: this.#appRef.injector,
523
+ elementInjector: this.#injector
524
+ });
525
+ compRef.setInput("appearance", winner.appearance);
526
+ compRef.setInput("size", winner.size);
527
+ compRef.setInput("text", winner.text);
528
+ compRef.setInput("cancellable", winner.cancellable);
529
+ compRef.setInput("cancelText", winner.cancelText);
530
+ compRef.setInput("fullPage", targetState.isFullPage);
531
+ if (winner.options.zIndex !== undefined) {
532
+ compRef.setInput("zIndex", winner.options.zIndex);
533
+ }
534
+ targetState.cancelSubscription = compRef.instance.cancel.subscribe(() => {
535
+ const currentWinner = this.#getWinningRequestForTarget(targetState);
536
+ if (currentWinner) {
537
+ this.#cancelRequest(currentWinner.id);
538
+ }
539
+ });
540
+ this.#appRef.attachView(compRef.hostView);
541
+ this.#renderer.appendChild(targetState.target, compRef.location.nativeElement);
542
+ compRef.changeDetectorRef.detectChanges();
543
+ targetState.componentRef = compRef;
544
+ targetState.visibleSince = Date.now();
545
+ }
546
+ else {
547
+ // Physical overlay already exists: update its inputs dynamically
548
+ const compRef = targetState.componentRef;
549
+ compRef.setInput("appearance", winner.appearance);
550
+ compRef.setInput("size", winner.size);
551
+ compRef.setInput("text", winner.text);
552
+ compRef.setInput("cancellable", winner.cancellable);
553
+ compRef.setInput("cancelText", winner.cancelText);
554
+ compRef.setInput("fullPage", targetState.isFullPage);
555
+ if (winner.options.zIndex !== undefined) {
556
+ compRef.setInput("zIndex", winner.options.zIndex);
557
+ }
558
+ compRef.changeDetectorRef.detectChanges();
559
+ }
560
+ }
561
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.6", ngImport: i0, type: SpinnerService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
562
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.6", ngImport: i0, type: SpinnerService, providedIn: "root" }); }
563
+ }
564
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.6", ngImport: i0, type: SpinnerService, decorators: [{
565
+ type: Injectable,
566
+ args: [{
567
+ providedIn: "root"
568
+ }]
569
+ }] });
570
+
571
+ /*
572
+ * Public API Surface of @nanahoshi/mona-ui/spinner
573
+ */
574
+
575
+ /**
576
+ * Generated bundle index. Do not edit.
577
+ */
578
+
579
+ export { SpinnerComponent, SpinnerService };
580
+ //# sourceMappingURL=nanahoshi-mona-ui-spinner.mjs.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanahoshi/mona-ui",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "A very light Angular UI framework",
5
5
  "author": "Nanahoshi",
6
6
  "repository": {
@@ -289,6 +289,10 @@
289
289
  "types": "./types/nanahoshi-mona-ui-slider.d.ts",
290
290
  "default": "./fesm2022/nanahoshi-mona-ui-slider.mjs"
291
291
  },
292
+ "./spinner": {
293
+ "types": "./types/nanahoshi-mona-ui-spinner.d.ts",
294
+ "default": "./fesm2022/nanahoshi-mona-ui-spinner.mjs"
295
+ },
292
296
  "./split-button": {
293
297
  "types": "./types/nanahoshi-mona-ui-split-button.d.ts",
294
298
  "default": "./fesm2022/nanahoshi-mona-ui-split-button.mjs"
@@ -0,0 +1,4 @@
1
+ {
2
+ "module": "../fesm2022/nanahoshi-mona-ui-spinner.mjs",
3
+ "typings": "../types/nanahoshi-mona-ui-spinner.d.ts"
4
+ }
@@ -1,11 +1,17 @@
1
1
  import * as _angular_core from '@angular/core';
2
- import { TemplateRef } from '@angular/core';
2
+ import { TemplateRef, ElementRef } from '@angular/core';
3
3
  import { FormValueControl } from '@angular/forms/signals';
4
4
  import * as class_variance_authority_types from 'class-variance-authority/types';
5
5
  import { VariantProps } from 'class-variance-authority';
6
6
  import { VariantInputs } from '@nanahoshi/mona-ui/internal';
7
7
  import * as _nanahoshi_mona_ui_segmented from '@nanahoshi/mona-ui/segmented';
8
8
 
9
+ interface SegmentedIndicatorGeometry {
10
+ height: number;
11
+ transform: string;
12
+ width: number;
13
+ }
14
+
9
15
  type SegmentedValue = string | number;
10
16
 
11
17
  interface SegmentedOption<T extends SegmentedValue = SegmentedValue> {
@@ -55,12 +61,26 @@ type SegmentedVariantProps = SegmentedContainerVariantProps & SegmentedOptionVar
55
61
  type SegmentedVariantInput = SegmentedContainerVariantInput & SegmentedOptionVariantInput;
56
62
 
57
63
  declare class SegmentedComponent<T extends SegmentedValue = SegmentedValue> implements SegmentedVariantInput, FormValueControl<T | null> {
64
+ #private;
58
65
  protected readonly containerClasses: _angular_core.Signal<string>;
59
66
  protected readonly groupName: string;
67
+ protected readonly indicatorClasses: _angular_core.Signal<string>;
68
+ protected readonly indicatorGeometry: _angular_core.WritableSignal<SegmentedIndicatorGeometry | null>;
69
+ protected readonly indicatorInitialized: _angular_core.WritableSignal<boolean>;
70
+ protected readonly indicatorTransitionEnabled: _angular_core.WritableSignal<boolean>;
60
71
  protected readonly inputClasses: _angular_core.Signal<string>;
61
72
  protected readonly invalidState: _angular_core.Signal<boolean>;
62
73
  protected readonly itemTemplate: _angular_core.Signal<TemplateRef<any> | undefined>;
63
74
  protected readonly optionClasses: _angular_core.Signal<string>;
75
+ protected readonly optionElements: _angular_core.Signal<readonly ElementRef<HTMLLabelElement>[]>;
76
+ protected readonly selectedIndex: _angular_core.Signal<number>;
77
+ protected readonly selectedOptionDisabled: _angular_core.Signal<boolean>;
78
+ /**
79
+ * @description Controls whether the selection indicator animates between selected options.
80
+ * When false, selection changes are applied immediately without transition.
81
+ * @default true
82
+ */
83
+ readonly animate: _angular_core.InputSignal<boolean>;
64
84
  /**
65
85
  * @description Accessible name for the radio group. Provide either `aria-label` or `aria-labelledby`.
66
86
  * @default null
@@ -117,11 +137,14 @@ declare class SegmentedComponent<T extends SegmentedValue = SegmentedValue> impl
117
137
  * @default null
118
138
  */
119
139
  readonly value: _angular_core.ModelSignal<T | null>;
140
+ constructor();
120
141
  protected createOptionContext(option: SegmentedOption<T>, index: number): SegmentedItemTemplateContext<T>;
121
142
  protected onOptionBlur(): void;
122
143
  protected onOptionChange(option: SegmentedOption<T>): void;
144
+ private setupResizeObserver;
145
+ private updateIndicatorGeometry;
123
146
  static ɵfac: _angular_core.ɵɵFactoryDeclaration<SegmentedComponent<any>, never>;
124
- static ɵcmp: _angular_core.ɵɵComponentDeclaration<SegmentedComponent<any>, "mona-segmented", never, { "ariaLabel": { "alias": "aria-label"; "required": false; "isSignal": true; }; "ariaLabelledBy": { "alias": "aria-labelledby"; "required": false; "isSignal": true; }; "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "invalid": { "alias": "invalid"; "required": false; "isSignal": true; }; "options": { "alias": "options"; "required": true; "isSignal": true; }; "rounded": { "alias": "rounded"; "required": false; "isSignal": true; }; "size": { "alias": "size"; "required": false; "isSignal": true; }; "touched": { "alias": "touched"; "required": false; "isSignal": true; }; "userClass": { "alias": "class"; "required": false; "isSignal": true; }; "value": { "alias": "value"; "required": false; "isSignal": true; }; }, { "touch": "touch"; "value": "valueChange"; }, ["itemTemplate"], never, true, never>;
147
+ static ɵcmp: _angular_core.ɵɵComponentDeclaration<SegmentedComponent<any>, "mona-segmented", never, { "animate": { "alias": "animate"; "required": false; "isSignal": true; }; "ariaLabel": { "alias": "aria-label"; "required": false; "isSignal": true; }; "ariaLabelledBy": { "alias": "aria-labelledby"; "required": false; "isSignal": true; }; "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "invalid": { "alias": "invalid"; "required": false; "isSignal": true; }; "options": { "alias": "options"; "required": true; "isSignal": true; }; "rounded": { "alias": "rounded"; "required": false; "isSignal": true; }; "size": { "alias": "size"; "required": false; "isSignal": true; }; "touched": { "alias": "touched"; "required": false; "isSignal": true; }; "userClass": { "alias": "class"; "required": false; "isSignal": true; }; "value": { "alias": "value"; "required": false; "isSignal": true; }; }, { "touch": "touch"; "value": "valueChange"; }, ["itemTemplate"], never, true, never>;
125
148
  }
126
149
 
127
150
  /**
@@ -0,0 +1,114 @@
1
+ import * as _angular_core from '@angular/core';
2
+ import { ElementRef, OnDestroy } from '@angular/core';
3
+ import { LucideLoader } from '@lucide/angular';
4
+ import { ClassInputType } from '@nanahoshi/mona-ui/common';
5
+ import * as class_variance_authority_types from 'class-variance-authority/types';
6
+ import { VariantProps } from 'class-variance-authority';
7
+ import { VariantInputs } from '@nanahoshi/mona-ui/internal';
8
+ import { Observable } from 'rxjs';
9
+
10
+ type SpinnerAppearance = "default" | "pulsing" | "pulsing-triad" | "pulsing-ring" | "converging-spinner";
11
+
12
+ type SpinnerSize = "small" | "medium" | "large";
13
+
14
+ declare const spinnerThemeVariants: (props?: ({
15
+ size?: "small" | "medium" | "large" | null | undefined;
16
+ } & class_variance_authority_types.ClassProp) | undefined) => string;
17
+ type SpinnerVariantProps = VariantProps<typeof spinnerThemeVariants>;
18
+ type SpinnerVariantInput = VariantInputs<SpinnerVariantProps>;
19
+
20
+ /**
21
+ * @description
22
+ * An indeterminate loading indicator primitive for communicating ongoing activity.
23
+ * Supports multiple appearances, standard sizes, and accessible status announcements.
24
+ */
25
+ declare class SpinnerComponent implements SpinnerVariantInput {
26
+ protected readonly baseClass: _angular_core.Signal<string>;
27
+ protected readonly loaderIcon: typeof LucideLoader;
28
+ protected readonly pixelSize: _angular_core.Signal<12 | 24 | 16>;
29
+ /**
30
+ * @description Visual appearance of the spinner animation.
31
+ * @default "default"
32
+ */
33
+ readonly appearance: _angular_core.InputSignal<SpinnerAppearance>;
34
+ /**
35
+ * @description Accessible text announced by assistive technology when the spinner is not decorative.
36
+ * @default "Loading"
37
+ */
38
+ readonly ariaLabel: _angular_core.InputSignal<string>;
39
+ /**
40
+ * @description When `true`, removes the `status` role and hides the spinner from assistive technology with `aria-hidden="true"`.
41
+ * Useful when a parent element already manages loading semantics (such as `aria-busy="true"` on a button).
42
+ * @default false
43
+ */
44
+ readonly decorative: _angular_core.InputSignal<boolean>;
45
+ /**
46
+ * @description Size preset controlling the dimensions of the indicator.
47
+ * @default "medium"
48
+ */
49
+ readonly size: _angular_core.InputSignal<SpinnerSize>;
50
+ /**
51
+ * @description Additional CSS classes merged onto the host element via `tailwind-merge`.
52
+ * @default ""
53
+ */
54
+ readonly userClass: _angular_core.InputSignalWithTransform<string, ClassInputType>;
55
+ static ɵfac: _angular_core.ɵɵFactoryDeclaration<SpinnerComponent, never>;
56
+ static ɵcmp: _angular_core.ɵɵComponentDeclaration<SpinnerComponent, "mona-spinner", never, { "appearance": { "alias": "appearance"; "required": false; "isSignal": true; }; "ariaLabel": { "alias": "aria-label"; "required": false; "isSignal": true; }; "decorative": { "alias": "decorative"; "required": false; "isSignal": true; }; "size": { "alias": "size"; "required": false; "isSignal": true; }; "userClass": { "alias": "class"; "required": false; "isSignal": true; }; }, {}, never, never, true, never>;
57
+ }
58
+
59
+ interface SpinnerCancelOptions {
60
+ onCancel?: () => void;
61
+ text?: string;
62
+ }
63
+ interface SpinnerOptions {
64
+ appearance?: SpinnerAppearance;
65
+ cancellable?: boolean | SpinnerCancelOptions;
66
+ delay?: number;
67
+ id?: string;
68
+ minimumVisibleDuration?: number;
69
+ size?: SpinnerSize;
70
+ target?: HTMLElement | ElementRef<HTMLElement> | readonly (HTMLElement | ElementRef<HTMLElement>)[];
71
+ text?: string;
72
+ zIndex?: number;
73
+ }
74
+
75
+ interface SpinnerUpdate {
76
+ appearance?: SpinnerAppearance;
77
+ size?: SpinnerSize;
78
+ text?: string;
79
+ }
80
+
81
+ interface SpinnerRef {
82
+ readonly cancelled$: Observable<void>;
83
+ readonly id: string;
84
+ close(): void;
85
+ update(update: SpinnerUpdate): void;
86
+ }
87
+
88
+ declare class SpinnerService implements OnDestroy {
89
+ #private;
90
+ /**
91
+ * Hides and destroys the spinner associated with the given `SpinnerRef` or string `id`.
92
+ */
93
+ hide(refOrId: SpinnerRef | string): void;
94
+ /**
95
+ * Tears down every active/pending spinner request and physical overlay so
96
+ * nothing is left behind if the service outlives its consumers.
97
+ */
98
+ ngOnDestroy(): void;
99
+ /**
100
+ * Displays a spinner loading overlay according to the provided options.
101
+ * Returns a `SpinnerRef` handle for lifecycle management and updates.
102
+ */
103
+ show(options?: SpinnerOptions): SpinnerRef;
104
+ /**
105
+ * Wraps an Observable operation with a Spinner lifecycle.
106
+ * Shows the spinner on subscription and closes it when the observable completes, errors, or is unsubscribed.
107
+ */
108
+ track<T>(source: Observable<T>, options?: SpinnerOptions): Observable<T>;
109
+ static ɵfac: _angular_core.ɵɵFactoryDeclaration<SpinnerService, never>;
110
+ static ɵprov: _angular_core.ɵɵInjectableDeclaration<SpinnerService>;
111
+ }
112
+
113
+ export { SpinnerComponent, SpinnerService };
114
+ export type { SpinnerAppearance, SpinnerCancelOptions, SpinnerOptions, SpinnerRef, SpinnerSize, SpinnerUpdate, SpinnerVariantProps };