@mimi-ng/cli 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -1
- package/dist/registry.json +274 -14
- package/dist/templates/components/calendar/calendar-texts.ts +72 -0
- package/dist/templates/components/calendar/calendar.ts +588 -0
- package/dist/templates/components/calendar/dates.ts +181 -0
- package/dist/templates/components/calendar/index.ts +3 -0
- package/dist/templates/components/chip/chip-texts.ts +40 -0
- package/dist/templates/components/chip/chip.ts +116 -0
- package/dist/templates/components/chip/index.ts +2 -0
- package/dist/templates/components/color-picker/color-picker-texts.ts +57 -0
- package/dist/templates/components/color-picker/color-picker.ts +718 -0
- package/dist/templates/components/color-picker/color.ts +157 -0
- package/dist/templates/components/color-picker/index.ts +3 -0
- package/dist/templates/components/combobox/combobox-texts.ts +70 -0
- package/dist/templates/components/combobox/combobox.ts +798 -0
- package/dist/templates/components/combobox/index.ts +2 -0
- package/dist/templates/components/date-picker/date-picker.ts +240 -0
- package/dist/templates/components/date-picker/index.ts +3 -0
- package/dist/templates/components/date-range/date-range.ts +308 -0
- package/dist/templates/components/date-range/index.ts +1 -0
- package/dist/templates/components/file-upload/file-upload-texts.ts +77 -0
- package/dist/templates/components/file-upload/file-upload.ts +698 -0
- package/dist/templates/components/file-upload/index.ts +2 -0
- package/dist/templates/components/input-mask/index.ts +1 -0
- package/dist/templates/components/input-mask/input-mask.ts +319 -0
- package/dist/templates/components/input-otp/index.ts +1 -0
- package/dist/templates/components/input-otp/input-otp.ts +212 -0
- package/dist/templates/components/number-input/index.ts +2 -0
- package/dist/templates/components/number-input/number-input-texts.ts +46 -0
- package/dist/templates/components/number-input/number-input.ts +450 -0
- package/dist/templates/components/option-list/index.ts +11 -0
- package/dist/templates/components/option-list/option-list.ts +518 -0
- package/dist/templates/components/password/index.ts +2 -0
- package/dist/templates/components/password/password-texts.ts +68 -0
- package/dist/templates/components/password/password.ts +363 -0
- package/dist/templates/components/popover/popover.ts +12 -4
- package/dist/templates/components/progress/index.ts +1 -0
- package/dist/templates/components/progress/progress.ts +217 -0
- package/dist/templates/components/select/select.ts +3 -2
- package/dist/templates/components/slider/index.ts +2 -0
- package/dist/templates/components/slider/slider-texts.ts +26 -0
- package/dist/templates/components/slider/slider.ts +425 -0
- package/dist/templates/components/tags-input/index.ts +2 -0
- package/dist/templates/components/tags-input/tags-input-texts.ts +44 -0
- package/dist/templates/components/tags-input/tags-input.ts +303 -0
- package/dist/templates/components/toggle-group/index.ts +1 -0
- package/dist/templates/components/toggle-group/toggle-group.ts +406 -0
- package/dist/templates/theme/provider.ts +13 -0
- package/dist/templates/theme/theme-base.css +103 -0
- package/dist/templates/theme/types.ts +216 -0
- package/dist/templates/utils/overlay.ts +17 -4
- package/dist/ui/index.js +105 -1
- package/dist/ui/schema.json +56 -0
- package/dist/utils/write-files.js +12 -2
- package/package.json +1 -1
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ChangeDetectionStrategy,
|
|
3
|
+
Component,
|
|
4
|
+
ElementRef,
|
|
5
|
+
booleanAttribute,
|
|
6
|
+
computed,
|
|
7
|
+
effect,
|
|
8
|
+
inject,
|
|
9
|
+
input,
|
|
10
|
+
linkedSignal,
|
|
11
|
+
model,
|
|
12
|
+
output,
|
|
13
|
+
signal,
|
|
14
|
+
untracked,
|
|
15
|
+
viewChild,
|
|
16
|
+
} from '@angular/core';
|
|
17
|
+
import type { FormValueControl } from '@angular/forms/signals';
|
|
18
|
+
import { MimiInput } from '@/components/ui/input';
|
|
19
|
+
import { MimiPopover, MimiPopoverContent, MimiPopoverTrigger } from '@/components/ui/popover';
|
|
20
|
+
import { cn } from '@/components/ui/utils/cn';
|
|
21
|
+
import {
|
|
22
|
+
buttonFocusStyles,
|
|
23
|
+
controlInvalidStyles,
|
|
24
|
+
controlPressStyles,
|
|
25
|
+
fieldFocusStyles,
|
|
26
|
+
} from '@/components/ui/utils/control-styles';
|
|
27
|
+
import {
|
|
28
|
+
MIMI_FIELD_CONTROL,
|
|
29
|
+
type MimiFieldControl,
|
|
30
|
+
injectFieldState,
|
|
31
|
+
} from '@/components/ui/utils/field-state';
|
|
32
|
+
import {
|
|
33
|
+
type MimiColorFormat,
|
|
34
|
+
type MimiHsv,
|
|
35
|
+
mimiFormatOklch,
|
|
36
|
+
mimiHexToRgb,
|
|
37
|
+
mimiHsvToRgb,
|
|
38
|
+
mimiNormalizeHex,
|
|
39
|
+
mimiParseColor,
|
|
40
|
+
mimiRgbToHex,
|
|
41
|
+
mimiRgbToHsv,
|
|
42
|
+
} from './color';
|
|
43
|
+
import { MIMI_COLOR_PICKER_TEXT } from './color-picker-texts';
|
|
44
|
+
|
|
45
|
+
/*
|
|
46
|
+
* Color Picker (docs/design/Mimi Color Picker.dc.html; prefijo de tokens `color-picker`, spec
|
|
47
|
+
* 13). Dos piezas (decidido por defecto en la G2.12, opción (a)): el panel `mimi-color-picker` y
|
|
48
|
+
* el disparador `mimi-color-input`, que lo abre en un Popover. El valor es siempre hex en
|
|
49
|
+
* minúsculas; se elige en HSV (el área y el tono, controles propios: el Slider de Mimi pinta la
|
|
50
|
+
* pista con un color, no con el degradado del espectro) y se ve y escribe en HEX u OKLCH.
|
|
51
|
+
*
|
|
52
|
+
* Los degradados del área y del tono son el espacio de color (datos, no tema): van en `style`. El
|
|
53
|
+
* borde y la sombra de los pulgares son tokens del componente (decidido por defecto).
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
const SPECTRUM =
|
|
57
|
+
'linear-gradient(to right, rgb(255 0 0), rgb(255 255 0), rgb(0 255 0), rgb(0 255 255), rgb(0 0 255), rgb(255 0 255), rgb(255 0 0))';
|
|
58
|
+
|
|
59
|
+
const THUMB =
|
|
60
|
+
'pointer-events-none absolute -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-[color:var(--mimi-color-picker-thumb-border,oklch(1_0_0))] shadow-[shadow:var(--mimi-color-picker-thumb-shadow,0_0_0_1px_oklch(0_0_0/.25),0_2px_4px_oklch(0_0_0/.3))]';
|
|
61
|
+
/** El foco del área y del tono: el contorno de los botones (separado 2px). */
|
|
62
|
+
const SLIDER_FOCUS = buttonFocusStyles;
|
|
63
|
+
const SWATCH_BASE = cn(
|
|
64
|
+
'size-full cursor-pointer border-0 shadow-[shadow:inset_0_0_0_1px_var(--mimi-color-picker-swatch-border,oklch(0_0_0/.1))] mimi-transition hover:scale-[1.12] active:scale-90',
|
|
65
|
+
buttonFocusStyles,
|
|
66
|
+
);
|
|
67
|
+
const SWATCH = {
|
|
68
|
+
palette: cn(SWATCH_BASE, 'aspect-square rounded-full'),
|
|
69
|
+
paletteOn: cn(
|
|
70
|
+
SWATCH_BASE,
|
|
71
|
+
'aspect-square rounded-full shadow-[shadow:0_0_0_2px_var(--mimi-popover),0_0_0_4px_var(--mimi-foreground)]',
|
|
72
|
+
),
|
|
73
|
+
recent: cn(SWATCH_BASE, 'aspect-square rounded-[var(--mimi-radius-sm)]'),
|
|
74
|
+
recentOn: cn(
|
|
75
|
+
SWATCH_BASE,
|
|
76
|
+
'aspect-square rounded-[var(--mimi-radius-sm)] shadow-[shadow:0_0_0_2px_var(--mimi-popover),0_0_0_4px_var(--mimi-foreground)]',
|
|
77
|
+
),
|
|
78
|
+
} as const;
|
|
79
|
+
const FORMAT_ITEM =
|
|
80
|
+
'h-7 flex-1 cursor-pointer rounded-[calc(var(--mimi-radius-sm)-2px)] border-0 text-[11.5px] font-semibold mimi-transition';
|
|
81
|
+
const FORMAT = {
|
|
82
|
+
on: cn(FORMAT_ITEM, 'bg-background text-foreground shadow-sm', buttonFocusStyles),
|
|
83
|
+
off: cn(
|
|
84
|
+
FORMAT_ITEM,
|
|
85
|
+
'bg-transparent text-muted-foreground hover:text-foreground',
|
|
86
|
+
buttonFocusStyles,
|
|
87
|
+
),
|
|
88
|
+
} as const;
|
|
89
|
+
const SECTION_TITLE = 'text-[11px] font-semibold tracking-[.06em] text-muted-foreground uppercase';
|
|
90
|
+
|
|
91
|
+
const toHsv = (hex: string): MimiHsv | null => {
|
|
92
|
+
const rgb = mimiHexToRgb(hex);
|
|
93
|
+
return rgb ? mimiRgbToHsv(rgb) : null;
|
|
94
|
+
};
|
|
95
|
+
const fromHsv = (hsv: MimiHsv) => mimiRgbToHex(mimiHsvToRgb(hsv));
|
|
96
|
+
|
|
97
|
+
let nextId = 0;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* El panel: área de saturación y brillo, tono, formato y valor, paleta y recientes.
|
|
101
|
+
*
|
|
102
|
+
* ```html
|
|
103
|
+
* <mimi-color-picker [(value)]="color" [swatches]="paleta" [(recent)]="recientes" />
|
|
104
|
+
* ```
|
|
105
|
+
*
|
|
106
|
+
* Implementa `FormValueControl<string>` (hex). Un valor inválido que llega del formulario se
|
|
107
|
+
* muestra en el campo sin reescribirlo.
|
|
108
|
+
*/
|
|
109
|
+
@Component({
|
|
110
|
+
selector: 'mimi-color-picker',
|
|
111
|
+
imports: [MimiInput],
|
|
112
|
+
providers: [{ provide: MIMI_FIELD_CONTROL, useExisting: MimiColorPicker }],
|
|
113
|
+
template: `
|
|
114
|
+
<div
|
|
115
|
+
#area
|
|
116
|
+
role="slider"
|
|
117
|
+
data-slot="area"
|
|
118
|
+
class="relative h-[var(--mimi-color-picker-area-height,150px)] cursor-crosshair touch-none rounded-[var(--mimi-color-picker-area-radius,var(--mimi-radius-sm))]"
|
|
119
|
+
[class]="sliderFocus"
|
|
120
|
+
[style.background]="areaBackground()"
|
|
121
|
+
[attr.tabindex]="disabled() ? -1 : 0"
|
|
122
|
+
[attr.aria-label]="texts.area"
|
|
123
|
+
[attr.aria-valuemin]="0"
|
|
124
|
+
[attr.aria-valuemax]="100"
|
|
125
|
+
[attr.aria-valuenow]="saturation()"
|
|
126
|
+
[attr.aria-valuetext]="texts.areaValue(saturation(), brightness())"
|
|
127
|
+
[attr.aria-disabled]="disabled() || null"
|
|
128
|
+
(pointerdown)="onAreaPointer($event, true)"
|
|
129
|
+
(pointermove)="onAreaPointer($event, false)"
|
|
130
|
+
(pointerup)="endDrag($event)"
|
|
131
|
+
(keydown)="onAreaKeydown($event)"
|
|
132
|
+
(focusout)="rememberRecent()"
|
|
133
|
+
>
|
|
134
|
+
<span
|
|
135
|
+
aria-hidden="true"
|
|
136
|
+
data-slot="area-thumb"
|
|
137
|
+
class="size-3.5"
|
|
138
|
+
[class]="thumbClasses"
|
|
139
|
+
[style.left.%]="saturation()"
|
|
140
|
+
[style.top.%]="100 - brightness()"
|
|
141
|
+
[style.background-color]="hex()"
|
|
142
|
+
></span>
|
|
143
|
+
</div>
|
|
144
|
+
|
|
145
|
+
<div class="flex items-center gap-3">
|
|
146
|
+
<div
|
|
147
|
+
#hueBar
|
|
148
|
+
role="slider"
|
|
149
|
+
data-slot="hue"
|
|
150
|
+
class="relative h-[var(--mimi-color-picker-hue-height,12px)] flex-1 cursor-pointer touch-none rounded-full"
|
|
151
|
+
[class]="sliderFocus"
|
|
152
|
+
[style.background]="spectrum"
|
|
153
|
+
[attr.tabindex]="disabled() ? -1 : 0"
|
|
154
|
+
[attr.aria-label]="texts.hue"
|
|
155
|
+
[attr.aria-valuemin]="0"
|
|
156
|
+
[attr.aria-valuemax]="360"
|
|
157
|
+
[attr.aria-valuenow]="hue()"
|
|
158
|
+
[attr.aria-disabled]="disabled() || null"
|
|
159
|
+
(pointerdown)="onHuePointer($event, true)"
|
|
160
|
+
(pointermove)="onHuePointer($event, false)"
|
|
161
|
+
(pointerup)="endDrag($event)"
|
|
162
|
+
(keydown)="onHueKeydown($event)"
|
|
163
|
+
(focusout)="rememberRecent()"
|
|
164
|
+
>
|
|
165
|
+
<span
|
|
166
|
+
aria-hidden="true"
|
|
167
|
+
data-slot="hue-thumb"
|
|
168
|
+
class="top-1/2 size-4"
|
|
169
|
+
[class]="thumbClasses"
|
|
170
|
+
[style.left.%]="(hue() / 360) * 100"
|
|
171
|
+
[style.background-color]="hueColor()"
|
|
172
|
+
></span>
|
|
173
|
+
</div>
|
|
174
|
+
<span
|
|
175
|
+
aria-hidden="true"
|
|
176
|
+
data-slot="current"
|
|
177
|
+
class="size-7 shrink-0 rounded-full shadow-[shadow:inset_0_0_0_1px_var(--mimi-color-picker-swatch-border,oklch(0_0_0/.1))]"
|
|
178
|
+
[style.background-color]="hex()"
|
|
179
|
+
></span>
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
<div class="flex items-center gap-2">
|
|
183
|
+
<div
|
|
184
|
+
role="radiogroup"
|
|
185
|
+
class="flex w-[104px] shrink-0 gap-0.5 rounded-[var(--mimi-radius-sm)] bg-muted p-0.5"
|
|
186
|
+
[attr.aria-label]="texts.format"
|
|
187
|
+
(keydown)="onFormatKeydown($event)"
|
|
188
|
+
>
|
|
189
|
+
@for (item of formats; track item.id) {
|
|
190
|
+
<button
|
|
191
|
+
type="button"
|
|
192
|
+
role="radio"
|
|
193
|
+
[attr.data-format]="item.id"
|
|
194
|
+
[attr.aria-checked]="shownFormat() === item.id"
|
|
195
|
+
[attr.tabindex]="shownFormat() === item.id ? 0 : -1"
|
|
196
|
+
[class]="shownFormat() === item.id ? formatOn : formatOff"
|
|
197
|
+
[disabled]="disabled()"
|
|
198
|
+
(click)="shownFormat.set(item.id)"
|
|
199
|
+
>
|
|
200
|
+
{{ item.label }}
|
|
201
|
+
</button>
|
|
202
|
+
}
|
|
203
|
+
</div>
|
|
204
|
+
<input
|
|
205
|
+
mimiInput
|
|
206
|
+
size="sm"
|
|
207
|
+
type="text"
|
|
208
|
+
spellcheck="false"
|
|
209
|
+
autocomplete="off"
|
|
210
|
+
data-slot="value"
|
|
211
|
+
class="h-8 min-w-0 flex-1 font-mono text-xs"
|
|
212
|
+
[value]="draft()"
|
|
213
|
+
[disabled]="disabled()"
|
|
214
|
+
[attr.aria-label]="texts.value"
|
|
215
|
+
[attr.aria-invalid]="draftInvalid() || null"
|
|
216
|
+
[attr.aria-describedby]="clipped() ? clippedId : null"
|
|
217
|
+
(input)="draft.set($any($event.target).value)"
|
|
218
|
+
(keydown.enter)="commitDraft()"
|
|
219
|
+
(blur)="commitDraft()"
|
|
220
|
+
/>
|
|
221
|
+
</div>
|
|
222
|
+
@if (clipped()) {
|
|
223
|
+
<span [id]="clippedId" data-slot="clipped" class="-mt-1.5 text-xs text-muted-foreground">{{
|
|
224
|
+
texts.clipped
|
|
225
|
+
}}</span>
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
@if (swatches().length) {
|
|
229
|
+
<div class="flex flex-col gap-1.5">
|
|
230
|
+
<span [class]="sectionTitle" [id]="paletteId">{{ texts.palette }}</span>
|
|
231
|
+
<div
|
|
232
|
+
role="group"
|
|
233
|
+
data-slot="palette"
|
|
234
|
+
class="grid grid-cols-8 gap-[var(--mimi-color-picker-swatch-gap,6px)]"
|
|
235
|
+
[attr.aria-labelledby]="paletteId"
|
|
236
|
+
(keydown)="onSwatchKeydown($event)"
|
|
237
|
+
>
|
|
238
|
+
@for (swatch of swatches(); track swatch; let i = $index) {
|
|
239
|
+
<button
|
|
240
|
+
type="button"
|
|
241
|
+
[class]="isCurrent(swatch) ? swatchClasses.paletteOn : swatchClasses.palette"
|
|
242
|
+
[style.background-color]="swatch"
|
|
243
|
+
[attr.aria-label]="swatch"
|
|
244
|
+
[attr.aria-pressed]="isCurrent(swatch)"
|
|
245
|
+
[attr.tabindex]="i === paletteIndex() ? 0 : -1"
|
|
246
|
+
[disabled]="disabled()"
|
|
247
|
+
(focus)="paletteIndex.set(i)"
|
|
248
|
+
(click)="pickSwatch(swatch)"
|
|
249
|
+
></button>
|
|
250
|
+
}
|
|
251
|
+
</div>
|
|
252
|
+
</div>
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
@if (showRecent()) {
|
|
256
|
+
<div class="flex flex-col gap-1.5">
|
|
257
|
+
<span [class]="sectionTitle" [id]="recentId">{{ texts.recent }}</span>
|
|
258
|
+
@if (recent().length) {
|
|
259
|
+
<div
|
|
260
|
+
role="group"
|
|
261
|
+
data-slot="recent"
|
|
262
|
+
class="grid grid-cols-8 gap-[var(--mimi-color-picker-swatch-gap,6px)]"
|
|
263
|
+
[attr.aria-labelledby]="recentId"
|
|
264
|
+
(keydown)="onSwatchKeydown($event)"
|
|
265
|
+
>
|
|
266
|
+
@for (swatch of recent(); track swatch; let i = $index) {
|
|
267
|
+
<button
|
|
268
|
+
type="button"
|
|
269
|
+
[class]="isCurrent(swatch) ? swatchClasses.recentOn : swatchClasses.recent"
|
|
270
|
+
[style.background-color]="swatch"
|
|
271
|
+
[attr.aria-label]="swatch"
|
|
272
|
+
[attr.aria-pressed]="isCurrent(swatch)"
|
|
273
|
+
[attr.tabindex]="i === recentIndex() ? 0 : -1"
|
|
274
|
+
[disabled]="disabled()"
|
|
275
|
+
(focus)="recentIndex.set(i)"
|
|
276
|
+
(click)="pickSwatch(swatch)"
|
|
277
|
+
></button>
|
|
278
|
+
}
|
|
279
|
+
</div>
|
|
280
|
+
} @else {
|
|
281
|
+
<span class="text-xs text-muted-foreground">{{ texts.noRecent }}</span>
|
|
282
|
+
}
|
|
283
|
+
</div>
|
|
284
|
+
}
|
|
285
|
+
`,
|
|
286
|
+
host: {
|
|
287
|
+
'[class]': 'classes()',
|
|
288
|
+
'[attr.data-disabled]': 'disabled() ? "" : null',
|
|
289
|
+
'[attr.aria-label]': 'null',
|
|
290
|
+
},
|
|
291
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
292
|
+
})
|
|
293
|
+
export class MimiColorPicker implements FormValueControl<string>, MimiFieldControl {
|
|
294
|
+
protected readonly texts = inject(MIMI_COLOR_PICKER_TEXT);
|
|
295
|
+
|
|
296
|
+
/** El color, en hex de 6 dígitos en minúsculas (`#4f46e5`). */
|
|
297
|
+
readonly value = model('');
|
|
298
|
+
/** La paleta (colores en hex). Sin paleta, no se muestra. */
|
|
299
|
+
readonly swatches = input<readonly string[]>([]);
|
|
300
|
+
/** Los recientes (hasta 8; el último elegido primero). `[(recent)]` para guardarlos. */
|
|
301
|
+
readonly recent = model<string[]>([]);
|
|
302
|
+
/** Muestra la sección de recientes. */
|
|
303
|
+
readonly showRecent = input(true, { transform: booleanAttribute });
|
|
304
|
+
/** Formato del campo al empezar (HEX u OKLCH). El valor sigue siendo hex. */
|
|
305
|
+
readonly format = input<MimiColorFormat>('hex');
|
|
306
|
+
readonly disabled = input(false, { transform: booleanAttribute });
|
|
307
|
+
readonly invalid = input(false, { transform: booleanAttribute });
|
|
308
|
+
readonly touched = input(false, { transform: booleanAttribute });
|
|
309
|
+
readonly dirty = input(false, { transform: booleanAttribute });
|
|
310
|
+
readonly userClass = input('', { alias: 'class' });
|
|
311
|
+
|
|
312
|
+
readonly touch = output<void>();
|
|
313
|
+
|
|
314
|
+
private readonly area = viewChild.required<ElementRef<HTMLElement>>('area');
|
|
315
|
+
private readonly hueBar = viewChild.required<ElementRef<HTMLElement>>('hueBar');
|
|
316
|
+
readonly fieldState = injectFieldState();
|
|
317
|
+
get controlElement(): HTMLElement {
|
|
318
|
+
return this.area().nativeElement;
|
|
319
|
+
}
|
|
320
|
+
readonly showInvalid = computed(() => this.invalid() && (this.touched() || this.dirty()));
|
|
321
|
+
|
|
322
|
+
private readonly baseId = `mimi-color-picker-${nextId++}`;
|
|
323
|
+
protected readonly paletteId = `${this.baseId}-palette`;
|
|
324
|
+
protected readonly recentId = `${this.baseId}-recent`;
|
|
325
|
+
protected readonly clippedId = `${this.baseId}-clipped`;
|
|
326
|
+
protected readonly spectrum = SPECTRUM;
|
|
327
|
+
protected readonly thumbClasses = THUMB;
|
|
328
|
+
protected readonly sliderFocus = SLIDER_FOCUS;
|
|
329
|
+
protected readonly swatchClasses = SWATCH;
|
|
330
|
+
protected readonly formatOn = FORMAT.on;
|
|
331
|
+
protected readonly formatOff = FORMAT.off;
|
|
332
|
+
protected readonly sectionTitle = SECTION_TITLE;
|
|
333
|
+
protected readonly formats = [
|
|
334
|
+
{ id: 'hex', label: 'HEX' },
|
|
335
|
+
{ id: 'oklch', label: 'OKLCH' },
|
|
336
|
+
] as const;
|
|
337
|
+
|
|
338
|
+
protected readonly classes = computed(() =>
|
|
339
|
+
cn(
|
|
340
|
+
'flex w-[var(--mimi-color-picker-width,280px)] max-w-full flex-col gap-[var(--mimi-color-picker-padding,12px)] font-sans text-foreground',
|
|
341
|
+
'data-[disabled]:pointer-events-none data-[disabled]:opacity-[var(--mimi-disabled-opacity,0.5)]',
|
|
342
|
+
this.userClass(),
|
|
343
|
+
),
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* HSV que se pinta. Sigue al valor, pero si el valor es el mismo hex que ya da este HSV, no se
|
|
348
|
+
* recalcula (no saltan los pulgares al redondear), y un gris o un negro conservan el tono.
|
|
349
|
+
*/
|
|
350
|
+
private readonly hsv = linkedSignal<string, MimiHsv>({
|
|
351
|
+
source: () => mimiNormalizeHex(this.value()) ?? '',
|
|
352
|
+
computation: (hex, previous) => {
|
|
353
|
+
const before = previous?.value ?? { h: 0, s: 0, v: 0 };
|
|
354
|
+
if (!hex) return before;
|
|
355
|
+
if (previous && fromHsv(before) === hex) return before;
|
|
356
|
+
const next = toHsv(hex);
|
|
357
|
+
if (!next) return before;
|
|
358
|
+
return next.s === 0 || next.v === 0 ? { ...next, h: before.h } : next;
|
|
359
|
+
},
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
protected readonly hex = computed(() => fromHsv(this.hsv()));
|
|
363
|
+
protected readonly hue = computed(() => Math.round(this.hsv().h));
|
|
364
|
+
protected readonly saturation = computed(() => Math.round(this.hsv().s * 100));
|
|
365
|
+
protected readonly brightness = computed(() => Math.round(this.hsv().v * 100));
|
|
366
|
+
protected readonly hueColor = computed(() => fromHsv({ h: this.hsv().h, s: 1, v: 1 }));
|
|
367
|
+
protected readonly areaBackground = computed(
|
|
368
|
+
() =>
|
|
369
|
+
`linear-gradient(to top, rgb(0 0 0), transparent), linear-gradient(to right, rgb(255 255 255), ${this.hueColor()})`,
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
protected readonly shownFormat = linkedSignal<MimiColorFormat>(() => this.format());
|
|
373
|
+
/** El texto del campo: el valor en el formato elegido, o lo que se está escribiendo. */
|
|
374
|
+
protected readonly draft = linkedSignal(() => {
|
|
375
|
+
const value = this.value();
|
|
376
|
+
const hex = mimiNormalizeHex(value);
|
|
377
|
+
if (!hex) return value;
|
|
378
|
+
return this.shownFormat() === 'oklch' ? mimiFormatOklch(hex) : hex;
|
|
379
|
+
});
|
|
380
|
+
protected readonly draftInvalid = signal(false);
|
|
381
|
+
/** El OKLCH escrito estaba fuera de sRGB y se recortó. */
|
|
382
|
+
protected readonly clipped = signal(false);
|
|
383
|
+
|
|
384
|
+
protected readonly paletteIndex = linkedSignal(() =>
|
|
385
|
+
Math.max(
|
|
386
|
+
0,
|
|
387
|
+
this.swatches().findIndex((s) => this.isCurrent(s)),
|
|
388
|
+
),
|
|
389
|
+
);
|
|
390
|
+
protected readonly recentIndex = linkedSignal(() =>
|
|
391
|
+
Math.max(
|
|
392
|
+
0,
|
|
393
|
+
this.recent().findIndex((s) => this.isCurrent(s)),
|
|
394
|
+
),
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
private dragging: HTMLElement | null = null;
|
|
398
|
+
|
|
399
|
+
constructor() {
|
|
400
|
+
// Al cambiar el formato, lo escrito vuelve a ser el valor en el formato nuevo.
|
|
401
|
+
effect(() => {
|
|
402
|
+
this.shownFormat();
|
|
403
|
+
untracked(() => {
|
|
404
|
+
this.draftInvalid.set(false);
|
|
405
|
+
this.clipped.set(false);
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
focus(options?: FocusOptions): void {
|
|
411
|
+
this.area().nativeElement.focus(options);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
protected isCurrent(color: string): boolean {
|
|
415
|
+
return mimiNormalizeHex(color) === mimiNormalizeHex(this.value());
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private setHsv(hsv: MimiHsv): void {
|
|
419
|
+
if (this.disabled()) return;
|
|
420
|
+
this.hsv.set(hsv);
|
|
421
|
+
this.value.set(fromHsv(hsv));
|
|
422
|
+
this.clipped.set(false);
|
|
423
|
+
this.draftInvalid.set(false);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** Guarda el color actual en los recientes (al soltar, al salir del control o al elegir). */
|
|
427
|
+
protected rememberRecent(): void {
|
|
428
|
+
const hex = mimiNormalizeHex(this.value());
|
|
429
|
+
if (!hex) return;
|
|
430
|
+
const recent = this.recent();
|
|
431
|
+
if (recent[0] === hex) return;
|
|
432
|
+
this.recent.set([hex, ...recent.filter((c) => mimiNormalizeHex(c) !== hex)].slice(0, 8));
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private ratio(event: PointerEvent, element: HTMLElement): { x: number; y: number } {
|
|
436
|
+
const rect = element.getBoundingClientRect();
|
|
437
|
+
const clamp = (n: number) => Math.min(1, Math.max(0, n));
|
|
438
|
+
return {
|
|
439
|
+
x: rect.width ? clamp((event.clientX - rect.left) / rect.width) : 0,
|
|
440
|
+
y: rect.height ? clamp((event.clientY - rect.top) / rect.height) : 0,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
private startDrag(event: PointerEvent, element: HTMLElement): void {
|
|
445
|
+
event.preventDefault();
|
|
446
|
+
element.focus();
|
|
447
|
+
element.setPointerCapture?.(event.pointerId);
|
|
448
|
+
this.dragging = element;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
protected endDrag(event: PointerEvent): void {
|
|
452
|
+
if (!this.dragging) return;
|
|
453
|
+
this.dragging.releasePointerCapture?.(event.pointerId);
|
|
454
|
+
this.dragging = null;
|
|
455
|
+
this.rememberRecent();
|
|
456
|
+
this.touch.emit();
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
protected onAreaPointer(event: PointerEvent, start: boolean): void {
|
|
460
|
+
if (this.disabled()) return;
|
|
461
|
+
const element = this.area().nativeElement;
|
|
462
|
+
if (start) this.startDrag(event, element);
|
|
463
|
+
else if (this.dragging !== element) return;
|
|
464
|
+
const { x, y } = this.ratio(event, element);
|
|
465
|
+
this.setHsv({ h: this.hsv().h, s: x, v: 1 - y });
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
protected onHuePointer(event: PointerEvent, start: boolean): void {
|
|
469
|
+
if (this.disabled()) return;
|
|
470
|
+
const element = this.hueBar().nativeElement;
|
|
471
|
+
if (start) this.startDrag(event, element);
|
|
472
|
+
else if (this.dragging !== element) return;
|
|
473
|
+
this.setHsv({ ...this.hsv(), h: this.ratio(event, element).x * 360 });
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** ← → saturación, ↑ ↓ brillo, de a 2 % (el diseño); Inicio/Fin, saturación 0 y 100 %. */
|
|
477
|
+
protected onAreaKeydown(event: KeyboardEvent): void {
|
|
478
|
+
const { s, v, h } = this.hsv();
|
|
479
|
+
const step = 0.02;
|
|
480
|
+
const keys: Record<string, MimiHsv> = {
|
|
481
|
+
ArrowLeft: { h, s: Math.max(0, s - step), v },
|
|
482
|
+
ArrowRight: { h, s: Math.min(1, s + step), v },
|
|
483
|
+
ArrowDown: { h, s, v: Math.max(0, v - step) },
|
|
484
|
+
ArrowUp: { h, s, v: Math.min(1, v + step) },
|
|
485
|
+
Home: { h, s: 0, v },
|
|
486
|
+
End: { h, s: 1, v },
|
|
487
|
+
};
|
|
488
|
+
const next = keys[event.key];
|
|
489
|
+
if (!next) return;
|
|
490
|
+
event.preventDefault();
|
|
491
|
+
this.setHsv(next);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** Flechas de a 3° (el diseño); Re Pág/Av Pág de a 30°; Inicio/Fin, 0° y 360°. */
|
|
495
|
+
protected onHueKeydown(event: KeyboardEvent): void {
|
|
496
|
+
const { h } = this.hsv();
|
|
497
|
+
const keys: Record<string, number> = {
|
|
498
|
+
ArrowLeft: h - 3,
|
|
499
|
+
ArrowDown: h - 3,
|
|
500
|
+
ArrowRight: h + 3,
|
|
501
|
+
ArrowUp: h + 3,
|
|
502
|
+
PageDown: h - 30,
|
|
503
|
+
PageUp: h + 30,
|
|
504
|
+
Home: 0,
|
|
505
|
+
End: 360,
|
|
506
|
+
};
|
|
507
|
+
const next = keys[event.key];
|
|
508
|
+
if (next === undefined) return;
|
|
509
|
+
event.preventDefault();
|
|
510
|
+
this.setHsv({ ...this.hsv(), h: Math.min(360, Math.max(0, next)) });
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** Flechas en el grupo de formato (radio). */
|
|
514
|
+
protected onFormatKeydown(event: KeyboardEvent): void {
|
|
515
|
+
if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) return;
|
|
516
|
+
event.preventDefault();
|
|
517
|
+
const next: MimiColorFormat = this.shownFormat() === 'hex' ? 'oklch' : 'hex';
|
|
518
|
+
this.shownFormat.set(next);
|
|
519
|
+
const group = event.currentTarget as HTMLElement;
|
|
520
|
+
queueMicrotask(() => group.querySelector<HTMLElement>(`[data-format="${next}"]`)?.focus());
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** Paleta y recientes: una parada de Tab y flechas (decidido por defecto en la G2.12). */
|
|
524
|
+
protected onSwatchKeydown(event: KeyboardEvent): void {
|
|
525
|
+
const buttons = Array.from(
|
|
526
|
+
(event.currentTarget as HTMLElement).querySelectorAll<HTMLButtonElement>('button'),
|
|
527
|
+
);
|
|
528
|
+
const index = buttons.indexOf(event.target as HTMLButtonElement);
|
|
529
|
+
if (index < 0) return;
|
|
530
|
+
const columns = 8;
|
|
531
|
+
const moves: Record<string, number> = {
|
|
532
|
+
ArrowLeft: index - 1,
|
|
533
|
+
ArrowRight: index + 1,
|
|
534
|
+
ArrowUp: index - columns,
|
|
535
|
+
ArrowDown: index + columns,
|
|
536
|
+
Home: 0,
|
|
537
|
+
End: buttons.length - 1,
|
|
538
|
+
};
|
|
539
|
+
const next = moves[event.key];
|
|
540
|
+
if (next === undefined) return;
|
|
541
|
+
event.preventDefault();
|
|
542
|
+
buttons[Math.min(buttons.length - 1, Math.max(0, next))]?.focus();
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
protected pickSwatch(color: string): void {
|
|
546
|
+
const hex = mimiNormalizeHex(color);
|
|
547
|
+
if (!hex || this.disabled()) return;
|
|
548
|
+
this.value.set(hex);
|
|
549
|
+
this.clipped.set(false);
|
|
550
|
+
this.draftInvalid.set(false);
|
|
551
|
+
this.rememberRecent();
|
|
552
|
+
this.touch.emit();
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** Enter o salir del campo: lo escrito pasa a ser el valor si se entiende. */
|
|
556
|
+
protected commitDraft(): void {
|
|
557
|
+
const text = (this.draft() ?? '').trim();
|
|
558
|
+
const current = mimiNormalizeHex(this.value());
|
|
559
|
+
const shown = current
|
|
560
|
+
? this.shownFormat() === 'oklch'
|
|
561
|
+
? mimiFormatOklch(current)
|
|
562
|
+
: current
|
|
563
|
+
: this.value();
|
|
564
|
+
if (text === shown) return;
|
|
565
|
+
const parsed = mimiParseColor(text);
|
|
566
|
+
if (!parsed) {
|
|
567
|
+
this.draftInvalid.set(true);
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
this.draftInvalid.set(false);
|
|
571
|
+
this.value.set(parsed.hex);
|
|
572
|
+
this.clipped.set(parsed.clipped);
|
|
573
|
+
// El campo muestra el valor guardado (recortado, si hacía falta).
|
|
574
|
+
this.draft.set(this.shownFormat() === 'oklch' ? mimiFormatOklch(parsed.hex) : parsed.hex);
|
|
575
|
+
this.rememberRecent();
|
|
576
|
+
this.touch.emit();
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** El disparador: la caja de Input con la muestra y el valor. */
|
|
581
|
+
const TRIGGER_CLASSES = cn(
|
|
582
|
+
'flex h-[var(--mimi-control-height,2.5rem)] w-full min-w-0 cursor-pointer items-center gap-2 px-3 text-left font-mono text-sm text-foreground',
|
|
583
|
+
'rounded-[var(--mimi-radius,0.75rem)] border border-input bg-input-background not-disabled:hover:border-input-hover',
|
|
584
|
+
'disabled:cursor-not-allowed disabled:opacity-[var(--mimi-disabled-opacity,0.5)]',
|
|
585
|
+
fieldFocusStyles,
|
|
586
|
+
controlInvalidStyles,
|
|
587
|
+
controlPressStyles,
|
|
588
|
+
);
|
|
589
|
+
|
|
590
|
+
let nextInputId = 0;
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Disparador con Popover (el diseño no lo dibuja; propuesta de la G2.12):
|
|
594
|
+
*
|
|
595
|
+
* ```html
|
|
596
|
+
* <mimi-color-input aria-label="Color de marca" [(value)]="color" [swatches]="paleta" />
|
|
597
|
+
* ```
|
|
598
|
+
*
|
|
599
|
+
* Implementa `FormValueControl<string>`.
|
|
600
|
+
*/
|
|
601
|
+
@Component({
|
|
602
|
+
selector: 'mimi-color-input',
|
|
603
|
+
imports: [MimiPopover, MimiPopoverTrigger, MimiPopoverContent, MimiColorPicker],
|
|
604
|
+
providers: [{ provide: MIMI_FIELD_CONTROL, useExisting: MimiColorInput }],
|
|
605
|
+
template: `
|
|
606
|
+
<mimi-popover
|
|
607
|
+
align="start"
|
|
608
|
+
[arrow]="false"
|
|
609
|
+
[class]="panelClasses()"
|
|
610
|
+
[(open)]="open"
|
|
611
|
+
(openChange)="onOpenChange($event)"
|
|
612
|
+
aria-label="{{ texts.pickColor }}"
|
|
613
|
+
>
|
|
614
|
+
<button
|
|
615
|
+
#trigger
|
|
616
|
+
type="button"
|
|
617
|
+
mimiPopoverTrigger
|
|
618
|
+
[id]="controlId()"
|
|
619
|
+
[class]="classes()"
|
|
620
|
+
[disabled]="disabled()"
|
|
621
|
+
[attr.aria-label]="ariaLabel() || null"
|
|
622
|
+
[attr.aria-labelledby]="ariaLabelledby() || null"
|
|
623
|
+
[attr.aria-describedby]="valueId"
|
|
624
|
+
[attr.aria-invalid]="showInvalid() || null"
|
|
625
|
+
[attr.aria-required]="required() || null"
|
|
626
|
+
>
|
|
627
|
+
<span
|
|
628
|
+
aria-hidden="true"
|
|
629
|
+
data-slot="swatch"
|
|
630
|
+
class="size-5 shrink-0 rounded-full shadow-[shadow:inset_0_0_0_1px_var(--mimi-color-picker-swatch-border,oklch(0_0_0/.1))]"
|
|
631
|
+
[style.background-color]="swatch()"
|
|
632
|
+
></span>
|
|
633
|
+
<span
|
|
634
|
+
class="min-w-0 flex-1 truncate"
|
|
635
|
+
[id]="valueId"
|
|
636
|
+
[class.text-muted-foreground]="!value()"
|
|
637
|
+
>{{ value() || placeholder() }}</span
|
|
638
|
+
>
|
|
639
|
+
</button>
|
|
640
|
+
<ng-template mimiPopoverContent>
|
|
641
|
+
<mimi-color-picker
|
|
642
|
+
class="w-full"
|
|
643
|
+
[value]="value()"
|
|
644
|
+
[swatches]="swatches()"
|
|
645
|
+
[(recent)]="recent"
|
|
646
|
+
[showRecent]="showRecent()"
|
|
647
|
+
[format]="format()"
|
|
648
|
+
(valueChange)="value.set($event)"
|
|
649
|
+
/>
|
|
650
|
+
</ng-template>
|
|
651
|
+
</mimi-popover>
|
|
652
|
+
`,
|
|
653
|
+
host: {
|
|
654
|
+
class: 'block w-full min-w-0',
|
|
655
|
+
'[attr.class]': 'null',
|
|
656
|
+
'[attr.id]': 'null',
|
|
657
|
+
'[attr.aria-label]': 'null',
|
|
658
|
+
'[attr.aria-labelledby]': 'null',
|
|
659
|
+
'[attr.data-state]': 'open() ? "open" : "closed"',
|
|
660
|
+
'[attr.data-invalid]': 'showInvalid() ? "" : null',
|
|
661
|
+
'[attr.data-disabled]': 'disabled() ? "" : null',
|
|
662
|
+
},
|
|
663
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
664
|
+
})
|
|
665
|
+
export class MimiColorInput implements FormValueControl<string>, MimiFieldControl {
|
|
666
|
+
protected readonly texts = inject(MIMI_COLOR_PICKER_TEXT);
|
|
667
|
+
|
|
668
|
+
readonly value = model('');
|
|
669
|
+
readonly swatches = input<readonly string[]>([]);
|
|
670
|
+
readonly recent = model<string[]>([]);
|
|
671
|
+
readonly showRecent = input(true, { transform: booleanAttribute });
|
|
672
|
+
readonly format = input<MimiColorFormat>('hex');
|
|
673
|
+
readonly placeholder = input('');
|
|
674
|
+
readonly disabled = input(false, { transform: booleanAttribute });
|
|
675
|
+
readonly invalid = input(false, { transform: booleanAttribute });
|
|
676
|
+
readonly touched = input(false, { transform: booleanAttribute });
|
|
677
|
+
readonly dirty = input(false, { transform: booleanAttribute });
|
|
678
|
+
readonly required = input(false, { transform: booleanAttribute });
|
|
679
|
+
readonly id = input<string>();
|
|
680
|
+
readonly ariaLabel = input<string>(undefined, { alias: 'aria-label' });
|
|
681
|
+
readonly ariaLabelledby = input<string>(undefined, { alias: 'aria-labelledby' });
|
|
682
|
+
readonly userClass = input('', { alias: 'class' });
|
|
683
|
+
readonly panelClass = input('');
|
|
684
|
+
|
|
685
|
+
readonly touch = output<void>();
|
|
686
|
+
|
|
687
|
+
private readonly trigger = viewChild.required<ElementRef<HTMLButtonElement>>('trigger');
|
|
688
|
+
readonly fieldState = injectFieldState();
|
|
689
|
+
get controlElement(): HTMLElement {
|
|
690
|
+
return this.trigger().nativeElement;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
private readonly baseId = `mimi-color-input-${nextInputId++}`;
|
|
694
|
+
protected readonly controlId = computed(() => this.id() || this.baseId);
|
|
695
|
+
protected readonly valueId = `${this.baseId}-value`;
|
|
696
|
+
readonly showInvalid = computed(() => this.invalid() && (this.touched() || this.dirty()));
|
|
697
|
+
protected readonly open = signal(false);
|
|
698
|
+
/** La muestra solo pinta un color válido. */
|
|
699
|
+
protected readonly swatch = computed(() => mimiNormalizeHex(this.value()) ?? 'transparent');
|
|
700
|
+
protected readonly classes = computed(() => cn(TRIGGER_CLASSES, this.userClass()));
|
|
701
|
+
protected readonly panelClasses = computed(() =>
|
|
702
|
+
cn(
|
|
703
|
+
'w-[var(--mimi-color-picker-width,280px)] p-[var(--mimi-color-picker-padding,12px)] bg-[color:var(--mimi-color-picker-bg,var(--mimi-popover))] shadow-[shadow:var(--mimi-color-picker-shadow,var(--mimi-shadow-popover))] rounded-[var(--mimi-color-picker-radius,var(--mimi-radius))]',
|
|
704
|
+
this.panelClass(),
|
|
705
|
+
),
|
|
706
|
+
);
|
|
707
|
+
|
|
708
|
+
focus(options?: FocusOptions): void {
|
|
709
|
+
this.trigger().nativeElement.focus(options);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
protected onOpenChange(open: boolean): void {
|
|
713
|
+
if (!open) this.touch.emit();
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/** Las dos piezas, para importarlas juntas. */
|
|
718
|
+
export const MimiColorPickerImports = [MimiColorPicker, MimiColorInput] as const;
|