@archbase/components 4.3.2 → 4.6.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/dist/archbase-components-4.6.0.tgz +0 -0
- package/dist/buttons/ArchbaseSlideToConfirm.d.ts +37 -0
- package/dist/buttons/index.d.ts +2 -0
- package/dist/display/ArchbaseDeviceFrame.d.ts +41 -0
- package/dist/display/ArchbaseGlowingCards.d.ts +46 -0
- package/dist/display/index.d.ts +4 -0
- package/dist/feedback/ArchbaseStickyTopBar.d.ts +51 -0
- package/dist/feedback/index.d.ts +2 -0
- package/dist/index.js +10470 -9763
- package/dist/list/ArchbaseReorderList.d.ts +52 -0
- package/dist/list/index.d.ts +2 -0
- package/dist/navigation/ArchbaseMagnifyDock.d.ts +49 -0
- package/dist/navigation/index.d.ts +2 -0
- package/package.json +4 -4
- package/src/buttons/ArchbaseSlideToConfirm.tsx +250 -0
- package/src/buttons/index.ts +6 -0
- package/src/display/ArchbaseDeviceFrame.tsx +172 -0
- package/src/display/ArchbaseGlowingCards.tsx +204 -0
- package/src/display/index.ts +9 -0
- package/src/feedback/ArchbaseStickyTopBar.tsx +160 -0
- package/src/feedback/index.ts +3 -1
- package/src/list/ArchbaseReorderList.tsx +249 -0
- package/src/list/index.ts +3 -0
- package/src/navigation/ArchbaseMagnifyDock.tsx +198 -0
- package/src/navigation/index.ts +3 -1
- package/dist/archbase-components-4.3.2.tgz +0 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useCallback,
|
|
4
|
+
useContext,
|
|
5
|
+
useEffect,
|
|
6
|
+
useRef,
|
|
7
|
+
type CSSProperties,
|
|
8
|
+
type KeyboardEvent,
|
|
9
|
+
type ReactNode,
|
|
10
|
+
type RefObject,
|
|
11
|
+
} from 'react';
|
|
12
|
+
import { Box, Paper } from '@mantine/core';
|
|
13
|
+
import { useReducedMotion } from '@mantine/hooks';
|
|
14
|
+
|
|
15
|
+
export interface ArchbaseGlowingCardsProps {
|
|
16
|
+
children: ReactNode;
|
|
17
|
+
/** Raio do brilho que acompanha o ponteiro, em pixels. */
|
|
18
|
+
glowRadius?: number;
|
|
19
|
+
/** Opacidade do brilho, entre 0 e 1. */
|
|
20
|
+
glowOpacity?: number;
|
|
21
|
+
gap?: number | string;
|
|
22
|
+
/** Colunas fixas. Ausente, ajusta ao espaco disponivel. */
|
|
23
|
+
columns?: number;
|
|
24
|
+
/** Largura minima do cartao quando as colunas sao automaticas. */
|
|
25
|
+
minCardWidth?: number;
|
|
26
|
+
className?: string;
|
|
27
|
+
style?: CSSProperties;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ArchbaseGlowingCardProps {
|
|
31
|
+
children: ReactNode;
|
|
32
|
+
/** Cor do brilho deste cartao. */
|
|
33
|
+
glowColor?: string;
|
|
34
|
+
/** Eleva o cartao ao passar o ponteiro. */
|
|
35
|
+
hoverEffect?: boolean;
|
|
36
|
+
onClick?: () => void;
|
|
37
|
+
className?: string;
|
|
38
|
+
style?: CSSProperties;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const ContextoDoGrupo = createContext<RefObject<HTMLDivElement | null> | null>(null);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Grade de cartoes com um unico brilho que atravessa todos, acompanhando o
|
|
45
|
+
* ponteiro.
|
|
46
|
+
*
|
|
47
|
+
* Adaptado de Lightswind UI (MIT, Muhilan / codewithMUHILAN).
|
|
48
|
+
*
|
|
49
|
+
* A posicao do ponteiro e publicada em custom properties no container, e cada
|
|
50
|
+
* cartao publica o proprio deslocamento dentro dele. O gradiente subtrai um do
|
|
51
|
+
* outro, o que faz o foco atravessar a grade como uma luz unica em vez de
|
|
52
|
+
* acender dentro de cada cartao isoladamente.
|
|
53
|
+
*
|
|
54
|
+
* Nada disso passa por estado do React: mover o ponteiro sobre uma grade grande
|
|
55
|
+
* nao dispara render. O original recalculava estado por evento, o que
|
|
56
|
+
* transforma um enfeite em travamento quando os cartoes se multiplicam.
|
|
57
|
+
*/
|
|
58
|
+
export function ArchbaseGlowingCards({
|
|
59
|
+
children,
|
|
60
|
+
glowRadius = 280,
|
|
61
|
+
glowOpacity = 0.35,
|
|
62
|
+
gap = 16,
|
|
63
|
+
columns,
|
|
64
|
+
minCardWidth = 240,
|
|
65
|
+
className,
|
|
66
|
+
style,
|
|
67
|
+
}: ArchbaseGlowingCardsProps) {
|
|
68
|
+
const container = useRef<HTMLDivElement>(null);
|
|
69
|
+
const movimentoReduzido = useReducedMotion();
|
|
70
|
+
|
|
71
|
+
const aoMover = useCallback(
|
|
72
|
+
(evento: React.PointerEvent<HTMLDivElement>) => {
|
|
73
|
+
if (movimentoReduzido) return;
|
|
74
|
+
const elemento = container.current;
|
|
75
|
+
if (!elemento) return;
|
|
76
|
+
|
|
77
|
+
const caixa = elemento.getBoundingClientRect();
|
|
78
|
+
elemento.style.setProperty('--archbase-glow-x', `${evento.clientX - caixa.left}px`);
|
|
79
|
+
elemento.style.setProperty('--archbase-glow-y', `${evento.clientY - caixa.top}px`);
|
|
80
|
+
elemento.style.setProperty('--archbase-glow-alpha', String(glowOpacity));
|
|
81
|
+
},
|
|
82
|
+
[glowOpacity, movimentoReduzido],
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const aoSair = useCallback(() => {
|
|
86
|
+
container.current?.style.setProperty('--archbase-glow-alpha', '0');
|
|
87
|
+
}, []);
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<ContextoDoGrupo.Provider value={container}>
|
|
91
|
+
<Box
|
|
92
|
+
ref={container}
|
|
93
|
+
className={className}
|
|
94
|
+
onPointerMove={aoMover}
|
|
95
|
+
onPointerLeave={aoSair}
|
|
96
|
+
style={
|
|
97
|
+
{
|
|
98
|
+
display: 'grid',
|
|
99
|
+
gap,
|
|
100
|
+
gridTemplateColumns: columns
|
|
101
|
+
? `repeat(${columns}, minmax(0, 1fr))`
|
|
102
|
+
: `repeat(auto-fit, minmax(${minCardWidth}px, 1fr))`,
|
|
103
|
+
'--archbase-glow-x': '50%',
|
|
104
|
+
'--archbase-glow-y': '50%',
|
|
105
|
+
'--archbase-glow-radius': `${glowRadius}px`,
|
|
106
|
+
// Com movimento reduzido o brilho fica visivel e parado no centro:
|
|
107
|
+
// o desenho permanece, o movimento nao.
|
|
108
|
+
'--archbase-glow-alpha': movimentoReduzido ? String(glowOpacity) : '0',
|
|
109
|
+
...style,
|
|
110
|
+
} as CSSProperties
|
|
111
|
+
}
|
|
112
|
+
>
|
|
113
|
+
{children}
|
|
114
|
+
</Box>
|
|
115
|
+
</ContextoDoGrupo.Provider>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Cartao do grupo. Fora de `ArchbaseGlowingCards` continua valido — apenas sem
|
|
121
|
+
* o brilho, porque as custom properties nao existem.
|
|
122
|
+
*/
|
|
123
|
+
export function ArchbaseGlowingCard({
|
|
124
|
+
children,
|
|
125
|
+
glowColor = 'var(--mantine-primary-color-filled)',
|
|
126
|
+
hoverEffect = true,
|
|
127
|
+
onClick,
|
|
128
|
+
className,
|
|
129
|
+
style,
|
|
130
|
+
}: ArchbaseGlowingCardProps) {
|
|
131
|
+
const cartao = useRef<HTMLDivElement>(null);
|
|
132
|
+
const container = useContext(ContextoDoGrupo);
|
|
133
|
+
const interativo = typeof onClick === 'function';
|
|
134
|
+
|
|
135
|
+
// O deslocamento do cartao dentro do grupo muda com o layout, nao com o
|
|
136
|
+
// ponteiro: medir aqui mantem o movimento livre de JavaScript.
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
const elemento = cartao.current;
|
|
139
|
+
const pai = container?.current;
|
|
140
|
+
if (!elemento || !pai) return;
|
|
141
|
+
|
|
142
|
+
const medir = () => {
|
|
143
|
+
const c = elemento.getBoundingClientRect();
|
|
144
|
+
const p = pai.getBoundingClientRect();
|
|
145
|
+
elemento.style.setProperty('--archbase-card-x', `${c.left - p.left}px`);
|
|
146
|
+
elemento.style.setProperty('--archbase-card-y', `${c.top - p.top}px`);
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
medir();
|
|
150
|
+
if (typeof ResizeObserver === 'undefined') return;
|
|
151
|
+
const observador = new ResizeObserver(medir);
|
|
152
|
+
observador.observe(pai);
|
|
153
|
+
observador.observe(elemento);
|
|
154
|
+
return () => observador.disconnect();
|
|
155
|
+
}, [container]);
|
|
156
|
+
|
|
157
|
+
const aoTeclar = useCallback(
|
|
158
|
+
(evento: KeyboardEvent<HTMLDivElement>) => {
|
|
159
|
+
if (!interativo) return;
|
|
160
|
+
if (evento.key === 'Enter' || evento.key === ' ') {
|
|
161
|
+
evento.preventDefault();
|
|
162
|
+
onClick?.();
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
[interativo, onClick],
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<Paper
|
|
170
|
+
ref={cartao}
|
|
171
|
+
withBorder
|
|
172
|
+
radius="md"
|
|
173
|
+
p="md"
|
|
174
|
+
className={className}
|
|
175
|
+
onClick={onClick}
|
|
176
|
+
// Cartao clicavel precisa ser alcancavel por teclado; cartao decorativo
|
|
177
|
+
// nao deve entrar na ordem de foco.
|
|
178
|
+
role={interativo ? 'button' : undefined}
|
|
179
|
+
tabIndex={interativo ? 0 : undefined}
|
|
180
|
+
onKeyDown={interativo ? aoTeclar : undefined}
|
|
181
|
+
style={{
|
|
182
|
+
position: 'relative',
|
|
183
|
+
overflow: 'hidden',
|
|
184
|
+
cursor: interativo ? 'pointer' : undefined,
|
|
185
|
+
transition: hoverEffect ? 'transform 160ms ease, box-shadow 160ms ease' : undefined,
|
|
186
|
+
...style,
|
|
187
|
+
}}
|
|
188
|
+
>
|
|
189
|
+
<span
|
|
190
|
+
aria-hidden
|
|
191
|
+
style={{
|
|
192
|
+
position: 'absolute',
|
|
193
|
+
inset: 0,
|
|
194
|
+
pointerEvents: 'none',
|
|
195
|
+
opacity: 'var(--archbase-glow-alpha, 0)' as unknown as number,
|
|
196
|
+
transition: 'opacity 200ms ease',
|
|
197
|
+
background: `radial-gradient(var(--archbase-glow-radius, 280px) circle at calc(var(--archbase-glow-x, 50%) - var(--archbase-card-x, 0px)) calc(var(--archbase-glow-y, 50%) - var(--archbase-card-y, 0px)), ${glowColor}, transparent 60%)`,
|
|
198
|
+
}}
|
|
199
|
+
/>
|
|
200
|
+
|
|
201
|
+
<div style={{ position: 'relative' }}>{children}</div>
|
|
202
|
+
</Paper>
|
|
203
|
+
);
|
|
204
|
+
}
|
package/src/display/index.ts
CHANGED
|
@@ -96,3 +96,12 @@ export type {
|
|
|
96
96
|
ArchbasePhotoAlbumLayout,
|
|
97
97
|
} from './ArchbasePhotoAlbum';
|
|
98
98
|
|
|
99
|
+
|
|
100
|
+
export { ArchbaseGlowingCards, ArchbaseGlowingCard } from './ArchbaseGlowingCards';
|
|
101
|
+
export type {
|
|
102
|
+
ArchbaseGlowingCardsProps,
|
|
103
|
+
ArchbaseGlowingCardProps,
|
|
104
|
+
} from './ArchbaseGlowingCards';
|
|
105
|
+
|
|
106
|
+
export { ArchbaseDeviceFrame } from './ArchbaseDeviceFrame';
|
|
107
|
+
export type { ArchbaseDeviceFrameProps, ArchbaseDeviceVariant } from './ArchbaseDeviceFrame';
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState, type ReactNode } from 'react';
|
|
2
|
+
import { ActionIcon, Box, Group, type MantineColor } from '@mantine/core';
|
|
3
|
+
import { useReducedMotion } from '@mantine/hooks';
|
|
4
|
+
import { IconX } from '@tabler/icons-react';
|
|
5
|
+
import { AnimatePresence, motion } from 'framer-motion';
|
|
6
|
+
|
|
7
|
+
export interface ArchbaseStickyTopBarProps {
|
|
8
|
+
children: ReactNode;
|
|
9
|
+
/**
|
|
10
|
+
* Visibilidade controlada. Ignorada quando `showOnScroll` esta ligado.
|
|
11
|
+
*/
|
|
12
|
+
visible?: boolean;
|
|
13
|
+
/** Aparece depois de rolar; util para barra de acao que so importa adiante. */
|
|
14
|
+
showOnScroll?: boolean;
|
|
15
|
+
/** Rolagem, em pixels, a partir da qual a barra aparece. */
|
|
16
|
+
scrollThreshold?: number;
|
|
17
|
+
/** Botao de fechar. */
|
|
18
|
+
dismissible?: boolean;
|
|
19
|
+
onDismiss?: () => void;
|
|
20
|
+
/**
|
|
21
|
+
* Chave de persistencia da dispensa. Definida, a barra nao volta a aparecer
|
|
22
|
+
* para quem ja a fechou — mensagem de sistema que reaparece a cada
|
|
23
|
+
* navegacao vira ruido e ensina o usuario a ignorar o lugar todo.
|
|
24
|
+
*/
|
|
25
|
+
dismissStorageKey?: string;
|
|
26
|
+
color?: MantineColor;
|
|
27
|
+
/** Fixa no topo da janela. Desligado, acompanha o fluxo da pagina. */
|
|
28
|
+
sticky?: boolean;
|
|
29
|
+
/** Altura reservada, em pixels. */
|
|
30
|
+
height?: number;
|
|
31
|
+
zIndex?: number;
|
|
32
|
+
/**
|
|
33
|
+
* Papel semantico. `status` para informacao passageira; `region` para
|
|
34
|
+
* conteudo que o usuario pode querer reencontrar.
|
|
35
|
+
*/
|
|
36
|
+
role?: 'status' | 'region' | 'alert';
|
|
37
|
+
'aria-label'?: string;
|
|
38
|
+
className?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function jaDispensada(chave?: string): boolean {
|
|
42
|
+
if (!chave || typeof window === 'undefined') return false;
|
|
43
|
+
try {
|
|
44
|
+
return window.localStorage.getItem(chave) === 'dispensada';
|
|
45
|
+
} catch {
|
|
46
|
+
// Modo privado ou armazenamento bloqueado: a barra volta a aparecer, que e
|
|
47
|
+
// o comportamento menos surpreendente entre os dois ruins.
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Barra fixa no topo, para aviso de sistema ou acao contextual.
|
|
54
|
+
*
|
|
55
|
+
* Adaptado de Lightswind UI (MIT, Muhilan / codewithMUHILAN), com o que faltava
|
|
56
|
+
* para uso em produto:
|
|
57
|
+
*
|
|
58
|
+
* - **Dispensavel, com memoria opcional.** O original nao fechava. Aviso que
|
|
59
|
+
* nao se fecha e o usuario cobre com a mao.
|
|
60
|
+
* - **Semantica.** Era um `div` mudo; agora recebe papel e rotulo, e um leitor
|
|
61
|
+
* de tela anuncia a mensagem quando ela chega.
|
|
62
|
+
* - **`useSyncExternalStore` nao entra aqui, mas o listener de rolagem e
|
|
63
|
+
* passivo** — sem isso o navegador nao pode adiantar a rolagem enquanto o
|
|
64
|
+
* manipulador roda.
|
|
65
|
+
* - **Movimento reduzido** troca o deslize por aparecer e sumir.
|
|
66
|
+
*/
|
|
67
|
+
export function ArchbaseStickyTopBar({
|
|
68
|
+
children,
|
|
69
|
+
visible = true,
|
|
70
|
+
showOnScroll = false,
|
|
71
|
+
scrollThreshold = 200,
|
|
72
|
+
dismissible = false,
|
|
73
|
+
onDismiss,
|
|
74
|
+
dismissStorageKey,
|
|
75
|
+
color = 'blue',
|
|
76
|
+
sticky = true,
|
|
77
|
+
height = 44,
|
|
78
|
+
zIndex = 200,
|
|
79
|
+
role = 'status',
|
|
80
|
+
'aria-label': ariaLabel,
|
|
81
|
+
className,
|
|
82
|
+
}: ArchbaseStickyTopBarProps) {
|
|
83
|
+
const movimentoReduzido = useReducedMotion();
|
|
84
|
+
const [dispensada, setDispensada] = useState(() => jaDispensada(dismissStorageKey));
|
|
85
|
+
const [passouDoLimite, setPassouDoLimite] = useState(false);
|
|
86
|
+
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
if (!showOnScroll || typeof window === 'undefined') return;
|
|
89
|
+
|
|
90
|
+
const aoRolar = () => setPassouDoLimite(window.scrollY > scrollThreshold);
|
|
91
|
+
aoRolar();
|
|
92
|
+
// `passive` permite ao navegador rolar sem esperar este manipulador.
|
|
93
|
+
window.addEventListener('scroll', aoRolar, { passive: true });
|
|
94
|
+
return () => window.removeEventListener('scroll', aoRolar);
|
|
95
|
+
}, [showOnScroll, scrollThreshold]);
|
|
96
|
+
|
|
97
|
+
const dispensar = useCallback(() => {
|
|
98
|
+
setDispensada(true);
|
|
99
|
+
if (dismissStorageKey && typeof window !== 'undefined') {
|
|
100
|
+
try {
|
|
101
|
+
window.localStorage.setItem(dismissStorageKey, 'dispensada');
|
|
102
|
+
} catch {
|
|
103
|
+
// Sem armazenamento, a dispensa vale so para esta sessao.
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
onDismiss?.();
|
|
107
|
+
}, [dismissStorageKey, onDismiss]);
|
|
108
|
+
|
|
109
|
+
const aparecendo = !dispensada && (showOnScroll ? passouDoLimite : visible);
|
|
110
|
+
|
|
111
|
+
const transicao = movimentoReduzido
|
|
112
|
+
? { duration: 0.01 }
|
|
113
|
+
: { type: 'spring' as const, stiffness: 320, damping: 30 };
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<AnimatePresence>
|
|
117
|
+
{aparecendo && (
|
|
118
|
+
<motion.div
|
|
119
|
+
initial={movimentoReduzido ? { opacity: 0 } : { y: -height, opacity: 0 }}
|
|
120
|
+
animate={movimentoReduzido ? { opacity: 1 } : { y: 0, opacity: 1 }}
|
|
121
|
+
exit={movimentoReduzido ? { opacity: 0 } : { y: -height, opacity: 0 }}
|
|
122
|
+
transition={transicao}
|
|
123
|
+
style={{
|
|
124
|
+
position: sticky ? 'sticky' : 'relative',
|
|
125
|
+
top: 0,
|
|
126
|
+
zIndex,
|
|
127
|
+
width: '100%',
|
|
128
|
+
}}
|
|
129
|
+
>
|
|
130
|
+
<Box
|
|
131
|
+
className={className}
|
|
132
|
+
role={role}
|
|
133
|
+
aria-label={ariaLabel}
|
|
134
|
+
aria-live={role === 'alert' ? 'assertive' : 'polite'}
|
|
135
|
+
bg={color}
|
|
136
|
+
style={{ minHeight: height, color: 'var(--mantine-color-white)' }}
|
|
137
|
+
px="md"
|
|
138
|
+
py={6}
|
|
139
|
+
>
|
|
140
|
+
<Group justify="space-between" wrap="nowrap" h="100%">
|
|
141
|
+
<Box style={{ flex: 1, minWidth: 0 }}>{children}</Box>
|
|
142
|
+
|
|
143
|
+
{dismissible && (
|
|
144
|
+
<ActionIcon
|
|
145
|
+
variant="subtle"
|
|
146
|
+
color="gray"
|
|
147
|
+
aria-label="Fechar aviso"
|
|
148
|
+
onClick={dispensar}
|
|
149
|
+
style={{ color: 'inherit' }}
|
|
150
|
+
>
|
|
151
|
+
<IconX size={16} />
|
|
152
|
+
</ActionIcon>
|
|
153
|
+
)}
|
|
154
|
+
</Group>
|
|
155
|
+
</Box>
|
|
156
|
+
</motion.div>
|
|
157
|
+
)}
|
|
158
|
+
</AnimatePresence>
|
|
159
|
+
);
|
|
160
|
+
}
|
package/src/feedback/index.ts
CHANGED
|
@@ -23,4 +23,6 @@ export type {
|
|
|
23
23
|
} from './ArchbaseSkeleton';
|
|
24
24
|
|
|
25
25
|
export { ArchbaseRipple, useArchbaseRipple } from './ArchbaseRipple';
|
|
26
|
-
export type { ArchbaseRippleProps, UseArchbaseRippleOptions, UseArchbaseRippleReturn } from './ArchbaseRipple';
|
|
26
|
+
export type { ArchbaseRippleProps, UseArchbaseRippleOptions, UseArchbaseRippleReturn } from './ArchbaseRipple';
|
|
27
|
+
export { ArchbaseStickyTopBar } from './ArchbaseStickyTopBar';
|
|
28
|
+
export type { ArchbaseStickyTopBarProps } from './ArchbaseStickyTopBar';
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { useCallback, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
|
|
2
|
+
import { ActionIcon, Group, Paper, Text, VisuallyHidden } from '@mantine/core';
|
|
3
|
+
import { IconGripVertical, IconX } from '@tabler/icons-react';
|
|
4
|
+
import { Reorder, useDragControls } from 'framer-motion';
|
|
5
|
+
|
|
6
|
+
export interface ArchbaseReorderListProps<T> {
|
|
7
|
+
/** Itens na ordem corrente. Componente controlado. */
|
|
8
|
+
items: T[];
|
|
9
|
+
/** Identidade estavel do item. */
|
|
10
|
+
getItemId: (item: T) => string | number;
|
|
11
|
+
/** Nova ordem. Sem isto o componente nao teria como refletir o arrasto. */
|
|
12
|
+
onReorder: (items: T[]) => void;
|
|
13
|
+
/** Conteudo do item. Ausente, usa `getItemLabel`. */
|
|
14
|
+
renderItem?: (item: T, estado: { arrastando: boolean; indice: number }) => ReactNode;
|
|
15
|
+
/** Rotulo textual, usado no conteudo padrao e nos anuncios de acessibilidade. */
|
|
16
|
+
getItemLabel?: (item: T) => string;
|
|
17
|
+
/** Descricao secundaria no conteudo padrao. */
|
|
18
|
+
getItemDescription?: (item: T) => string | undefined;
|
|
19
|
+
onRemove?: (item: T) => void;
|
|
20
|
+
disabled?: boolean;
|
|
21
|
+
gap?: number;
|
|
22
|
+
className?: string;
|
|
23
|
+
labels?: {
|
|
24
|
+
dragHandle?: string;
|
|
25
|
+
remove?: string;
|
|
26
|
+
/** `{item}`, `{de}` e `{para}` sao substituidos. */
|
|
27
|
+
moved?: string;
|
|
28
|
+
instructions?: string;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const ROTULOS_PADRAO = {
|
|
33
|
+
dragHandle: 'Arrastar para reordenar',
|
|
34
|
+
remove: 'Remover',
|
|
35
|
+
moved: '{item} movido da posicao {de} para {para}',
|
|
36
|
+
instructions:
|
|
37
|
+
'Use as setas para cima e para baixo com a tecla Alt para mover o item selecionado.',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function interpolar(texto: string, valores: Record<string, string | number>): string {
|
|
41
|
+
return texto.replace(/\{(\w+)\}/g, (m, chave: string) => String(valores[chave] ?? m));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Lista reordenavel por arrasto e por teclado.
|
|
46
|
+
*
|
|
47
|
+
* Unifica dois componentes do Lightswind UI (MIT, Muhilan / codewithMUHILAN) —
|
|
48
|
+
* `drag-order-list` e `draggable-reorder-list` — que resolviam o mesmo problema
|
|
49
|
+
* com implementacoes diferentes. Entregar os dois seria oferecer duas respostas
|
|
50
|
+
* para uma pergunta.
|
|
51
|
+
*
|
|
52
|
+
* Diferencas em relacao aos originais:
|
|
53
|
+
*
|
|
54
|
+
* - **Reordenacao por teclado.** Arrastar e, por natureza, inacessivel: quem
|
|
55
|
+
* navega por teclado nao tem gesto equivalente. Aqui `Alt+Setas` move o item
|
|
56
|
+
* focado, e cada movimento e anunciado por regiao viva. Sem isso, a
|
|
57
|
+
* funcionalidade simplesmente nao existe para parte dos usuarios.
|
|
58
|
+
* - **Controlado.** O original copiava `items` para estado interno e disparava
|
|
59
|
+
* `onReorder` num efeito com `[list]` na dependencia — o que notificava ja na
|
|
60
|
+
* montagem, sem ninguem ter reordenado nada, e ignorava mudancas vindas de
|
|
61
|
+
* fora depois disso.
|
|
62
|
+
* - **Generico.** O `drag-order-list` fixava a forma do item em `{ title,
|
|
63
|
+
* subtitle, date, link }`. Numa biblioteca isso obriga o consumidor a
|
|
64
|
+
* traduzir o dominio dele para um formato alheio.
|
|
65
|
+
*/
|
|
66
|
+
export function ArchbaseReorderList<T>({
|
|
67
|
+
items,
|
|
68
|
+
getItemId,
|
|
69
|
+
onReorder,
|
|
70
|
+
renderItem,
|
|
71
|
+
getItemLabel,
|
|
72
|
+
getItemDescription,
|
|
73
|
+
onRemove,
|
|
74
|
+
disabled = false,
|
|
75
|
+
gap = 8,
|
|
76
|
+
className,
|
|
77
|
+
labels,
|
|
78
|
+
}: ArchbaseReorderListProps<T>) {
|
|
79
|
+
const textos = { ...ROTULOS_PADRAO, ...labels };
|
|
80
|
+
const [anuncio, setAnuncio] = useState('');
|
|
81
|
+
|
|
82
|
+
const rotuloDe = useCallback(
|
|
83
|
+
(item: T) => getItemLabel?.(item) ?? String(getItemId(item)),
|
|
84
|
+
[getItemLabel, getItemId],
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const mover = useCallback(
|
|
88
|
+
(de: number, para: number) => {
|
|
89
|
+
if (disabled) return;
|
|
90
|
+
if (para < 0 || para >= items.length || de === para) return;
|
|
91
|
+
|
|
92
|
+
const proximos = [...items];
|
|
93
|
+
const [movido] = proximos.splice(de, 1);
|
|
94
|
+
if (movido === undefined) return;
|
|
95
|
+
proximos.splice(para, 0, movido);
|
|
96
|
+
|
|
97
|
+
onReorder(proximos);
|
|
98
|
+
setAnuncio(
|
|
99
|
+
interpolar(textos.moved, { item: rotuloDe(movido), de: de + 1, para: para + 1 }),
|
|
100
|
+
);
|
|
101
|
+
},
|
|
102
|
+
[items, onReorder, disabled, textos.moved, rotuloDe],
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<>
|
|
107
|
+
{/* Regiao viva: o leitor de tela precisa saber que a ordem mudou, senao a
|
|
108
|
+
reordenacao por teclado acontece sem retorno algum. */}
|
|
109
|
+
<VisuallyHidden aria-live="polite" role="status">
|
|
110
|
+
{anuncio}
|
|
111
|
+
</VisuallyHidden>
|
|
112
|
+
|
|
113
|
+
<Reorder.Group
|
|
114
|
+
axis="y"
|
|
115
|
+
values={items}
|
|
116
|
+
onReorder={onReorder}
|
|
117
|
+
className={className}
|
|
118
|
+
style={{ listStyle: 'none', margin: 0, padding: 0, display: 'grid', gap }}
|
|
119
|
+
>
|
|
120
|
+
{items.map((item, indice) => (
|
|
121
|
+
<ArchbaseReorderItem
|
|
122
|
+
key={getItemId(item)}
|
|
123
|
+
item={item}
|
|
124
|
+
indice={indice}
|
|
125
|
+
total={items.length}
|
|
126
|
+
disabled={disabled}
|
|
127
|
+
rotulo={rotuloDe(item)}
|
|
128
|
+
descricao={getItemDescription?.(item)}
|
|
129
|
+
renderItem={renderItem}
|
|
130
|
+
onMover={mover}
|
|
131
|
+
onRemove={onRemove}
|
|
132
|
+
textos={textos}
|
|
133
|
+
/>
|
|
134
|
+
))}
|
|
135
|
+
</Reorder.Group>
|
|
136
|
+
</>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
interface ItemProps<T> {
|
|
141
|
+
item: T;
|
|
142
|
+
indice: number;
|
|
143
|
+
total: number;
|
|
144
|
+
disabled: boolean;
|
|
145
|
+
rotulo: string;
|
|
146
|
+
descricao?: string;
|
|
147
|
+
renderItem?: (item: T, estado: { arrastando: boolean; indice: number }) => ReactNode;
|
|
148
|
+
onMover: (de: number, para: number) => void;
|
|
149
|
+
onRemove?: (item: T) => void;
|
|
150
|
+
textos: typeof ROTULOS_PADRAO;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function ArchbaseReorderItem<T>({
|
|
154
|
+
item,
|
|
155
|
+
indice,
|
|
156
|
+
total,
|
|
157
|
+
disabled,
|
|
158
|
+
rotulo,
|
|
159
|
+
descricao,
|
|
160
|
+
renderItem,
|
|
161
|
+
onMover,
|
|
162
|
+
onRemove,
|
|
163
|
+
textos,
|
|
164
|
+
}: ItemProps<T>) {
|
|
165
|
+
const controles = useDragControls();
|
|
166
|
+
const [arrastando, setArrastando] = useState(false);
|
|
167
|
+
const referencia = useRef<HTMLLIElement>(null);
|
|
168
|
+
|
|
169
|
+
const aoTeclar = useCallback(
|
|
170
|
+
(evento: KeyboardEvent<HTMLLIElement>) => {
|
|
171
|
+
if (disabled || !evento.altKey) return;
|
|
172
|
+
|
|
173
|
+
if (evento.key === 'ArrowUp') {
|
|
174
|
+
evento.preventDefault();
|
|
175
|
+
onMover(indice, indice - 1);
|
|
176
|
+
// O foco acompanha o item, nao a posicao: sem isto, mover duas vezes
|
|
177
|
+
// seguidas exigiria reencontrar o item na lista.
|
|
178
|
+
requestAnimationFrame(() => referencia.current?.focus());
|
|
179
|
+
} else if (evento.key === 'ArrowDown') {
|
|
180
|
+
evento.preventDefault();
|
|
181
|
+
onMover(indice, indice + 1);
|
|
182
|
+
requestAnimationFrame(() => referencia.current?.focus());
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
[disabled, indice, onMover],
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
return (
|
|
189
|
+
<Reorder.Item
|
|
190
|
+
ref={referencia}
|
|
191
|
+
value={item}
|
|
192
|
+
dragListener={false}
|
|
193
|
+
dragControls={controles}
|
|
194
|
+
onDragStart={() => setArrastando(true)}
|
|
195
|
+
onDragEnd={() => setArrastando(false)}
|
|
196
|
+
tabIndex={disabled ? -1 : 0}
|
|
197
|
+
onKeyDown={aoTeclar}
|
|
198
|
+
aria-roledescription="Item reordenavel"
|
|
199
|
+
aria-label={`${rotulo}. ${indice + 1} de ${total}. ${textos.instructions}`}
|
|
200
|
+
style={{ listStyle: 'none' }}
|
|
201
|
+
>
|
|
202
|
+
<Paper withBorder p="xs" radius="md" shadow={arrastando ? 'md' : undefined}>
|
|
203
|
+
<Group gap="sm" wrap="nowrap">
|
|
204
|
+
<ActionIcon
|
|
205
|
+
variant="subtle"
|
|
206
|
+
color="gray"
|
|
207
|
+
aria-label={textos.dragHandle}
|
|
208
|
+
disabled={disabled}
|
|
209
|
+
// O arrasto so comeca pela alca: arrastar o item inteiro impede
|
|
210
|
+
// selecionar texto e clicar em controles dentro dele.
|
|
211
|
+
onPointerDown={(evento) => !disabled && controles.start(evento)}
|
|
212
|
+
style={{ cursor: disabled ? 'not-allowed' : 'grab', touchAction: 'none' }}
|
|
213
|
+
>
|
|
214
|
+
<IconGripVertical size={18} />
|
|
215
|
+
</ActionIcon>
|
|
216
|
+
|
|
217
|
+
<div style={{ flex: 1, minWidth: 0 }}>
|
|
218
|
+
{renderItem ? (
|
|
219
|
+
renderItem(item, { arrastando, indice })
|
|
220
|
+
) : (
|
|
221
|
+
<>
|
|
222
|
+
<Text size="sm" fw={500} truncate>
|
|
223
|
+
{rotulo}
|
|
224
|
+
</Text>
|
|
225
|
+
{descricao && (
|
|
226
|
+
<Text size="xs" c="dimmed" truncate>
|
|
227
|
+
{descricao}
|
|
228
|
+
</Text>
|
|
229
|
+
)}
|
|
230
|
+
</>
|
|
231
|
+
)}
|
|
232
|
+
</div>
|
|
233
|
+
|
|
234
|
+
{onRemove && (
|
|
235
|
+
<ActionIcon
|
|
236
|
+
variant="subtle"
|
|
237
|
+
color="red"
|
|
238
|
+
aria-label={`${textos.remove}: ${rotulo}`}
|
|
239
|
+
disabled={disabled}
|
|
240
|
+
onClick={() => onRemove(item)}
|
|
241
|
+
>
|
|
242
|
+
<IconX size={16} />
|
|
243
|
+
</ActionIcon>
|
|
244
|
+
)}
|
|
245
|
+
</Group>
|
|
246
|
+
</Paper>
|
|
247
|
+
</Reorder.Item>
|
|
248
|
+
);
|
|
249
|
+
}
|
package/src/list/index.ts
CHANGED
|
@@ -10,3 +10,6 @@ export * from './treeview';
|
|
|
10
10
|
|
|
11
11
|
export { ArchbaseTreeList } from './ArchbaseTreeList';
|
|
12
12
|
export type { ArchbaseTreeListProps, ArchbaseTreeListColumn } from './ArchbaseTreeList';
|
|
13
|
+
|
|
14
|
+
export { ArchbaseReorderList } from './ArchbaseReorderList';
|
|
15
|
+
export type { ArchbaseReorderListProps } from './ArchbaseReorderList';
|