diff --git a/cfasim-ui/components/src/NumberInput/NumberInput.md b/cfasim-ui/components/src/NumberInput/NumberInput.md
index 0718672..b48ba29 100644
--- a/cfasim-ui/components/src/NumberInput/NumberInput.md
+++ b/cfasim-ui/components/src/NumberInput/NumberInput.md
@@ -16,6 +16,12 @@ const ageRange = ref([18, 65])
const coverageRange = ref([0.2, 0.8])
const minAge = ref(18)
const maxAge = ref(65)
+const dayMs = 24 * 60 * 60 * 1000
+const dateStart = Date.UTC(2024, 0, 1)
+const dateEnd = Date.UTC(2024, 11, 31)
+const dateRange = ref([Date.UTC(2024, 2, 1), Date.UTC(2024, 8, 30)])
+const formatDate = (ms) =>
+ new Date(ms).toLocaleDateString("en-US", { month: "short", day: "numeric" })
@@ -226,6 +232,51 @@ Range mode works with `percent` and `live` as well:
+### Custom slider display
+
+Pass `slider-display` (a `(value: number) => string` function) to format the
+thumb labels and the min/max labels however you like. The internal model is
+still a number — only the displayed text changes. This applies to single
+sliders and ranges; the regular text input is unaffected.
+
+
+
+
+
+
+
+
+```vue
+
+
+
+```
+
+
+
+
### Live slider
With `live`, the model updates while dragging the slider thumb rather than only on release.
diff --git a/cfasim-ui/components/src/NumberInput/NumberInput.test.ts b/cfasim-ui/components/src/NumberInput/NumberInput.test.ts
index 1146fc0..63f711d 100644
--- a/cfasim-ui/components/src/NumberInput/NumberInput.test.ts
+++ b/cfasim-ui/components/src/NumberInput/NumberInput.test.ts
@@ -801,6 +801,33 @@ describe("NumberInput", () => {
expect((input.element as HTMLInputElement).value).toBe("2.500");
});
+ it("applies sliderDisplay to single-slider thumb and labels", () => {
+ const wrapper = mount(NumberInput, {
+ props: {
+ modelValue: 50,
+ slider: true,
+ min: 0,
+ max: 100,
+ sliderDisplay: (v: number) => `${v} days`,
+ },
+ });
+ expect(wrapper.find(".slider-current").text()).toBe("50 days");
+ const labels = wrapper.findAll(".slider-labels span");
+ expect(labels[0].text()).toBe("0 days");
+ expect(labels[1].text()).toBe("100 days");
+ });
+
+ it("ignores sliderDisplay for regular (non-slider) inputs", () => {
+ const wrapper = mount(NumberInput, {
+ props: {
+ modelValue: 50,
+ sliderDisplay: (v: number) => `${v} days`,
+ },
+ });
+ const input = wrapper.find("input");
+ expect((input.element as HTMLInputElement).value).toBe("50");
+ });
+
it("syncs local value when model changes externally", async () => {
const wrapper = mount(NumberInput, {
props: {
@@ -946,6 +973,27 @@ describe("NumberInput", () => {
expect(slider.props("modelValue")).toEqual([10, 90]);
});
+ it("uses sliderDisplay to format thumb labels and min/max labels", () => {
+ const fmt = (v: number) => new Date(v).toISOString().slice(0, 10);
+ const start = Date.UTC(2024, 0, 1);
+ const mid = Date.UTC(2024, 5, 1);
+ const end = Date.UTC(2024, 11, 31);
+ const wrapper = mount(NumberInput, {
+ props: {
+ range: [mid, end] as NumberRange,
+ min: start,
+ max: end,
+ sliderDisplay: fmt,
+ },
+ });
+ const thumbs = wrapper.findAll(".slider-thumb");
+ expect(thumbs[0].text()).toBe("2024-06-01");
+ expect(thumbs[1].text()).toBe("2024-12-31");
+ const labels = wrapper.findAll(".slider-labels span");
+ expect(labels[0].text()).toBe("2024-01-01");
+ expect(labels[1].text()).toBe("2024-12-31");
+ });
+
it("syncs slider when range model changes externally", async () => {
const wrapper = mount(NumberInput, {
props: {
diff --git a/cfasim-ui/components/src/NumberInput/NumberInput.vue b/cfasim-ui/components/src/NumberInput/NumberInput.vue
index 68a3897..65aedcb 100644
--- a/cfasim-ui/components/src/NumberInput/NumberInput.vue
+++ b/cfasim-ui/components/src/NumberInput/NumberInput.vue
@@ -29,6 +29,10 @@ const props = defineProps<{
numberType?: "integer" | "float";
required?: boolean;
decimals?: number;
+ // Custom formatter for slider thumb labels and min/max labels. Overrides
+ // the default percent/decimal formatting when provided. Only consulted in
+ // slider/range mode — the text input keeps its own number-shaped formatting.
+ sliderDisplay?: (value: number) => string;
}>();
function isRangeValue(v: unknown): v is NumberRange {
@@ -109,6 +113,7 @@ function roundToDecimals(v: number, d: number): number {
function formatSliderValue(v: number | undefined) {
if (v == null) return "";
+ if (props.sliderDisplay) return props.sliderDisplay(v);
const d = displayDecimals.value;
if (props.percent) return (v * 100).toFixed(d) + "%";
return v.toLocaleString("en-US", {