Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions cfasim-ui/components/src/NumberInput/NumberInput.md
Original file line number Diff line number Diff line change
Expand Up @@ -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" })
</script>

<ComponentDemo>
Expand Down Expand Up @@ -226,6 +232,51 @@ Range mode works with `percent` and `live` as well:
</template>
</ComponentDemo>

### 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.

<ComponentDemo>
<div style="width: 300px">
<NumberInput
v-model:range="dateRange"
label="Date range"
:min="dateStart"
:max="dateEnd"
:step="dayMs"
:slider-display="formatDate"
/>
</div>

<template #code>

```vue
<script setup>
import { ref } from "vue";
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" });
</script>

<NumberInput
v-model:range="dateRange"
label="Date range"
:min="dateStart"
:max="dateEnd"
:step="dayMs"
:slider-display="formatDate"
/>
```

</template>
</ComponentDemo>

### Live slider

With `live`, the model updates while dragging the slider thumb rather than only on release.
Expand Down
48 changes: 48 additions & 0 deletions cfasim-ui/components/src/NumberInput/NumberInput.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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: {
Expand Down
5 changes: 5 additions & 0 deletions cfasim-ui/components/src/NumberInput/NumberInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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", {
Expand Down
Loading