ghostguild-org/app/components/NaturalDateInput.vue
Jennie Robinson Faber 3fea484585 Switch UI components to new design system tokens
Standardizes color values and styling using the new tokens:
- Replaces hardcoded colors with semantic variables
- Updates background/text/border classes for light/dark mode
- Migrates inputs to UInput/USelect/UTextarea components
- Removes redundant style declarations
2025-10-13 15:05:29 +01:00

244 lines
6.2 KiB
Vue

<template>
<div class="space-y-2">
<div class="relative">
<UInput
v-model="naturalInput"
:placeholder="placeholder"
:color="
hasError && naturalInput.trim()
? 'error'
: isValidParse && naturalInput.trim()
? 'success'
: undefined
"
@input="parseNaturalInput"
@blur="onBlur"
>
<template #trailing>
<Icon
v-if="isValidParse && naturalInput.trim()"
name="heroicons:check-circle"
class="w-5 h-5 text-green-500"
/>
<Icon
v-else-if="hasError && naturalInput.trim()"
name="heroicons:exclamation-circle"
class="w-5 h-5 text-red-500"
/>
</template>
</UInput>
</div>
<div
v-if="parsedDate && isValidParse"
class="text-sm text-green-700 bg-green-50 px-3 py-2 rounded-lg border border-green-200"
>
<div class="flex items-center gap-2">
<Icon name="heroicons:calendar" class="w-4 h-4" />
<span>{{ formatParsedDate(parsedDate) }}</span>
</div>
</div>
<div
v-if="hasError && naturalInput.trim()"
class="text-sm text-red-700 bg-red-50 px-3 py-2 rounded-lg border border-red-200"
>
<div class="flex items-center gap-2">
<Icon name="heroicons:exclamation-triangle" class="w-4 h-4" />
<span>{{ errorMessage }}</span>
</div>
</div>
<!-- Fallback datetime-local input -->
<details class="text-sm">
<summary class="cursor-pointer text-muted hover:text-default">
Use traditional date picker
</summary>
<div class="mt-2">
<UInput
v-model="datetimeValue"
type="datetime-local"
@change="onDatetimeChange"
/>
</div>
</details>
</div>
</template>
<script setup>
import * as chrono from "chrono-node";
const props = defineProps({
modelValue: {
type: String,
default: "",
},
placeholder: {
type: String,
default: 'e.g., "tomorrow at 3pm", "next Friday at 9am", "in 2 hours"',
},
inputClass: {
type: String,
default: "",
},
required: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["update:modelValue"]);
const naturalInput = ref("");
const parsedDate = ref(null);
const isValidParse = ref(false);
const hasError = ref(false);
const errorMessage = ref("");
const datetimeValue = ref("");
// Initialize with current value
onMounted(() => {
if (props.modelValue) {
const date = new Date(props.modelValue);
if (!isNaN(date.getTime())) {
parsedDate.value = date;
datetimeValue.value = formatForDatetimeLocal(date);
isValidParse.value = true;
}
}
});
// Watch for external changes to modelValue
watch(
() => props.modelValue,
(newValue) => {
if (newValue && newValue !== formatForDatetimeLocal(parsedDate.value)) {
const date = new Date(newValue);
if (!isNaN(date.getTime())) {
parsedDate.value = date;
datetimeValue.value = formatForDatetimeLocal(date);
isValidParse.value = true;
naturalInput.value = ""; // Clear natural input when set externally
}
} else if (!newValue) {
reset();
}
},
);
const parseNaturalInput = () => {
const input = naturalInput.value.trim();
if (!input) {
reset();
return;
}
try {
// Parse with chrono-node
const results = chrono.parse(input);
if (results.length > 0) {
const result = results[0];
const date = result.date();
// Validate the parsed date
if (date && !isNaN(date.getTime())) {
parsedDate.value = date;
isValidParse.value = true;
hasError.value = false;
datetimeValue.value = formatForDatetimeLocal(date);
emit("update:modelValue", formatForDatetimeLocal(date));
} else {
setError("Could not parse this date format");
}
} else {
setError(
'Could not understand this date format. Try something like "tomorrow at 3pm" or "next Friday"',
);
}
} catch (error) {
setError("Error parsing date");
}
};
const onBlur = () => {
// If we have a valid parse but the input changed, try to parse again
if (naturalInput.value.trim() && !isValidParse.value) {
parseNaturalInput();
}
};
const onDatetimeChange = () => {
if (datetimeValue.value) {
const date = new Date(datetimeValue.value);
if (!isNaN(date.getTime())) {
parsedDate.value = date;
isValidParse.value = true;
hasError.value = false;
naturalInput.value = ""; // Clear natural input when using traditional picker
emit("update:modelValue", datetimeValue.value);
}
} else {
reset();
}
};
const reset = () => {
parsedDate.value = null;
isValidParse.value = false;
hasError.value = false;
errorMessage.value = "";
emit("update:modelValue", "");
};
const setError = (message) => {
isValidParse.value = false;
hasError.value = true;
errorMessage.value = message;
parsedDate.value = null;
};
const formatForDatetimeLocal = (date) => {
if (!date) return "";
// Format as YYYY-MM-DDTHH:MM for datetime-local input
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
return `${year}-${month}-${day}T${hours}:${minutes}`;
};
const formatParsedDate = (date) => {
if (!date) return "";
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
const isTomorrow = date.toDateString() === tomorrow.toDateString();
const timeStr = date.toLocaleString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
});
if (isToday) {
return `Today at ${timeStr}`;
} else if (isTomorrow) {
return `Tomorrow at ${timeStr}`;
} else {
return date.toLocaleString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
hour12: true,
});
}
};
</script>