@sapphire-caitlyn/schema-kit-ng 0.0.4 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +2 -0
  2. package/docs/inputs/date/input-date/README.md +37 -0
  3. package/docs/inputs/date/input-date-span/README.md +36 -0
  4. package/docs/inputs/date/input-date-time/README.md +32 -0
  5. package/docs/inputs/date/input-date-time-span/README.md +32 -0
  6. package/docs/inputs/date/input-time/README.md +28 -0
  7. package/docs/inputs/date/input-time-span/README.md +29 -0
  8. package/docs/inputs/input-base/README.md +42 -0
  9. package/docs/inputs/input-bool/README.md +30 -0
  10. package/docs/inputs/input-color/README.md +24 -0
  11. package/docs/inputs/input-color/color-picker/README.md +25 -0
  12. package/docs/inputs/input-cpf-cnpj/README.md +34 -0
  13. package/docs/inputs/input-file/README.md +33 -0
  14. package/docs/inputs/input-icon/README.md +28 -0
  15. package/docs/inputs/input-json/README.md +36 -0
  16. package/docs/inputs/input-mask/README.md +39 -0
  17. package/docs/inputs/input-number/README.md +44 -0
  18. package/docs/inputs/input-phone/README.md +25 -0
  19. package/docs/inputs/input-select/README.md +42 -0
  20. package/docs/inputs/input-string/README.md +32 -0
  21. package/docs/inputs/input-text-area/README.md +27 -0
  22. package/docs/schema-table/README.md +444 -0
  23. package/docs/schema-view/README.md +439 -0
  24. package/docs/ui/blurry-text-reveal/README.md +49 -0
  25. package/docs/ui/breadcrumbs/README.md +62 -0
  26. package/docs/ui/file-preview/README.md +60 -0
  27. package/docs/ui/infinte-scroll/README.md +10 -0
  28. package/docs/ui/loading/README.md +62 -0
  29. package/docs/ui/save-button/README.md +68 -0
  30. package/docs/ui/schema-dialog/README.md +154 -0
  31. package/docs/ui/suspended-slider/README.md +95 -0
  32. package/docs/ui/tree-drag-drop/README.md +202 -0
  33. package/fesm2022/sapphire-caitlyn-schema-kit-ng-helm.mjs.map +1 -1
  34. package/fesm2022/sapphire-caitlyn-schema-kit-ng.mjs +106 -103
  35. package/fesm2022/sapphire-caitlyn-schema-kit-ng.mjs.map +1 -1
  36. package/package.json +1 -1
  37. package/types/sapphire-caitlyn-schema-kit-ng.d.ts +93 -17
@@ -0,0 +1,62 @@
1
+ # loading
2
+
3
+ Indicador de carregamento: um spinner (`hlm-spinner`) centralizado sobre um fundo translúcido com bordas esmaecidas, que
4
+ entra e sai com animação.
5
+
6
+ Exemplos reais de uso: [profile](../../../../../../../src/app/pages/profile/profile.component.html) (sobre um card) e
7
+ [login](../../../../../../../src/app/pages/auth/login/login.component.html) (dentro de botões).
8
+
9
+ ## Uso
10
+
11
+ ```ts
12
+ import { LoadingComponent } from '@schema-kit';
13
+
14
+ @Component({
15
+ imports: [LoadingComponent],
16
+ // ...
17
+ })
18
+ export class ExemploComponent {}
19
+ ```
20
+
21
+ ### Sobre uma área (`absolute`, padrão)
22
+
23
+ Cobre o elemento pai inteiro. O pai precisa ser posicionado (`relative`, `absolute` etc.):
24
+
25
+ ```html
26
+ <div class="relative">
27
+ <loading [active]="carregando()" />
28
+ <!-- conteúdo -->
29
+ </div>
30
+ ```
31
+
32
+ ### Dentro de um botão (`relative`)
33
+
34
+ Ocupa espaço no layout, ao lado do texto. Ao ficar inativo, a largura vai a 0 com transição e o restante do botão
35
+ desliza para o lugar. Use `gap-0` no botão para não sobrar espaço quando o indicador some:
36
+
37
+ ```html
38
+ <button hlmBtn class="gap-0" [disabled]="enviando()" (click)="enviar()">
39
+ <loading [active]="enviando()" position="relative" />
40
+ {{ enviando() ? 'Enviando...' : 'Enviar' }}
41
+ </button>
42
+ ```
43
+
44
+ ## API
45
+
46
+ ### Inputs
47
+
48
+ | Input | Tipo | Padrão | Descrição |
49
+ | ---------- | -------------------------- | ------------ | -------------------------------------------------------------------------------------- |
50
+ | `active` | `boolean` | `true` | Exibe ou esconde o indicador |
51
+ | `position` | `'absolute' \| 'relative'` | `'absolute'` | `absolute`: cobre o pai (`absolute inset-0`). `relative`: ocupa espaço no layout |
52
+
53
+ ## Comportamentos a saber
54
+
55
+ - **Continua no DOM:** com `active` = `false` o elemento não é removido. Ele faz fade out, passa a
56
+ `pointer-events-none` (não bloqueia cliques) e segue na árvore de acessibilidade. Para removê-lo de fato, use `@if`.
57
+ - **Animação:** entra com fade deslizando a partir da esquerda e sai deslizando para a esquerda (classes do
58
+ `tw-animate-css`).
59
+ - **Sobreposição:** no modo `absolute`, enquanto ativo, fica por cima do conteúdo não posicionado do pai e recebe os
60
+ cliques. Não há `z-index`: elementos posicionados que vêm depois dele no DOM ficam por cima.
61
+ - **Acessibilidade:** o `hlm-spinner` tem `role="status"` e `aria-label="Loading"`, em inglês. O componente não permite
62
+ trocar esse rótulo.
@@ -0,0 +1,68 @@
1
+ # save-button
2
+
3
+ Botão padrão de salvar, com estado "salvando": troca o ícone por um spinner, o texto por "Salvando..." e mostra um brilho
4
+ percorrendo a borda (`border-shimmer`).
5
+
6
+ Usado no rodapé do [schema-view](../../schema-view/schema-view.component.html) e em dialogs como o
7
+ [modulo-create-dialog](../../../../../../../src/app/pages/modulo/modulo-create-dialog/modulo-create-dialog.component.html).
8
+
9
+ ## Uso
10
+
11
+ ```ts
12
+ import { SaveButtonComponent } from '@schema-kit';
13
+
14
+ @Component({
15
+ imports: [SaveButtonComponent],
16
+ // ...
17
+ })
18
+ export class ExemploComponent {}
19
+ ```
20
+
21
+ ```html
22
+ <save-button [saving]="salvando()" (save)="salvar()" />
23
+ ```
24
+
25
+ ### Com um `schema-view`
26
+
27
+ Para colocar o botão fora do `schema-view` (ex.: no rodapé de um dialog), esconda o botão dele com `noSave` e ligue o
28
+ `save-button` ao estado do view:
29
+
30
+ ```html
31
+ <schema-view #view [noSave]="true" [schema]="schema()" ... />
32
+
33
+ <hlm-dialog-footer>
34
+ <save-button [saving]="view.saving()" (save)="view.save()" />
35
+ </hlm-dialog-footer>
36
+ ```
37
+
38
+ ## API
39
+
40
+ ### Inputs
41
+
42
+ | Input | Tipo | Padrão | Descrição |
43
+ | -------- | --------- | ------- | ------------------------------- |
44
+ | `saving` | `boolean` | `false` | Ativa o estado "Salvando..." |
45
+
46
+ ### Outputs
47
+
48
+ | Output | Tipo | Descrição |
49
+ | ------ | ------ | ---------------------- |
50
+ | `save` | `void` | Emitido a cada clique |
51
+
52
+ ### Estados
53
+
54
+ | Estado | Variante | Ícone | Texto |
55
+ | -------- | ---------------------------- | --------------------------------- | ------------- |
56
+ | Normal | `default` | `lucideSave` | Salvar |
57
+ | Salvando | `ghost` + `border-shimmer` | `lucideRefreshCw` (girando) | Salvando... |
58
+
59
+ ## Comportamentos a saber
60
+
61
+ - **Não desabilita:** durante `saving` o botão continua clicável, e cada clique emite `save` de novo. Se a ação não
62
+ pode rodar duas vezes ao mesmo tempo, proteja no handler. O `save()` do `schema-view` também não verifica se já está
63
+ salvando.
64
+ - **`type`:** o `<button>` não define `type`, então dentro de um `<form>` ele funciona como `submit`.
65
+ - **Fixos:** os textos ("Salvar" / "Salvando...") e o tamanho (`sm`) não são configuráveis.
66
+ - **Ícones:** o componente não registra os próprios ícones. Ele depende do `provideIcons` global do app
67
+ ([app.config.ts](../../../../../../../src/app/app.config.ts)), que registra todos os ícones Lucide. Em outro app,
68
+ registre `lucideSave` e `lucideRefreshCw`.
@@ -0,0 +1,154 @@
1
+ # schema-dialog
2
+
3
+ Abre dialogs (`hlm-dialog` do Spartan) por código e devolve o resultado como uma `Promise`. São duas peças:
4
+
5
+ - `SchemaDialogComponent<Result>`: classe base do componente do dialog;
6
+ - `SchemaDialogController`: serviço que cria o componente, passa os inputs, abre o dialog e resolve `closed` quando ele
7
+ fecha.
8
+
9
+ Exemplos reais de uso: [modulo-create-dialog](../../../../../../../src/app/pages/modulo/modulo-create-dialog/modulo-create-dialog.component.ts)
10
+ (fecha com `true` ao criar; aberto em [modulo.component.ts](../../../../../../../src/app/pages/modulo/modulo.component.ts))
11
+ e [perfil-reorder](../../../../../../../src/app/pages/perfil/perfil-reorder/perfil-reorder.component.ts) (fecha com a
12
+ hierarquia montada).
13
+
14
+ ## Uso
15
+
16
+ ### 1. O componente do dialog
17
+
18
+ Estenda `SchemaDialogComponent` informando o tipo do resultado. O template precisa ter um `<hlm-dialog>`, com o conteúdo
19
+ dentro de `*hlmDialogPortal`:
20
+
21
+ ```ts
22
+ import { SchemaDialogComponent } from '@schema-kit';
23
+
24
+ /** Fecha com `true` quando o módulo é criado. */
25
+ @Component({
26
+ selector: 'modulo-create-dialog',
27
+ templateUrl: './modulo-create-dialog.component.html',
28
+ })
29
+ export class ModuloCreateDialogComponent extends SchemaDialogComponent<boolean> {
30
+ uuidModulo = input<string>();
31
+
32
+ async criar() {
33
+ // ...
34
+ this.close(true);
35
+ }
36
+ }
37
+ ```
38
+
39
+ ```html
40
+ <hlm-dialog>
41
+ <hlm-dialog-content *hlmDialogPortal="let ctx">
42
+ <hlm-dialog-header>
43
+ <h3 hlmDialogTitle>Novo módulo</h3>
44
+ </hlm-dialog-header>
45
+
46
+ <!-- conteúdo -->
47
+
48
+ <hlm-dialog-footer>
49
+ <button hlmBtn variant="outline" hlmDialogClose>Cancelar</button>
50
+ <button hlmBtn (click)="criar()">Criar</button>
51
+ </hlm-dialog-footer>
52
+ </hlm-dialog-content>
53
+ </hlm-dialog>
54
+ ```
55
+
56
+ ### 2. Abrindo o dialog
57
+
58
+ ```ts
59
+ import { SchemaDialogController } from '@schema-kit';
60
+
61
+ export class ModuloComponent {
62
+ private schemaDialogController = inject(SchemaDialogController);
63
+
64
+ async openCreateDialog() {
65
+ const { closed } = this.schemaDialogController.open(ModuloCreateDialogComponent, { uuidModulo: this.uuidModulo() });
66
+ if (await closed) {
67
+ this.table().refresh();
68
+ }
69
+ }
70
+ }
71
+ ```
72
+
73
+ O segundo argumento é tipado a partir do componente: aceita só os inputs de signal (`input()` / `model()`), com o valor
74
+ direto (não o signal). `closed` usa o `Result` da classe base: aqui, `Promise<boolean | undefined>`.
75
+
76
+ ## API
77
+
78
+ ### `SchemaDialogComponent<Result = void>`
79
+
80
+ | Membro | Tipo | Descrição |
81
+ | --------------- | ---------------------------- | ---------------------------------------------------------------------------------- |
82
+ | `dialog` | `Signal<HlmDialog>` | O `hlm-dialog` do template (`viewChild.required`). Dá acesso a `open()` e `close()` |
83
+ | `close(result)` | `(result: Result) => void` | Fecha o dialog e resolve `closed` com `result` |
84
+
85
+ Com `Result = void`, `close()` pode ser chamado sem argumento.
86
+
87
+ ### `SchemaDialogController.open(component, inputs?, options?)`
88
+
89
+ | Parâmetro | Tipo | Descrição |
90
+ | ----------- | ---------------------- | ------------------------------------------------------------------------------- |
91
+ | `component` | `Type<T>` | Componente do dialog (`T` estende `SchemaDialogComponent`) |
92
+ | `inputs` | `DialogInputs<T>?` | Valores dos inputs, aplicados com `setInput` antes de abrir |
93
+ | `options` | `ISchemaDialogOptions?`| Ver abaixo |
94
+
95
+ `ISchemaDialogOptions`:
96
+
97
+ | Opção | Tipo | Padrão | Descrição |
98
+ | ---------------- | --------- | ------ | ------------------------------------------ |
99
+ | `destroyOnClose` | `boolean` | `true` | Destrói o componente quando o dialog fecha |
100
+ | `open` | `boolean` | `true` | Abre o dialog logo depois de criar |
101
+
102
+ Retorno, `ISchemaDialogReturn<T>`:
103
+
104
+ | Campo | Tipo | Descrição |
105
+ | ----------- | ------------------------------ | --------------------------------------------------------------------------------------------- |
106
+ | `component` | `ComponentRef<T>` | Ref do componente criado |
107
+ | `instance` | `T` | Instância do componente |
108
+ | `closed` | `Promise<Result \| undefined>` | Resolve quando o dialog fecha. Ver [Resultado](#resultado) |
109
+
110
+ ## Ciclo de vida
111
+
112
+ ### Resultado
113
+
114
+ `closed` resolve **uma única vez**, no primeiro fechamento:
115
+
116
+ | Como fechou | Valor |
117
+ | --------------------------------------------------- | -------------------- |
118
+ | `close(result)` | `result` |
119
+ | `hlmDialogClose`, ESC, clique fora, `dialog().close()` | `undefined` |
120
+ | Ref destruída sem o dialog ter fechado | `undefined` |
121
+
122
+ Por isso, trate `undefined` como "cancelado": `if (await closed)` só segue quando houve resultado.
123
+
124
+ ### Destruição
125
+
126
+ - **Padrão:** ao fechar, `closed` resolve primeiro e o componente é destruído em seguida.
127
+ - **`destroyOnClose: false`:** o componente sobrevive ao fechamento e pode ser reaberto com `instance.dialog().open()`
128
+ (sem novo valor em `closed`). Destruir a ref com `component.destroy()` fica a cargo de quem abriu.
129
+ - **`open: false`:** o componente é criado, mas não abre. Abra depois com `instance.dialog().open()`; a partir daí vale o
130
+ `destroyOnClose`. Se o dialog nunca for aberto, quem chamou precisa destruir a ref.
131
+
132
+ ## Comportamentos a saber
133
+
134
+ - **Injeção:** o componente é criado com o `EnvironmentInjector` raiz (o controller é um `@Service()` global). Ele não
135
+ enxerga providers do componente que o abriu, nem de rotas ou módulos lazy. Os serviços que o dialog injeta precisam
136
+ estar na raiz (`@Service()` ou `providedIn: 'root'`).
137
+ - **Fora do DOM:** o elemento host do componente não é inserido na página; só o conteúdo de `*hlmDialogPortal` aparece,
138
+ no overlay do dialog. O que estiver no template fora do portal nunca é exibido.
139
+ - **Detecção de mudanças:** a view é anexada ao `ApplicationRef`, então o dialog é atualizado como qualquer outro
140
+ componente.
141
+ - **`dialog` obrigatório:** o `<hlm-dialog>` precisa estar no template do próprio componente, não em um filho. Sem ele, o
142
+ `viewChild.required` lança erro na abertura.
143
+ - **`<schema-dialog>`:** não use o seletor diretamente. O template da classe base é só um aviso de implementação
144
+ incorreta; as subclasses sempre declaram o próprio template.
145
+ - **Inputs com `@Input()`:** não entram no tipo `DialogInputs` e não podem ser passados em `open`. Use `input()` ou
146
+ `model()`.
147
+
148
+ ## Implementação
149
+
150
+ | Arquivo | Conteúdo |
151
+ | ------------------------------------- | -------------------------------------------------------------------------------- |
152
+ | `schema-dialog.component.ts` | Classe base `SchemaDialogComponent` |
153
+ | `schema-dialog.component.html` | Aviso exibido apenas se `<schema-dialog>` for usado diretamente |
154
+ | `schema-dialog-controller.service.ts` | `SchemaDialogController`, `ISchemaDialogOptions`, `ISchemaDialogReturn` e os tipos internos `DialogInputs` / `DialogResult` |
@@ -0,0 +1,95 @@
1
+ # suspended-slider
2
+
3
+ Alterna entre slides nomeados, com transição: o slide ativo entra com fade deslizando da direita e o anterior sai para a
4
+ direita. Os slides inativos continuam renderizados ("suspensos"), então mantêm o estado (formulários, scroll,
5
+ componentes filhos) quando voltam a ficar ativos.
6
+
7
+ Exemplos reais de uso: [login](../../../../../../../src/app/pages/auth/login/login.component.html) (etapas de login,
8
+ "esqueci a senha" e OTP) e [nav-sidebar](../../../../../../../src/app/components/nav-sidebar/nav-sidebar.component.html)
9
+ (um slide por menu de primeiro nível, gerados com `@for`).
10
+
11
+ ## Uso
12
+
13
+ Importe o componente e a diretiva dos slides:
14
+
15
+ ```ts
16
+ import { SuspendedSlideDirective, SuspendedSliderComponent } from '@schema-kit';
17
+
18
+ @Component({
19
+ imports: [SuspendedSliderComponent, SuspendedSlideDirective],
20
+ // ...
21
+ })
22
+ export class ExemploComponent {
23
+ step = signal<'login' | 'senha'>('login');
24
+ }
25
+ ```
26
+
27
+ ```html
28
+ <suspended-slider class="h-96" [step]="step()">
29
+ <ng-template suspendedSlide="login">
30
+ <!-- formulário de login -->
31
+ <a (click)="step.set('senha')">Esqueceu sua senha?</a>
32
+ </ng-template>
33
+
34
+ <ng-template suspendedSlide="senha">
35
+ <!-- formulário de recuperação -->
36
+ <button (click)="step.set('login')">Voltar</button>
37
+ </ng-template>
38
+ </suspended-slider>
39
+ ```
40
+
41
+ Os slides também podem ser gerados dinamicamente:
42
+
43
+ ```html
44
+ <suspended-slider class="min-h-0 flex-1" [step]="activeMenu()">
45
+ @for (topLevel of items(); track topLevel.uuid) {
46
+ <ng-template [suspendedSlide]="topLevel.uuid">
47
+ <ul class="size-full overflow-y-auto">
48
+ <!-- ... -->
49
+ </ul>
50
+ </ng-template>
51
+ }
52
+ </suspended-slider>
53
+ ```
54
+
55
+ ## API
56
+
57
+ ### `suspended-slider`
58
+
59
+ | Input | Tipo | Descrição |
60
+ | ------ | ----------------------- | -------------------- |
61
+ | `step` | `string` (obrigatório) | Nome do slide ativo |
62
+
63
+ ### `ng-template[suspendedSlide]`
64
+
65
+ | Input | Tipo | Descrição |
66
+ | ---------------- | ---------------------- | ------------------------------------ |
67
+ | `suspendedSlide` | `string` (obrigatório) | Nome do slide, comparado com `step` |
68
+
69
+ ## Layout
70
+
71
+ - Os slides são empilhados com `absolute inset-0`, então **o slider não cresce com o conteúdo**. Dê um tamanho a ele
72
+ (ex.: `absolute inset-0`, `h-96` ou `flex-1 min-h-0` dentro de um container flex). O host é `block`.
73
+ - Cada slide ocupa a área toda e centraliza o conteúdo nos dois eixos (`flex items-center justify-center`). Para
74
+ rolagem, use `size-full overflow-y-auto` no conteúdo do slide, como no nav-sidebar.
75
+ - As camadas internas usam `p-[inherit]`: o padding aplicado no host se propaga para dentro dos slides.
76
+
77
+ ## Comportamentos a saber
78
+
79
+ - **Todos os slides ficam montados:** cada template é instanciado uma vez e continua no DOM mesmo inativo. Componentes,
80
+ `effect`s e requisições dentro de slides inativos seguem vivos. Se isso for um problema, use `@if` dentro do slide,
81
+ sabendo que o conteúdo perde o estado e some sem a animação de saída.
82
+ - **Slides inativos:** recebem `inert` e `aria-hidden="true"`. Não recebem foco nem cliques e ficam ocultos para leitores
83
+ de tela.
84
+ - **`step` sem slide correspondente:** nenhum slide fica visível.
85
+ - **Nomes únicos:** os slides são rastreados pelo nome (`track slide.name()`). Dois slides com o mesmo nome ficam ativos
86
+ juntos, e o Angular avisa sobre chaves duplicadas.
87
+ - **Direção:** a transição é sempre para a direita, independentemente da ordem dos slides. Não há animação invertida ao
88
+ "voltar".
89
+
90
+ ## Implementação
91
+
92
+ | Arquivo | Conteúdo |
93
+ | ------------------------------------ | --------------------------------------------------------------------- |
94
+ | `suspended-slider.component.ts/html` | Componente: empilha os slides e aplica as classes de entrada e saída |
95
+ | `suspended-slide.directive.ts` | Diretiva `suspendedSlide`, que expõe o `TemplateRef` e o nome do slide |
@@ -0,0 +1,202 @@
1
+ # tree-drag-drop
2
+
3
+ Árvore com reordenação por drag and drop, inclusive entre níveis: um item pode ser reordenado entre os irmãos, virar
4
+ filho de outro ou subir de nível. Também funciona pelo teclado.
5
+
6
+ Exemplo real de uso: [perfil-reorder](../../../../../../../src/app/pages/perfil/perfil-reorder/perfil-reorder.component.ts).
7
+
8
+ ## Uso
9
+
10
+ Importe o componente e, se for customizar o conteúdo dos nós, a diretiva:
11
+
12
+ ```ts
13
+ import { ITreeNode, ITreeNodeMove, TreeDragDropComponent, TreeDragDropNodeDirective } from '@schema-kit';
14
+
15
+ @Component({
16
+ imports: [TreeDragDropComponent, TreeDragDropNodeDirective],
17
+ // ...
18
+ })
19
+ export class ExemploComponent {
20
+ tree = signal<ITreeNode<IPerfil>[]>([]);
21
+
22
+ onMoved(event: ITreeNodeMove<IPerfil>) {
23
+ // `tree` já está atualizada aqui
24
+ }
25
+ }
26
+ ```
27
+
28
+ ```html
29
+ <tree-drag-drop [(nodes)]="tree" label="Hierarquia dos perfis" (nodeMoved)="onMoved($event)" />
30
+ ```
31
+
32
+ A árvore é controlada por `nodes`: a cada movimentação o componente grava uma **nova referência** da árvore no
33
+ `model`, e quem usa decide quando e como persistir.
34
+
35
+ ### Montando a árvore
36
+
37
+ O componente recebe a árvore pronta. A partir de uma lista plana com o id do pai:
38
+
39
+ ```ts
40
+ function buildTree(lstPerfil: IPerfil[]): ITreeNode<IPerfil>[] {
41
+ const setUuid = new Set(lstPerfil.map((p) => p.uuid));
42
+ const build = (isChild: (p: IPerfil) => boolean): ITreeNode<IPerfil>[] =>
43
+ lstPerfil
44
+ .filter(isChild)
45
+ .map((perfil) => ({
46
+ id: perfil.uuid,
47
+ label: perfil.nome,
48
+ data: perfil,
49
+ children: build((p) => p.uuidPerfilPai === perfil.uuid),
50
+ }));
51
+ // Raízes: sem pai ou com o pai fora da lista
52
+ return build((p) => !p.uuidPerfilPai || !setUuid.has(p.uuidPerfilPai));
53
+ }
54
+ ```
55
+
56
+ ### Lendo o resultado
57
+
58
+ Para salvar, percorra `nodes` em pré-ordem: o pai de cada item é o nó em que ele está, e a posição entre os irmãos é a
59
+ ordem. Por exemplo, para atribuir uma `ordem` sequencial em cada nível:
60
+
61
+ ```ts
62
+ function toOrdem(nodes: ITreeNode<IMenu>[], uuidPai: string | null = null): { uuid: string; uuidPai: string | null; ordem: number }[] {
63
+ return nodes.flatMap((node, index) => [
64
+ { uuid: node.data!.uuid, uuidPai, ordem: index + 1 },
65
+ ...toOrdem(node.children ?? [], node.data!.uuid),
66
+ ]);
67
+ }
68
+ ```
69
+
70
+ Se a lista vier filtrada (ex.: por módulo), itens cujo pai ficou de fora aparecem na raiz. Ao salvar, preserve o pai
71
+ original desses itens para não desfazer o vínculo sem querer (o `toHierarquia` do perfil-reorder faz isso).
72
+
73
+ ## API
74
+
75
+ ### `ITreeNode<T>`
76
+
77
+ | Campo | Tipo | Descrição |
78
+ | ---------- | ------------------ | ------------------------------------------------------------------ |
79
+ | `id` | `string \| number` | Identificador único na árvore inteira |
80
+ | `label` | `string` | Texto exibido e nome acessível do item |
81
+ | `icon` | `string?` | Nome de um ícone registrado no ng-icons, exibido antes do label |
82
+ | `data` | `T?` | Objeto de origem; o componente nunca o altera |
83
+ | `children` | `ITreeNode<T>[]?` | Filhos, na ordem de exibição |
84
+
85
+ ### Inputs
86
+
87
+ | Input | Tipo | Padrão | Descrição |
88
+ | ----------- | ------------------------------------------------------------ | ------------ | ----------------------------------------------------------------------------------------- |
89
+ | `nodes` | `ITreeNode<T>[]` (model, obrigatório) | — | Árvore exibida. Use `[(nodes)]` para receber as atualizações |
90
+ | `label` | `string` | — | Rótulo acessível da árvore (`aria-label`) |
91
+ | `indent` | `number` | `24` | Recuo, em px, de cada nível. Também é quanto se arrasta na horizontal para mudar de nível |
92
+ | `maxLevels` | `number` | `Infinity` | Quantidade máxima de níveis (1 = lista plana) |
93
+ | `canDrop` | `(node: ITreeNode<T>, parent: ITreeNode<T> \| null) => boolean` | `() => true` | Define se `node` pode ser filho de `parent` (`null` = raiz) |
94
+ | `canDrag` | `(node: ITreeNode<T>) => boolean` | `() => true` | Define se `node` pode ser movido. Os bloqueados mostram um cadeado no lugar da alça |
95
+ | `disabled` | `boolean` | `false` | Bloqueia arrasto e movimentação pelo teclado; a navegação continua funcionando |
96
+
97
+ ### Outputs
98
+
99
+ | Output | Tipo | Descrição |
100
+ | ----------- | ------------------ | -------------------------------------------------------------------- |
101
+ | `nodeMoved` | `ITreeNodeMove<T>` | Emitido após cada movimentação, com `nodes` já atualizada |
102
+
103
+ `ITreeNodeMove<T>`:
104
+
105
+ | Campo | Tipo | Descrição |
106
+ | ---------------- | ---------------------- | ----------------------------------------------- |
107
+ | `node` | `ITreeNode<T>` | Nó movido (mesma referência, com os filhos) |
108
+ | `previousParent` | `ITreeNode<T> \| null` | Pai anterior (`null` = raiz) |
109
+ | `previousIndex` | `number` | Posição anterior entre os irmãos |
110
+ | `parent` | `ITreeNode<T> \| null` | Novo pai, já com `node` entre os filhos |
111
+ | `index` | `number` | Nova posição entre os irmãos |
112
+
113
+ Soltar o item onde ele já estava não emite nada.
114
+
115
+ ### Template do nó: `treeDragDropNode`
116
+
117
+ Substitui o conteúdo padrão (ícone + label) de cada item. A alça de arrasto e o botão de expandir continuam sendo do
118
+ componente.
119
+
120
+ ```html
121
+ <tree-drag-drop [(nodes)]="tree">
122
+ <ng-template treeDragDropNode let-node let-level="level" let-expanded="expanded">
123
+ <div class="flex min-w-0 flex-col">
124
+ <span class="truncate text-sm">{{ node.label }}</span>
125
+ <span class="text-muted-foreground truncate text-xs">{{ node.data.descricao }}</span>
126
+ </div>
127
+ </ng-template>
128
+ </tree-drag-drop>
129
+ ```
130
+
131
+ | Variável | Tipo | Descrição |
132
+ | ------------ | -------------- | ----------------------------------------------- |
133
+ | `$implicit` | `ITreeNode<T>` | O nó |
134
+ | `level` | `number` | Nível, começando em 1 |
135
+ | `expanded` | `boolean` | Se os filhos estão visíveis |
136
+
137
+ ## Interação
138
+
139
+ ### Mouse e toque
140
+
141
+ O arrasto começa pela alça (ícone à esquerda do item). Itens bloqueados por `canDrag` mostram um cadeado no lugar dela
142
+ e não podem ser arrastados.
143
+
144
+ - **Vertical** define a posição.
145
+ - **Horizontal** define o nível: cada `indent` px para a direita desce um nível, para a esquerda sobe um.
146
+
147
+ O placeholder tracejado mostra onde o item vai cair, já no nível final. O nível possível fica sempre entre o do próximo
148
+ item (para não tomar os filhos dele) e o do item de cima + 1 (no máximo vira filho dele). Dentro desse intervalo, vale o
149
+ nível mais próximo do arrasto que `maxLevels` e `canDrop` aceitarem. Se nenhum for aceito, o placeholder fica vermelho e
150
+ soltar ali não altera nada.
151
+
152
+ Ao arrastar um item com filhos, os descendentes saem da lista e vão junto com ele; o preview mostra quantos são
153
+ (`+N`). Soltar logo abaixo de um item recolhido, com recuo, coloca o item como **primeiro** filho dele. O pai que recebe
154
+ um item é expandido.
155
+
156
+ ### Teclado
157
+
158
+ A árvore segue o padrão WAI-ARIA de tree view, com um único item na ordem de Tab.
159
+
160
+ | Tecla | Ação |
161
+ | --------------------- | ---------------------------------------------------------- |
162
+ | `↑` / `↓` | Item anterior / próximo |
163
+ | `Home` / `End` | Primeiro / último item |
164
+ | `→` | Expande; se já expandido, vai para o primeiro filho |
165
+ | `←` | Recolhe; se já recolhido ou sem filhos, vai para o pai |
166
+ | `Alt` + `↑` / `↓` | Move entre os irmãos |
167
+ | `Alt` + `→` | Vira o **último** filho do irmão de cima |
168
+ | `Alt` + `←` | Sobe um nível, logo depois do pai atual |
169
+
170
+ As movimentações pelo teclado respeitam `canDrag`, `maxLevels` e `canDrop` e são anunciadas para leitores de tela. Em
171
+ um item bloqueado, o leitor anuncia que ele não pode ser movido.
172
+
173
+ ## Comportamentos a saber
174
+
175
+ - **Validação:** `maxLevels` considera a subárvore inteira do item movido: um item com netos ocupa três níveis.
176
+ No arrasto, `canDrop` é consultado com o pai de destino em toda posição, mesmo quando o pai não muda; pelo teclado,
177
+ só em `Alt` + `→` / `←` (reordenar entre irmãos com `Alt` + `↑` / `↓` não consulta). `canDrag` bloqueia apenas o
178
+ próprio item: um item bloqueado ainda pode receber filhos, se `canDrop` permitir.
179
+ - **Ciclos:** não é possível colocar um item dentro dos próprios descendentes (eles saem da lista durante o arrasto).
180
+ - **Estado de expansão:** é interno e começa com tudo expandido.
181
+ - **Imutabilidade:** a cada movimentação só os nós no caminho até o pai antigo e o novo são recriados; os demais mantêm
182
+ a referência, e `data` nunca é alterado.
183
+ - **Dentro de dialogs:** durante o arrasto o CDK move o item para o `<body>`, e o clique ao soltar seria interpretado
184
+ como clique fora do dialog, fechando-o. O componente descarta esse clique.
185
+ - **Rolagem automática:** para rolar enquanto arrasta dentro de um container com scroll próprio (ex.: corpo de um
186
+ dialog), marque o container com `cdkScrollable` (`@angular/cdk/scrolling`). Sem isso, o CDK só rola a janela e a
187
+ própria lista.
188
+ - **`id`s** precisam ser únicos na árvore inteira, não só entre irmãos.
189
+
190
+ ## Implementação
191
+
192
+ | Arquivo | Conteúdo |
193
+ | ------------------------------------ | ------------------------------------------------------------------------- |
194
+ | `tree-drag-drop.component.ts` | Componente, tipos públicos, navegação e movimentação pelo teclado |
195
+ | `tree-drag-drop.component.html/scss` | Lista, placeholder, preview e estados visuais do arrasto |
196
+ | `tree-drag-drop.utils.ts` | Funções puras: achatar a árvore, calcular onde o item cai, mover nós |
197
+ | `tree-drag-drop-node.directive.ts` | Diretiva `treeDragDropNode` |
198
+
199
+ A árvore é renderizada como **uma única lista achatada** (`cdkDropList`) com recuo por nível, em vez de um
200
+ `cdkDropList` por nível. Listas aninhadas do CDK não funcionam bem para mover entre níveis: a lista externa captura o
201
+ drop destinado à interna e itens sem filhos não têm onde receber um drop. Na lista achatada, o CDK só cuida da posição
202
+ vertical; o nível vem do deslocamento horizontal (`projectDrop` em `tree-drag-drop.utils.ts`).