@yewdiel21/motion-kit 0.17.0 → 0.19.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 +240 -199
- package/dist/auto/index.d.ts +19 -3
- package/dist/auto/index.js +35 -3
- package/dist/auto/index.js.map +1 -1
- package/dist/{chunk-QFBOHPWZ.js → chunk-CQP66AZK.js} +2 -2
- package/dist/{chunk-QP4QJOEN.js → chunk-H4STLMCM.js} +2 -2
- package/dist/{chunk-BZLMCHUD.js → chunk-KDIJX6G4.js} +3 -3
- package/dist/{chunk-DOOMULVV.js → chunk-OLT3CWDJ.js} +4 -2
- package/dist/chunk-OLT3CWDJ.js.map +1 -0
- package/dist/{chunk-VJFPTLAK.js → chunk-PEDYJ2TV.js} +8 -22
- package/dist/chunk-PEDYJ2TV.js.map +1 -0
- package/dist/{chunk-7IQTMF7Y.js → chunk-Y4GIBTRG.js} +5130 -2153
- package/dist/chunk-Y4GIBTRG.js.map +1 -0
- package/dist/css/components/badge-button.css +16 -0
- package/dist/css/components/bento-grid.css +13 -0
- package/dist/css/components/code-block.css +267 -0
- package/dist/css/components/command-search.css +274 -2
- package/dist/css/components/dialog.css +264 -0
- package/dist/css/components/drilldown-menu.css +11 -7
- package/dist/css/components/equalizer-slider.css +26 -0
- package/dist/css/components/handwriting-text.css +15 -0
- package/dist/css/components/heatmap-chart.css +267 -1
- package/dist/css/components/magnetize-button.css +18 -0
- package/dist/css/components/morphing-page-dots.css +13 -0
- package/dist/css/components/not-found.css +22 -0
- package/dist/css/components/notification-stack.css +42 -0
- package/dist/css/components/particle-button.css +14 -0
- package/dist/css/components/radio-group.css +29 -0
- package/dist/css/components/rating-scale.css +23 -0
- package/dist/css/components/scroll-area.css +261 -0
- package/dist/css/components/scroll-preview-dock.css +18 -0
- package/dist/css/components/select.css +300 -4
- package/dist/css/components/signature-pad.css +130 -103
- package/dist/css/components/stepper.css +36 -0
- package/dist/css/components/sticky-header.css +10 -0
- package/dist/css/components/temperature-slider.css +29 -0
- package/dist/css/components/voice-message-bubble.css +62 -45
- package/dist/css/components/waveform-player.css +49 -34
- package/dist/css/components/wheel-picker.css +16 -15
- package/dist/css/tokens.css +4 -0
- package/dist/elements/index.d.ts +217 -4
- package/dist/elements/index.js +614 -9
- package/dist/elements/index.js.map +1 -1
- package/dist/{engine-Bn6hC87X.d.ts → engine-D6XV-WNt.d.ts} +390 -1
- package/dist/gl/index.js +2 -2
- package/dist/index.css +886 -177
- package/dist/index.d.ts +23 -23
- package/dist/index.js +37 -5
- package/dist/index.js.map +1 -1
- package/dist/react/index.d.ts +126 -7
- package/dist/react/index.js +722 -7
- package/dist/react/index.js.map +1 -1
- package/dist/{reduced-YS6TZjpa.d.ts → reduced-4croIXl-.d.ts} +1 -1
- package/dist/{store-CZ6vdtIE.d.ts → store-CAHWkurn.d.ts} +1 -1
- package/package.json +110 -110
- package/registry.json +1357 -1160
- package/src/auto/auto-init.ts +341 -308
- package/src/components/badge-button/README.md +28 -0
- package/src/components/badge-button/badge-button.css +15 -0
- package/src/components/badge-button/element.ts +16 -0
- package/src/components/badge-button/engine.ts +85 -0
- package/src/components/badge-button/react.tsx +31 -0
- package/src/components/bento-grid/README.md +26 -0
- package/src/components/bento-grid/bento-grid.css +12 -0
- package/src/components/bento-grid/element.ts +22 -0
- package/src/components/bento-grid/engine.ts +153 -0
- package/src/components/bento-grid/react.tsx +25 -0
- package/src/components/code-block/README.md +1 -1
- package/src/components/code-block/code-block.css +5 -0
- package/src/components/code-block/engine.ts +5 -0
- package/src/components/command-search/README.md +1 -1
- package/src/components/command-search/command-search.css +12 -2
- package/src/components/command-search/engine.ts +6 -0
- package/src/components/dialog/README.md +104 -103
- package/src/components/dialog/dialog.css +2 -0
- package/src/components/dialog/engine.ts +465 -449
- package/src/components/drilldown-menu/drilldown-menu.css +11 -7
- package/src/components/drilldown-menu/engine.ts +44 -4
- package/src/components/equalizer-slider/README.md +43 -0
- package/src/components/equalizer-slider/element.ts +41 -0
- package/src/components/equalizer-slider/engine.ts +32 -0
- package/src/components/equalizer-slider/equalizer-slider.css +26 -0
- package/src/components/equalizer-slider/react.tsx +60 -0
- package/src/components/equalizer-slider/shared.ts +161 -0
- package/src/components/handwriting-text/README.md +29 -0
- package/src/components/handwriting-text/element.ts +14 -0
- package/src/components/handwriting-text/engine.ts +108 -0
- package/src/components/handwriting-text/glyphs.ts +71 -0
- package/src/components/handwriting-text/handwriting-text.css +14 -0
- package/src/components/handwriting-text/react.tsx +23 -0
- package/src/components/heatmap-chart/README.md +48 -48
- package/src/components/heatmap-chart/engine.ts +6 -1
- package/src/components/heatmap-chart/heatmap-chart.css +5 -1
- package/src/components/icons.ts +1 -0
- package/src/components/magnetize-button/README.md +26 -0
- package/src/components/magnetize-button/element.ts +13 -0
- package/src/components/magnetize-button/engine.ts +84 -0
- package/src/components/magnetize-button/magnetize-button.css +17 -0
- package/src/components/magnetize-button/react.tsx +22 -0
- package/src/components/morphing-page-dots/README.md +28 -0
- package/src/components/morphing-page-dots/element.ts +16 -0
- package/src/components/morphing-page-dots/engine.ts +89 -0
- package/src/components/morphing-page-dots/morphing-page-dots.css +12 -0
- package/src/components/morphing-page-dots/react.tsx +32 -0
- package/src/components/not-found/README.md +26 -0
- package/src/components/not-found/element.ts +15 -0
- package/src/components/not-found/engine.ts +57 -0
- package/src/components/not-found/not-found.css +21 -0
- package/src/components/not-found/react.tsx +23 -0
- package/src/components/notification-stack/README.md +24 -0
- package/src/components/notification-stack/element.ts +26 -0
- package/src/components/notification-stack/engine.ts +329 -0
- package/src/components/notification-stack/notification-stack.css +41 -0
- package/src/components/notification-stack/react.tsx +28 -0
- package/src/components/particle-button/README.md +26 -0
- package/src/components/particle-button/element.ts +13 -0
- package/src/components/particle-button/engine.ts +52 -0
- package/src/components/particle-button/particle-button.css +13 -0
- package/src/components/particle-button/react.tsx +22 -0
- package/src/components/radio-group/README.md +43 -0
- package/src/components/radio-group/element.ts +41 -0
- package/src/components/radio-group/engine.ts +11 -0
- package/src/components/radio-group/radio-group.css +28 -0
- package/src/components/radio-group/react.tsx +60 -0
- package/src/components/radio-group/shared.ts +118 -0
- package/src/components/rating-scale/README.md +43 -0
- package/src/components/rating-scale/element.ts +41 -0
- package/src/components/rating-scale/engine.ts +63 -0
- package/src/components/rating-scale/rating-scale.css +22 -0
- package/src/components/rating-scale/react.tsx +60 -0
- package/src/components/scroll-area/README.md +89 -0
- package/src/components/scroll-area/element.ts +35 -0
- package/src/components/scroll-area/engine.ts +528 -0
- package/src/components/scroll-area/react.tsx +67 -0
- package/src/components/scroll-area/scroll-area.css +260 -0
- package/src/components/scroll-preview-dock/README.md +24 -0
- package/src/components/scroll-preview-dock/element.ts +20 -0
- package/src/components/scroll-preview-dock/engine.ts +228 -0
- package/src/components/scroll-preview-dock/react.tsx +26 -0
- package/src/components/scroll-preview-dock/scroll-preview-dock.css +17 -0
- package/src/components/select/README.md +23 -9
- package/src/components/select/engine.ts +16 -4
- package/src/components/select/select.css +38 -4
- package/src/components/signature-pad/engine.ts +550 -437
- package/src/components/signature-pad/signature-pad.css +130 -103
- package/src/components/stepper/README.md +46 -0
- package/src/components/stepper/element.ts +42 -0
- package/src/components/stepper/engine.ts +103 -0
- package/src/components/stepper/react.tsx +60 -0
- package/src/components/stepper/stepper.css +35 -0
- package/src/components/sticky-header/README.md +30 -0
- package/src/components/sticky-header/element.ts +19 -0
- package/src/components/sticky-header/engine.ts +95 -0
- package/src/components/sticky-header/react.tsx +25 -0
- package/src/components/sticky-header/sticky-header.css +9 -0
- package/src/components/temperature-slider/README.md +42 -0
- package/src/components/temperature-slider/element.ts +53 -0
- package/src/components/temperature-slider/engine.ts +44 -0
- package/src/components/temperature-slider/react.tsx +66 -0
- package/src/components/temperature-slider/temperature-slider.css +29 -0
- package/src/components/voice-message-bubble/engine.ts +11 -0
- package/src/components/voice-message-bubble/voice-message-bubble.css +14 -12
- package/src/components/waveform-player/README.md +1 -1
- package/src/components/waveform-player/engine.ts +344 -261
- package/src/components/waveform-player/waveform-player.css +50 -35
- package/src/components/wheel-picker/README.md +3 -1
- package/src/components/wheel-picker/element.ts +12 -5
- package/src/components/wheel-picker/engine.ts +37 -8
- package/src/components/wheel-picker/wheel-picker.css +17 -16
- package/src/core/timing.ts +2 -0
- package/src/css/tokens.css +4 -0
- package/src/elements/index.ts +321 -273
- package/src/index.ts +312 -295
- package/src/motion/morph.ts +5 -2
- package/src/react/index.ts +17 -0
- package/dist/chunk-7IQTMF7Y.js.map +0 -1
- package/dist/chunk-DOOMULVV.js.map +0 -1
- package/dist/chunk-VJFPTLAK.js.map +0 -1
- /package/dist/{chunk-QFBOHPWZ.js.map → chunk-CQP66AZK.js.map} +0 -0
- /package/dist/{chunk-QP4QJOEN.js.map → chunk-H4STLMCM.js.map} +0 -0
- /package/dist/{chunk-BZLMCHUD.js.map → chunk-KDIJX6G4.js.map} +0 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Badge button
|
|
2
|
+
|
|
3
|
+
Botón con contador: el primer elemento hace aparecer el badge con `pop`; las cifras cambian con `rollDigits` y una pequeña pulsación `swatch`. El valor y el nombre accesible se actualizan en el momento de la acción. El badge visual usa `99+` por defecto, mientras el nombre conserva la cantidad real.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { createBadgeButton } from '@yewdiel21/motion-kit';
|
|
7
|
+
import '@yewdiel21/motion-kit/css/badge-button';
|
|
8
|
+
const button = createBadgeButton(document.querySelector('#pending'), {
|
|
9
|
+
count: 0, onChange: (count) => updateLocalList(count),
|
|
10
|
+
});
|
|
11
|
+
button.setCount(10); // sin notificar
|
|
12
|
+
button.setCount(0, true);
|
|
13
|
+
// button.destroy();
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`BadgeButtonOptions`: `label` (Guardar, cuando falta contenido), `count` (0), `step` (1, incremento por activación; admite 0 y valores negativos), `hideZero` (true), `max` (99), `onChange(count)`. Los valores se redondean y limitan a enteros no negativos. `BadgeButtonInstance`: `setCount(count, notify = false)`, `count`, `destroy()`. Emite `mk:change` con `{count}` al notificar. Un paso 0 conserva el contador y permite que la app gestione su acción con un listener nativo.
|
|
17
|
+
|
|
18
|
+
```tsx
|
|
19
|
+
<BadgeButton label="Añadir pendiente" count={count} onChange={setCount} />
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
React admite `count` controlado o `defaultCount`, atributos de botón e `instanceRef`. El valor controlado sigue siendo la autoridad si el padre rechaza un cambio. Usa `label`; los hijos y `dangerouslySetInnerHTML` se filtran en ejecución. Cambiar presentación conserva el estado no controlado.
|
|
23
|
+
|
|
24
|
+
```html
|
|
25
|
+
<mk-badge-button count="0" hide-zero="true"><button type="button">Añadir pendiente</button></mk-badge-button>
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`defineBadgeButton()` registra `MkBadgeButtonElement`; atributos `label`, `count`, `step`, `hide-zero`, `max`. Conserva el contador vivo en cambios de presentación y reconexiones; el atributo `count` explícito lo reemplaza. El botón nativo tiene type button por defecto y respeta disabled. Movimiento reducido actualiza directamente. `destroy()` restaura el contenido y estilos escritos por el consumidor y libera las animaciones y eventos.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
.mk-badge-button,
|
|
2
|
+
mk-badge-button { display: inline-flex; }
|
|
3
|
+
[data-mk-badge-button] {
|
|
4
|
+
display: inline-flex; align-items: center; justify-content: center; gap: .7rem; position: relative;
|
|
5
|
+
min-height: 48px; padding: .65rem 1rem .65rem 1.25rem; border: 1px solid var(--mk-outline-soft); border-radius: 16px;
|
|
6
|
+
background: var(--mk-surface-3); color: var(--mk-text); font: 500 .9375rem var(--mk-font); cursor: pointer;
|
|
7
|
+
transition: background 160ms, border-radius 320ms var(--mk-ease-swatch);
|
|
8
|
+
}
|
|
9
|
+
[data-mk-badge-button]:hover:not(:disabled) { border-radius: 24px; background: var(--mk-surface-4); }
|
|
10
|
+
[data-mk-badge-button]:focus-visible { outline: 2px solid var(--mk-accent); outline-offset: 4px; }
|
|
11
|
+
[data-mk-badge-button]:disabled,
|
|
12
|
+
[data-mk-badge-button][aria-disabled='true'] { opacity: .4; cursor: not-allowed; }
|
|
13
|
+
[data-mk-badge-count] { display: inline-grid; place-items: center; min-width: 26px; height: 26px; padding: 0 6px; border-radius: 999px; background: var(--mk-accent); color: var(--mk-on-accent); font-size: .75rem; font-weight: 650; font-variant-numeric: tabular-nums; }
|
|
14
|
+
[data-mk-badge-count][hidden] { display: none; }
|
|
15
|
+
@media (prefers-reduced-motion: reduce) { [data-mk-badge-button] { transition: none; } }
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { MkElement, defineElement, type OptionType } from '../../elements/base';
|
|
2
|
+
import { createBadgeButton, type BadgeButtonInstance } from './engine';
|
|
3
|
+
|
|
4
|
+
export class MkBadgeButtonElement extends MkElement<BadgeButtonInstance> {
|
|
5
|
+
static options: Record<string, OptionType> = { label: 'string', count: 'number', step: 'number', hideZero: 'boolean', max: 'number' };
|
|
6
|
+
private retained: number | undefined;
|
|
7
|
+
protected create(options: Record<string, unknown>) { return createBadgeButton(this, { ...options, ...(this.retained === undefined ? {} : { count: this.retained }) }); }
|
|
8
|
+
disconnectedCallback() { this.retained = this.instance?.count ?? this.retained; super.disconnectedCallback(); }
|
|
9
|
+
attributeChangedCallback(name: string, previous: string | null, next: string | null) {
|
|
10
|
+
const focused = this.contains(document.activeElement);
|
|
11
|
+
if (previous !== next) this.retained = name === 'count' ? undefined : this.instance?.count ?? this.retained;
|
|
12
|
+
super.attributeChangedCallback(name, previous, next);
|
|
13
|
+
if (focused) this.querySelector<HTMLButtonElement>('button')?.focus({ preventScroll: true });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export const defineBadgeButton = () => defineElement('mk-badge-button', MkBadgeButtonElement);
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { gsap, EASE, registerMotion } from '../../core/gsap';
|
|
2
|
+
import { reduced } from '../../core/reduced';
|
|
3
|
+
import { createScope } from '../../core/scope';
|
|
4
|
+
import { inert, type Instance } from '../../core/types';
|
|
5
|
+
import { press, rollDigits } from '../../motion/micro';
|
|
6
|
+
|
|
7
|
+
export interface BadgeButtonOptions {
|
|
8
|
+
label?: string;
|
|
9
|
+
count?: number;
|
|
10
|
+
/** Increment requested by an activation; use 0 for a host-owned count. Default 1. */
|
|
11
|
+
step?: number;
|
|
12
|
+
/** Hide the badge when its count is zero (default true). */
|
|
13
|
+
hideZero?: boolean;
|
|
14
|
+
max?: number;
|
|
15
|
+
onChange?: (count: number) => void;
|
|
16
|
+
}
|
|
17
|
+
export interface BadgeButtonInstance extends Instance {
|
|
18
|
+
setCount(count: number, notify?: boolean): void;
|
|
19
|
+
readonly count: number;
|
|
20
|
+
}
|
|
21
|
+
const normalize = (value: number) => Number.isFinite(value) ? Math.max(0, Math.min(Number.MAX_SAFE_INTEGER, Math.round(value))) : 0;
|
|
22
|
+
let sequence = 0;
|
|
23
|
+
export function createBadgeButton(root: HTMLElement | null, options: BadgeButtonOptions = {}): BadgeButtonInstance {
|
|
24
|
+
if (!root) return inert({ setCount() {}, count: 0 });
|
|
25
|
+
registerMotion();
|
|
26
|
+
const scope = createScope();
|
|
27
|
+
let button = root.matches('button') ? root as HTMLButtonElement : root.querySelector<HTMLButtonElement>('button');
|
|
28
|
+
if (!button) { button = document.createElement('button'); root.append(button); const made = button; scope.add(() => made.remove()); }
|
|
29
|
+
const control = button;
|
|
30
|
+
if (!control.textContent?.trim() && !control.querySelector('[aria-label], svg, img')) {
|
|
31
|
+
const label = document.createElement('span'); label.textContent = options.label ?? 'Guardar'; control.append(label); scope.add(() => label.remove());
|
|
32
|
+
}
|
|
33
|
+
const originalStyle = control.getAttribute('style');
|
|
34
|
+
const originalTransform = control.style.transform;
|
|
35
|
+
scope.add(() => { if (originalStyle === null) control.removeAttribute('style'); else control.setAttribute('style', originalStyle); });
|
|
36
|
+
const badge = document.createElement('span'); badge.dataset.mkBadgeCount = ''; badge.setAttribute('aria-hidden', 'true'); control.append(badge); scope.add(() => badge.remove());
|
|
37
|
+
// Keep the actual quantity in the accessible name even when the visual badge reads "99+".
|
|
38
|
+
const spoken = document.createElement('span'); spoken.className = 'mk-sr-only'; control.append(spoken); scope.add(() => spoken.remove());
|
|
39
|
+
const authoredLabel = control.getAttribute('aria-label');
|
|
40
|
+
const labelledBy = control.getAttribute('aria-labelledby');
|
|
41
|
+
if (labelledBy?.trim()) {
|
|
42
|
+
spoken.id = `mk-badge-quantity-${++sequence}`;
|
|
43
|
+
scope.attr(control, 'aria-labelledby', `${labelledBy} ${spoken.id}`);
|
|
44
|
+
}
|
|
45
|
+
scope.attr(control, 'type', control.getAttribute('type') ?? 'button');
|
|
46
|
+
scope.attr(control, 'data-mk-badge-button', '');
|
|
47
|
+
scope.attr(root, 'data-mk-ready', '');
|
|
48
|
+
let count = normalize(options.count ?? 0);
|
|
49
|
+
const max = Math.max(1, normalize(options.max ?? 99));
|
|
50
|
+
const step = Number.isFinite(options.step) ? Math.round(options.step!) : 1;
|
|
51
|
+
let rolling: gsap.core.Animation | null = null;
|
|
52
|
+
let bump: gsap.core.Animation | null = null;
|
|
53
|
+
let pressing: gsap.core.Animation | null = null;
|
|
54
|
+
const format = () => count > max ? `${max}+` : String(count);
|
|
55
|
+
const announce = () => {
|
|
56
|
+
spoken.textContent = ` (${count})`;
|
|
57
|
+
if (!labelledBy?.trim() && authoredLabel?.trim()) scope.attr(control, 'aria-label', `${authoredLabel} (${count})`);
|
|
58
|
+
};
|
|
59
|
+
const paint = () => { badge.hidden = options.hideZero !== false && count === 0; badge.textContent = format(); announce(); };
|
|
60
|
+
const setCount = (next: number, notify = false) => {
|
|
61
|
+
if (scope.disposed || !Number.isFinite(next)) return;
|
|
62
|
+
const previous = count;
|
|
63
|
+
count = normalize(next);
|
|
64
|
+
if (count === previous) return;
|
|
65
|
+
const wasHidden = badge.hidden;
|
|
66
|
+
badge.hidden = options.hideZero !== false && count === 0;
|
|
67
|
+
announce();
|
|
68
|
+
rolling?.kill(); bump?.kill();
|
|
69
|
+
if (reduced() || badge.hidden) paint();
|
|
70
|
+
else {
|
|
71
|
+
rolling = rollDigits(badge, format(), count >= previous ? 1 : -1, .34);
|
|
72
|
+
bump = gsap.fromTo(badge, { scale: wasHidden ? .35 : .8, y: wasHidden ? 7 : 0 }, { scale: 1, y: 0, duration: .5, ease: wasHidden ? EASE.pop : EASE.swatch });
|
|
73
|
+
}
|
|
74
|
+
if (notify) { options.onChange?.(count); root.dispatchEvent(new CustomEvent('mk:change', { bubbles: true, detail: { count } })); }
|
|
75
|
+
};
|
|
76
|
+
scope.on(control, 'click', (event) => {
|
|
77
|
+
if (control.disabled || control.getAttribute('aria-disabled') === 'true') { event.preventDefault(); return; }
|
|
78
|
+
pressing?.kill(); pressing = press(control);
|
|
79
|
+
pressing?.eventCallback('onComplete', () => { gsap.set(control, { clearProps: 'transform,scale' }); control.style.transform = originalTransform; if (!control.getAttribute('style')) control.removeAttribute('style'); });
|
|
80
|
+
setCount(count + step, true);
|
|
81
|
+
});
|
|
82
|
+
paint();
|
|
83
|
+
scope.add(() => { rolling?.kill(); bump?.kill(); pressing?.kill(); gsap.killTweensOf(badge); });
|
|
84
|
+
return { setCount, get count() { return count; }, destroy: () => scope.dispose() };
|
|
85
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useRef, type ButtonHTMLAttributes, type Ref } from 'react';
|
|
3
|
+
import { useIsomorphicLayoutEffect, useLatest } from '../../react/use-recipe';
|
|
4
|
+
import { createBadgeButton, type BadgeButtonInstance, type BadgeButtonOptions } from './engine';
|
|
5
|
+
|
|
6
|
+
export interface BadgeButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children' | 'dangerouslySetInnerHTML' | 'onChange'>, BadgeButtonOptions {
|
|
7
|
+
defaultCount?: number;
|
|
8
|
+
instanceRef?: Ref<BadgeButtonInstance>;
|
|
9
|
+
}
|
|
10
|
+
export function BadgeButton({ label = 'Guardar', count, defaultCount = 0, step, hideZero, max, onChange, instanceRef, type = 'button', className, ...props }: BadgeButtonProps) {
|
|
11
|
+
const { children: _children, dangerouslySetInnerHTML: _html, ...safe } = props as ButtonHTMLAttributes<HTMLButtonElement>;
|
|
12
|
+
const root = useRef<HTMLButtonElement>(null);
|
|
13
|
+
const instance = useRef<BadgeButtonInstance | null>(null);
|
|
14
|
+
const current = useLatest({ count, onChange });
|
|
15
|
+
const retained = useRef(defaultCount);
|
|
16
|
+
useIsomorphicLayoutEffect(() => {
|
|
17
|
+
let created: BadgeButtonInstance;
|
|
18
|
+
created = createBadgeButton(root.current, {
|
|
19
|
+
label, count: current.current.count ?? retained.current, step, hideZero, max,
|
|
20
|
+
onChange: (next) => { current.current.onChange?.(next); if (current.current.count !== undefined) created.setCount(current.current.count); },
|
|
21
|
+
});
|
|
22
|
+
instance.current = created;
|
|
23
|
+
return () => { retained.current = created.count; created.destroy(); instance.current = null; };
|
|
24
|
+
}, [label, step, hideZero, max]);
|
|
25
|
+
useIsomorphicLayoutEffect(() => { if (count !== undefined) instance.current?.setCount(count); }, [count]);
|
|
26
|
+
useIsomorphicLayoutEffect(() => {
|
|
27
|
+
if (typeof instanceRef === 'function') instanceRef(instance.current); else if (instanceRef) instanceRef.current = instance.current;
|
|
28
|
+
return () => { if (typeof instanceRef === 'function') instanceRef(null); else if (instanceRef) instanceRef.current = null; };
|
|
29
|
+
}, [instanceRef, label, step, hideZero, max]);
|
|
30
|
+
return <button {...safe} type={type} ref={root} className={['mk-badge-button', className].filter(Boolean).join(' ')} />;
|
|
31
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Bento grid
|
|
2
|
+
|
|
3
|
+
A reusable responsive authored grid. Existing cards travel with Flip when reordered or filtered; entering cards pop through a short blur. No hover-repulsion recipe is required.
|
|
4
|
+
|
|
5
|
+
```html
|
|
6
|
+
<div id="grid">
|
|
7
|
+
<article data-mk-bento-item="activity" data-mk-col-span="2" data-mk-category="work">…</article>
|
|
8
|
+
<article data-mk-bento-item="notes" data-mk-row-span="2" data-mk-category="work files">…</article>
|
|
9
|
+
</div>
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
const grid = createBentoGrid(document.querySelector('#grid'), { columns: 3, gap: 12 });
|
|
14
|
+
grid.setFilter('files'); // null shows everything
|
|
15
|
+
grid.reorder(['notes', 'activity']);
|
|
16
|
+
grid.mutate(() => document.querySelector('#grid').append(newCard));
|
|
17
|
+
grid.destroy();
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Exports: `createBentoGrid`, `BentoGridOptions`, `BentoGridInstance`; React `BentoGrid`, `BentoGridProps`; elements `MkBentoGridElement`, `defineBentoGrid` (`mk-bento-grid`). CSS: `bento-grid.css` plus kit tokens/theme/fonts.
|
|
21
|
+
|
|
22
|
+
Options: `columns` (default 3, bounded 1–12), `gap` (pixels, default 12), `filter` (category or null), `duration` (seconds, default .55), `onChange(keys)`. Direct children opt in with unique `data-mk-bento-item` keys; column/row spans default to one. The grid reduces to two columns below 440px of its own width and one below 280px; spans clamp to available columns.
|
|
23
|
+
|
|
24
|
+
Instance: `refresh()`, `setFilter(filter)`, `reorder(keys)`, `mutate(change)`, `setOptions(partial)`, readonly `filter`/`keys` (visible order), `destroy()`. `mk:change` carries `{keys,filter}`. `mutate` measures before authored DOM edits; a MutationObserver also handles framework additions and authored category/span changes. Unknown/duplicate reorder keys are ignored; unlisted keys follow in their existing order. Focus survives reordering; filtering a focused card returns focus to the grid. Filtered cards are hidden and inert. Customize the empty copy with `data-mk-empty-label`.
|
|
25
|
+
|
|
26
|
+
React deliberately accepts authored children with stable keys and the same data attributes. Change their array order to reorder declaratively. Elements accept scalar option attributes and expose the instance methods. Destroy restores authored attributes and original order, retaining externally added children. Reduced motion keeps filtering and layout immediate.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
.mk-bento-grid, mk-bento-grid { container-type: inline-size; display: grid; grid-template-columns: repeat(var(--mk-bento-columns, 3), minmax(0, 1fr)); grid-auto-rows: minmax(112px, auto); gap: var(--mk-bento-gap, 12px); color: var(--mk-text); font-family: var(--mk-font); }
|
|
2
|
+
.mk-bento-grid > [data-mk-bento-item], mk-bento-grid > [data-mk-bento-item] { box-sizing: border-box; min-width: 0; grid-column: span var(--mk-bento-col-span, 1); grid-row: span var(--mk-bento-row-span, 1); padding: 20px; border-radius: 24px; background: var(--mk-surface); transition: background .2s, box-shadow .25s; }
|
|
3
|
+
.mk-bento-grid > [data-mk-bento-item][hidden], mk-bento-grid > [data-mk-bento-item][hidden] { display: none !important; }
|
|
4
|
+
.mk-bento-grid > [data-mk-bento-item]:focus-within, mk-bento-grid > [data-mk-bento-item]:focus-within { background: var(--mk-surface-2); }
|
|
5
|
+
.mk-bento-grid :is(a,button):focus-visible, mk-bento-grid :is(a,button):focus-visible { outline: 2px solid var(--mk-accent); outline-offset: 4px; }
|
|
6
|
+
.mk-bento-grid :is(button,a)[aria-disabled=true], .mk-bento-grid button:disabled { opacity: .4; cursor: not-allowed; }
|
|
7
|
+
.mk-bento-grid[data-mk-empty]::after, mk-bento-grid[data-mk-empty]::after { content: attr(data-mk-empty-label); grid-column: 1 / -1; min-height: 112px; display: grid; place-items: center; border-radius: 24px; color: var(--mk-muted); background: var(--mk-surface); font-size: 13px; }
|
|
8
|
+
@media (hover: hover) { :is(.mk-bento-grid,mk-bento-grid) > [data-mk-bento-item]:hover { background: var(--mk-surface-2); box-shadow: 0 10px 24px -24px var(--mk-text); } }
|
|
9
|
+
/* The container can be a 640px playground card, independent of viewport width. */
|
|
10
|
+
@container (max-width: 440px) { :is(.mk-bento-grid,mk-bento-grid) > [data-mk-bento-item] { grid-column: span min(var(--mk-bento-col-span, 1), 2); padding: 16px; border-radius: 16px; } }
|
|
11
|
+
@media (max-width: 480px) { :is(.mk-bento-grid,mk-bento-grid) > [data-mk-bento-item] { padding: 16px; border-radius: 16px; } }
|
|
12
|
+
@media (prefers-reduced-motion: reduce) { :is(.mk-bento-grid,mk-bento-grid) * { transition: none !important; } }
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { MkElement, defineElement, type OptionType } from '../../elements/base';
|
|
2
|
+
import { createBentoGrid, type BentoGridInstance, type BentoGridOptions } from './engine';
|
|
3
|
+
|
|
4
|
+
export class MkBentoGridElement extends MkElement<BentoGridInstance> {
|
|
5
|
+
static options: Record<string, OptionType> = {"columns":"number","gap":"number","filter":"string","duration":"number"};
|
|
6
|
+
protected create(options: Record<string, unknown>) { return createBentoGrid(this, options); }
|
|
7
|
+
setOptions(options: Partial<BentoGridOptions>) { this.instance?.setOptions(options); }
|
|
8
|
+
|
|
9
|
+
refresh() { this.instance?.refresh(); }
|
|
10
|
+
setFilter(filter: string | null) { this.instance?.setFilter(filter); }
|
|
11
|
+
reorder(keys: string[]) { this.instance?.reorder(keys); }
|
|
12
|
+
mutate(change: () => void) { this.instance?.mutate(change); }
|
|
13
|
+
get filter() { return this.instance?.filter ?? null; }
|
|
14
|
+
get keys() { return this.instance?.keys ?? []; }
|
|
15
|
+
attributeChangedCallback(name: string, previous: string | null, next: string | null) {
|
|
16
|
+
if (previous === next || !this.isConnected || !this.instance) return;
|
|
17
|
+
const option = name.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase());
|
|
18
|
+
const defaults: Record<string, unknown> = {"columns":3,"gap":12,"filter":null,"duration":0.55};
|
|
19
|
+
this.instance.setOptions({ [option]: this.options[option] ?? defaults[option] });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export const defineBentoGrid = () => defineElement('mk-bento-grid', MkBentoGridElement);
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { Flip, gsap, EASE, registerMotion } from '../../core/gsap';
|
|
2
|
+
import { createScope } from '../../core/scope';
|
|
3
|
+
import { reduced } from '../../core/reduced';
|
|
4
|
+
import { inert, type Instance } from '../../core/types';
|
|
5
|
+
import { blurIn, press } from '../../motion/micro';
|
|
6
|
+
|
|
7
|
+
export interface BentoGridOptions {
|
|
8
|
+
columns?: number;
|
|
9
|
+
gap?: number;
|
|
10
|
+
/** Matches a whitespace-separated data-mk-category. Null shows every item. */
|
|
11
|
+
filter?: string | null;
|
|
12
|
+
duration?: number;
|
|
13
|
+
onChange?: (keys: string[]) => void;
|
|
14
|
+
}
|
|
15
|
+
export interface BentoGridInstance extends Instance {
|
|
16
|
+
refresh(): void;
|
|
17
|
+
setFilter(filter: string | null): void;
|
|
18
|
+
reorder(keys: string[]): void;
|
|
19
|
+
/** Capture before an authored DOM mutation (add/remove/replace), then animate to its layout. */
|
|
20
|
+
mutate(change: () => void): void;
|
|
21
|
+
setOptions(options: Partial<BentoGridOptions>): void;
|
|
22
|
+
readonly filter: string | null;
|
|
23
|
+
readonly keys: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Authored direct children are the grid items. Spans live in data-mk-col-span / data-mk-row-span. */
|
|
27
|
+
export function createBentoGrid(root: HTMLElement | null, options: BentoGridOptions = {}): BentoGridInstance {
|
|
28
|
+
if (!root) return inert({ refresh() {}, setFilter() {}, reorder() {}, mutate() {}, setOptions() {}, filter: null, keys: [] });
|
|
29
|
+
registerMotion();
|
|
30
|
+
const scope = createScope();
|
|
31
|
+
let opts = { ...options };
|
|
32
|
+
let filter = opts.filter ?? null;
|
|
33
|
+
let animation: gsap.core.Timeline | null = null;
|
|
34
|
+
const micro = new Set<gsap.core.Animation>();
|
|
35
|
+
const track = (value: gsap.core.Animation | null) => { if (value) micro.add(value); };
|
|
36
|
+
const originals = [...root.childNodes];
|
|
37
|
+
const originalItems = new Set(root.children);
|
|
38
|
+
const remembered = new Set<HTMLElement>();
|
|
39
|
+
let queued = 0;
|
|
40
|
+
scope.attr(root, 'class', `${root.className} mk-bento-grid`.trim());
|
|
41
|
+
scope.attr(root, 'style', root.getAttribute('style'));
|
|
42
|
+
scope.attr(root, 'data-mk-ready', '');
|
|
43
|
+
const items = () => [...root.children].filter((node): node is HTMLElement => node instanceof HTMLElement && node.hasAttribute('data-mk-bento-item'));
|
|
44
|
+
const key = (item: HTMLElement) => item.dataset.mkBentoItem!;
|
|
45
|
+
const visible = () => items().filter((item) => !item.hidden);
|
|
46
|
+
const save = (item: HTMLElement) => {
|
|
47
|
+
if (remembered.has(item)) return;
|
|
48
|
+
remembered.add(item);
|
|
49
|
+
for (const attr of ['style', 'hidden', 'inert', 'aria-hidden', 'data-flip-id']) scope.attr(item, attr, item.getAttribute(attr));
|
|
50
|
+
};
|
|
51
|
+
items().forEach(save);
|
|
52
|
+
let last: ReturnType<typeof Flip.getState> | undefined;
|
|
53
|
+
const notify = () => {
|
|
54
|
+
const keys = visible().map(key);
|
|
55
|
+
opts.onChange?.(keys);
|
|
56
|
+
root.dispatchEvent(new CustomEvent('mk:change', { bubbles: true, detail: { keys, filter } }));
|
|
57
|
+
};
|
|
58
|
+
const paint = () => {
|
|
59
|
+
const desired = Number.isFinite(opts.columns) ? Math.max(1, Math.min(12, Math.round(opts.columns!))) : 3;
|
|
60
|
+
const cols = root.clientWidth > 0 && root.clientWidth < 280 ? 1 : root.clientWidth > 0 && root.clientWidth < 440 ? Math.min(2, desired) : desired;
|
|
61
|
+
root.style.setProperty('--mk-bento-columns', String(cols));
|
|
62
|
+
root.style.setProperty('--mk-bento-gap', `${Math.max(0, opts.gap ?? 12)}px`);
|
|
63
|
+
items().forEach((item) => {
|
|
64
|
+
save(item);
|
|
65
|
+
item.style.setProperty('--mk-bento-col-span', String(Math.max(1, Math.min(cols, Number(item.dataset.mkColSpan) || 1))));
|
|
66
|
+
item.style.setProperty('--mk-bento-row-span', String(Math.max(1, Math.min(8, Number(item.dataset.mkRowSpan) || 1))));
|
|
67
|
+
const show = !filter || (item.dataset.mkCategory ?? '').split(/\s+/).includes(filter);
|
|
68
|
+
if (!show && item.contains(document.activeElement)) { scope.attr(root, 'tabindex', root.getAttribute('tabindex')); root.tabIndex = -1; root.focus(); }
|
|
69
|
+
item.hidden = !show;
|
|
70
|
+
item.inert = !show;
|
|
71
|
+
item.setAttribute('aria-hidden', String(!show));
|
|
72
|
+
});
|
|
73
|
+
scope.attr(root, 'data-mk-empty', visible().length ? null : '');
|
|
74
|
+
if (!root.hasAttribute('data-mk-empty-label')) scope.attr(root, 'data-mk-empty-label', 'No hay elementos en esta vista.');
|
|
75
|
+
};
|
|
76
|
+
const transition = (state = last, entering: HTMLElement[] = []) => {
|
|
77
|
+
if (scope.disposed) return;
|
|
78
|
+
animation?.kill();
|
|
79
|
+
paint();
|
|
80
|
+
if (!reduced()) {
|
|
81
|
+
animation = Flip.from(state ?? Flip.getState([]), { targets: visible(), duration: Math.max(0, opts.duration ?? .55), ease: EASE.signature, stagger: .025, absolute: false, scale: false, prune: true, onComplete: () => { if (!scope.disposed) last = Flip.getState(visible()); } });
|
|
82
|
+
track(blurIn(entering.filter((item) => !item.hidden), { from: 'soft', stagger: .04, duration: .35 }));
|
|
83
|
+
}
|
|
84
|
+
if (!reduced()) last = Flip.getState(visible());
|
|
85
|
+
};
|
|
86
|
+
const capture = () => {
|
|
87
|
+
animation?.kill();
|
|
88
|
+
if (reduced()) return undefined;
|
|
89
|
+
const focused = document.activeElement as HTMLElement | null;
|
|
90
|
+
Flip.killFlipsOf(items());
|
|
91
|
+
const state = Flip.getState(visible());
|
|
92
|
+
if (focused?.isConnected && focused !== document.activeElement) focused.focus({ preventScroll: true });
|
|
93
|
+
return state;
|
|
94
|
+
};
|
|
95
|
+
const mutate = (change: () => void) => {
|
|
96
|
+
if (scope.disposed) return;
|
|
97
|
+
const focused = document.activeElement as HTMLElement | null;
|
|
98
|
+
const state = capture();
|
|
99
|
+
const before = new Set(items());
|
|
100
|
+
change();
|
|
101
|
+
transition(state, items().filter((item) => !before.has(item)));
|
|
102
|
+
if (focused && focused !== document.body && root.contains(focused) && !focused.closest('[hidden], [inert]')) focused.focus({ preventScroll: true });
|
|
103
|
+
else if (focused && !focused.isConnected) { scope.attr(root, 'tabindex', root.getAttribute('tabindex')); root.tabIndex = -1; root.focus(); }
|
|
104
|
+
observer?.takeRecords();
|
|
105
|
+
notify();
|
|
106
|
+
};
|
|
107
|
+
const setFilter = (next: string | null) => {
|
|
108
|
+
if (scope.disposed || next === filter) return;
|
|
109
|
+
const state = capture();
|
|
110
|
+
const before = new Set(visible());
|
|
111
|
+
filter = next;
|
|
112
|
+
paint();
|
|
113
|
+
transition(state, visible().filter((item) => !before.has(item)));
|
|
114
|
+
notify();
|
|
115
|
+
};
|
|
116
|
+
const refresh = () => {
|
|
117
|
+
if (scope.disposed) return;
|
|
118
|
+
transition(last, items().filter((item) => !remembered.has(item)));
|
|
119
|
+
};
|
|
120
|
+
const observer = typeof MutationObserver === 'function' ? scope.observe(new MutationObserver((records) => {
|
|
121
|
+
// Observe authored data only: engine style/hidden writes and nested content
|
|
122
|
+
// changes must not schedule another layout or create an observer loop.
|
|
123
|
+
if (!records.some((record) => record.type === 'childList' ? record.target === root : record.target instanceof HTMLElement && record.target.parentElement === root)) return;
|
|
124
|
+
cancelAnimationFrame(queued);
|
|
125
|
+
queued = requestAnimationFrame(refresh);
|
|
126
|
+
})) : null;
|
|
127
|
+
observer?.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-mk-category', 'data-mk-col-span', 'data-mk-row-span'] });
|
|
128
|
+
scope.on(root, 'click', (event) => {
|
|
129
|
+
const control = (event.target as Element).closest<HTMLElement>('button, a');
|
|
130
|
+
if (control && root.contains(control) && !control.matches(':disabled, [aria-disabled="true"]')) { scope.attr(control, 'style', control.getAttribute('style')); track(press(control)); }
|
|
131
|
+
});
|
|
132
|
+
if (typeof ResizeObserver === 'function') {
|
|
133
|
+
let width = root.clientWidth;
|
|
134
|
+
const resize = scope.observe(new ResizeObserver(() => { if (root.clientWidth !== width) { width = root.clientWidth; animation?.kill(); paint(); last = Flip.getState(visible()); } }));
|
|
135
|
+
resize.observe(root);
|
|
136
|
+
}
|
|
137
|
+
paint(); if (!reduced()) last = Flip.getState(visible());
|
|
138
|
+
track(blurIn(visible(), { from: 'soft', stagger: .04 }));
|
|
139
|
+
return {
|
|
140
|
+
refresh, mutate, setFilter,
|
|
141
|
+
reorder(keys) { mutate(() => { const byKey = new Map(items().map((item) => [key(item), item])); for (const id of new Set(keys)) { const item = byKey.get(id); if (item) { root.append(item); byKey.delete(id); } } byKey.forEach((item) => root.append(item)); }); },
|
|
142
|
+
setOptions(next) { if (scope.disposed) return; const state = capture(); opts = { ...opts, ...next }; if (next.filter !== undefined) filter = next.filter; transition(state); },
|
|
143
|
+
get filter() { return filter; }, get keys() { return visible().map(key); },
|
|
144
|
+
destroy() {
|
|
145
|
+
if (scope.disposed) return;
|
|
146
|
+
cancelAnimationFrame(queued); animation?.kill(); micro.forEach((value) => value.kill()); if (!reduced()) Flip.killFlipsOf(items()); gsap.killTweensOf([...remembered]);
|
|
147
|
+
scope.dispose();
|
|
148
|
+
// Preserve externally added content while restoring the initial authored order.
|
|
149
|
+
const added = [...root.childNodes].filter((node) => !originals.includes(node) && !(node instanceof Element && originalItems.has(node)));
|
|
150
|
+
root.replaceChildren(...originals, ...added);
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useRef, type HTMLAttributes, type MutableRefObject } from 'react';
|
|
3
|
+
import { useIsomorphicLayoutEffect, useLatest } from '../../react/use-recipe';
|
|
4
|
+
import { createBentoGrid, type BentoGridOptions, type BentoGridInstance } from './engine';
|
|
5
|
+
|
|
6
|
+
export interface BentoGridProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'>, BentoGridOptions {
|
|
7
|
+
instanceRef?: MutableRefObject<BentoGridInstance | null>;
|
|
8
|
+
|
|
9
|
+
}
|
|
10
|
+
export function BentoGrid({ columns, gap, filter, duration, onChange, instanceRef, className, children, ...props }: BentoGridProps) {
|
|
11
|
+
const root = useRef<HTMLDivElement>(null);
|
|
12
|
+
const instance = useRef<BentoGridInstance | null>(null);
|
|
13
|
+
const onChangeRef = useLatest(onChange);
|
|
14
|
+
useIsomorphicLayoutEffect(() => {
|
|
15
|
+
const created = createBentoGrid(root.current, { columns, gap, filter, duration, onChange: (...args) => onChangeRef.current?.(...args) });
|
|
16
|
+
instance.current = created;
|
|
17
|
+
return () => { created.destroy(); instance.current = null; };
|
|
18
|
+
// Options update the existing engine so timers, selection and scroll state survive.
|
|
19
|
+
}, []);
|
|
20
|
+
useIsomorphicLayoutEffect(() => {
|
|
21
|
+
instance.current?.setOptions({ columns, gap, filter, duration });
|
|
22
|
+
}, [columns, gap, filter, duration]);
|
|
23
|
+
useIsomorphicLayoutEffect(() => { if (instanceRef) instanceRef.current = instance.current; return () => { if (instanceRef) instanceRef.current = null; }; }, [instanceRef]);
|
|
24
|
+
return <div {...props} ref={root} className={['mk-bento-grid', className].filter(Boolean).join(' ')}>{children}</div>;
|
|
25
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# code-block
|
|
2
2
|
|
|
3
|
-
Bloque de código con título y lenguaje opcionales, números de línea y botón copiar con confirmación. El código siempre se trata como texto: una cadena que parece HTML se muestra como código, nunca se interpreta.
|
|
3
|
+
Bloque de código con título y lenguaje opcionales, números de línea y botón copiar con confirmación. El código siempre se trata como texto: una cadena que parece HTML se muestra como código, nunca se interpreta. Las líneas largas se desplazan con la barra de [`scroll-area`](../scroll-area/README.md), clara sobre el editor; al arrastrarla toma el color de las palabras clave.
|
|
4
4
|
|
|
5
5
|
```html
|
|
6
6
|
<mk-code-block class="mk-code-block" title="Instalar" language="bash">
|
|
@@ -20,6 +20,11 @@ mk-code-block {
|
|
|
20
20
|
--mk-code-property: #8fd6ff;
|
|
21
21
|
--mk-code-punctuation: #9aa0aa;
|
|
22
22
|
--mk-code-operator: #c9ced6;
|
|
23
|
+
/* The scroll-area bar, light on the editor in both modes; dragged, it takes the keyword colour like the focus ring. */
|
|
24
|
+
--mk-scroll-thumb: rgb(255 255 255 / 0.2);
|
|
25
|
+
--mk-scroll-thumb-hover: rgb(255 255 255 / 0.36);
|
|
26
|
+
--mk-scroll-thumb-active: var(--mk-code-keyword);
|
|
27
|
+
--mk-scroll-rail: rgb(255 255 255 / 0.05);
|
|
23
28
|
position: relative;
|
|
24
29
|
display: block;
|
|
25
30
|
overflow: hidden;
|
|
@@ -3,6 +3,7 @@ import { reduced } from '../../core/reduced';
|
|
|
3
3
|
import { createScope } from '../../core/scope';
|
|
4
4
|
import { inert, type Instance } from '../../core/types';
|
|
5
5
|
import { press } from '../../motion/micro';
|
|
6
|
+
import { createScrollArea } from '../scroll-area/engine';
|
|
6
7
|
import { languageFamily, renderCode } from './highlight';
|
|
7
8
|
|
|
8
9
|
/*
|
|
@@ -11,6 +12,7 @@ import { languageFamily, renderCode } from './highlight';
|
|
|
11
12
|
* show as code. Copy writes the exact string with the Clipboard API and reports
|
|
12
13
|
* success or failure in a polite status; on failure the code is selected so it
|
|
13
14
|
* can be copied by hand. A copy that finishes after destroy changes nothing.
|
|
15
|
+
* Long lines scroll under the kit's scroll-area bar, light on the dark editor.
|
|
14
16
|
* Parts (made when absent): pre > code, [data-mk-code-copy], [data-mk-code-status].
|
|
15
17
|
*/
|
|
16
18
|
|
|
@@ -136,6 +138,9 @@ export function createCodeBlock(root: HTMLElement | null, options: CodeBlockOpti
|
|
|
136
138
|
if (options.language) scope.attr(root, 'data-mk-language', options.language);
|
|
137
139
|
if (options.lineNumbers) scope.attr(root, 'data-mk-line-numbers', '');
|
|
138
140
|
scope.attr(root, 'data-mk-ready', '');
|
|
141
|
+
// The <pre> is already the keyboard's way in; the bar follows it under the header.
|
|
142
|
+
const area = createScrollArea(root, { viewport: pre, focusable: false, decorative: true });
|
|
143
|
+
scope.add(() => area.destroy());
|
|
139
144
|
|
|
140
145
|
// A framework that already rendered the lines owns them; the engine only copies.
|
|
141
146
|
const prerendered = Boolean(code.querySelector('[data-mk-code-line]'));
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# command-search
|
|
2
2
|
|
|
3
|
-
Paleta de comandos en un `<dialog>` modal sobre vidrio oscuro. Se abre con ⌘K / Ctrl+K o con un botón `[data-mk-command-open]` y crece desde el botón que la abre (o desde el disparador visible con ⌘K/Ctrl+K) y se pliega de vuelta en él al cerrar: el color del botón se funde en el panel y la forma viaja 0,7 s; `open(origen)` elige otro control y `open(null)` la abre en su sitio. Al escribir filtra por coincidencia aproximada (letras en orden, sin tildes ni mayúsculas) y marca las letras con `<mark>` creado como texto. Los resultados se agrupan; una píldora se desliza bajo el activo con la curva swing.
|
|
3
|
+
Paleta de comandos en un `<dialog>` modal sobre vidrio oscuro. Se abre con ⌘K / Ctrl+K o con un botón `[data-mk-command-open]` y crece desde el botón que la abre (o desde el disparador visible con ⌘K/Ctrl+K) y se pliega de vuelta en él al cerrar: el color del botón se funde en el panel y la forma viaja 0,7 s; `open(origen)` elige otro control y `open(null)` la abre en su sitio. Al escribir filtra por coincidencia aproximada (letras en orden, sin tildes ni mayúsculas) y marca las letras con `<mark>` creado como texto. Los resultados se agrupan; una píldora se desliza bajo el activo con la curva swing. Los resultados largos se desplazan con la barra de [`scroll-area`](../scroll-area/README.md), clara sobre el vidrio oscuro; el foco sigue en el campo.
|
|
4
4
|
|
|
5
5
|
```html
|
|
6
6
|
<mk-command-search class="mk-command-search" items='[{"id":"charts","label":"Charts","group":"Páginas","href":"/chart"},{"id":"chart","label":"Chart","group":"Componentes","href":"/chart"}]'>
|
|
@@ -86,8 +86,18 @@ mk-command-search {
|
|
|
86
86
|
overflow-y: auto;
|
|
87
87
|
padding: 0 0.6rem 0.6rem;
|
|
88
88
|
overscroll-behavior: contain;
|
|
89
|
-
|
|
90
|
-
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/* The scroll-area bar, light on the dark glass in both modes; long results leave it a lane. */
|
|
92
|
+
[data-mk-command] [data-mk-command-panel] {
|
|
93
|
+
--mk-scroll-thumb: rgb(255 255 255 / 0.2);
|
|
94
|
+
--mk-scroll-thumb-hover: rgb(255 255 255 / 0.36);
|
|
95
|
+
--mk-scroll-thumb-active: rgb(255 255 255 / 0.62);
|
|
96
|
+
--mk-scroll-rail: rgb(255 255 255 / 0.05);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
[data-mk-command] [data-mk-command-panel][data-mk-overflow-y] [data-mk-command-scroll] {
|
|
100
|
+
padding-right: 14px;
|
|
91
101
|
}
|
|
92
102
|
|
|
93
103
|
[data-mk-command] [data-mk-command-pill] {
|
|
@@ -5,6 +5,7 @@ import { inert, type Instance } from '../../core/types';
|
|
|
5
5
|
import { blurIn } from '../../motion/micro';
|
|
6
6
|
import { morphClose, morphOpen, releaseShared, showOrigin } from '../../motion/morph';
|
|
7
7
|
import { fadeBackdrop, openModal } from '../../motion/surface';
|
|
8
|
+
import { createScrollArea } from '../scroll-area/engine';
|
|
8
9
|
import { matchScore } from './match';
|
|
9
10
|
|
|
10
11
|
/*
|
|
@@ -17,6 +18,7 @@ import { matchScore } from './match';
|
|
|
17
18
|
* under the active result on the swing curve; arrows, Home and End move it,
|
|
18
19
|
* Enter picks it (onSelect, mk:select; an href navigates), Escape closes and
|
|
19
20
|
* focus goes back. Combobox + listbox semantics with aria-activedescendant.
|
|
21
|
+
* Long results scroll under the kit's scroll-area bar, kept light on the glass.
|
|
20
22
|
*/
|
|
21
23
|
|
|
22
24
|
export interface CommandItem {
|
|
@@ -119,6 +121,9 @@ export function createCommandSearch(root: HTMLElement | null, options: CommandSe
|
|
|
119
121
|
root.append(dialog);
|
|
120
122
|
scope.add(() => dialog.remove());
|
|
121
123
|
scope.attr(root, 'data-mk-ready', '');
|
|
124
|
+
// The bar runs between the field and the footer; the input keeps the focus, so it is not a tab stop.
|
|
125
|
+
const area = createScrollArea(panel, { viewport: scroller, axis: 'y', focusable: false, decorative: true });
|
|
126
|
+
scope.add(() => area.destroy());
|
|
122
127
|
|
|
123
128
|
let isOpen = false;
|
|
124
129
|
let opener: HTMLElement | null = null;
|
|
@@ -255,6 +260,7 @@ export function createCommandSearch(root: HTMLElement | null, options: CommandSe
|
|
|
255
260
|
openModal(dialog);
|
|
256
261
|
// The rows grow with the panel: no entrance of their own on open.
|
|
257
262
|
render(false);
|
|
263
|
+
area.refresh();
|
|
258
264
|
input.focus();
|
|
259
265
|
void fadeBackdrop(dialog, 'in');
|
|
260
266
|
morphOpen(panel, from, { hideOrigin: true });
|