@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']:
|
|
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
|
|
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
|
|
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.
|
|
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"
|
|
@@ -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 };
|