Files
KArtSell.Aegis/frontend/src/shared/ui/components/KsNumberField.vue
T
kjh2064 9e2d2010ba
deploy / deploy (push) Failing after 48s
deploy / notify (push) Successful in 1s
feat(fe): Apply form field navigation to all input components
- KsNumberField: Enter -> next field
- KsMoneyField: Enter -> next field
- KsMultiSelect: Enter (when closed) -> next field
- Complete Tab-like Enter key behavior across all form inputs
- Enables seamless form navigation with Enter key (matching grid behavior)
2026-08-16 21:58:04 +09:00

316 lines
7.5 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useFormFieldNavigation } from '../composables/useFormFieldNavigation'
export interface KsNumberFieldProps {
modelValue: number | null
label?: string
type?: 'integer' | 'decimal'
disabled?: boolean
readonly?: boolean
required?: boolean
error?: string
help?: string
min?: number
max?: number
step?: number
placeholder?: string
inputId?: string
size?: 'sm' | 'md' | 'lg'
currency?: string
decimalPlaces?: number
maxFractionDigits?: number
}
const props = withDefaults(defineProps<KsNumberFieldProps>(), {
type: 'decimal',
disabled: false,
readonly: false,
required: false,
step: 1,
size: 'md',
decimalPlaces: 2,
})
const emit = defineEmits<{
'update:modelValue': [value: number | null]
blur: [event: FocusEvent]
focus: [event: FocusEvent]
change: [value: number | null]
}>()
const isFocused = ref(false)
const id = computed(() => props.inputId || `number-${Math.random().toString(36).substr(2, 9)}`)
const displayValue = computed(() => {
if (props.modelValue === null || props.modelValue === undefined) return ''
if (props.currency) {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: props.currency }).format(props.modelValue)
}
return props.modelValue.toString()
})
const containerClass = computed(() => [
'ks-number-field',
`ks-number-field--${props.size}`,
{
'ks-number-field--focused': isFocused.value,
'ks-number-field--filled': props.modelValue !== null && props.modelValue !== undefined && props.modelValue !== 0,
'ks-number-field--disabled': props.disabled,
'ks-number-field--error': !!props.error,
'ks-number-field--required': props.required,
},
])
const handleInput = (event: Event) => {
const target = event.target as HTMLInputElement
const value = target.value === '' ? null : parseFloat(target.value)
if (value !== null && !isNaN(value)) {
emit('update:modelValue', value)
emit('change', value)
} else if (value === null) {
emit('update:modelValue', null)
emit('change', null)
}
}
const handleFocus = (event: FocusEvent) => {
isFocused.value = true
emit('focus', event)
}
const handleBlur = (event: FocusEvent) => {
isFocused.value = false
emit('blur', event)
}
const { inputRef, handleKeyDown } = useFormFieldNavigation()
const handleKeyPress = (event: KeyboardEvent) => {
handleKeyDown(event, false)
}
const increment = () => {
const newValue = (props.modelValue ?? 0) + (props.step ?? 1)
if (!props.max || newValue <= props.max) {
emit('update:modelValue', newValue)
emit('change', newValue)
}
}
const decrement = () => {
const newValue = (props.modelValue ?? 0) - (props.step ?? 1)
if (!props.min || newValue >= props.min) {
emit('update:modelValue', newValue)
emit('change', newValue)
}
}
</script>
<template>
<div :class="containerClass">
<!-- Label -->
<label :for="id" class="ks-number-field__label">
{{ label }}
<span v-if="required" class="ks-number-field__required">*</span>
</label>
<!-- Input container -->
<div class="ks-number-field__container">
<!-- Decrement button -->
<button
type="button"
class="ks-number-field__btn ks-number-field__btn--down"
:disabled="disabled || (min !== undefined && modelValue !== null && modelValue <= min)"
aria-label="Decrease"
@click="decrement"
>
</button>
<!-- Input -->
<input
ref="inputRef"
:id="id"
type="number"
:value="modelValue ?? ''"
:disabled="disabled"
:readonly="readonly"
:required="required"
:min="min"
:max="max"
:step="step"
:placeholder="placeholder"
class="ks-number-field__input"
:aria-invalid="!!error"
:aria-describedby="error || help ? `${id}-hint` : undefined"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@keydown="handleKeyPress"
/>
<!-- Increment button -->
<button
type="button"
class="ks-number-field__btn ks-number-field__btn--up"
:disabled="disabled || (max !== undefined && modelValue !== null && modelValue >= max)"
aria-label="Increase"
@click="increment"
>
+
</button>
</div>
<!-- Error or help text -->
<div v-if="error || help" :id="`${id}-hint`" :class="{ 'ks-number-field__error': error, 'ks-number-field__help': help }">
{{ error || help }}
</div>
</div>
</template>
<style scoped>
.ks-number-field {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
.ks-number-field--sm {
--input-height: 32px;
--font-size: var(--font-size-sm);
}
.ks-number-field--md {
--input-height: 36px;
--font-size: var(--font-size-sm);
}
.ks-number-field--lg {
--input-height: 44px;
--font-size: var(--font-size-base);
}
/* Label */
.ks-number-field__label {
display: flex;
align-items: center;
gap: var(--spacing-1);
font-weight: var(--font-weight-medium);
color: var(--color-text-primary);
font-size: var(--font-size-sm);
cursor: pointer;
}
.ks-number-field__required {
color: var(--color-danger-500);
}
/* Container */
.ks-number-field__container {
display: flex;
align-items: center;
border: 1px solid var(--color-border-primary);
border-radius: var(--border-radius-sm);
background-color: var(--color-background-primary);
transition: all var(--transition-normal);
overflow: hidden;
}
/* Input */
.ks-number-field__input {
flex: 1;
height: var(--input-height);
border: none;
background: transparent;
font-size: var(--font-size);
color: var(--color-text-primary);
outline: none;
font-family: inherit;
text-align: center;
padding: 0 var(--spacing-2);
}
.ks-number-field__input::-webkit-outer-spin-button,
.ks-number-field__input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.ks-number-field__input[type='number'] {
-moz-appearance: textfield;
}
.ks-number-field__input::placeholder {
color: var(--color-text-tertiary);
}
/* Buttons */
.ks-number-field__btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: var(--input-height);
border: none;
background: transparent;
color: var(--color-text-secondary);
cursor: pointer;
transition: all var(--transition-normal);
font-weight: var(--font-weight-semibold);
flex-shrink: 0;
}
.ks-number-field__btn:hover:not(:disabled) {
background-color: var(--color-background-secondary);
color: var(--color-text-primary);
}
.ks-number-field__btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.ks-number-field__btn--down {
border-right: 1px solid var(--color-border-primary);
}
.ks-number-field__btn--up {
border-left: 1px solid var(--color-border-primary);
}
/* Focus state */
.ks-number-field--focused .ks-number-field__container {
border-color: var(--color-primary-500);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
/* Disabled state */
.ks-number-field--disabled .ks-number-field__container {
background-color: var(--color-background-secondary);
opacity: 0.6;
}
/* Error state */
.ks-number-field--error .ks-number-field__container {
border-color: var(--color-danger-500);
}
.ks-number-field--error .ks-number-field__label {
color: var(--color-danger-500);
}
/* Error text */
.ks-number-field__error {
font-size: var(--font-size-xs);
color: var(--color-danger-500);
}
/* Help text */
.ks-number-field__help {
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
}
</style>