Skip to content

Commit

Permalink
TypeScript Rollout Tier 9 - Numberinput (#375)
Browse files Browse the repository at this point in the history
* feat(lib): rewrite Numberinput in TS

Rewrites `Numberinput` in the `src/components/numberinput` folder in
Typescript:
- `modelValue` accepts `null` in addition to `number`
- Since `computedValue` may be `string`, a leading plus (`+`) is added
  to each occurrence of `computedValue` to make it a number
- Introduces new types:
  - `ControlsAlignment`: type of the `controlsAlignment` prop. Values
    are defined as a constant `CONTROLS_ALIGNMENTS`.
  - `ControlOperation`: represents an operation associated with a
    button (plus or minus). Values are defined as a constant
    `CONTROL_OPERATIONS`.
- Introduces a missing data field `_$intervalRef`

Here is a tip for TypeScript migration:
- Registers Buefy components with the "direct" names rather than the
  `name` fields so that they are type-checked.

* test(lib): rewrite Numberinput.spec in TS

Rewrites the spec for `Numberinput` in the `src/components/numberinput`
folder in TypeScript:
- Replaces an empty string given to the `modelValue` prop with `null`,
  which was illegal

Other changes are straightforward. Here are some tips for Jest → Vitest
migration:
- Imports the spec building blocks from the `vitest` package
- Replaces `jest` with `vi`

* chore(lib): bundle numberinput in TS

`rollup.config.mjs` removes "numberinput" from `JS_COMPONENTS`.

* feat(docs): rewrite Numberinput docs in TS

Rewrites the documentation for `Numberinput` in the
`src/pages/components/numberinput` folder in TypeScript. All the changes
are straightforward.

Here is a TypeScript migration tip:
- Explicitly import and register components so that they are type
  checked. No type-checking is performed for globally registered
  components.
  • Loading branch information
kikuomax authored Jan 12, 2025
1 parent 8308232 commit 248dad0
Show file tree
Hide file tree
Showing 14 changed files with 147 additions and 81 deletions.
1 change: 0 additions & 1 deletion packages/buefy-next/rollup.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ const JS_COMPONENTS = [
'clockpicker',
'datepicker',
'datetimepicker',
'numberinput',
'table',
'timepicker',
]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { shallowMount, mount } from '@vue/test-utils'
import BNumberinput from '@components/numberinput/Numberinput'
import type { VueWrapper } from '@vue/test-utils'
import BNumberinput from '@components/numberinput/Numberinput.vue'

let wrapper
let wrapper: VueWrapper<InstanceType<typeof BNumberinput>>

describe('BNumberinput', () => {
describe('Rendered', () => {
Expand All @@ -10,7 +12,7 @@ describe('BNumberinput', () => {
})

afterEach(() => {
jest.useRealTimers()
vi.useRealTimers()
})

it('is called', () => {
Expand All @@ -37,7 +39,7 @@ describe('BNumberinput', () => {
it('increments/decrements on long pressing exponentially', async () => {
// we should not depend on a real timer
// otherwise the results will depend on the machine
jest.useFakeTimers()
vi.useFakeTimers()

await wrapper.setProps({ exponential: true, modelValue: 1, step: 1 })

Expand All @@ -47,16 +49,16 @@ describe('BNumberinput', () => {
for (let n = 1; n <= 10; ++n) {
// while Jest's fake setTimeout truncates the delay to an exact
// ms when it schedules the callback,
// jest.advanceTimersByTime floors the value
// vi.advanceTimersByTime floors the value
// and accumulates the remainder.
// so should be floored to prevent the accumulated remainder
// from triggering an extra callback
jest.advanceTimersByTime(Math.floor(250 / n))
vi.advanceTimersByTime(Math.floor(250 / n))
expect(wrapper.vm.computedValue).toBe(n + 2)
}

wrapper.find('.control.plus').trigger('mouseup')
jest.runAllTimers()
vi.runAllTimers()
await wrapper.vm.$nextTick()
expect(wrapper.vm.computedValue).toBe(12)

Expand All @@ -68,22 +70,22 @@ describe('BNumberinput', () => {
for (let n = 1; n <= 20; ++n) {
// while Jest's fake setTimeout truncates the delay to an exact
// ms when it schedules the callback,
// jest.advanceTimersByTime floors the value
// vi.advanceTimersByTime floors the value
// and accumulates the remainder.
// so should be floored to prevent the accumulated remainder
// from triggering an extra callback
jest.advanceTimersByTime(Math.floor(250 / (n * 3)))
vi.advanceTimersByTime(Math.floor(250 / (n * 3)))
expect(wrapper.vm.computedValue).toBe(n + 1)
}

wrapper.find('.control.plus').trigger('mouseup')
jest.runAllTimers()
vi.runAllTimers()
await wrapper.vm.$nextTick()
expect(wrapper.vm.computedValue).toBe(21)
})

it('increments/decrements on long pressing', async () => {
jest.useFakeTimers()
vi.useFakeTimers()

let val = 0

Expand All @@ -92,9 +94,9 @@ describe('BNumberinput', () => {
val++

// await wait(2000)
jest.runOnlyPendingTimers()
jest.runOnlyPendingTimers()
jest.runOnlyPendingTimers()
vi.runOnlyPendingTimers()
vi.runOnlyPendingTimers()
vi.runOnlyPendingTimers()
val += 3

wrapper.find('.control.plus').trigger('mouseup')
Expand All @@ -104,8 +106,8 @@ describe('BNumberinput', () => {
wrapper.find('.control.minus button').trigger('mousedown')
val--

jest.runOnlyPendingTimers()
jest.runOnlyPendingTimers()
vi.runOnlyPendingTimers()
vi.runOnlyPendingTimers()
val -= 2

wrapper.find('.control.minus button').trigger('mouseup')
Expand All @@ -119,25 +121,25 @@ describe('BNumberinput', () => {
await wrapper.setProps({
longPress: false
})
jest.useFakeTimers()
vi.useFakeTimers()
wrapper.vm.computedValue = 0

// Increment
wrapper.find('.control.plus button').trigger('mousedown')

// await wait(2000)
jest.runOnlyPendingTimers()
jest.runOnlyPendingTimers()
jest.runOnlyPendingTimers()
vi.runOnlyPendingTimers()
vi.runOnlyPendingTimers()
vi.runOnlyPendingTimers()

wrapper.find('.control.plus').trigger('mouseup')
expect(wrapper.vm.computedValue).toBe(1)

// Decrement
wrapper.find('.control.minus button').trigger('mousedown')

jest.runOnlyPendingTimers()
jest.runOnlyPendingTimers()
vi.runOnlyPendingTimers()
vi.runOnlyPendingTimers()

wrapper.find('.control.minus button').trigger('mouseup')
// Trigger it another time to check for unexpected browser behavior
Expand Down Expand Up @@ -172,7 +174,7 @@ describe('BNumberinput', () => {
expect(wrapper.find('input').element.checkValidity()).toEqual(true)
await wrapper.setProps({ step: 'any', modelValue: 1 })
expect(wrapper.find('input').element.checkValidity()).toEqual(true)
await wrapper.setProps({ step: 'any', modelValue: '' }) // produces a warning
await wrapper.setProps({ step: 'any', modelValue: null })
expect(wrapper.find('input').element.checkValidity()).toEqual(true)
})
})
Expand Down
102 changes: 61 additions & 41 deletions packages/buefy-next/src/components/numberinput/Numberinput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@
:expanded="expanded"
:placeholder="placeholder"
:use-html5-validation="useHtml5Validation"
@focus="$emit('focus', $event)"
@blur="$emit('blur', $event)"
@focus="$emit('focus', $event!)"
@blur="$emit('blur', $event!)"
/>

<p
Expand Down Expand Up @@ -96,17 +96,30 @@
</div>
</template>

<script>
import Icon from '../icon/Icon.vue'
import Input from '../input/Input.vue'
<script lang="ts">
import { defineComponent } from 'vue'
import type { PropType } from 'vue'
import BField from '../field/Field.vue'
import BIcon from '../icon/Icon.vue'
import BInput from '../input/Input.vue'
import CompatFallthroughMixin from '../../utils/CompatFallthroughMixin'
import FormElementMixin from '../../utils/FormElementMixin'
export default {
type BFieldInstance = InstanceType<typeof BField>
type BInputInstance = InstanceType<typeof BInput>
export const CONTROLS_ALIGNMENTS = ['left', 'right', 'center'] as const
export type ControlsAlignment = typeof CONTROLS_ALIGNMENTS[number]
const CONTROL_OPERATIONS = ['plus', 'minus'] as const
export type ControlOperation = typeof CONTROL_OPERATIONS[number]
export default defineComponent({
name: 'BNumberinput',
components: {
[Icon.name]: Icon,
[Input.name]: Input
BIcon,
BInput
},
mixins: [CompatFallthroughMixin, FormElementMixin],
inject: {
Expand All @@ -116,7 +129,7 @@ export default {
}
},
props: {
modelValue: Number,
modelValue: [Number, null] as PropType<number | null>,
min: {
type: [Number, String]
},
Expand All @@ -138,14 +151,10 @@ export default {
default: true
},
controlsAlignment: {
type: String,
type: String as PropType<ControlsAlignment>,
default: 'center',
validator: (value) => {
return [
'left',
'right',
'center'
].indexOf(value) >= 0
validator: (value: ControlsAlignment) => {
return CONTROLS_ALIGNMENTS.indexOf(value) >= 0
}
},
controlsRounded: {
Expand All @@ -161,22 +170,31 @@ export default {
default: true
}
},
emits: ['blur', 'focus', 'update:modelValue'],
emits: {
/* eslint-disable @typescript-eslint/no-unused-vars */
blur: (_event: Event) => true,
focus: (_event: Event) => true,
'update:modelValue': (_value: number | null | undefined) => true
/* eslint-enable @typescript-eslint/no-unused-vars */
},
data() {
return {
newValue: this.modelValue,
newStep: this.step || 1,
newMinStep: this.minStep,
timesPressed: 1,
_elementRef: 'input'
_elementRef: 'input',
_$intervalRef: undefined as ReturnType<typeof setTimeout> | undefined
}
},
computed: {
computedValue: {
get() {
// getter has to include `string` in the return type so that the
// setter can accept `string`
get(): number | string | null | undefined {
return this.newValue
},
set(value) {
set(value: number | string | null | undefined) {
// Parses the number, so that "0" => 0, and "invalid" => null
let newValue = (Number(value) === 0) ? 0 : (Number(value) || null)
if (value === '' || value === undefined || value === null) {
Expand All @@ -185,23 +203,25 @@ export default {
this.newValue = newValue
if (newValue === null) {
this.$emit('update:modelValue', newValue)
} else if (!isNaN(newValue) && newValue !== '-0') {
// I decided to comment out `newValue !== '-0'` until we fix
// the regression of https://github.com/buefy/buefy/pull/3170
} else if (!isNaN(newValue)/* && newValue !== '-0' */) {
this.$emit('update:modelValue', Number(newValue))
}
this.$nextTick(() => {
if (this.$refs.input) {
this.$refs.input.checkHtml5Validity()
(this.$refs.input as BInputInstance).checkHtml5Validity()
}
})
}
},
controlsLeft() {
controlsLeft(): ControlOperation[] {
if (this.controls && this.controlsAlignment !== 'right') {
return this.controlsAlignment === 'left' ? ['minus', 'plus'] : ['minus']
}
return []
},
controlsRight() {
controlsRight(): ControlOperation[] {
if (this.controls && this.controlsAlignment !== 'left') {
return this.controlsAlignment === 'right' ? ['minus', 'plus'] : ['plus']
}
Expand Down Expand Up @@ -237,10 +257,10 @@ export default {
return typeof step === 'string' ? parseFloat(step) : step
},
disabledMin() {
return this.computedValue - this.stepNumber < this.minNumber
return +this.computedValue! - this.stepNumber < this.minNumber!
},
disabledMax() {
return this.computedValue + this.stepNumber > this.maxNumber
return +this.computedValue! + this.stepNumber > this.maxNumber!
},
stepDecimals() {
const step = this.minStepNumber.toString()
Expand All @@ -258,7 +278,7 @@ export default {
}
},
watch: {
/**
/*
* When v-model is changed:
* 1. Set internal value.
*/
Expand All @@ -276,7 +296,7 @@ export default {
}
},
methods: {
isDisabled(control) {
isDisabled(control: ControlOperation) {
return this.disabled || (control === 'plus' ? this.disabledMax : this.disabledMin)
},
decrement() {
Expand All @@ -287,56 +307,56 @@ export default {
}
this.computedValue = 0
}
if (typeof this.minNumber === 'undefined' || (this.computedValue - this.stepNumber) >= this.minNumber) {
const value = this.computedValue - this.stepNumber
if (typeof this.minNumber === 'undefined' || (+this.computedValue - this.stepNumber) >= this.minNumber) {
const value = +this.computedValue - this.stepNumber
this.computedValue = parseFloat(value.toFixed(this.stepDecimals))
}
},
increment() {
if (this.computedValue === null || typeof this.computedValue === 'undefined' || this.computedValue < this.minNumber) {
if (this.computedValue === null || typeof this.computedValue === 'undefined' || +this.computedValue < this.minNumber!) {
if (this.minNumber !== null && typeof this.minNumber !== 'undefined') {
this.computedValue = this.minNumber
return
}
this.computedValue = 0
}
if (typeof this.maxNumber === 'undefined' || (this.computedValue + this.stepNumber) <= this.maxNumber) {
const value = this.computedValue + this.stepNumber
if (typeof this.maxNumber === 'undefined' || (+this.computedValue + this.stepNumber) <= this.maxNumber) {
const value = +this.computedValue + this.stepNumber
this.computedValue = parseFloat(value.toFixed(this.stepDecimals))
}
},
onControlClick(event, inc) {
onControlClick(event: MouseEvent, inc: boolean) {
// IE 11 -> filter click event
if (event.detail !== 0 || event.type !== 'click') return
if (inc) this.increment()
else this.decrement()
},
longPressTick(inc) {
longPressTick(inc: boolean) {
if (inc) this.increment()
else this.decrement()
if (!this.longPress) return
this._$intervalRef = setTimeout(() => {
this.longPressTick(inc)
}, this.exponential ? (250 / (this.exponential * this.timesPressed++)) : 250)
}, this.exponential ? (250 / (+this.exponential * this.timesPressed++)) : 250)
},
onStartLongPress(event, inc) {
if (event.button !== 0 && event.type !== 'touchstart') return
onStartLongPress(event: MouseEvent | TouchEvent, inc: boolean) {
if ((event as MouseEvent).button !== 0 && (event as TouchEvent).type !== 'touchstart') return
clearTimeout(this._$intervalRef)
this.longPressTick(inc)
},
onStopLongPress() {
if (!this._$intervalRef) return
this.timesPressed = 1
clearTimeout(this._$intervalRef)
this._$intervalRef = null
this._$intervalRef = undefined
}
},
mounted() {
// tells the field that it is wrapping a number input
// if the field is the direct parent.
if (this.field === this.$parent) {
this.$parent.wrapNumberinput({
(this.$parent as BFieldInstance).wrapNumberinput({
controlsPosition: this.controlsPosition,
size: this.size
})
Expand All @@ -346,5 +366,5 @@ export default {
beforeUnmount() {
clearTimeout(this._$intervalRef)
}
}
})
</script>
Loading

0 comments on commit 248dad0

Please sign in to comment.