ghostguild-org/app/components/NaturalDateInput.vue
Jennie Robinson Faber 4a05e91715 feat(admin-events): form layout overhaul + agenda input + date input rewrite
Admin event create form:
- Wraps body in a form-layout/form-main container for upcoming sidebar work.
- Bigger autoresize textareas for description/content; adds an Agenda
  textarea (one item per line, persisted as event.agenda).
- Reorganises settings into Event Settings + conditional Cancellation
  Message sections.
- Pulls event-type options from EVENT_TYPES; location becomes optional;
  passes displayTimezone through to NaturalDateInput.

NaturalDateInput: rewritten to a single always-visible UInput with chrono
parsing and trailing status icon, instead of toggling between input and
parsed-summary blocks. Cleaner state model (rawInput / parsedDate /
isValid / hasError) and timezone-aware update emission.
2026-05-21 17:50:56 +01:00

238 lines
6.2 KiB
Vue

<template>
<div class="natural-date-input">
<UInput
:model-value="rawInput"
:placeholder="placeholder"
:color="trailingState"
@update:model-value="onInputChange"
>
<template #trailing>
<Icon
v-if="isValid && rawInput.trim()"
name="heroicons:check-circle"
class="w-5 h-5"
style="color: var(--candle)"
/>
<Icon
v-else-if="hasError && rawInput.trim()"
name="heroicons:exclamation-circle"
class="w-5 h-5"
style="color: var(--ember)"
/>
</template>
</UInput>
<p
v-if="rawInput.trim() && isValid"
class="preview-line"
style="color: var(--candle)"
>
&rarr; {{ previewText }}
</p>
<p
v-else-if="rawInput.trim() && hasError"
class="preview-line"
style="color: var(--ember)"
>
{{ errorMessage }}
</p>
</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", "Nov 22 at 3pm"',
},
displayTimezone: { type: String, default: "" },
required: { type: Boolean, default: false },
});
const emit = defineEmits(["update:modelValue"]);
const rawInput = ref("");
const isValid = ref(false);
const hasError = ref(false);
const errorMessage = ref("");
// previewDate holds the parsed value as a UTC Date so we can format it in
// arbitrary timezones without re-parsing. Source of truth for the preview.
const previewDate = ref(null);
const trailingState = computed(() => {
if (!rawInput.value.trim()) return undefined;
if (hasError.value) return "error";
if (isValid.value) return "success";
return undefined;
});
const previewText = computed(() => {
if (!previewDate.value) return "";
const tz = activeTZ();
const date = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
weekday: "short",
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
hour12: true,
}).format(previewDate.value);
const abbr = shortTimezoneName(previewDate.value, tz);
return abbr ? `${date} ${abbr}` : date;
});
const activeTZ = () =>
props.displayTimezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
// Seed the input from modelValue without triggering chrono. The parent's
// value is canonical — we just render it as a chrono-friendly readable
// string so the user can backspace and tweak in place.
const seedFromModelValue = () => {
if (!props.modelValue) {
rawInput.value = "";
isValid.value = false;
hasError.value = false;
errorMessage.value = "";
previewDate.value = null;
return;
}
const tz = activeTZ();
const utc = zonedLocalToUTC(props.modelValue, tz);
if (!utc) return;
previewDate.value = utc;
isValid.value = true;
hasError.value = false;
errorMessage.value = "";
rawInput.value = readableSeed(utc, tz);
};
onMounted(seedFromModelValue);
watch(
() => props.modelValue,
(next) => {
const tz = activeTZ();
const expected = previewDate.value
? utcToZonedLocal(previewDate.value, tz)
: "";
if (next === expected) return;
seedFromModelValue();
},
);
watch(
() => props.displayTimezone,
() => {
// Re-interpret the current input under the new TZ so the preview and
// emitted value reflect the new timezone semantics.
if (rawInput.value.trim()) parse(rawInput.value);
},
);
const onInputChange = (value) => {
rawInput.value = value;
parse(value);
};
const parse = (input) => {
const trimmed = input.trim();
if (!trimmed) {
isValid.value = false;
hasError.value = false;
errorMessage.value = "";
previewDate.value = null;
emit("update:modelValue", "");
return;
}
const tz = activeTZ();
let results;
try {
results = chrono.parse(trimmed, referenceNowInTZ(tz));
} catch {
setError("Couldn't read that — try \"tomorrow 3pm\" or \"Nov 22 3pm\"");
return;
}
if (!results.length) {
setError("Couldn't read that — try \"tomorrow 3pm\" or \"Nov 22 3pm\"");
return;
}
const date = results[0].date();
if (!date || Number.isNaN(date.getTime())) {
setError("Couldn't read that date");
return;
}
// chrono returned a Date whose browser-local components match what the
// user typed in the event timezone (because we shifted the reference).
// Read those components as wall-clock in displayTimezone.
const localStr = browserComponentsToString(date);
const utc = zonedLocalToUTC(localStr, tz);
if (!utc) {
setError("Couldn't parse this date");
return;
}
isValid.value = true;
hasError.value = false;
errorMessage.value = "";
previewDate.value = utc;
emit("update:modelValue", localStr);
};
const setError = (msg) => {
isValid.value = false;
hasError.value = true;
errorMessage.value = msg;
previewDate.value = null;
emit("update:modelValue", "");
};
// Build a Date object whose browser-local components equal the current
// wall-clock in the given IANA timezone, so chrono's "tomorrow"/"next
// Friday" anchor to the event TZ rather than the editor's browser TZ.
const referenceNowInTZ = (tz) => {
const nowStr = utcToZonedLocal(new Date(), tz);
if (!nowStr) return new Date();
const [d, t] = nowStr.split("T");
const [y, mo, day] = d.split("-").map(Number);
const [h, mi] = t.split(":").map(Number);
return new Date(y, mo - 1, day, h, mi);
};
const browserComponentsToString = (date) => {
const y = date.getFullYear();
const mo = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
const h = String(date.getHours()).padStart(2, "0");
const mi = String(date.getMinutes()).padStart(2, "0");
return `${y}-${mo}-${d}T${h}:${mi}`;
};
const readableSeed = (utc, tz) => {
// Format chosen to round-trip cleanly through chrono.parse.
return new Intl.DateTimeFormat("en-US", {
timeZone: tz,
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
hour12: true,
}).format(utc);
};
</script>
<style scoped>
.natural-date-input {
display: flex;
flex-direction: column;
gap: 6px;
}
.preview-line {
font-size: 12px;
margin: 0;
}
</style>