@yewdiel21/motion-kit 0.9.1 → 0.10.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.
Files changed (129) hide show
  1. package/README.md +93 -83
  2. package/dist/auto/index.d.ts +22 -3
  3. package/dist/auto/index.js +41 -3
  4. package/dist/auto/index.js.map +1 -1
  5. package/dist/{chunk-BFC27F2G.js → chunk-IKQ5FODN.js} +2 -2
  6. package/dist/{chunk-JQMJQSXD.js → chunk-TFNRIZHU.js} +1614 -11
  7. package/dist/chunk-TFNRIZHU.js.map +1 -0
  8. package/dist/{chunk-XDWBVFHQ.js → chunk-XAKC2QY7.js} +1202 -3
  9. package/dist/chunk-XAKC2QY7.js.map +1 -0
  10. package/dist/css/components/calendar.css +159 -0
  11. package/dist/css/components/hover-card.css +14 -0
  12. package/dist/css/components/input-otp.css +10 -0
  13. package/dist/css/components/pagination.css +11 -0
  14. package/dist/css/components/quantity.css +16 -0
  15. package/dist/css/components/slot-picker.css +126 -0
  16. package/dist/css/components/studio-dock.css +35 -0
  17. package/dist/css/components/trailer.css +37 -0
  18. package/dist/css/controls.css +65 -0
  19. package/dist/elements/index.d.ts +72 -4
  20. package/dist/elements/index.js +170 -4
  21. package/dist/elements/index.js.map +1 -1
  22. package/dist/index.css +1017 -0
  23. package/dist/index.d.ts +3 -3
  24. package/dist/index.js +45 -3
  25. package/dist/index.js.map +1 -1
  26. package/dist/react/index.d.ts +105 -6
  27. package/dist/react/index.js +350 -3
  28. package/dist/react/index.js.map +1 -1
  29. package/dist/{reduced-B98f8LPs.d.ts → reduced-BmBzKH94.d.ts} +199 -2
  30. package/dist/{reveal-C0hCDcB_.d.ts → reveal-jsX-uwUz.d.ts} +211 -1
  31. package/dist/{store-pHiUvxP8.d.ts → store-DYb3tTsZ.d.ts} +1 -1
  32. package/package.json +1 -1
  33. package/registry.json +178 -104
  34. package/skills/motion-kit/SKILL.md +209 -189
  35. package/src/auto/auto-init.ts +38 -0
  36. package/src/components/calendar/README.md +82 -0
  37. package/src/components/calendar/calendar.css +158 -0
  38. package/src/components/calendar/element.ts +27 -0
  39. package/src/components/calendar/engine.ts +412 -0
  40. package/src/components/calendar/react.tsx +69 -0
  41. package/src/components/hover-card/README.md +16 -0
  42. package/src/components/hover-card/element.ts +8 -0
  43. package/src/components/hover-card/engine.ts +69 -0
  44. package/src/components/hover-card/hover-card.css +13 -0
  45. package/src/components/hover-card/react.tsx +19 -0
  46. package/src/components/input-otp/README.md +18 -0
  47. package/src/components/input-otp/element.ts +14 -0
  48. package/src/components/input-otp/engine.ts +106 -0
  49. package/src/components/input-otp/input-otp.css +9 -0
  50. package/src/components/input-otp/react.tsx +21 -0
  51. package/src/components/pagination/README.md +17 -0
  52. package/src/components/pagination/element.ts +14 -0
  53. package/src/components/pagination/engine.ts +102 -0
  54. package/src/components/pagination/pagination.css +10 -0
  55. package/src/components/pagination/react.tsx +22 -0
  56. package/src/components/quantity/README.md +20 -0
  57. package/src/components/quantity/element.ts +14 -0
  58. package/src/components/quantity/engine.ts +85 -0
  59. package/src/components/quantity/quantity.css +15 -0
  60. package/src/components/quantity/react.tsx +21 -0
  61. package/src/components/slot-picker/README.md +68 -0
  62. package/src/components/slot-picker/element.ts +27 -0
  63. package/src/components/slot-picker/engine.ts +412 -0
  64. package/src/components/slot-picker/react.tsx +76 -0
  65. package/src/components/slot-picker/slot-picker.css +125 -0
  66. package/src/components/studio-dock/README.md +44 -0
  67. package/src/components/studio-dock/element.ts +11 -0
  68. package/src/components/studio-dock/engine.ts +212 -0
  69. package/src/components/studio-dock/react.tsx +23 -0
  70. package/src/components/studio-dock/studio-dock.css +34 -0
  71. package/src/components/trailer/README.md +28 -0
  72. package/src/components/trailer/element.ts +13 -0
  73. package/src/components/trailer/engine.ts +189 -0
  74. package/src/components/trailer/react.tsx +16 -0
  75. package/src/components/trailer/trailer.css +36 -0
  76. package/src/core/dates.ts +98 -0
  77. package/src/css/controls/README.md +14 -0
  78. package/src/css/controls/status.css +63 -0
  79. package/src/elements/index.ts +24 -0
  80. package/src/index.ts +25 -0
  81. package/src/react/index.ts +21 -0
  82. package/src/react/use-action-button.ts +6 -0
  83. package/src/react/use-editorial-reveal.ts +7 -0
  84. package/src/react/use-film-hero.ts +6 -0
  85. package/src/react/use-font-cycle.ts +7 -0
  86. package/src/react/use-image-zoom.ts +5 -0
  87. package/src/react/use-joined-cta.ts +5 -0
  88. package/src/react/use-local-clock.ts +5 -0
  89. package/src/react/use-scroll-dock.ts +5 -0
  90. package/src/react/use-sticker.ts +5 -0
  91. package/src/react/use-studio-dock.ts +8 -0
  92. package/src/react/use-thumbnail-nav.ts +7 -0
  93. package/src/react/use-trailer.ts +6 -0
  94. package/src/react/use-weight-hover.ts +5 -0
  95. package/src/recipes/action-button/README.md +31 -0
  96. package/src/recipes/action-button/action-button.css +113 -0
  97. package/src/recipes/action-button/create-action-button.ts +56 -0
  98. package/src/recipes/editorial-reveal/README.md +35 -0
  99. package/src/recipes/editorial-reveal/create-editorial-reveal.ts +91 -0
  100. package/src/recipes/editorial-reveal/editorial-reveal.css +17 -0
  101. package/src/recipes/film-hero/README.md +23 -0
  102. package/src/recipes/film-hero/create-film-hero.ts +65 -0
  103. package/src/recipes/film-hero/film-hero.css +29 -0
  104. package/src/recipes/font-cycle/README.md +11 -0
  105. package/src/recipes/font-cycle/create-font-cycle.ts +126 -0
  106. package/src/recipes/font-cycle/font-cycle.css +36 -0
  107. package/src/recipes/image-zoom/README.md +16 -0
  108. package/src/recipes/image-zoom/create-image-zoom.ts +27 -0
  109. package/src/recipes/image-zoom/image-zoom.css +7 -0
  110. package/src/recipes/joined-cta/README.md +13 -0
  111. package/src/recipes/joined-cta/create-joined-cta.ts +22 -0
  112. package/src/recipes/joined-cta/joined-cta.css +17 -0
  113. package/src/recipes/local-clock/README.md +31 -0
  114. package/src/recipes/local-clock/create-local-clock.ts +61 -0
  115. package/src/recipes/scroll-dock/README.md +46 -0
  116. package/src/recipes/scroll-dock/create-scroll-dock.ts +72 -0
  117. package/src/recipes/scroll-dock/scroll-dock.css +70 -0
  118. package/src/recipes/sticker/README.md +59 -0
  119. package/src/recipes/sticker/create-sticker.ts +490 -0
  120. package/src/recipes/sticker/sticker.css +112 -0
  121. package/src/recipes/thumbnail-nav/README.md +16 -0
  122. package/src/recipes/thumbnail-nav/create-thumbnail-nav.ts +165 -0
  123. package/src/recipes/thumbnail-nav/thumbnail-nav.css +108 -0
  124. package/src/recipes/weight-hover/README.md +37 -0
  125. package/src/recipes/weight-hover/create-weight-hover.ts +136 -0
  126. package/src/recipes/weight-hover/weight-hover.css +7 -0
  127. package/dist/chunk-JQMJQSXD.js.map +0 -1
  128. package/dist/chunk-XDWBVFHQ.js.map +0 -1
  129. /package/dist/{chunk-BFC27F2G.js.map → chunk-IKQ5FODN.js.map} +0 -0
@@ -0,0 +1,8 @@
1
+ import { MkElement, defineElement, type OptionType } from '../../elements/base';
2
+ import { createHoverCard, type HoverCardInstance } from './engine';
3
+ /** Light DOM: supply the same child markup shown in the README. */
4
+ export class MkHoverCardElement extends MkElement<HoverCardInstance> {
5
+ static options: Record<string, OptionType> = { openDelay: 'number', closeDelay: 'number', placement: 'string' };
6
+ protected create(options: Record<string, unknown>) { return createHoverCard(this, options); }
7
+ }
8
+ export const defineHoverCard = () => defineElement('mk-hover-card', MkHoverCardElement);
@@ -0,0 +1,69 @@
1
+ import { createScope } from '../../core/scope';
2
+ import { inert, type Instance } from '../../core/types';
3
+
4
+ export interface HoverCardOptions {
5
+ openDelay?: number;
6
+ closeDelay?: number;
7
+ placement?: 'top' | 'bottom';
8
+ onOpenChange?: (open: boolean) => void;
9
+ }
10
+ export interface HoverCardInstance extends Instance {
11
+ open(): void;
12
+ close(): void;
13
+ readonly isOpen: boolean;
14
+ }
15
+ let sequence = 0;
16
+
17
+ /** Nonmodal preview: pointer, keyboard focus and tap all reveal the same panel. */
18
+ export function createHoverCard(root: HTMLElement | null, options: HoverCardOptions = {}): HoverCardInstance {
19
+ const trigger = root?.querySelector<HTMLElement>('[data-mk-hover-trigger]');
20
+ const panel = root?.querySelector<HTMLElement>('[data-mk-hover-panel]');
21
+ if (!root || !trigger || !panel) return inert({ open() {}, close() {}, isOpen: false });
22
+ const scope = createScope();
23
+ const openDelay = Number.isFinite(options.openDelay) ? Math.max(0, options.openDelay!) : 120;
24
+ const closeDelay = Number.isFinite(options.closeDelay) ? Math.max(0, options.closeDelay!) : 140;
25
+ let opened = false;
26
+ let timer: ReturnType<typeof setTimeout> | undefined;
27
+ let pointerInside = false;
28
+ let id = panel.id;
29
+ if (!id) { do { id = `mk-hover-${++sequence}`; } while (document.getElementById(id)); }
30
+ scope.attr(panel, 'id', id);
31
+ scope.attr(trigger, 'aria-controls', id);
32
+ scope.attr(root, 'data-mk-placement', options.placement ?? 'top');
33
+ scope.attr(root, 'data-mk-ready', '');
34
+ const cancel = () => { clearTimeout(timer); timer = undefined; };
35
+ const set = (value: boolean) => {
36
+ cancel();
37
+ if (scope.disposed) return;
38
+ const changed = value !== opened;
39
+ opened = value;
40
+ scope.attr(trigger, 'aria-expanded', String(value));
41
+ scope.attr(panel, 'hidden', value ? null : '');
42
+ scope.attr(root, 'data-mk-open', value ? '' : null);
43
+ if (changed) {
44
+ options.onOpenChange?.(value);
45
+ root.dispatchEvent(new CustomEvent('mk:open-change', { bubbles: true, detail: { open: value } }));
46
+ }
47
+ };
48
+ const schedule = (value: boolean, delay: number) => { cancel(); timer = setTimeout(() => set(value), delay); };
49
+ scope.on(root, 'pointerenter', () => { pointerInside = true; schedule(true, openDelay); });
50
+ scope.on(root, 'pointerleave', () => {
51
+ pointerInside = false;
52
+ if (!root.contains(document.activeElement)) schedule(false, closeDelay);
53
+ });
54
+ scope.on(root, 'focusin', () => set(true));
55
+ scope.on(root, 'focusout', (event) => {
56
+ if (!pointerInside && !root.contains((event as FocusEvent).relatedTarget as Node | null)) schedule(false, closeDelay);
57
+ });
58
+ scope.on(trigger, 'click', () => set(true));
59
+ scope.on(document, 'pointerdown', (event) => { if (!root.contains(event.target as Node)) set(false); });
60
+ scope.on(root, 'keydown', (event) => {
61
+ if (event.key !== 'Escape' || !opened) return;
62
+ event.preventDefault();
63
+ trigger.focus();
64
+ set(false);
65
+ });
66
+ scope.add(cancel);
67
+ set(false);
68
+ return { open: () => set(true), close: () => set(false), get isOpen() { return opened; }, destroy: () => scope.dispose() };
69
+ }
@@ -0,0 +1,13 @@
1
+ .mk-hover-card, mk-hover-card { position: relative; display: inline-flex; }
2
+ .mk-hover-card [data-mk-hover-panel], mk-hover-card [data-mk-hover-panel] {
3
+ position: absolute; z-index: 10; bottom: calc(100% + .75rem); left: 0;
4
+ width: min(19rem, calc(100vw - 3rem)); padding: 1.25rem; border-radius: var(--mk-radius-lg);
5
+ color: var(--mk-text); background: var(--mk-surface-3); border: 1px solid var(--mk-outline-soft);
6
+ box-shadow: 0 12px 40px color-mix(in srgb, var(--mk-shadow-color) 20%, transparent);
7
+ animation: mk-hover-card-in .28s cubic-bezier(.2,.8,.2,1) both;
8
+ }
9
+ .mk-hover-card [data-mk-hover-panel]::after, mk-hover-card [data-mk-hover-panel]::after { content: ''; position: absolute; inset: -1rem 0; z-index: -1; }
10
+ .mk-hover-card[data-mk-placement=bottom] [data-mk-hover-panel], mk-hover-card[data-mk-placement=bottom] [data-mk-hover-panel] { top: calc(100% + .75rem); bottom: auto; }
11
+ .mk-hover-card [hidden], mk-hover-card [hidden] { display: none !important; }
12
+ @keyframes mk-hover-card-in { from { opacity: 0; transform: translateY(7px) scale(.97); } to { opacity: 1; transform: none; } }
13
+ @media (prefers-reduced-motion: reduce) { .mk-hover-card [data-mk-hover-panel], mk-hover-card [data-mk-hover-panel] { animation: none; } }
@@ -0,0 +1,19 @@
1
+ 'use client';
2
+ import { useRef, type HTMLAttributes, type ReactNode } from 'react';
3
+ import { useIsomorphicLayoutEffect, useLatest } from '../../react/use-recipe';
4
+ import { createHoverCard, type HoverCardOptions } from './engine';
5
+ export interface HoverCardProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'>, HoverCardOptions {
6
+ trigger: ReactNode; children: ReactNode; triggerLabel?: string; panelLabel?: string;
7
+ }
8
+ export function HoverCard({ trigger, children, triggerLabel, panelLabel = 'Vista previa', openDelay, closeDelay, placement, onOpenChange, className, ...props }: HoverCardProps) {
9
+ const ref = useRef<HTMLDivElement>(null);
10
+ const change = useLatest(onOpenChange);
11
+ useIsomorphicLayoutEffect(() => {
12
+ const instance = createHoverCard(ref.current, { openDelay, closeDelay, placement, onOpenChange: (n) => change.current?.(n) });
13
+ return () => instance.destroy();
14
+ }, [openDelay, closeDelay, placement]);
15
+ return <div {...props} ref={ref} className={['mk-hover-card', className].filter(Boolean).join(' ')}>
16
+ <button type="button" className="mk-button mk-button--tonal" data-mk-hover-trigger aria-label={triggerLabel} aria-expanded="false">{trigger}</button>
17
+ <div data-mk-hover-panel role="region" aria-label={panelLabel} hidden>{children}</div>
18
+ </div>;
19
+ }
@@ -0,0 +1,18 @@
1
+ # input-otp
2
+
3
+ OTP numérico con un único input nativo y slots decorativos. Conserva edición, selección, pegado, ceros iniciales y autocomplete=one-time-code.
4
+
5
+ ```html
6
+ <mk-input-otp class="mk-input-otp" length="6">
7
+ <span data-mk-otp-slots aria-hidden="true"></span>
8
+ <input data-mk-otp-input type="text" name="code" aria-label="Código de verificación" />
9
+ </mk-input-otp>
10
+ ```
11
+
12
+ `defineElements()`, `createInputOtp(root, options)`, o `<InputOtp length={6} onChange={setCode} onComplete={verify} />`.
13
+
14
+ Opciones: `length=6` (1…16), `value`, `mask=false`, `onChange(value)`, `onComplete(value)`. API `setValue(text, notify=true)`, `focus()`, `value`, `destroy()`. Limpia caracteres no ASCII numéricos y limita longitud. Eventos `mk:change {value,complete}`, `mk:complete {value}`; escribir el mismo valor no repite callbacks. No envía ni valida códigos contra un servidor.
15
+
16
+ React acepta `defaultValue`, `label`, `name`, `disabled`, `required`, `invalid`. Variables `--mk-otp-size`, `--mk-otp-gap`; foco y error usan tokens. Sin movimiento con reduced-motion. Al destruir restaura input y nodos previos.
17
+
18
+ El reset nativo del formulario sincroniza el control sin emitir callbacks de edición. Los atributos de custom elements conservan el estado editado si value no está presente; value explícito tiene precedencia.
@@ -0,0 +1,14 @@
1
+ import { MkElement, defineElement, type OptionType } from '../../elements/base';
2
+ import { createInputOtp, type InputOtpInstance } from './engine';
3
+ /** Light DOM: supply the same child markup shown in the README. */
4
+ export class MkInputOtpElement extends MkElement<InputOtpInstance> {
5
+ static options: Record<string, OptionType> = { length: 'number', value: 'string', mask: 'boolean' };
6
+ protected create(options: Record<string, unknown>) { return createInputOtp(this, options); }
7
+ attributeChangedCallback(name: string, previous: string | null, next: string | null) {
8
+ if (previous === next || !this.isConnected || !this.instance) return;
9
+ const value = this.instance.value;
10
+ this.instance.destroy();
11
+ this.instance = this.create({ ...(!this.hasAttribute('value') ? { value } : {}), ...this.options });
12
+ }
13
+ }
14
+ export const defineInputOtp = () => defineElement('mk-input-otp', MkInputOtpElement);
@@ -0,0 +1,106 @@
1
+ import { createScope } from '../../core/scope';
2
+ import { inert, type Instance } from '../../core/types';
3
+
4
+ export interface InputOtpOptions {
5
+ length?: number;
6
+ value?: string;
7
+ mask?: boolean;
8
+ onChange?: (value: string) => void;
9
+ onComplete?: (value: string) => void;
10
+ }
11
+ export interface InputOtpInstance extends Instance {
12
+ setValue(value: string, notify?: boolean): void;
13
+ focus(): void;
14
+ readonly value: string;
15
+ }
16
+
17
+ /** One real input preserves native editing, mobile autofill and password managers. */
18
+ export function createInputOtp(root: HTMLElement | null, options: InputOtpOptions = {}): InputOtpInstance {
19
+ const input = root?.querySelector<HTMLInputElement>('[data-mk-otp-input]');
20
+ const slots = root?.querySelector<HTMLElement>('[data-mk-otp-slots]');
21
+ if (!root || !input || !slots) return inert({ setValue() {}, focus() {}, value: '' });
22
+ const scope = createScope();
23
+ const length = Number.isFinite(options.length) ? Math.min(16, Math.max(1, Math.round(options.length!))) : 6;
24
+ const clean = (text: string) => text.replace(/[^0-9]/g, '').slice(0, length);
25
+ const original = input.value;
26
+ const originalNodes = [...slots.childNodes];
27
+ let value = clean(options.value ?? input.value);
28
+ let pendingReset: Event | null = null;
29
+ const cells = Array.from({ length }, () => {
30
+ const cell = document.createElement('span');
31
+ cell.className = 'mk-input-otp__slot';
32
+ return cell;
33
+ });
34
+ slots.replaceChildren(...cells);
35
+ scope.attr(slots, 'aria-hidden', 'true');
36
+ scope.attr(input, 'type', 'text');
37
+ scope.attr(input, 'inputmode', 'numeric');
38
+ scope.attr(input, 'autocomplete', 'one-time-code');
39
+ scope.attr(input, 'maxlength', String(length));
40
+ scope.attr(input, 'pattern', `[0-9]{${length}}`);
41
+ scope.attr(root, 'data-mk-ready', '');
42
+ const render = () => {
43
+ if (pendingReset) {
44
+ if (pendingReset.eventPhase !== 0) return;
45
+ if (!pendingReset.defaultPrevented) value = clean(input.value);
46
+ pendingReset = null;
47
+ }
48
+ input.value = value;
49
+ const active = document.activeElement === input ? Math.min(length - 1, input.selectionStart ?? value.length) : -1;
50
+ cells.forEach((cell, i) => {
51
+ const text = value[i] ? options.mask ? '•' : value[i]! : '';
52
+ if (cell.textContent !== text) cell.textContent = text;
53
+ cell.toggleAttribute('data-mk-active', i === active);
54
+ cell.toggleAttribute('data-mk-filled', i < value.length);
55
+ });
56
+ scope.attr(root, 'data-mk-complete', value.length === length ? '' : null);
57
+ };
58
+ const setValue = (next: string, notify = true) => {
59
+ if (scope.disposed) return;
60
+ if (pendingReset?.eventPhase === 0) pendingReset = null;
61
+ const previous = value;
62
+ value = clean(String(next));
63
+ render();
64
+ if (notify && value !== previous) {
65
+ options.onChange?.(value);
66
+ root.dispatchEvent(new CustomEvent('mk:change', { bubbles: true, detail: { value, complete: value.length === length } }));
67
+ if (value.length === length) {
68
+ options.onComplete?.(value);
69
+ root.dispatchEvent(new CustomEvent('mk:complete', { bubbles: true, detail: { value } }));
70
+ }
71
+ }
72
+ };
73
+ scope.on(input, 'input', (event) => {
74
+ if ((event as InputEvent).isComposing) return;
75
+ const caret = clean(input.value.slice(0, input.selectionStart ?? input.value.length)).length;
76
+ setValue(input.value);
77
+ input.setSelectionRange(caret, caret);
78
+ render();
79
+ });
80
+ scope.on(input, 'compositionend', () => setValue(input.value));
81
+ scope.on(input, 'paste', (event) => {
82
+ const text = (event as ClipboardEvent).clipboardData?.getData('text');
83
+ if (text === undefined) return;
84
+ event.preventDefault();
85
+ const start = input.selectionStart ?? value.length;
86
+ const end = input.selectionEnd ?? start;
87
+ const inserted = clean(text);
88
+ setValue(value.slice(0, start) + inserted + value.slice(end));
89
+ const caret = Math.min(length, start + inserted.length);
90
+ input.setSelectionRange(caret, caret);
91
+ render();
92
+ });
93
+ scope.on(input, 'focus', render);
94
+ scope.on(input, 'blur', render);
95
+ scope.on(input, 'keyup', render);
96
+ scope.on(input, 'click', render);
97
+ scope.on(document, 'selectionchange', () => { if (document.activeElement === input) render(); });
98
+ if (input.form) scope.on(input.form, 'reset', (event) => {
99
+ render();
100
+ pendingReset = event;
101
+ scope.timeout(() => { if (pendingReset === event) render(); }, 0);
102
+ });
103
+ scope.add(() => { slots.replaceChildren(...originalNodes); input.value = original; });
104
+ render();
105
+ return { setValue, focus: () => { if (!scope.disposed) input.focus(); }, get value() { return value; }, destroy: () => scope.dispose() };
106
+ }
@@ -0,0 +1,9 @@
1
+ .mk-input-otp, mk-input-otp { --mk-otp-size: 2.75rem; --mk-otp-gap: .4rem; display: inline-block; position: relative; max-width: 100%; }
2
+ .mk-input-otp [data-mk-otp-slots], mk-input-otp [data-mk-otp-slots] { display: flex; flex-wrap: wrap; gap: var(--mk-otp-gap); }
3
+ .mk-input-otp__slot { box-sizing: border-box; width: var(--mk-otp-size); height: 3.5rem; display: grid; place-items: center; border: 1px solid var(--mk-outline-soft); border-radius: var(--mk-radius-sm); color: var(--mk-text); background: var(--mk-surface-1); font: 1.5rem var(--mk-font-mono, monospace); transition: border-color .2s, background .2s, transform .2s; }
4
+ .mk-input-otp__slot[data-mk-active] { outline: 2px solid var(--mk-accent); outline-offset: 2px; background: var(--mk-accent-soft); color: var(--mk-on-accent-soft); transform: translateY(-2px); }
5
+ .mk-input-otp [data-mk-otp-input], mk-input-otp [data-mk-otp-input] { position: absolute; inset: 0; box-sizing: border-box; width: 100%; height: 100%; border: 0; background: transparent; color: transparent; caret-color: transparent; outline: 0; font: 1.5rem var(--mk-font-mono, monospace); letter-spacing: calc(var(--mk-otp-size) + var(--mk-otp-gap) - 1ch); padding: 0 0 0 calc((var(--mk-otp-size) - 1ch) / 2); }
6
+ .mk-input-otp:has([aria-invalid=true]) .mk-input-otp__slot, mk-input-otp:has([aria-invalid=true]) .mk-input-otp__slot { border-color: var(--mk-error); }
7
+ .mk-input-otp:has(:disabled), mk-input-otp:has(:disabled) { opacity: .45; }
8
+ @media (max-width: 420px) { .mk-input-otp, mk-input-otp { --mk-otp-size: 2.25rem; --mk-otp-gap: .3rem; } }
9
+ @media (prefers-reduced-motion: reduce) { .mk-input-otp__slot { transition: none; transform: none !important; } }
@@ -0,0 +1,21 @@
1
+ 'use client';
2
+ import { useRef, type HTMLAttributes } from 'react';
3
+ import { useIsomorphicLayoutEffect, useLatest } from '../../react/use-recipe';
4
+ import { createInputOtp, type InputOtpOptions } from './engine';
5
+ export interface InputOtpProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange' | 'onComplete' | 'defaultValue'>, InputOtpOptions {
6
+ label?: string; name?: string; disabled?: boolean; required?: boolean; invalid?: boolean; defaultValue?: string;
7
+ }
8
+ export function InputOtp({ length, value, defaultValue, mask, onChange, onComplete, label = 'Código de verificación', name, disabled, required, invalid, className, ...props }: InputOtpProps) {
9
+ const ref = useRef<HTMLDivElement>(null);
10
+ const edited = useRef<string | undefined>(undefined);
11
+ const change = useLatest(onChange);
12
+ const complete = useLatest(onComplete);
13
+ useIsomorphicLayoutEffect(() => {
14
+ const instance = createInputOtp(ref.current, { length, value: value ?? edited.current, mask, onChange: (n) => change.current?.(n), onComplete: (n) => complete.current?.(n) });
15
+ return () => { edited.current = instance.value; instance.destroy(); };
16
+ }, [length, value, mask]);
17
+ return <div {...props} ref={ref} className={['mk-input-otp', className].filter(Boolean).join(' ')}>
18
+ <span data-mk-otp-slots aria-hidden="true" />
19
+ <input data-mk-otp-input type="text" aria-label={label} name={name} defaultValue={value ?? defaultValue ?? ''} disabled={disabled} required={required} aria-invalid={invalid || undefined} />
20
+ </div>;
21
+ }
@@ -0,0 +1,17 @@
1
+ # pagination
2
+
3
+ Paginación con ventana acotada de números, extremos, elipsis y respuesta de elevación/presión.
4
+
5
+ ```html
6
+ <mk-pagination class="mk-pagination" total-pages="30" page="4" role="navigation" aria-label="Artículos">
7
+ <button type="button" data-mk-pagination-previous aria-label="Página anterior">←</button>
8
+ <span data-mk-pagination-pages></span>
9
+ <button type="button" data-mk-pagination-next aria-label="Página siguiente">→</button>
10
+ </mk-pagination>
11
+ ```
12
+
13
+ `defineElements()`, `createPagination(root, options)`, o `<Pagination totalPages={30} page={page} onChange={setPage} />`.
14
+
15
+ Opciones: `totalPages=10`, `page=1`, `siblings=1` (0…10), `pageLabel(n)`, `onChange(n)`. Total mínimo 1. API: `setPage(n, notify=true)`, `setTotalPages(n)`, `page`, `totalPages`, `destroy()`. `mk:change` lleva `{page,totalPages}`. Los cambios del total acotan la página y conservan el foco.
16
+
17
+ Flechas izquierda/derecha, Home y End cambian la selección; botones nativos y aria-current=page. La aplicación decide cómo cargar los resultados. Sin animación con movimiento reducido.
@@ -0,0 +1,14 @@
1
+ import { MkElement, defineElement, type OptionType } from '../../elements/base';
2
+ import { createPagination, type PaginationInstance } from './engine';
3
+ /** Light DOM: supply the same child markup shown in the README. */
4
+ export class MkPaginationElement extends MkElement<PaginationInstance> {
5
+ static options: Record<string, OptionType> = { totalPages: 'number', page: 'number', siblings: 'number' };
6
+ protected create(options: Record<string, unknown>) { return createPagination(this, options); }
7
+ attributeChangedCallback(name: string, previous: string | null, next: string | null) {
8
+ if (previous === next || !this.isConnected || !this.instance) return;
9
+ const page = this.instance.page;
10
+ this.instance.destroy();
11
+ this.instance = this.create({ ...(!this.hasAttribute('page') ? { page } : {}), ...this.options });
12
+ }
13
+ }
14
+ export const definePagination = () => defineElement('mk-pagination', MkPaginationElement);
@@ -0,0 +1,102 @@
1
+ import { createScope } from '../../core/scope';
2
+ import { inert, type Instance } from '../../core/types';
3
+
4
+ export interface PaginationOptions {
5
+ totalPages?: number;
6
+ page?: number;
7
+ siblings?: number;
8
+ pageLabel?: (page: number) => string;
9
+ onChange?: (page: number) => void;
10
+ }
11
+ export interface PaginationInstance extends Instance {
12
+ setPage(page: number, notify?: boolean): void;
13
+ setTotalPages(total: number): void;
14
+ readonly page: number;
15
+ readonly totalPages: number;
16
+ }
17
+ // Reserve room for the page-window arithmetic so incrementing always progresses.
18
+ const positive = (n: number | undefined, fallback: number) => Number.isFinite(n) ? Math.max(1, Math.min(Number.MAX_SAFE_INTEGER - 32, Math.floor(n!))) : fallback;
19
+
20
+ /** Renders a bounded window of pages, even when totalPages is very large. */
21
+ export function createPagination(root: HTMLElement | null, options: PaginationOptions = {}): PaginationInstance {
22
+ const pages = root?.querySelector<HTMLElement>('[data-mk-pagination-pages]');
23
+ if (!root || !pages) return inert({ setPage() {}, setTotalPages() {}, page: 1, totalPages: 1 });
24
+ const scope = createScope();
25
+ const original = [...pages.childNodes];
26
+ const previous = root.querySelector<HTMLButtonElement>('[data-mk-pagination-previous]');
27
+ const next = root.querySelector<HTMLButtonElement>('[data-mk-pagination-next]');
28
+ let total = positive(options.totalPages, 10);
29
+ let page = Math.min(total, positive(options.page, 1));
30
+ const siblings = Math.min(10, Math.max(0, Number.isFinite(options.siblings) ? Math.floor(options.siblings!) : 1));
31
+ scope.attr(root, 'data-mk-ready', '');
32
+ const render = () => {
33
+ const hadFocus = pages.contains(document.activeElement);
34
+ const selected = new Set([1, total]);
35
+ for (let n = Math.max(1, page - siblings); n <= Math.min(total, page + siblings); n++) selected.add(n);
36
+ const fragment = document.createDocumentFragment();
37
+ let last = 0;
38
+ for (const n of [...selected].sort((a, b) => a - b)) {
39
+ if (last && n - last > 1) {
40
+ const gap = document.createElement('span');
41
+ gap.className = 'mk-pagination__gap';
42
+ gap.textContent = '…';
43
+ gap.setAttribute('aria-hidden', 'true');
44
+ fragment.append(gap);
45
+ }
46
+ const button = document.createElement('button');
47
+ button.type = 'button';
48
+ button.dataset.mkPage = String(n);
49
+ button.textContent = String(n);
50
+ button.setAttribute('aria-label', options.pageLabel?.(n) ?? `Página ${n}`);
51
+ if (n === page) button.setAttribute('aria-current', 'page');
52
+ fragment.append(button);
53
+ last = n;
54
+ }
55
+ pages.replaceChildren(fragment);
56
+ if (hadFocus) pages.querySelector<HTMLButtonElement>('[aria-current]')?.focus();
57
+ if (previous) scope.attr(previous, 'disabled', page === 1 ? '' : null);
58
+ if (next) scope.attr(next, 'disabled', page === total ? '' : null);
59
+ };
60
+ const setPage = (value: number, notify = true) => {
61
+ if (scope.disposed || !Number.isFinite(value)) return;
62
+ const candidate = Math.max(1, Math.min(total, Math.round(value)));
63
+ if (candidate === page) return;
64
+ const focused = pages.contains(document.activeElement);
65
+ page = candidate;
66
+ render();
67
+ if (focused) pages.querySelector<HTMLButtonElement>('[aria-current]')?.focus();
68
+ if (notify) {
69
+ options.onChange?.(page);
70
+ root.dispatchEvent(new CustomEvent('mk:change', { bubbles: true, detail: { page, totalPages: total } }));
71
+ }
72
+ };
73
+ scope.on(root, 'click', (event) => {
74
+ const target = (event.target as Element).closest<HTMLButtonElement>('button');
75
+ if (!target || target.disabled) return;
76
+ if (target === previous) setPage(page - 1);
77
+ else if (target === next) setPage(page + 1);
78
+ else if (target.dataset.mkPage) setPage(Number(target.dataset.mkPage));
79
+ });
80
+ scope.on(root, 'keydown', (event) => {
81
+ if (!(event.target instanceof HTMLButtonElement)) return;
82
+ const to = event.key === 'ArrowRight' ? page + 1 : event.key === 'ArrowLeft' ? page - 1 : event.key === 'Home' ? 1 : event.key === 'End' ? total : null;
83
+ if (to === null) return;
84
+ event.preventDefault();
85
+ setPage(to);
86
+ pages.querySelector<HTMLButtonElement>('[aria-current]')?.focus();
87
+ });
88
+ scope.add(() => pages.replaceChildren(...original));
89
+ render();
90
+ return {
91
+ setPage,
92
+ setTotalPages(value) {
93
+ if (scope.disposed || !Number.isFinite(value)) return;
94
+ total = positive(value, 1);
95
+ if (page > total) setPage(total);
96
+ else render();
97
+ },
98
+ get page() { return page; },
99
+ get totalPages() { return total; },
100
+ destroy: () => scope.dispose(),
101
+ };
102
+ }
@@ -0,0 +1,10 @@
1
+ .mk-pagination, mk-pagination { display: flex; flex-wrap: wrap; justify-content: center; align-items: center; gap: .35rem; }
2
+ .mk-pagination [data-mk-pagination-pages], mk-pagination [data-mk-pagination-pages] { display: flex; align-items: center; gap: .25rem; }
3
+ .mk-pagination button, mk-pagination button { min-width: 2.5rem; height: 2.5rem; border: 0; border-radius: var(--mk-radius-full); background: var(--mk-surface-2); color: var(--mk-text); font: inherit; cursor: pointer; transition: transform .3s, background .3s, color .3s; }
4
+ .mk-pagination button[aria-current], mk-pagination button[aria-current] { background: var(--mk-accent); color: var(--mk-on-accent); }
5
+ .mk-pagination button:hover:not(:disabled), mk-pagination button:hover:not(:disabled) { transform: translateY(-3px); }
6
+ .mk-pagination button:active:not(:disabled), mk-pagination button:active:not(:disabled) { transform: scale(.9); }
7
+ .mk-pagination button:focus-visible, mk-pagination button:focus-visible { outline: 2px solid var(--mk-accent); outline-offset: 3px; }
8
+ .mk-pagination button:disabled, mk-pagination button:disabled { opacity: .35; cursor: default; }
9
+ .mk-pagination__gap { padding: 0 .15rem; color: var(--mk-muted); }
10
+ @media (prefers-reduced-motion: reduce) { .mk-pagination button, mk-pagination button { transition: none; transform: none !important; } }
@@ -0,0 +1,22 @@
1
+ 'use client';
2
+ import { useRef, type HTMLAttributes } from 'react';
3
+ import { useIsomorphicLayoutEffect, useLatest } from '../../react/use-recipe';
4
+ import { createPagination, type PaginationOptions } from './engine';
5
+ export interface PaginationProps extends Omit<HTMLAttributes<HTMLElement>, 'onChange'>, PaginationOptions {
6
+ previousLabel?: string; nextLabel?: string;
7
+ }
8
+ export function Pagination({ totalPages, page, siblings, pageLabel, onChange, previousLabel = 'Página anterior', nextLabel = 'Página siguiente', className, ...props }: PaginationProps) {
9
+ const ref = useRef<HTMLElement>(null);
10
+ const selected = useRef<number | undefined>(undefined);
11
+ const change = useLatest(onChange);
12
+ const label = useLatest(pageLabel);
13
+ useIsomorphicLayoutEffect(() => {
14
+ const instance = createPagination(ref.current, { totalPages, page: page ?? selected.current, siblings, pageLabel: (n) => label.current?.(n) ?? `Página ${n}`, onChange: (n) => change.current?.(n) });
15
+ return () => { selected.current = instance.page; instance.destroy(); };
16
+ }, [totalPages, page, siblings, pageLabel]);
17
+ return <nav aria-label="Paginación" {...props} ref={ref} className={['mk-pagination', className].filter(Boolean).join(' ')}>
18
+ <button type="button" data-mk-pagination-previous aria-label={previousLabel}>←</button>
19
+ <span data-mk-pagination-pages />
20
+ <button type="button" data-mk-pagination-next aria-label={nextLabel}>→</button>
21
+ </nav>;
22
+ }
@@ -0,0 +1,20 @@
1
+ # quantity
2
+
3
+ Selector inspirado en [Bernice](https://bernicebakery.com/collections/cakes). Botones nativos, mordida geométrica propia y giro de 15° en .4 s. La forma adapta el original; no usa su SVG.
4
+
5
+ ```html
6
+ <mk-quantity class="mk-quantity" min="1" max="9">
7
+ <button type="button" data-mk-quantity-decrease aria-label="Disminuir">−</button>
8
+ <input data-mk-quantity-input type="number" value="1" aria-label="Cantidad" />
9
+ <button type="button" data-mk-quantity-increase aria-label="Aumentar">+</button>
10
+ </mk-quantity>
11
+ ```
12
+
13
+ Registrar con `defineElements()` de `/elements`, o usar `createQuantity(root, options)`. React: `<Quantity min={1} max={9} value={value} onChange={setValue} />` desde `/react`.
14
+
15
+ Opciones: `min=1`, `max=999`, `step=1`, `value`, `disabled`, `onChange(value)`. API: `setValue(number, notify=true)`, `value`, `destroy()`. El input acepta edición: un número válido y dentro del rango se aplica mientras escribes (sin tocar el texto); uno parcial o fuera de rango se ajusta al paso desde min y se acota al confirmar o salir del campo. Evento `mk:change` con `{value, previous}`. No realiza operaciones de carrito.
16
+
17
+ Variables: `--mk-quantity-size` (3rem), colores semánticos. Movimiento reducido conserva controles sin rotación. React acepta label/decreaseLabel/increaseLabel/name y props HTML. Desmontar llama destroy y restaura marcado/valores originales.
18
+
19
+ El reset nativo del formulario sincroniza el control sin emitir callbacks de edición. Los atributos de custom elements conservan el estado editado si value no está presente; value explícito tiene precedencia.
20
+ Si max no coincide con un paso desde min, el límite efectivo es el mayor min+n×step que no supere max, para mantener validez HTML nativa.
@@ -0,0 +1,14 @@
1
+ import { MkElement, defineElement, type OptionType } from '../../elements/base';
2
+ import { createQuantity, type QuantityInstance } from './engine';
3
+ /** Light DOM: supply the same child markup shown in the README. */
4
+ export class MkQuantityElement extends MkElement<QuantityInstance> {
5
+ static options: Record<string, OptionType> = { min: 'number', max: 'number', step: 'number', value: 'number', disabled: 'boolean' };
6
+ protected create(options: Record<string, unknown>) { return createQuantity(this, options); }
7
+ attributeChangedCallback(name: string, previous: string | null, next: string | null) {
8
+ if (previous === next || !this.isConnected || !this.instance) return;
9
+ const value = this.instance.value;
10
+ this.instance.destroy();
11
+ this.instance = this.create({ ...(!this.hasAttribute('value') ? { value } : {}), ...this.options });
12
+ }
13
+ }
14
+ export const defineQuantity = () => defineElement('mk-quantity', MkQuantityElement);
@@ -0,0 +1,85 @@
1
+ import { createScope } from '../../core/scope';
2
+ import { inert, type Instance } from '../../core/types';
3
+
4
+ export interface QuantityOptions {
5
+ min?: number;
6
+ max?: number;
7
+ step?: number;
8
+ value?: number;
9
+ disabled?: boolean;
10
+ onChange?: (value: number) => void;
11
+ }
12
+ export interface QuantityInstance extends Instance {
13
+ setValue(value: number, notify?: boolean): void;
14
+ readonly value: number;
15
+ }
16
+
17
+ /** Bounded numeric control. Bite/rotation is CSS; buttons remain native. */
18
+ export function createQuantity(root: HTMLElement | null, options: QuantityOptions = {}): QuantityInstance {
19
+ const input = root?.querySelector<HTMLInputElement>('[data-mk-quantity-input]');
20
+ if (!root || !input) return inert({ setValue() {}, value: 0 });
21
+ const scope = createScope();
22
+ const min = Number.isFinite(options.min) ? options.min! : 1;
23
+ const max = Number.isFinite(options.max) ? Math.max(min, options.max!) : Math.max(min, 999);
24
+ const step = Number.isFinite(options.step) && options.step! > 0 ? options.step! : 1;
25
+ const steps = Math.floor((max - min) / step + 1e-9);
26
+ const upper = Math.min(max, Number((min + steps * step).toFixed(10)));
27
+ const normalize = (n: number) => Math.max(min, Math.min(upper, Number((min + Math.round((n - min) / step) * step).toFixed(10))));
28
+ const original = input.value;
29
+ const disabled = options.disabled ?? input.disabled;
30
+ const decrease = root.querySelector<HTMLButtonElement>('[data-mk-quantity-decrease]');
31
+ const increase = root.querySelector<HTMLButtonElement>('[data-mk-quantity-increase]');
32
+ scope.attr(input, 'min', String(min));
33
+ scope.attr(input, 'max', String(max));
34
+ scope.attr(input, 'step', String(step));
35
+ scope.attr(input, 'disabled', disabled ? '' : null);
36
+ scope.attr(root, 'data-mk-ready', '');
37
+ const initial = input.value.trim() ? Number(input.value) : NaN;
38
+ let value = normalize(Number.isFinite(options.value) ? options.value! : Number.isFinite(initial) ? initial : min);
39
+ let pendingReset: Event | null = null;
40
+ const typed = () => (input.value.trim() && Number.isFinite(input.valueAsNumber) ? input.valueAsNumber : NaN);
41
+ const render = (syncInput = true) => {
42
+ if (pendingReset) {
43
+ if (pendingReset.eventPhase !== 0) return;
44
+ if (!pendingReset.defaultPrevented) value = normalize(Number.isFinite(typed()) ? typed() : min);
45
+ pendingReset = null;
46
+ }
47
+ if (syncInput) input.value = String(value);
48
+ if (decrease) scope.attr(decrease, 'disabled', disabled || value <= min ? '' : null);
49
+ if (increase) scope.attr(increase, 'disabled', disabled || value >= upper ? '' : null);
50
+ };
51
+ const commit = (next: number, notify: boolean, syncInput: boolean) => {
52
+ if (scope.disposed || !Number.isFinite(next)) return;
53
+ if (pendingReset?.eventPhase === 0) pendingReset = null;
54
+ const previous = value;
55
+ value = normalize(next);
56
+ render(syncInput);
57
+ if (notify && value !== previous) {
58
+ options.onChange?.(value);
59
+ root.dispatchEvent(new CustomEvent('mk:change', { bubbles: true, detail: { value, previous } }));
60
+ }
61
+ };
62
+ const setValue = (next: number, notify = true) => commit(next, notify, true);
63
+ if (decrease) scope.on(decrease, 'click', () => { if (!decrease.disabled) setValue(value - step); });
64
+ if (increase) scope.on(increase, 'click', () => { if (!increase.disabled) setValue(value + step); });
65
+ // While typing, commit only numbers that are already valid and leave the text alone;
66
+ // partial or out-of-range input settles on change/blur.
67
+ scope.on(input, 'input', () => {
68
+ const n = typed();
69
+ if (Number.isFinite(n) && normalize(n) === n) commit(n, true, false);
70
+ });
71
+ scope.on(input, 'change', () => {
72
+ if (Number.isFinite(typed())) setValue(typed());
73
+ else render();
74
+ });
75
+ scope.on(input, 'blur', () => render());
76
+ if (input.form) scope.on(input.form, 'reset', (event) => {
77
+ // reset's native default action runs after its listeners. Match the final input silently.
78
+ render();
79
+ pendingReset = event;
80
+ scope.timeout(() => { if (pendingReset === event) render(); }, 0);
81
+ });
82
+ scope.add(() => { input.value = original; });
83
+ render();
84
+ return { setValue, get value() { return value; }, destroy: () => scope.dispose() };
85
+ }
@@ -0,0 +1,15 @@
1
+ .mk-quantity, mk-quantity { display: inline-flex; align-items: center; gap: .5rem; --mk-quantity-size: 3rem; }
2
+ .mk-quantity [data-mk-quantity-decrease], .mk-quantity [data-mk-quantity-increase], mk-quantity button {
3
+ width: var(--mk-quantity-size); height: var(--mk-quantity-size); border: 0; border-radius: 50%;
4
+ background: var(--mk-secondary-soft); color: var(--mk-on-secondary-soft); font: inherit; font-size: 1.65rem;
5
+ cursor: pointer; transition: transform .4s cubic-bezier(.28,.71,0,.98), opacity .2s;
6
+ }
7
+ .mk-quantity [data-mk-quantity-increase], mk-quantity [data-mk-quantity-increase] { background: var(--mk-accent-soft); color: var(--mk-on-accent-soft); }
8
+ .mk-quantity button:is(:hover,:focus-visible):not(:disabled), mk-quantity button:is(:hover,:focus-visible):not(:disabled) {
9
+ transform: rotate(15deg); mask-image: radial-gradient(circle at 100% 0, transparent 22%, #000 23%);
10
+ }
11
+ .mk-quantity button:focus-visible, mk-quantity button:focus-visible { outline: 2px solid var(--mk-accent); outline-offset: 4px; }
12
+ .mk-quantity button:disabled, mk-quantity button:disabled { opacity: .4; cursor: default; }
13
+ .mk-quantity input, mk-quantity input { width: 3.5rem; padding: .4rem 0; border: 0; border-radius: var(--mk-radius-sm); text-align: center; font: inherit; font-variant-numeric: tabular-nums; background: transparent; color: var(--mk-text); appearance: textfield; }
14
+ .mk-quantity input::-webkit-inner-spin-button, mk-quantity input::-webkit-inner-spin-button { appearance: none; }
15
+ @media (prefers-reduced-motion: reduce) { .mk-quantity button, mk-quantity button { transition: none; transform: none !important; } }
@@ -0,0 +1,21 @@
1
+ 'use client';
2
+ import { useRef, type HTMLAttributes } from 'react';
3
+ import { useIsomorphicLayoutEffect, useLatest } from '../../react/use-recipe';
4
+ import { createQuantity, type QuantityOptions } from './engine';
5
+ export interface QuantityProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange' | 'defaultValue'>, QuantityOptions {
6
+ label?: string; decreaseLabel?: string; increaseLabel?: string; name?: string;
7
+ }
8
+ export function Quantity({ min, max, step, value, disabled, onChange, label = 'Cantidad', decreaseLabel = 'Disminuir', increaseLabel = 'Aumentar', name, className, ...props }: QuantityProps) {
9
+ const ref = useRef<HTMLDivElement>(null);
10
+ const edited = useRef<number | undefined>(undefined);
11
+ const change = useLatest(onChange);
12
+ useIsomorphicLayoutEffect(() => {
13
+ const instance = createQuantity(ref.current, { min, max, step, value: value ?? edited.current, disabled, onChange: (n) => change.current?.(n) });
14
+ return () => { edited.current = instance.value; instance.destroy(); };
15
+ }, [min, max, step, value, disabled]);
16
+ return <div {...props} ref={ref} className={['mk-quantity', className].filter(Boolean).join(' ')} role="group" aria-label={label}>
17
+ <button type="button" data-mk-quantity-decrease aria-label={decreaseLabel}>−</button>
18
+ <input data-mk-quantity-input type="number" aria-label={label} name={name} defaultValue={value ?? min ?? 1} disabled={disabled} />
19
+ <button type="button" data-mk-quantity-increase aria-label={increaseLabel}>+</button>
20
+ </div>;
21
+ }