@yewdiel21/motion-kit 0.15.0 → 0.17.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 +25 -0
- package/dist/auto/index.d.ts +8 -3
- package/dist/auto/index.js +13 -3
- package/dist/auto/index.js.map +1 -1
- package/dist/{chunk-A6QQCF6V.js → chunk-7IQTMF7Y.js} +1542 -54
- package/dist/chunk-7IQTMF7Y.js.map +1 -0
- package/dist/{chunk-6IRJYXJU.js → chunk-BZLMCHUD.js} +2 -2
- package/dist/{chunk-JXRS3OEA.js → chunk-VJFPTLAK.js} +29 -7
- package/dist/chunk-VJFPTLAK.js.map +1 -0
- package/dist/css/components/chart.css +7 -0
- package/dist/css/components/drilldown-menu.css +130 -0
- package/dist/css/components/heatmap-chart.css +7 -0
- package/dist/css/components/pie-chart.css +7 -0
- package/dist/css/components/radar-chart.css +7 -0
- package/dist/css/components/radial-chart.css +16 -2
- package/dist/css/components/sankey-chart.css +7 -0
- package/dist/css/components/scroll-island.css +3 -0
- package/dist/css/components/signature-pad.css +104 -0
- package/dist/css/components/voice-message-bubble.css +50 -0
- package/dist/css/components/waveform-player.css +36 -0
- package/dist/css/components/wheel-picker.css +17 -0
- package/dist/elements/index.d.ts +90 -4
- package/dist/elements/index.js +290 -8
- package/dist/elements/index.js.map +1 -1
- package/dist/{reveal-BfyKKsw4.d.ts → engine-Bn6hC87X.d.ts} +206 -1
- package/dist/index.css +324 -2
- package/dist/index.d.ts +3 -3
- package/dist/index.js +13 -3
- package/dist/index.js.map +1 -1
- package/dist/react/index.d.ts +48 -10
- package/dist/react/index.js +240 -10
- package/dist/react/index.js.map +1 -1
- package/dist/{reduced-6X_bns60.d.ts → reduced-YS6TZjpa.d.ts} +1 -1
- package/dist/{store-CA2Qcun_.d.ts → store-CZ6vdtIE.d.ts} +5 -1
- package/package.json +110 -110
- package/registry.json +64 -0
- package/src/auto/auto-init.ts +308 -293
- package/src/components/add-to-cart/README.md +49 -34
- package/src/components/add-to-cart/react.tsx +4 -0
- package/src/components/chart/README.md +49 -34
- package/src/components/chart/chart.css +7 -0
- package/src/components/chart/element.ts +2 -1
- package/src/components/chart/engine.ts +105 -13
- package/src/components/chart/react.tsx +2 -2
- package/src/components/drilldown-menu/README.md +163 -0
- package/src/components/drilldown-menu/drilldown-menu.css +129 -0
- package/src/components/drilldown-menu/element.ts +59 -0
- package/src/components/drilldown-menu/engine.ts +338 -0
- package/src/components/drilldown-menu/react.tsx +76 -0
- package/src/components/radial-chart/README.md +10 -0
- package/src/components/radial-chart/element.ts +2 -2
- package/src/components/radial-chart/engine.ts +33 -6
- package/src/components/radial-chart/radial-chart.css +9 -2
- package/src/components/radial-chart/react.tsx +2 -2
- package/src/components/scroll-island/README.md +44 -34
- package/src/components/scroll-island/element.ts +1 -1
- package/src/components/scroll-island/engine.ts +25 -3
- package/src/components/scroll-island/react.tsx +3 -3
- package/src/components/scroll-island/scroll-island.css +3 -0
- package/src/components/signature-pad/README.md +97 -0
- package/src/components/signature-pad/element.ts +57 -0
- package/src/components/signature-pad/engine.ts +437 -0
- package/src/components/signature-pad/react.tsx +56 -0
- package/src/components/signature-pad/signature-pad.css +103 -0
- package/src/components/voice-message-bubble/README.md +31 -0
- package/src/components/voice-message-bubble/element.ts +20 -0
- package/src/components/voice-message-bubble/engine.ts +68 -0
- package/src/components/voice-message-bubble/react.tsx +27 -0
- package/src/components/voice-message-bubble/voice-message-bubble.css +12 -0
- package/src/components/waveform-player/README.md +30 -0
- package/src/components/waveform-player/element.ts +21 -0
- package/src/components/waveform-player/engine.ts +261 -0
- package/src/components/waveform-player/react.tsx +27 -0
- package/src/components/waveform-player/waveform-player.css +35 -0
- package/src/components/wheel-picker/README.md +33 -0
- package/src/components/wheel-picker/element.ts +18 -0
- package/src/components/wheel-picker/engine.ts +210 -0
- package/src/components/wheel-picker/react.tsx +32 -0
- package/src/components/wheel-picker/wheel-picker.css +16 -0
- package/src/elements/index.ts +273 -258
- package/src/index.ts +295 -290
- package/src/react/index.ts +176 -171
- package/dist/chunk-A6QQCF6V.js.map +0 -1
- package/dist/chunk-JXRS3OEA.js.map +0 -1
- /package/dist/{chunk-6IRJYXJU.js.map → chunk-BZLMCHUD.js.map} +0 -0
|
@@ -1,34 +1,49 @@
|
|
|
1
|
-
# add-to-cart
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
<
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
1
|
+
# add-to-cart
|
|
2
|
+
|
|
3
|
+
El producto se empaca en una caja y vuela hasta un carrito; al llegar, el contador cambia y el carrito responde con un pulso. Cada clic tiene su propio vuelo. Con movimiento reducido, el contador se actualiza al instante.
|
|
4
|
+
|
|
5
|
+
## React
|
|
6
|
+
|
|
7
|
+
```tsx
|
|
8
|
+
import '@yewdiel21/motion-kit/css';
|
|
9
|
+
import { AddToCart } from '@yewdiel21/motion-kit/react';
|
|
10
|
+
|
|
11
|
+
<div id="my-cart">Carrito <span data-mk-cart-count>0</span></div>
|
|
12
|
+
<article data-mk-cart-item>
|
|
13
|
+
<img src="/product.webp" alt="Producto" data-mk-cart-image />
|
|
14
|
+
<AddToCart target="#my-cart" onLand={count => console.log(count)}>
|
|
15
|
+
<button type="button">Agregar al carrito</button>
|
|
16
|
+
</AddToCart>
|
|
17
|
+
</article>
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
**El botón es obligatorio.** Para ver el recorrido necesitas un destino visible y una imagen. Usa un destino único por demo o carrito para no mezclar instancias. El componente anima la interfaz; la aplicación gestiona el carrito y el inventario reales.
|
|
21
|
+
|
|
22
|
+
## HTML / Astro
|
|
23
|
+
|
|
24
|
+
```html
|
|
25
|
+
<div id="my-cart">Carrito <span data-mk-cart-count>0</span></div>
|
|
26
|
+
<article data-mk-cart-item>
|
|
27
|
+
<img src="/product.webp" alt="Producto" data-mk-cart-image>
|
|
28
|
+
<mk-add-to-cart target="#my-cart"><button type="button">Agregar</button></mk-add-to-cart>
|
|
29
|
+
</article>
|
|
30
|
+
<script>
|
|
31
|
+
import { defineElements } from '@yewdiel21/motion-kit/elements';
|
|
32
|
+
defineElements(['mk-add-to-cart']);
|
|
33
|
+
</script>
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
En vanilla puedes llamar a `createAddToCart(root, options)`. `destroy()` limpia los vuelos y listeners.
|
|
37
|
+
|
|
38
|
+
## Opciones y eventos
|
|
39
|
+
|
|
40
|
+
- `target`: HTMLElement o selector del carrito; por defecto busca `[data-mk-cart-target]` visible.
|
|
41
|
+
- `image`: HTMLElement o selector; por defecto `[data-mk-cart-image]` dentro del producto.
|
|
42
|
+
- `quantity`: unidades por clic, 1 por defecto.
|
|
43
|
+
- `count`: contador inicial; en React también sincroniza cambios posteriores.
|
|
44
|
+
- `label`: anuncio al llegar, por defecto “Añadido al carrito”.
|
|
45
|
+
- `onAdd(count)`: al pulsar; `onLand(count)`: al terminar el vuelo.
|
|
46
|
+
- Eventos DOM: `mk:add` y `mk:land`, con `detail.count`.
|
|
47
|
+
- Instancia: `add()`, `setCount(count)`, `count`, `destroy()`; React expone `instanceRef`.
|
|
48
|
+
|
|
49
|
+
El estado final se anuncia con `role="status"`. Usa un botón nativo con nombre accesible. Los vuelos son decorativos, no capturan el puntero y se eliminan al desmontar.
|
|
@@ -12,15 +12,19 @@ export function AddToCart({ target, image, quantity, count, label, onAdd, onLand
|
|
|
12
12
|
const ref = useRef<HTMLDivElement>(null);
|
|
13
13
|
const add = useLatest(onAdd);
|
|
14
14
|
const landed = useLatest(onLand);
|
|
15
|
+
const instance = useRef<AddToCartInstance | null>(null);
|
|
15
16
|
useIsomorphicLayoutEffect(() => {
|
|
16
17
|
const created = createAddToCart(ref.current, { target, image, quantity, count, label, onAdd: (n) => add.current?.(n), onLand: (n) => landed.current?.(n) });
|
|
18
|
+
instance.current = created;
|
|
17
19
|
if (instanceRef) instanceRef.current = created;
|
|
18
20
|
return () => {
|
|
19
21
|
created.destroy();
|
|
22
|
+
instance.current = null;
|
|
20
23
|
if (instanceRef) instanceRef.current = null;
|
|
21
24
|
};
|
|
22
25
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
23
26
|
}, [typeof target === 'string' ? target : null, typeof image === 'string' ? image : null, quantity, label]);
|
|
27
|
+
useIsomorphicLayoutEffect(() => { if (count !== undefined) instance.current?.setCount(count); }, [count]);
|
|
24
28
|
return (
|
|
25
29
|
<div {...props} ref={ref} className={['mk-add-to-cart', className].filter(Boolean).join(' ')}>
|
|
26
30
|
{children}
|
|
@@ -1,34 +1,49 @@
|
|
|
1
|
-
# chart
|
|
2
|
-
|
|
3
|
-
Desde 0.15 también puedes mezclar series con `type="composed"`. Cada serie declara `type: 'line' | 'bar' | 'area'`. Un área acepta `lowerValues` para rellenar el intervalo entre la frontera inferior y `values`; los huecos `null` interrumpen el relleno. La tabla y el tooltip muestran ambos límites.
|
|
4
|
-
|
|
5
|
-
```tsx
|
|
6
|
-
<Chart type="composed" labels={['Lun', 'Mar', 'Mié']} series={[
|
|
7
|
-
{ name: 'Ventas', type: 'bar', values: [20, 32, 28] },
|
|
8
|
-
{ name: 'Meta', type: 'line', values: [25, 30, 35] },
|
|
9
|
-
{ name: 'Rango', type: 'area', values: [24, 36, 32], lowerValues: [16, 26, 22] },
|
|
10
|
-
]} axis endLabel={false} />
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
Gráfica SVG reutilizable de línea, área y barras (agrupadas o apiladas) sin motor canvas ni dependencias. Sigue las gráficas de métricas de Melon Mind: base en cero, línea de 2 px, área que se desvanece de .25 a 0, barras de hasta 24 px con extremo redondeado de 4 px y huecos de 2 px, rejilla fina y tooltip con superficie invertida.
|
|
14
|
-
|
|
15
|
-
```html
|
|
16
|
-
<mk-chart class="mk-chart" type="bar" stacked label="Sesiones por semana"
|
|
17
|
-
labels="S1,S2,S3,S4"
|
|
18
|
-
series='[{"name":"Ana","values":[4,6,5,7]},{"name":"Luis","values":[2,3,4,1]}]'></mk-chart>
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
```ts
|
|
22
|
-
const chart = createChart(root, { type: 'area', labels: days, series: [{ name: 'Ingresos', values }], formatValue: money, label: 'Ingresos de octubre' });
|
|
23
|
-
chart.update({ series: [{ name: 'Ingresos', values: next }] });
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
React: `<Chart type="line" labels={days} series={series} formatValue={money} label="Ingresos" onSelect={setDay} />`.
|
|
27
|
-
|
|
28
|
-
Datos: `labels` + `series: ChartSeries[]` (`{name, values: (number | null)[], color?, dash?}`; `dash` es un patrón SVG para líneas, p. ej. `'5 5'` para un ritmo o `'1 6'` para una proyección) o `points: ChartPoint[]` (`{label, value}`) para una serie. `null` es un hueco sin dato: la línea se corta y un punto aislado se muestra con marcador. Opciones: `type='line'|'area'|'bar'`, `stacked` (barras), `formatValue(n)` (ejes, tooltip y tabla), `label` (nombre accesible y título de la tabla), `height=240`, `emptyLabel`, `legend` (por defecto con dos o más series), `axis` (valores y líneas guía en el eje y; por defecto sólo la línea base), `endLabel` (líneas: el último valor de la primera serie en una píldora al final; por defecto `true`), `tooltip` (por defecto `true`; con `false` la gráfica deja la guía y los marcadores y la tarjeta la pone `chart-tooltip`), `onSelect(index | null)`. API: `update(options)` (combina y redibuja), `select(index | null)`, `selected`, `destroy()`. Evento `mk:select` con `{index, label, values, x, y, series}`: `x`/`y` son el punto señalado en píxeles del área de la gráfica y `series` trae `{name, color, value, previous}` por serie.
|
|
29
|
-
|
|
30
|
-
Puntero y toque eligen el punto más cercano (el toque mantiene la selección; el ratón la quita al salir). Con foco en la gráfica, flechas/Inicio/Fin recorren los valores y Escape los oculta; un estado educado anuncia el valor elegido. La gráfica tiene un resumen como nombre accesible y una tabla para lectores de pantalla con todos los valores (y el total en barras apiladas). Sin datos muestra `emptyLabel`.
|
|
31
|
-
|
|
32
|
-
Movimiento: el primer dibujo traza la línea de izquierda a derecha (barras: crecen desde la base) y cada `update` desliza los valores nuevos desde los anteriores; el tooltip aparece con un pop de cristal y se desliza entre puntos. Con movimiento reducido todo cambia sin transición.
|
|
33
|
-
|
|
34
|
-
Colores: una sola serie es monocroma (`--mk-chart-mono`, el color del texto, al estilo de los indicadores de Melon Mind); con varias, cada serie usa su `color` o la ranura `--mk-chart-N` en orden fijo (paleta comprobada para daltonismo, con pasos propios en oscuro); a partir de la novena serie todas comparten `--mk-chart-other`: agrúpalas en «Otros». Los textos usan los colores de texto del tema; el color identifica la marca. Se redimensiona con su contenedor (ResizeObserver) y `destroy()` quita todo lo creado. Variables: `--mk-chart-mono`, `--mk-chart-1…8`, `--mk-chart-other`, `--mk-chart-surface` (anillo de marcadores), `--mk-chart-grid`.
|
|
1
|
+
# chart
|
|
2
|
+
|
|
3
|
+
Desde 0.15 también puedes mezclar series con `type="composed"`. Cada serie declara `type: 'line' | 'bar' | 'area'`. Un área acepta `lowerValues` para rellenar el intervalo entre la frontera inferior y `values`; los huecos `null` interrumpen el relleno. La tabla y el tooltip muestran ambos límites.
|
|
4
|
+
|
|
5
|
+
```tsx
|
|
6
|
+
<Chart type="composed" labels={['Lun', 'Mar', 'Mié']} series={[
|
|
7
|
+
{ name: 'Ventas', type: 'bar', values: [20, 32, 28] },
|
|
8
|
+
{ name: 'Meta', type: 'line', values: [25, 30, 35] },
|
|
9
|
+
{ name: 'Rango', type: 'area', values: [24, 36, 32], lowerValues: [16, 26, 22] },
|
|
10
|
+
]} axis endLabel={false} />
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Gráfica SVG reutilizable de línea, área y barras (agrupadas o apiladas) sin motor canvas ni dependencias. Sigue las gráficas de métricas de Melon Mind: base en cero, línea de 2 px, área que se desvanece de .25 a 0, barras de hasta 24 px con extremo redondeado de 4 px y huecos de 2 px, rejilla fina y tooltip con superficie invertida.
|
|
14
|
+
|
|
15
|
+
```html
|
|
16
|
+
<mk-chart class="mk-chart" type="bar" stacked label="Sesiones por semana"
|
|
17
|
+
labels="S1,S2,S3,S4"
|
|
18
|
+
series='[{"name":"Ana","values":[4,6,5,7]},{"name":"Luis","values":[2,3,4,1]}]'></mk-chart>
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
const chart = createChart(root, { type: 'area', labels: days, series: [{ name: 'Ingresos', values }], formatValue: money, label: 'Ingresos de octubre' });
|
|
23
|
+
chart.update({ series: [{ name: 'Ingresos', values: next }] });
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
React: `<Chart type="line" labels={days} series={series} formatValue={money} label="Ingresos" onSelect={setDay} />`.
|
|
27
|
+
|
|
28
|
+
Datos: `labels` + `series: ChartSeries[]` (`{name, values: (number | null)[], color?, dash?}`; `dash` es un patrón SVG para líneas, p. ej. `'5 5'` para un ritmo o `'1 6'` para una proyección) o `points: ChartPoint[]` (`{label, value}`) para una serie. `null` es un hueco sin dato: la línea se corta y un punto aislado se muestra con marcador. Opciones: `type='line'|'area'|'bar'`, `stacked` (barras), `formatValue(n)` (ejes, tooltip y tabla), `label` (nombre accesible y título de la tabla), `height=240`, `emptyLabel`, `legend` (por defecto con dos o más series), `axis` (valores y líneas guía en el eje y; por defecto sólo la línea base), `endLabel` (líneas: el último valor de la primera serie en una píldora al final; por defecto `true`), `tooltip` (por defecto `true`; con `false` la gráfica deja la guía y los marcadores y la tarjeta la pone `chart-tooltip`), `onSelect(index | null)`. API: `update(options)` (combina y redibuja), `select(index | null)`, `selected`, `destroy()`. Evento `mk:select` con `{index, label, values, x, y, series}`: `x`/`y` son el punto señalado en píxeles del área de la gráfica y `series` trae `{name, color, value, previous}` por serie.
|
|
29
|
+
|
|
30
|
+
Puntero y toque eligen el punto más cercano (el toque mantiene la selección; el ratón la quita al salir). Con foco en la gráfica, flechas/Inicio/Fin recorren los valores y Escape los oculta; un estado educado anuncia el valor elegido. La gráfica tiene un resumen como nombre accesible y una tabla para lectores de pantalla con todos los valores (y el total en barras apiladas). Sin datos muestra `emptyLabel`.
|
|
31
|
+
|
|
32
|
+
Movimiento: el primer dibujo traza la línea de izquierda a derecha (barras: crecen desde la base) y cada `update` desliza los valores nuevos desde los anteriores; el tooltip aparece con un pop de cristal y se desliza entre puntos. Con movimiento reducido todo cambia sin transición.
|
|
33
|
+
|
|
34
|
+
Colores: una sola serie es monocroma (`--mk-chart-mono`, el color del texto, al estilo de los indicadores de Melon Mind); con varias, cada serie usa su `color` o la ranura `--mk-chart-N` en orden fijo (paleta comprobada para daltonismo, con pasos propios en oscuro); a partir de la novena serie todas comparten `--mk-chart-other`: agrúpalas en «Otros». Los textos usan los colores de texto del tema; el color identifica la marca. Se redimensiona con su contenedor (ResizeObserver) y `destroy()` quita todo lo creado. Variables: `--mk-chart-mono`, `--mk-chart-1…8`, `--mk-chart-other`, `--mk-chart-surface` (anillo de marcadores), `--mk-chart-grid`.
|
|
35
|
+
|
|
36
|
+
## Variantes visuales · 0.16
|
|
37
|
+
|
|
38
|
+
```tsx
|
|
39
|
+
<Chart type="bar" barStyle="pill" barTrack barFill="spotlight" barWidth={40}
|
|
40
|
+
labels={['Lun', 'Mar', 'Mié']} series={[{ name: 'Enfoque', values: [32, 56, 44] }]} />
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
- `barStyle`: `rounded` (compatibilidad) o `pill` (ambos extremos redondos). Las pilas comparten un recorte exterior por columna.
|
|
44
|
+
- `barTrack`: fondo de capacidad, false por defecto.
|
|
45
|
+
- `barFill`: `solid`, `gradient` o `spotlight` (selección con degradado sobre columnas rayadas). Sin selección, spotlight destaca la última columna.
|
|
46
|
+
- `barWidth`: ancho máximo, 24 px por defecto; se adapta al espacio disponible.
|
|
47
|
+
- `curve`: `linear` o `smooth`. La interpolación suave conserva los extremos reales y también funciona en áreas por rangos.
|
|
48
|
+
|
|
49
|
+
La selección de una píldora muestra un valor flotante. Pointer, tacto y teclado usan la misma selección. La entrada se escalona por columnas y los cambios de datos continúan desde los valores dibujados, también al interrumpir una transición.
|
|
@@ -15,6 +15,7 @@ mk-chart {
|
|
|
15
15
|
--mk-chart-other: var(--mk-muted);
|
|
16
16
|
--mk-chart-surface: var(--mk-surface-1);
|
|
17
17
|
--mk-chart-grid: color-mix(in srgb, var(--mk-text) 14%, transparent);
|
|
18
|
+
--mk-chart-track: color-mix(in srgb, var(--mk-text) 5%, transparent);
|
|
18
19
|
position: relative;
|
|
19
20
|
display: grid;
|
|
20
21
|
gap: 0.75rem;
|
|
@@ -120,6 +121,12 @@ mk-chart {
|
|
|
120
121
|
fill: color-mix(in srgb, var(--mk-text) 6%, transparent);
|
|
121
122
|
}
|
|
122
123
|
|
|
124
|
+
:is(.mk-chart, mk-chart) [data-mk-part='bar-track'] { fill: var(--mk-chart-track); }
|
|
125
|
+
:is(.mk-chart, mk-chart) [data-mk-part='bar'] { transition: opacity .22s ease, filter .22s ease; }
|
|
126
|
+
:is(.mk-chart, mk-chart) [data-mk-part='bar'][data-mk-active] { filter: brightness(1.12); }
|
|
127
|
+
:is(.mk-chart, mk-chart) [data-mk-part='bar-value'] rect { fill: var(--mk-text); }
|
|
128
|
+
:is(.mk-chart, mk-chart) [data-mk-part='bar-value'] text { fill: var(--mk-bg); font-size: 11px; font-weight: 600; }
|
|
129
|
+
|
|
123
130
|
:is(.mk-chart, mk-chart) [data-mk-part='series'] path[data-mk-index] {
|
|
124
131
|
transition: opacity 0.2s ease;
|
|
125
132
|
}
|
|
@@ -13,6 +13,7 @@ export class MkChartElement extends MkElement<ChartInstance> {
|
|
|
13
13
|
series: 'json',
|
|
14
14
|
points: 'json',
|
|
15
15
|
stacked: 'boolean',
|
|
16
|
+
barStyle: 'string', barTrack: 'boolean', barFill: 'string', barWidth: 'number', curve: 'string',
|
|
16
17
|
label: 'string',
|
|
17
18
|
height: 'number',
|
|
18
19
|
emptyLabel: 'string',
|
|
@@ -28,7 +29,7 @@ export class MkChartElement extends MkElement<ChartInstance> {
|
|
|
28
29
|
|
|
29
30
|
attributeChangedCallback(_name: string, previous: string | null, next: string | null) {
|
|
30
31
|
if (previous === next || !this.instance) return;
|
|
31
|
-
this.instance.update({ stacked: false, legend: undefined, ...(this.options as ChartOptions) });
|
|
32
|
+
this.instance.update({ stacked: false, barTrack: false, barStyle: undefined, barFill: undefined, barWidth: undefined, curve: undefined, legend: undefined, ...(this.options as ChartOptions) });
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
update(options: ChartOptions) {
|
|
@@ -47,6 +47,16 @@ export interface ChartOptions {
|
|
|
47
47
|
points?: ChartPoint[];
|
|
48
48
|
/** Bars only: stack the series. */
|
|
49
49
|
stacked?: boolean;
|
|
50
|
+
/** Rounded ends (default) or a full capsule, including stacked columns. */
|
|
51
|
+
barStyle?: 'rounded' | 'pill';
|
|
52
|
+
/** Show the available capacity behind each column. */
|
|
53
|
+
barTrack?: boolean;
|
|
54
|
+
/** Solid (default), gradient, or a gradient selection over muted striped columns. */
|
|
55
|
+
barFill?: 'solid' | 'gradient' | 'spotlight';
|
|
56
|
+
/** Maximum column width in px (default 24; pill demos use 40). */
|
|
57
|
+
barWidth?: number;
|
|
58
|
+
/** Smooth monotone curves preserve the data's extrema (default linear). */
|
|
59
|
+
curve?: 'linear' | 'smooth';
|
|
50
60
|
formatValue?: (value: number) => string;
|
|
51
61
|
/** Accessible name (and table caption). */
|
|
52
62
|
label?: string;
|
|
@@ -102,6 +112,25 @@ function barPath(x: number, width: number, base: number, end: number, rounded: b
|
|
|
102
112
|
return `M${round(x)},${round(base)}V${round(end + dir * r)}Q${round(x)},${round(end)} ${round(x + r)},${round(end)}H${round(x + width - r)}Q${round(x + width)},${round(end)} ${round(x + width)},${round(end + dir * r)}V${round(base)}Z`;
|
|
103
113
|
}
|
|
104
114
|
|
|
115
|
+
function pillPath(x: number, width: number, a: number, b: number) {
|
|
116
|
+
const top = Math.min(a, b), bottom = Math.max(a, b);
|
|
117
|
+
const r = Math.min(width / 2, (bottom - top) / 2);
|
|
118
|
+
return `M${round(x + r)},${round(top)}H${round(x + width - r)}Q${round(x + width)},${round(top)} ${round(x + width)},${round(top + r)}V${round(bottom - r)}Q${round(x + width)},${round(bottom)} ${round(x + width - r)},${round(bottom)}H${round(x + r)}Q${round(x)},${round(bottom)} ${round(x)},${round(bottom - r)}V${round(top + r)}Q${round(x)},${round(top)} ${round(x + r)},${round(top)}Z`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Cubic Hermite interpolation with bounded tangents: no invented peaks between samples. */
|
|
122
|
+
function linePath(points: number[][], smooth: boolean) {
|
|
123
|
+
if (!points.length) return '';
|
|
124
|
+
let path = `M${round(points[0][0])},${round(points[0][1])}`;
|
|
125
|
+
const slopes = points.slice(1).map((point, i) => (point[1] - points[i][1]) / (point[0] - points[i][0] || 1));
|
|
126
|
+
const tangents = points.map((_, i) => i === 0 ? slopes[0] : i === points.length - 1 ? slopes[i - 1] : slopes[i - 1] * slopes[i] <= 0 ? 0 : 2 * slopes[i - 1] * slopes[i] / (slopes[i - 1] + slopes[i]));
|
|
127
|
+
for (let i = 1; i < points.length; i++) {
|
|
128
|
+
const [x0, y0] = points[i - 1], [x, y] = points[i], dx = (x - x0) / 3;
|
|
129
|
+
path += smooth ? `C${round(x0 + dx)},${round(y0 + tangents[i - 1] * dx)} ${round(x - dx)},${round(y - tangents[i] * dx)} ${round(x)},${round(y)}` : `L${round(x)},${round(y)}`;
|
|
130
|
+
}
|
|
131
|
+
return path;
|
|
132
|
+
}
|
|
133
|
+
|
|
105
134
|
interface Model {
|
|
106
135
|
labels: string[];
|
|
107
136
|
series: Array<ChartSeries & { color: string }>;
|
|
@@ -357,6 +386,7 @@ export function createChart(root: HTMLElement | null, initial: ChartOptions = {}
|
|
|
357
386
|
const slot = plotW / n;
|
|
358
387
|
const xs = model.labels.map((_, i) => (bar ? left + slot * (i + 0.5) : n === 1 ? left + plotW / 2 : left + (plotW * i) / (n - 1)));
|
|
359
388
|
geometry = { width, height, top, bottom: top + plotH, left, right: width - right, xs, y, base };
|
|
389
|
+
shown = { values, lower, lo, hi, type: type(), stacked: stacked() };
|
|
360
390
|
|
|
361
391
|
// Baseline (and, with `axis`, hairlines with their values).
|
|
362
392
|
const grid = svg('g', { 'data-mk-part': 'grid' });
|
|
@@ -401,10 +431,46 @@ export function createChart(root: HTMLElement | null, initial: ChartOptions = {}
|
|
|
401
431
|
if (bar) {
|
|
402
432
|
const barIndices = model.series.flatMap((_, index) => seriesType(index) === 'bar' ? [index] : []);
|
|
403
433
|
const groups = stacked() ? 1 : barIndices.length;
|
|
404
|
-
const
|
|
434
|
+
const maxWidth = finite(options.barWidth) ? Math.max(2, Math.min(96, options.barWidth)) : BAR_MAX;
|
|
435
|
+
const barWidth = Math.max(2, Math.min(maxWidth, (slot * 0.62 - GAP * (groups - 1)) / groups));
|
|
436
|
+
const pill = options.barStyle === 'pill';
|
|
437
|
+
const trackLayer = svg('g', { 'data-mk-part': 'bar-tracks' });
|
|
438
|
+
canvas.append(trackLayer);
|
|
439
|
+
if (options.barFill && options.barFill !== 'solid') {
|
|
440
|
+
model.series.forEach((series, k) => {
|
|
441
|
+
const gradient = svg('linearGradient', { id: `${uid}-bar-${k}`, x1: 0, y1: 0, x2: 0, y2: 1 });
|
|
442
|
+
for (const [offset, opacity] of [['0', '1'], ['1', '0.4']]) {
|
|
443
|
+
const stop = svg('stop', { offset }); stop.style.stopColor = series.color; stop.style.stopOpacity = opacity; gradient.append(stop);
|
|
444
|
+
}
|
|
445
|
+
defs.append(gradient);
|
|
446
|
+
});
|
|
447
|
+
const hatch = svg('pattern', { id: `${uid}-hatch`, patternUnits: 'userSpaceOnUse', width: 6, height: 6, patternTransform: 'rotate(35)' });
|
|
448
|
+
const ground = svg('rect', { width: 6, height: 6 }); ground.style.fill = 'var(--mk-chart-track)';
|
|
449
|
+
const stripe = svg('line', { x1: 0, x2: 0, y1: 0, y2: 6 }); stripe.style.stroke = 'var(--mk-chart-grid)'; stripe.style.strokeWidth = '2';
|
|
450
|
+
hatch.append(ground, stripe); defs.append(hatch);
|
|
451
|
+
}
|
|
452
|
+
const fillBar = (path: SVGPathElement, k: number, index: number) => {
|
|
453
|
+
const fill = options.barFill && options.barFill !== 'solid' ? `url(#${uid}-bar-${k})` : model.series[k]!.color;
|
|
454
|
+
path.style.setProperty('--mk-bar-fill', fill);
|
|
455
|
+
path.style.fill = options.barFill === 'spotlight' && index !== (selected ?? n - 1) ? `url(#${uid}-hatch)` : fill;
|
|
456
|
+
};
|
|
457
|
+
const addTrack = (x: number) => {
|
|
458
|
+
if (!options.barTrack) return;
|
|
459
|
+
const path = svg('path', { d: pillPath(x, barWidth, top, base), 'data-mk-part': 'bar-track' });
|
|
460
|
+
trackLayer.append(path);
|
|
461
|
+
};
|
|
405
462
|
const totalsLayer = svg('g', { 'data-mk-part': 'totals' });
|
|
406
463
|
model.labels.forEach((_, i) => {
|
|
407
464
|
if (stacked()) {
|
|
465
|
+
const clipId = `${uid}-stack-${i}`;
|
|
466
|
+
if (pill) {
|
|
467
|
+
const sum = totalsOf(values, i);
|
|
468
|
+
const clip = svg('clipPath', { id: clipId });
|
|
469
|
+
if (sum.up) clip.append(svg('path', { d: pillPath(xs[i]! - barWidth / 2, barWidth, base, y(sum.up)) }));
|
|
470
|
+
if (sum.down) clip.append(svg('path', { d: pillPath(xs[i]! - barWidth / 2, barWidth, base, y(sum.down)) }));
|
|
471
|
+
defs.append(clip);
|
|
472
|
+
}
|
|
473
|
+
addTrack(xs[i]! - barWidth / 2);
|
|
408
474
|
let up = 0;
|
|
409
475
|
let down = 0;
|
|
410
476
|
const outer = { up: -1, down: -1 };
|
|
@@ -424,8 +490,9 @@ export function createChart(root: HTMLElement | null, initial: ChartOptions = {}
|
|
|
424
490
|
const start = y(from) + (from !== 0 ? (value > 0 ? -GAP : GAP) : 0);
|
|
425
491
|
const end = y(to);
|
|
426
492
|
if ((value > 0 && end >= start) || (value < 0 && end <= start)) return;
|
|
427
|
-
const path = svg('path', { d: barPath(xs[i]! - barWidth / 2, barWidth, start, end, k === (value > 0 ? outer.up : outer.down)), 'data-mk-index': i, 'data-mk-series': k });
|
|
428
|
-
path.
|
|
493
|
+
const path = svg('path', { d: barPath(xs[i]! - barWidth / 2, barWidth, start, end, !pill && k === (value > 0 ? outer.up : outer.down)), 'data-mk-part': 'bar', 'data-mk-index': i, 'data-mk-series': k });
|
|
494
|
+
if (pill) path.setAttribute('clip-path', `url(#${clipId})`);
|
|
495
|
+
fillBar(path, k, i);
|
|
429
496
|
marks.append(path);
|
|
430
497
|
});
|
|
431
498
|
// The stack total above it, when the columns have room.
|
|
@@ -440,10 +507,11 @@ export function createChart(root: HTMLElement | null, initial: ChartOptions = {}
|
|
|
440
507
|
values.forEach((list, k) => {
|
|
441
508
|
if (seriesType(k) !== 'bar') return;
|
|
442
509
|
const value = list[i];
|
|
443
|
-
if (!finite(value) || value === 0) return;
|
|
444
510
|
const x = xs[i]! - groupWidth / 2 + barIndices.indexOf(k) * (barWidth + GAP);
|
|
445
|
-
|
|
446
|
-
|
|
511
|
+
addTrack(x);
|
|
512
|
+
if (!finite(value) || value === 0) return;
|
|
513
|
+
const path = svg('path', { d: pill ? pillPath(x, barWidth, base, y(value)) : barPath(x, barWidth, base, y(value), true), 'data-mk-part': 'bar', 'data-mk-index': i, 'data-mk-series': k });
|
|
514
|
+
fillBar(path, k, i);
|
|
447
515
|
marks.append(path);
|
|
448
516
|
});
|
|
449
517
|
}
|
|
@@ -477,7 +545,9 @@ export function createChart(root: HTMLElement | null, initial: ChartOptions = {}
|
|
|
477
545
|
defs.append(gradient);
|
|
478
546
|
for (const points of runs) {
|
|
479
547
|
if (points.length < 2) continue;
|
|
480
|
-
const
|
|
548
|
+
const upper = linePath(points, options.curve === 'smooth');
|
|
549
|
+
const bottom = linePath([...points].reverse().map(([px, , py]) => [px, py]), options.curve === 'smooth');
|
|
550
|
+
const d = `${upper}${bottom.replace(/^M/, 'L')}Z`;
|
|
481
551
|
const area = svg('path', { d, 'data-mk-part': 'area', 'data-mk-series': k });
|
|
482
552
|
area.style.fill = `url(#${uid}-fill-${k})`;
|
|
483
553
|
marks.append(area);
|
|
@@ -491,7 +561,7 @@ export function createChart(root: HTMLElement | null, initial: ChartOptions = {}
|
|
|
491
561
|
marks.append(dot);
|
|
492
562
|
continue;
|
|
493
563
|
}
|
|
494
|
-
const line = svg('path', { d:
|
|
564
|
+
const line = svg('path', { d: linePath(points, options.curve === 'smooth'), 'data-mk-part': 'line' });
|
|
495
565
|
line.style.stroke = series.color;
|
|
496
566
|
// Dotted patterns (a first dash of 1 or less) draw round dots; dashes stay thin.
|
|
497
567
|
const dotted = Boolean(series.dash) && Number(series.dash!.trim().split(/[\s,]+/)[0]) <= 1;
|
|
@@ -531,6 +601,11 @@ export function createChart(root: HTMLElement | null, initial: ChartOptions = {}
|
|
|
531
601
|
cursor.replaceChildren();
|
|
532
602
|
if (band) band.style.display = 'none';
|
|
533
603
|
for (const mark of canvas.querySelectorAll('[data-mk-index]')) mark.removeAttribute('data-mk-dim');
|
|
604
|
+
for (const mark of canvas.querySelectorAll<SVGPathElement>('[data-mk-part="bar"]')) {
|
|
605
|
+
const index = Number(mark.dataset.mkIndex);
|
|
606
|
+
mark.toggleAttribute('data-mk-active', index === selected);
|
|
607
|
+
if (options.barFill === 'spotlight') mark.style.fill = index === (selected ?? model.labels.length - 1) ? mark.style.getPropertyValue('--mk-bar-fill') : `url(#${uid}-hatch)`;
|
|
608
|
+
}
|
|
534
609
|
if (selected === null || !hasData()) {
|
|
535
610
|
if (!tooltip.hidden && !reduced()) {
|
|
536
611
|
gsap.killTweensOf(tooltip, 'opacity,scale');
|
|
@@ -555,6 +630,20 @@ export function createChart(root: HTMLElement | null, initial: ChartOptions = {}
|
|
|
555
630
|
for (const mark of canvas.querySelectorAll<SVGElement>('[data-mk-index]')) if (mark.getAttribute('data-mk-index') !== String(i)) mark.setAttribute('data-mk-dim', '');
|
|
556
631
|
const { up, down } = totals(i);
|
|
557
632
|
anchorY = stacked() ? geometry.y(up || down) : Math.min(...model.series.map((series) => (finite(series.values[i]) ? geometry.y(series.values[i]!) : geometry.bottom)));
|
|
633
|
+
if (options.barStyle === 'pill') {
|
|
634
|
+
const amount = stacked() ? up + down : model.series[0]?.values[i];
|
|
635
|
+
if (finite(amount)) {
|
|
636
|
+
const label = format(amount), width = Math.max(40, label.length * 7 + 18);
|
|
637
|
+
const bx = Math.max(0, Math.min(geometry.width - width, x - width / 2));
|
|
638
|
+
const by = Math.max(0, anchorY - 32);
|
|
639
|
+
const group = svg('g', { 'data-mk-part': 'bar-value' });
|
|
640
|
+
group.append(svg('rect', { x: round(bx), y: round(by), width, height: 24, rx: 12 }));
|
|
641
|
+
const text = svg('text', { x: round(bx + width / 2), y: round(by + 12), 'text-anchor': 'middle', 'dominant-baseline': 'middle' });
|
|
642
|
+
text.textContent = label; group.append(text); cursor.append(group);
|
|
643
|
+
const marker = svg('circle', { cx: round(x), cy: round(anchorY), r: 4, 'data-mk-part': 'marker' }); marker.style.fill = model.series[0]?.color ?? 'var(--mk-text)'; cursor.append(marker);
|
|
644
|
+
if (fresh && !reduced()) keep(gsap.fromTo(group, { opacity: 0, y: 6 }, { opacity: 1, y: 0, duration: 0.3, ease: EASE.pop }));
|
|
645
|
+
}
|
|
646
|
+
}
|
|
558
647
|
} else {
|
|
559
648
|
cursor.append(svg('line', { x1: round(x), x2: round(x), y1: geometry.top, y2: geometry.bottom, 'data-mk-part': 'guide' }));
|
|
560
649
|
model.series.forEach((series) => {
|
|
@@ -644,6 +733,7 @@ export function createChart(root: HTMLElement | null, initial: ChartOptions = {}
|
|
|
644
733
|
glide?.kill();
|
|
645
734
|
canvas.setAttribute('aria-label', describe());
|
|
646
735
|
scope.attr(root, 'data-mk-chart-type', type());
|
|
736
|
+
scope.attr(root, 'data-mk-bar-style', options.barStyle ?? 'rounded');
|
|
647
737
|
renderTable();
|
|
648
738
|
renderLegend();
|
|
649
739
|
const ok = hasData();
|
|
@@ -700,12 +790,14 @@ export function createChart(root: HTMLElement | null, initial: ChartOptions = {}
|
|
|
700
790
|
// First appearance: bars grow from the baseline, lines wipe in from the left.
|
|
701
791
|
finish();
|
|
702
792
|
if (type() === 'bar') {
|
|
703
|
-
const
|
|
793
|
+
const stagger = Math.min(0.045, 0.22 / Math.max(1, model.labels.length - 1));
|
|
794
|
+
const length = 0.7 + stagger * (model.labels.length - 1);
|
|
795
|
+
const state = { t: 0 }, ease = gsap.parseEase(EASE.swatch);
|
|
704
796
|
glide = gsap.to(state, {
|
|
705
|
-
t:
|
|
706
|
-
duration:
|
|
707
|
-
ease:
|
|
708
|
-
onUpdate: () => paint(target.map((list) => list.map((value) => (finite(value) ? value * state.t : value))), domain.lo, domain.hi, domain.step),
|
|
797
|
+
t: length,
|
|
798
|
+
duration: length,
|
|
799
|
+
ease: 'none',
|
|
800
|
+
onUpdate: () => paint(target.map((list) => list.map((value, i) => (finite(value) ? value * Math.max(0, ease(Math.min(1, Math.max(0, (state.t - i * stagger) / 0.7)))) : value))), domain.lo, domain.hi, domain.step),
|
|
709
801
|
onComplete: () => {
|
|
710
802
|
finish();
|
|
711
803
|
settle();
|
|
@@ -11,13 +11,13 @@ export interface ChartProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onSele
|
|
|
11
11
|
* <Chart type="bar" stacked labels={weeks} series={[{ name: 'Ana', values }]} formatValue={money} label="Sesiones por semana" />.
|
|
12
12
|
* Data changes redraw in place; the selection survives when its point still exists.
|
|
13
13
|
*/
|
|
14
|
-
export function Chart({ type, labels, series, points, stacked, formatValue, label, height, emptyLabel, legend, tooltip, axis, endLabel, onSelect, instanceRef, className, ...props }: ChartProps) {
|
|
14
|
+
export function Chart({ type, labels, series, points, stacked, barStyle, barTrack, barFill, barWidth, curve, formatValue, label, height, emptyLabel, legend, tooltip, axis, endLabel, onSelect, instanceRef, className, ...props }: ChartProps) {
|
|
15
15
|
const ref = useRef<HTMLDivElement>(null);
|
|
16
16
|
const instance = useRef<ChartInstance | null>(null);
|
|
17
17
|
const format = useLatest(formatValue);
|
|
18
18
|
const select = useLatest(onSelect);
|
|
19
19
|
const formatted = formatValue !== undefined;
|
|
20
|
-
const data = { type, labels, series, points, stacked, label, height, emptyLabel, legend, tooltip, axis, endLabel };
|
|
20
|
+
const data = { type, labels, series, points, stacked, barStyle, barTrack, barFill, barWidth, curve, label, height, emptyLabel, legend, tooltip, axis, endLabel };
|
|
21
21
|
const key = JSON.stringify(data);
|
|
22
22
|
const latest = useLatest(data);
|
|
23
23
|
const options = (): ChartOptions => ({
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# Drilldown Menu
|
|
2
|
+
|
|
3
|
+
A hierarchical action menu that shows one level at a time. Opening a branch slides the new level in from the right through a 6 px blur; going back reverses the direction. Motion uses the kit's signature curve over 360 ms. The engine keeps one live menu, cancels interrupted motion, and honors reduced motion.
|
|
4
|
+
|
|
5
|
+
## Vanilla
|
|
6
|
+
|
|
7
|
+
```html
|
|
8
|
+
<div id="actions" class="mk-drilldown-menu"></div>
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { createDrilldownMenu, type DrilldownMenuItem } from '@yewdiel21/motion-kit';
|
|
13
|
+
import '@yewdiel21/motion-kit/css';
|
|
14
|
+
|
|
15
|
+
const items: DrilldownMenuItem[] = [
|
|
16
|
+
{
|
|
17
|
+
id: 'project', label: 'Project', description: 'Manage your workspace',
|
|
18
|
+
children: [{
|
|
19
|
+
id: 'share', label: 'Share',
|
|
20
|
+
children: [
|
|
21
|
+
{ id: 'link', label: 'Copy link', description: 'Anyone with the link can view' },
|
|
22
|
+
{ id: 'invite', label: 'Invite people', disabled: true },
|
|
23
|
+
],
|
|
24
|
+
}],
|
|
25
|
+
},
|
|
26
|
+
{ id: 'help', label: 'Help' },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const menu = createDrilldownMenu(document.querySelector('#actions'), {
|
|
30
|
+
items,
|
|
31
|
+
label: 'Workspace actions',
|
|
32
|
+
onSelect: (item, path) => console.log(item.label, path),
|
|
33
|
+
onPathChange: (path) => console.log('Level', path),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
menu.open('project');
|
|
37
|
+
menu.open('share');
|
|
38
|
+
menu.back();
|
|
39
|
+
menu.root(); // reset() also returns to the root
|
|
40
|
+
menu.setPath(['project', 'share']);
|
|
41
|
+
menu.setItems(items);
|
|
42
|
+
menu.update({ label: 'Quick actions', emptyText: 'No available actions' });
|
|
43
|
+
// When the component is removed:
|
|
44
|
+
menu.destroy();
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Use `@yewdiel21/motion-kit/css/components/drilldown-menu` for just this component's styles; theme tokens must already be present. The root needs `mk-drilldown-menu`; the custom element receives the same styles directly.
|
|
48
|
+
|
|
49
|
+
## React
|
|
50
|
+
|
|
51
|
+
```tsx
|
|
52
|
+
import { useState } from 'react';
|
|
53
|
+
import { DrilldownMenu } from '@yewdiel21/motion-kit/react';
|
|
54
|
+
import type { DrilldownMenuItem } from '@yewdiel21/motion-kit';
|
|
55
|
+
|
|
56
|
+
function Actions({ items }: { items: DrilldownMenuItem[] }) {
|
|
57
|
+
const [path, setPath] = useState<string[]>([]);
|
|
58
|
+
const [selection, setSelection] = useState('Choose an action');
|
|
59
|
+
return <>
|
|
60
|
+
<DrilldownMenu
|
|
61
|
+
items={items}
|
|
62
|
+
label="Workspace actions"
|
|
63
|
+
path={path}
|
|
64
|
+
onPathChange={setPath}
|
|
65
|
+
onSelect={(item) => setSelection(item.label)}
|
|
66
|
+
/>
|
|
67
|
+
<p role="status">{selection}</p>
|
|
68
|
+
</>;
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Omit `path` for internal navigation; use `defaultPath` to choose its initial level. Passing `path` makes navigation controlled: `onPathChange` requests the next branch path, and the parent accepts it by updating the prop. A parent that keeps the previous path declines the request. Invalid paths are safely truncated to their valid prefix. Updating `items` retains a valid path and focused action; removing or disabling an ancestor returns to the nearest valid parent and reports the repaired path.
|
|
73
|
+
|
|
74
|
+
`DrilldownMenuProps` also accepts normal `<div>` attributes, excluding children and the native `onSelect`. A forwarded `DrilldownMenuHandle` exposes `open`, `back`, `root`, `reset`, `setPath`, and `focusItem`. In controlled mode the `path` prop remains authoritative over ref-driven navigation.
|
|
75
|
+
|
|
76
|
+
## Custom element
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
import { defineDrilldownMenu, type MkDrilldownMenuElement } from '@yewdiel21/motion-kit/elements';
|
|
80
|
+
defineDrilldownMenu(); // safe to call more than once
|
|
81
|
+
|
|
82
|
+
const element = document.querySelector<MkDrilldownMenuElement>('mk-drilldown-menu')!;
|
|
83
|
+
element.items = items; // may also be assigned before connection
|
|
84
|
+
element.path = ['project'];
|
|
85
|
+
element.addEventListener('mk:select', (event) => {
|
|
86
|
+
console.log((event as CustomEvent).detail); // { item, path }
|
|
87
|
+
});
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
```html
|
|
91
|
+
<mk-drilldown-menu
|
|
92
|
+
label="Workspace actions"
|
|
93
|
+
back-label="Previous level"
|
|
94
|
+
empty-text="No available actions"
|
|
95
|
+
items='[{"id":"project","label":"Project","children":[{"id":"share","label":"Share","children":[{"id":"link","label":"Copy link"}]}]}]'
|
|
96
|
+
path='["project"]'
|
|
97
|
+
></mk-drilldown-menu>
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`items` and `path` attributes accept JSON arrays and update the live instance; malformed JSON leaves the last valid value intact. `items`/`path` properties and `setItems`/`setPath` methods accept arrays directly. `open`, `back`, `root`, `reset`, and `focusItem` forward to the live engine. The `instance` property is null while disconnected. Reconnecting creates a fresh instance from the current attributes/properties. The element uses light DOM, so inherited themes apply.
|
|
101
|
+
|
|
102
|
+
## Data and API
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
interface DrilldownMenuItem {
|
|
106
|
+
id: string;
|
|
107
|
+
label: string;
|
|
108
|
+
description?: string;
|
|
109
|
+
disabled?: boolean;
|
|
110
|
+
children?: DrilldownMenuItem[];
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
IDs must be unique among siblings. The same ID may appear in unrelated levels. A `children` array marks a branch; `children: []` opens an empty level. An item without children is an action. Labels/descriptions are inserted as text; they never execute HTML. Input data and returned item arrays are copied, so the engine does not mutate application data. Supply finite, acyclic data.
|
|
115
|
+
|
|
116
|
+
| Option | Default | Meaning |
|
|
117
|
+
| --- | --- | --- |
|
|
118
|
+
| `items` | `[]` | Root actions and branches |
|
|
119
|
+
| `path` | `[]` | Initial branch IDs (engine); controlled branch IDs (React) |
|
|
120
|
+
| `label` | `Actions` | Root heading and accessible menu name |
|
|
121
|
+
| `backLabel` | `Back` | Accessible name of the back button |
|
|
122
|
+
| `emptyText` | `No actions available` | Empty-level status |
|
|
123
|
+
| `onSelect(item, path)` | — | Leaf action and full ID path, including the leaf |
|
|
124
|
+
| `onPathChange(path)` | — | User/programmatic navigation or a repaired data path |
|
|
125
|
+
|
|
126
|
+
`DrilldownMenuInstance` exposes:
|
|
127
|
+
|
|
128
|
+
| Member | Behavior |
|
|
129
|
+
| --- | --- |
|
|
130
|
+
| `open(id): boolean` | Opens a current-level enabled branch and focuses its first action |
|
|
131
|
+
| `back(): boolean` | Returns one level and restores focus to its parent branch |
|
|
132
|
+
| `root()` / `reset()` | Returns to the root; keeps focus inside if it was inside |
|
|
133
|
+
| `setPath(path, notify = false)` | Navigates to a valid prefix; optional path callback/event |
|
|
134
|
+
| `setItems(items)` | Replaces root data, repairs invalid paths and preserves focus |
|
|
135
|
+
| `update(options)` | Updates data, path, text and callbacks together |
|
|
136
|
+
| `focusItem(id)` | Focuses an enabled current-level item |
|
|
137
|
+
| `path` | Copy of the current branch ID path |
|
|
138
|
+
| `items` | Copy of the current level's items |
|
|
139
|
+
| `element` | Original root element |
|
|
140
|
+
| `destroy()` | Cancels motion, removes listeners, restores original child nodes/attributes; idempotent |
|
|
141
|
+
|
|
142
|
+
`open`, `back`, `root`, and `reset` notify on a changed path. Explicit `setPath` and `update({ path })` are silent by default. `setItems` reports a path repaired by removed/disabled/non-branch ancestors. `mk:path-change` bubbles with `{ path }`; `mk:select` bubbles with `{ item, path }`. Callbacks receive the same information. Choosing an action leaves the menu at its current level.
|
|
143
|
+
|
|
144
|
+
## Keyboard and accessibility
|
|
145
|
+
|
|
146
|
+
The current level has `role="menu"`, named by its visible header. Each action is a button with `role="menuitem"`; its label provides its accessible name and its description is referenced separately. Branches expose `aria-haspopup="menu"`. Disabled actions use both native `disabled` and `aria-disabled` and are skipped by focus navigation. Header/back and ancestor breadcrumbs sit outside the menu. The current breadcrumb uses `aria-current="location"`.
|
|
147
|
+
|
|
148
|
+
| Key | Action |
|
|
149
|
+
| --- | --- |
|
|
150
|
+
| Up / Down | Previous / next enabled item, wrapping |
|
|
151
|
+
| Home / End | First / last enabled item |
|
|
152
|
+
| Enter / Space | Open branch or choose leaf action |
|
|
153
|
+
| Right | Open a focused branch |
|
|
154
|
+
| Left / Escape | Return one level and focus the branch you came from |
|
|
155
|
+
| Tab / Shift+Tab | Normal page navigation through header/crumb controls and one roving menu stop |
|
|
156
|
+
|
|
157
|
+
At the root, Left/Escape are left available to a containing surface. Empty and all-disabled levels keep the menu itself focusable so the back keys work. The initial render does not take focus; opening a branch intentionally focuses the new level. Updates preserve focus only when focus was already inside. Reduced-motion users receive immediate navigation without slide, blur or animated height.
|
|
158
|
+
|
|
159
|
+
## Styling
|
|
160
|
+
|
|
161
|
+
The component inherits `--mk-text`, `--mk-muted`, `--mk-surface-1`, `--mk-surface-3`, `--mk-outline-soft`, and `--mk-accent`. Override `--mk-drilldown-radius` (28 px) and `--mk-drilldown-item-radius` (18 px) for corner shapes. Width is fluid up to 26 rem; override `max-width` on the root for wider panels. Labels wrap, breadcrumbs wrap, and descriptions remain readable in narrow surfaces.
|
|
162
|
+
|
|
163
|
+
This is an inline hierarchical action menu. Place it in an existing dialog/popover if you need an anchored or dismissible surface. It does not route to URLs, fetch children, virtualize data, or provide typeahead.
|