melhoria no componente de data

This commit is contained in:
Luiz Silva 2026-08-09 06:28:53 -03:00
parent dbf08a312d
commit 85658f80d0
12 changed files with 2542 additions and 2569 deletions

4708
dist/eli-vue.es.js vendored

File diff suppressed because one or more lines are too long

60
dist/eli-vue.umd.js vendored

File diff suppressed because one or more lines are too long

View file

@ -1,5 +1,14 @@
import dayjs from "dayjs";
import type { CelulaCalendario, EntradaDataHora } from "./useDataHora";
export interface CelulaCalendario {
dia: number;
data: dayjs.Dayjs;
atual: boolean;
hoje: boolean;
selecionado: boolean;
desabilitado: boolean;
}
export declare const OPCOES_HORAS: string[];
export declare const OPCOES_MINUTOS: string[];
/**
* Gera as 42 células (6 semanas) do calendário para o mês de referência.
* Preenche dias do mês anterior, atual e posterior para alinhar a grade.
@ -10,7 +19,7 @@ export declare function gerarCelulas(referencia: dayjs.Dayjs, diaSelecionado: da
* @param valor - string ISO 8601 ou null
* @param modo - "data" exibe apenas a data, "dataHora" inclui hora
*/
export declare function formatarValor(valor: string | null, modo: EntradaDataHora["opcoes"]["modo"]): string;
export declare function formatarValor(valor: string | null, modo: "data" | "dataHora"): string;
/**
* Aplica máscara visual de data/hora (DD/MM/YYYY ou DD/MM/YYYY HH:mm) em texto digitado.
*/

View file

@ -2,18 +2,10 @@ import dayjs from "dayjs";
import "dayjs/locale/pt-br";
import type { CampoDensidade, CampoVariante } from "../../../tipos";
import type { PadroesEntradas } from "../tiposEntradas";
import { type CelulaCalendario } from "./calendarioHelpers";
import { diasSemana, MESES } from "./useDataHoraProps";
export { diasSemana, MESES };
export { type CelulaCalendario, diasSemana, MESES };
export type EntradaDataHora = PadroesEntradas["dataHora"];
export interface CelulaCalendario {
dia: number;
data: dayjs.Dayjs;
atual: boolean;
hoje: boolean;
selecionado: boolean;
desabilitado: boolean;
}
/** Função de emit genérica do Vue (compatível com defineComponent) */
type EmitFn = (event: string, payload?: unknown) => void;
type Props = {
value?: string | null;
@ -405,6 +397,7 @@ export declare function useDataHora(props: Props, emit: EmitFn): {
fechar: () => void;
navegarMes: (delta: number) => void;
selecionarDia: (cell: CelulaCalendario) => void;
definirAgora: () => void;
confirmar: () => void;
limpar: () => void;
onTextInput: (e: Event) => void;

View file

@ -55,5 +55,5 @@ export declare function useDataHoraProps({ props, referencia, diaSelecionado }:
anoAtual: import("vue").ComputedRef<number>;
mesAtual: import("vue").ComputedRef<number>;
listaAnos: import("vue").ComputedRef<number[]>;
celulas: import("vue").ComputedRef<import("./useDataHora").CelulaCalendario[]>;
celulas: import("vue").ComputedRef<import("./calendarioHelpers").CelulaCalendario[]>;
};

View file

@ -1,6 +1,6 @@
{
"name": "eli-vue",
"version": "0.1.230",
"version": "0.1.237",
"private": false,
"main": "./dist/eli-vue.umd.js",
"module": "./dist/eli-vue.es.js",

View file

@ -162,64 +162,64 @@
.eli-dh__hora-secao {
border-top: 1px solid var(--cor-eli-borda, #D5D8CC);
padding: 12px 16px 14px;
padding: 10px 14px;
display: flex;
align-items: center;
gap: 10px;
justify-content: space-between;
gap: 8px;
background: var(--cor-eli-fundo-suave, #F8F9F5);
}
.eli-dh__hora-icone { width: 16px; height: 16px; color: var(--cor-eli-neutro, #5E6553); flex-shrink: 0; }
.eli-dh__hora-rotulo { display: flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 600; color: var(--cor-eli-neutro-escuro, #3E4436); }
.eli-dh__hora-icone { width: 16px; height: 16px; color: var(--cor-eli-primaria, #688C00); flex-shrink: 0; }
.eli-dh__hora-campos { display: flex; align-items: center; gap: 6px; }
.eli-dh__hora-campos { display: flex; align-items: center; gap: 6px; flex: 1; }
.eli-dh__hora-input {
width: 52px;
padding: 6px 8px;
.eli-dh__hora-select {
height: 32px;
padding: 2px 6px;
border: 1.5px solid var(--cor-eli-borda, #D5D8CC);
border-radius: 6px;
background: var(--cor-eli-superficie, #fff);
text-align: center;
font-size: 16px;
font-size: 13px;
font-weight: 600;
font-family: inherit;
color: var(--cor-eli-neutro-escuro, #3E4436);
transition: border-color 0.15s;
cursor: pointer;
outline: none;
-moz-appearance: textfield;
transition: border-color 0.15s, box-shadow 0.15s;
}
.eli-dh__hora-input::-webkit-inner-spin-button,
.eli-dh__hora-input::-webkit-outer-spin-button { -webkit-appearance: none; }
.eli-dh__hora-input:focus {
.eli-dh__hora-select:focus {
border-color: var(--cor-eli-primaria, #688C00);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--cor-eli-primaria, #688C00) 14%, transparent);
}
.eli-dh__hora-sep { font-size: 20px; font-weight: 700; color: var(--cor-eli-neutro, #5E6553); line-height: 1; margin-bottom: 2px; }
.eli-dh__hora-select option { background: var(--cor-eli-superficie, #fff); color: var(--cor-eli-neutro-escuro, #3E4436); font-weight: 600; }
.eli-dh__hora-sep { font-size: 16px; font-weight: 700; color: var(--cor-eli-neutro, #5E6553); line-height: 1; }
.eli-dh__rodape {
padding: 10px 14px 12px;
display: flex;
justify-content: flex-end;
gap: 8px;
border-top: 1px solid var(--cor-eli-borda, #D5D8CC);
}
.eli-dh__btn {
.eli-dh__btn-agora {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 7px 16px;
border: none;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
justify-content: center;
height: 32px;
padding: 0 10px;
border: 1px solid var(--cor-eli-primaria, #688C00);
border-radius: 6px;
background: color-mix(in srgb, var(--cor-eli-primaria, #688C00) 10%, transparent);
color: var(--cor-eli-primaria, #688C00);
font-size: 12px;
font-weight: 700;
font-family: inherit;
cursor: pointer;
transition: background 0.14s, opacity 0.14s;
transition: background 0.15s, color 0.15s;
white-space: nowrap;
}
.eli-dh__btn-agora:hover { background: var(--cor-eli-primaria, #688C00); color: #fff; }
.eli-dh__rodape { padding: 10px 14px 12px; display: flex; align-items: center; justify-content: space-between; gap: 8px; border-top: 1px solid var(--cor-eli-borda, #D5D8CC); }
.eli-dh__rodape-acoes { display: flex; align-items: center; gap: 8px; margin-left: auto; }
.eli-dh__btn { display: inline-flex; align-items: center; gap: 5px; padding: 7px 16px; border: none; border-radius: 8px; font-size: 13px; font-weight: 600; font-family: inherit; cursor: pointer; transition: background 0.14s, opacity 0.14s; }
.eli-dh__btn--cancelar { background: var(--cor-eli-fundo, #F2F3EE); color: var(--cor-eli-neutro, #5E6553); }
.eli-dh__btn--cancelar:hover { background: var(--cor-eli-borda, #D5D8CC); }
.eli-dh__btn--confirmar { background: var(--cor-eli-primaria, #688C00); color: #fff; }

View file

@ -76,48 +76,58 @@
<!-- Seção de hora -->
<div v-if="modo === 'dataHora'" class="eli-dh__hora-secao">
<svg class="eli-dh__hora-icone" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
</svg>
<div class="eli-dh__hora-rotulo">
<svg class="eli-dh__hora-icone" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
</svg>
<span>Horário:</span>
</div>
<div class="eli-dh__hora-campos">
<input
:id="`eli-dh-hora-${uid}`"
<select
:value="horaTemp"
class="eli-dh__hora-input"
type="text"
inputmode="numeric"
maxlength="2"
placeholder="HH"
:aria-label="`Hora de ${rotulo}`"
@input="onHoraInput"
@blur="onHoraBlur"
@keydown="onHoraKeydown"
@focus="onInputFocusSelect"
class="eli-dh__hora-select"
:aria-label="`Selecionar hora de ${rotulo}`"
@change="onHoraSelectChange"
@click.stop
/>
>
<option v-for="h in OPCOES_HORAS" :key="h" :value="h">{{ h }}h</option>
</select>
<span class="eli-dh__hora-sep" aria-hidden="true">:</span>
<input
:id="`eli-dh-min-${uid}`"
<select
:value="minutoTemp"
class="eli-dh__hora-input"
type="text"
inputmode="numeric"
maxlength="2"
placeholder="MM"
:aria-label="`Minuto de ${rotulo}`"
@input="onMinutoInput"
@blur="onMinutoBlur"
@keydown="onMinutoKeydown"
@focus="onInputFocusSelect"
class="eli-dh__hora-select"
:aria-label="`Selecionar minuto de ${rotulo}`"
@change="onMinutoSelectChange"
@click.stop
/>
>
<option v-for="m in OPCOES_MINUTOS" :key="m" :value="m">{{ m }}m</option>
</select>
<button
class="eli-dh__btn-agora"
type="button"
title="Definir horário atual"
@click="$emit('definirAgora')"
>
Agora
</button>
</div>
</div>
<!-- Rodapé -->
<div class="eli-dh__rodape">
<button class="eli-dh__btn eli-dh__btn--cancelar" type="button" @click="$emit('fechar')">Cancelar</button>
<button class="eli-dh__btn eli-dh__btn--confirmar" type="button" :disabled="!diaSelecionado" @click="$emit('confirmar')">✓ Confirmar</button>
<button
v-if="modo === 'data'"
class="eli-dh__btn-agora"
type="button"
title="Definir para a data de hoje"
@click="$emit('definirAgora')"
>
Hoje
</button>
<div class="eli-dh__rodape-acoes">
<button class="eli-dh__btn eli-dh__btn--cancelar" type="button" @click="$emit('fechar')">Cancelar</button>
<button class="eli-dh__btn eli-dh__btn--confirmar" type="button" :disabled="!diaSelecionado" @click="$emit('confirmar')">✓ Confirmar</button>
</div>
</div>
</div>
</Transition>
@ -127,6 +137,7 @@
<script lang="ts">
import type dayjs from "dayjs"
import { defineComponent, type PropType } from "vue"
import { OPCOES_HORAS, OPCOES_MINUTOS } from "./calendarioHelpers"
import type { CelulaCalendario } from "./useDataHora"
import { diasSemana, MESES } from "./useDataHora"
import "./Calendario.css"
@ -151,6 +162,7 @@ export default defineComponent({
emits: {
fechar: () => true,
confirmar: () => true,
definirAgora: () => true,
navegarMes: (_delta: number) => true,
selecionarDia: (_cell: CelulaCalendario) => true,
mudarMes: (_mes: number) => true,
@ -158,83 +170,29 @@ export default defineComponent({
"update:horaTemp": (_v: string) => true,
"update:minutoTemp": (_v: string) => true,
},
setup(props, { emit }) {
setup(_props, { emit }) {
function onMesSelectChange(e: Event) {
const v = Number.parseInt((e.target as HTMLSelectElement).value, 10)
emit("mudarMes", v)
emit("mudarMes", Number.parseInt((e.target as HTMLSelectElement).value, 10))
}
function onAnoSelectChange(e: Event) {
const v = Number.parseInt((e.target as HTMLSelectElement).value, 10)
emit("mudarAno", v)
emit("mudarAno", Number.parseInt((e.target as HTMLSelectElement).value, 10))
}
function onHoraInput(e: Event) {
const raw = (e.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 2)
emit("update:horaTemp", raw)
function onHoraSelectChange(e: Event) {
emit("update:horaTemp", (e.target as HTMLSelectElement).value)
}
function onHoraBlur() {
const v = Number.parseInt(props.horaTemp, 10)
emit(
"update:horaTemp",
Number.isNaN(v) ? "00" : String(Math.min(23, Math.max(0, v))).padStart(2, "0"),
)
}
function onHoraKeydown(e: KeyboardEvent) {
if (e.key === "ArrowUp") {
e.preventDefault()
emit(
"update:horaTemp",
String(((Number.parseInt(props.horaTemp, 10) || 0) + 1) % 24).padStart(2, "0"),
)
} else if (e.key === "ArrowDown") {
e.preventDefault()
emit(
"update:horaTemp",
String(((Number.parseInt(props.horaTemp, 10) || 0) - 1 + 24) % 24).padStart(2, "0"),
)
}
}
function onMinutoInput(e: Event) {
const raw = (e.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 2)
emit("update:minutoTemp", raw)
}
function onMinutoBlur() {
const v = Number.parseInt(props.minutoTemp, 10)
emit(
"update:minutoTemp",
Number.isNaN(v) ? "00" : String(Math.min(59, Math.max(0, v))).padStart(2, "0"),
)
}
function onMinutoKeydown(e: KeyboardEvent) {
if (e.key === "ArrowUp") {
e.preventDefault()
emit(
"update:minutoTemp",
String(((Number.parseInt(props.minutoTemp, 10) || 0) + 1) % 60).padStart(2, "0"),
)
} else if (e.key === "ArrowDown") {
e.preventDefault()
emit(
"update:minutoTemp",
String(((Number.parseInt(props.minutoTemp, 10) || 0) - 1 + 60) % 60).padStart(2, "0"),
)
}
}
function onInputFocusSelect(e: Event) {
;(e.target as HTMLInputElement).select()
function onMinutoSelectChange(e: Event) {
emit("update:minutoTemp", (e.target as HTMLSelectElement).value)
}
return {
diasSemana,
MESES,
OPCOES_HORAS,
OPCOES_MINUTOS,
onMesSelectChange,
onAnoSelectChange,
onHoraInput,
onHoraBlur,
onHoraKeydown,
onMinutoInput,
onMinutoBlur,
onMinutoKeydown,
onInputFocusSelect,
onHoraSelectChange,
onMinutoSelectChange,
}
},
})

View file

@ -1,5 +1,16 @@
import dayjs from "dayjs"
import type { CelulaCalendario, EntradaDataHora } from "./useDataHora"
export interface CelulaCalendario {
dia: number
data: dayjs.Dayjs
atual: boolean
hoje: boolean
selecionado: boolean
desabilitado: boolean
}
export const OPCOES_HORAS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, "0"))
export const OPCOES_MINUTOS = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, "0"))
/**
* Gera as 42 células (6 semanas) do calendário para o mês de referência.
@ -62,10 +73,7 @@ export function gerarCelulas(
* @param valor - string ISO 8601 ou null
* @param modo - "data" exibe apenas a data, "dataHora" inclui hora
*/
export function formatarValor(
valor: string | null,
modo: EntradaDataHora["opcoes"]["modo"],
): string {
export function formatarValor(valor: string | null, modo: "data" | "dataHora"): string {
if (!valor) return ""
const d = dayjs(valor)
if (!d.isValid()) return ""

View file

@ -93,6 +93,7 @@
@mudarAno="mudarAno"
@update:horaTemp="atualizarHoraTemp"
@update:minutoTemp="atualizarMinutoTemp"
@definirAgora="definirAgora"
/>
</div>
</template>

View file

@ -3,27 +3,16 @@ import "dayjs/locale/pt-br"
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue"
import type { CampoDensidade, CampoVariante } from "../../../tipos"
import type { PadroesEntradas } from "../tiposEntradas"
import { formatarValor } from "./calendarioHelpers"
import { type CelulaCalendario, formatarValor } from "./calendarioHelpers"
import { useDataHoraInput } from "./useDataHoraInput"
import { useDataHoraPosicao } from "./useDataHoraPosicao"
import { diasSemana, MESES, useDataHoraProps } from "./useDataHoraProps"
export { diasSemana, MESES }
export { type CelulaCalendario, diasSemana, MESES }
dayjs.locale("pt-br")
export type EntradaDataHora = PadroesEntradas["dataHora"]
export interface CelulaCalendario {
dia: number
data: dayjs.Dayjs
atual: boolean
hoje: boolean
selecionado: boolean
desabilitado: boolean
}
/** Função de emit genérica do Vue (compatível com defineComponent) */
type EmitFn = (event: string, payload?: unknown) => void
type Props = {
@ -99,37 +88,31 @@ export function useDataHora(props: Props, emit: EmitFn) {
referencia.value = d.startOf("month")
horaTemp.value = d.format("HH")
minutoTemp.value = d.format("mm")
textoDigitado.value = formatarValor(v, modoEfetivo.value)
if (!focado.value) textoDigitado.value = formatarValor(v, modoEfetivo.value)
return
}
}
diaSelecionado.value = null
horaTemp.value = "00"
minutoTemp.value = "00"
textoDigitado.value = ""
if (!focado.value) textoDigitado.value = ""
},
{ immediate: true },
)
function abrirSeHabilitado() {
if (desabilitadoEfetivo.value) return
if (!aberto.value) {
aberto.value = true
calcularPosicao()
emit("foco")
emit("focus")
nextTick(calcularPosicao)
adicionarListenersPosicao()
}
if (desabilitadoEfetivo.value || aberto.value) return
aberto.value = true
calcularPosicao()
emit("foco")
emit("focus")
nextTick(calcularPosicao)
adicionarListenersPosicao()
}
function alternarPopover() {
if (desabilitadoEfetivo.value) return
if (aberto.value) {
fechar()
} else {
abrirSeHabilitado()
}
aberto.value ? fechar() : abrirSeHabilitado()
}
function fechar() {
@ -155,7 +138,24 @@ export function useDataHora(props: Props, emit: EmitFn) {
function selecionarDia(cell: CelulaCalendario) {
if (cell.desabilitado) return
diaSelecionado.value = cell.data.startOf("day")
confirmar()
if (modoEfetivo.value === "data") {
confirmar()
} else {
const h = Number.parseInt(horaTemp.value, 10) || 0
const m = Number.parseInt(minutoTemp.value, 10) || 0
emitirValor(diaSelecionado.value.hour(h).minute(m).second(0).format())
}
}
function definirAgora() {
const agora = dayjs()
diaSelecionado.value = agora.startOf("day")
referencia.value = agora.startOf("month")
horaTemp.value = agora.format("HH")
minutoTemp.value = agora.format("mm")
const r = modoEfetivo.value === "data" ? agora.startOf("day") : agora.second(0)
emitirValor(r.format())
if (modoEfetivo.value === "data") fechar()
}
function atualizarHoraTemp(v: string) {
@ -163,8 +163,7 @@ export function useDataHora(props: Props, emit: EmitFn) {
if (diaSelecionado.value) {
const h = Number.parseInt(v, 10) || 0
const m = Number.parseInt(minutoTemp.value, 10) || 0
const r = diaSelecionado.value.hour(h).minute(m).second(0)
emitirValor(r.format())
emitirValor(diaSelecionado.value.hour(h).minute(m).second(0).format())
}
}
@ -173,8 +172,7 @@ export function useDataHora(props: Props, emit: EmitFn) {
if (diaSelecionado.value) {
const h = Number.parseInt(horaTemp.value, 10) || 0
const m = Number.parseInt(v, 10) || 0
const r = diaSelecionado.value.hour(h).minute(m).second(0)
emitirValor(r.format())
emitirValor(diaSelecionado.value.hour(h).minute(m).second(0).format())
}
}
@ -239,6 +237,7 @@ export function useDataHora(props: Props, emit: EmitFn) {
fechar,
navegarMes,
selecionarDia,
definirAgora,
confirmar,
limpar,
onTextInput,

View file

@ -40,6 +40,19 @@ export function useDataHoraInput({
minuto: minutoTemp.value,
})
if (iso) {
const digitos = comMascara.replace(/\D/g, "")
if (modoEfetivo.value === "dataHora") {
if (digitos.length >= 10) {
horaTemp.value = String(
Math.min(23, Number.parseInt(digitos.slice(8, 10), 10) || 0),
).padStart(2, "0")
}
if (digitos.length >= 12) {
minutoTemp.value = String(
Math.min(59, Number.parseInt(digitos.slice(10, 12), 10) || 0),
).padStart(2, "0")
}
}
emitirValor(iso)
}
}