Basic Form Components

main
fandi.susanto.bts 1 month ago
parent 3495e9d544
commit 1e702a0f58

@ -1,3 +1,5 @@
import type { CFormControlSize } from '../form/types'
export type CButtonVariant = 'default' | 'primary' | 'success' | 'warning' | 'danger'
export type CButtonType = 'button' | 'submit' | 'reset'
export type CButtonSize = 'small' | 'medium' | 'large'
export type CButtonSize = CFormControlSize

@ -0,0 +1,217 @@
<script setup lang="ts">
import { computed, ref, watchEffect } from 'vue'
import type { CFormControlSize } from '../form/types'
import { useFormControl } from '../form/useFormControl'
import type { CCheckboxModelValue, CCheckboxValue } from './types'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
modelValue?: CCheckboxModelValue
value?: CCheckboxValue
trueValue?: CCheckboxValue
falseValue?: CCheckboxValue
size?: CFormControlSize
indeterminate?: boolean
threeState?: boolean
disabled?: boolean
required?: boolean
invalid?: boolean
}>(),
{
modelValue: false,
value: true,
trueValue: true,
falseValue: false,
size: 'medium',
indeterminate: false,
threeState: false,
disabled: false,
required: false,
invalid: false,
},
)
const emit = defineEmits<{
'update:modelValue': [value: CCheckboxModelValue]
}>()
const input = ref<HTMLInputElement>()
const { controlId, describedBy, invalid, required } = useFormControl(props)
const checked = computed(() =>
Array.isArray(props.modelValue)
? props.modelValue.some((entry) => Object.is(entry, props.value))
: Object.is(props.modelValue, props.trueValue),
)
const usesThreeStates = computed(() => props.threeState && !Array.isArray(props.modelValue))
const isIndeterminate = computed(() =>
usesThreeStates.value ? props.modelValue === null : props.indeterminate,
)
watchEffect(() => {
if (input.value) input.value.indeterminate = isIndeterminate.value
})
function updateValue(event: Event) {
const nextChecked = (event.target as HTMLInputElement).checked
if (usesThreeStates.value) {
if (Object.is(props.modelValue, props.falseValue)) {
emit('update:modelValue', props.trueValue)
} else if (Object.is(props.modelValue, props.trueValue)) {
emit('update:modelValue', null)
} else {
emit('update:modelValue', props.falseValue)
}
return
}
if (Array.isArray(props.modelValue)) {
const next = props.modelValue.filter((entry) => !Object.is(entry, props.value))
if (nextChecked) next.push(props.value)
emit('update:modelValue', next)
return
}
emit('update:modelValue', nextChecked ? props.trueValue : props.falseValue)
}
</script>
<template>
<label class="c-checkbox" :class="[`is-${size}`, { 'is-disabled': disabled }]">
<input
ref="input"
v-bind="$attrs"
class="input"
type="checkbox"
:id="controlId"
:checked="checked"
:disabled="disabled"
:required="required"
:aria-checked="isIndeterminate ? 'mixed' : undefined"
:aria-invalid="invalid ? 'true' : undefined"
:aria-describedby="describedBy"
@change="updateValue"
/>
<span v-if="$slots.default" class="label"><slot /></span>
</label>
</template>
<style scoped lang="scss">
.c-checkbox {
display: inline-flex;
align-items: flex-start;
width: fit-content;
min-width: 0;
gap: 6px;
color: var(--c-text-color, #20242a);
font: inherit;
line-height: 1.35;
cursor: pointer;
.input {
box-sizing: border-box;
position: relative;
flex: none;
width: 16px;
height: 16px;
margin: 1px 0 0;
appearance: none;
cursor: inherit;
background: var(--c-input-background, #fff);
border: 1px solid var(--c-control-border-color, #bfc5ce);
border-radius: 2px;
&::after {
position: absolute;
display: none;
content: '';
}
&:checked,
&:indeterminate {
background: var(--c-primary-color, #286aa6);
border-color: var(--c-primary-border-color, #245f95);
}
&:checked::after {
top: 1px;
left: 4px;
display: block;
width: 4px;
height: 8px;
border: solid #fff;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
&:indeterminate::after {
top: 6px;
left: 3px;
display: block;
width: 8px;
height: 2px;
background: #fff;
}
&:focus-visible {
outline: 2px solid var(--c-focus-color, #3578c6);
outline-offset: 2px;
}
&[aria-invalid='true'] {
border-color: var(--c-danger-color, #b42318);
}
}
&.is-disabled {
color: var(--c-disabled-text-color, #8a9099);
cursor: not-allowed;
.input {
background-color: var(--c-disabled-background-color, #f1f3f5);
border-color: var(--c-disabled-border-color, #d5d9df);
}
}
&.is-small {
font-size: 12px;
.input {
width: 14px;
height: 14px;
&:checked::after {
top: 0;
left: 3px;
}
&:indeterminate::after {
top: 5px;
left: 2px;
}
}
}
&.is-large {
font-size: 14px;
.input {
width: 18px;
height: 18px;
&:checked::after {
top: 2px;
left: 5px;
}
&:indeterminate::after {
top: 7px;
left: 4px;
}
}
}
}
</style>

@ -0,0 +1,2 @@
export type CCheckboxValue = string | number | boolean
export type CCheckboxModelValue = CCheckboxValue | null | CCheckboxValue[]

@ -0,0 +1,112 @@
<script setup lang="ts">
import { computed, provide, useId, useSlots } from 'vue'
import { cFormFieldKey } from './context'
const props = withDefaults(
defineProps<{
label?: string
for?: string
hint?: string
error?: string
required?: boolean
invalid?: boolean
}>(),
{
label: undefined,
for: undefined,
hint: undefined,
error: undefined,
required: false,
invalid: false,
},
)
const slots = useSlots()
const uid = useId()
const controlId = computed(() => props.for ?? `c-field-${uid}`)
const hasHint = computed(() => Boolean(props.hint || slots.hint))
const hasError = computed(() => Boolean(props.error || slots.error))
const invalid = computed(() => props.invalid || hasError.value)
const hintId = computed(() => `${controlId.value}-hint`)
const errorId = computed(() => `${controlId.value}-error`)
const describedBy = computed(() => {
const ids = []
if (hasHint.value) ids.push(hintId.value)
if (hasError.value) ids.push(errorId.value)
return ids.length ? ids.join(' ') : undefined
})
provide(cFormFieldKey, {
controlId,
describedBy,
invalid,
required: computed(() => props.required),
})
</script>
<template>
<div class="c-form-field" :class="{ 'is-invalid': invalid }">
<label v-if="label || $slots.label" class="label" :for="controlId">
<slot name="label">{{ label }}</slot>
<span v-if="required" class="required" aria-hidden="true">*</span>
</label>
<div class="control">
<slot
:id="controlId"
:described-by="describedBy"
:invalid="invalid"
:required="required"
/>
</div>
<div v-if="hasHint" :id="hintId" class="hint">
<slot name="hint">{{ hint }}</slot>
</div>
<div v-if="hasError" :id="errorId" class="error" role="alert">
<slot name="error">{{ error }}</slot>
</div>
</div>
</template>
<style scoped lang="scss">
.c-form-field {
display: grid;
min-width: 0;
gap: 4px;
color: var(--c-text-color, #20242a);
font-family: var(--c-font-family, system-ui, -apple-system, "Segoe UI", sans-serif);
font-size: var(--c-font-size, 13px);
.label {
width: fit-content;
font-weight: 600;
line-height: 1.3;
}
.required {
margin-inline-start: 3px;
color: var(--c-danger-color, #b42318);
}
.control {
min-width: 0;
}
.hint,
.error {
font-size: 12px;
line-height: 1.35;
}
.hint {
color: var(--c-muted-text-color, #626a75);
}
.error {
color: var(--c-danger-color, #b42318);
}
}
</style>

@ -0,0 +1,10 @@
import type { ComputedRef, InjectionKey } from 'vue'
export interface CFormFieldContext {
controlId: ComputedRef<string>
describedBy: ComputedRef<string | undefined>
invalid: ComputedRef<boolean>
required: ComputedRef<boolean>
}
export const cFormFieldKey: InjectionKey<CFormFieldContext> = Symbol('c-form-field')

@ -0,0 +1 @@
export type CFormControlSize = 'small' | 'medium' | 'large'

@ -0,0 +1,23 @@
import { computed, inject, useAttrs } from 'vue'
import { cFormFieldKey } from '../form-field/context'
export function useFormControl(props: { invalid: boolean; required: boolean }) {
const attrs = useAttrs()
const field = inject(cFormFieldKey, undefined)
const controlId = computed(() => {
const id = attrs.id
return typeof id === 'string' ? id : field?.controlId.value
})
const describedBy = computed(() => {
const value = attrs['aria-describedby']
return typeof value === 'string' ? value : field?.describedBy.value
})
const invalid = computed(() => props.invalid || Boolean(field?.invalid.value))
const required = computed(() => props.required || Boolean(field?.required.value))
return { controlId, describedBy, invalid, required }
}

@ -0,0 +1,54 @@
<script setup lang="ts">
import type { CFormControlSize } from '../form/types'
withDefaults(
defineProps<{
tag?: string
size?: CFormControlSize
}>(),
{
tag: 'span',
size: 'medium',
},
)
</script>
<template>
<component :is="tag" class="c-input-addon" :class="`is-${size}`">
<slot />
</component>
</template>
<style scoped lang="scss">
.c-input-addon {
box-sizing: border-box;
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
height: 30px;
min-width: 30px;
padding: 4px 8px;
color: var(--c-muted-text-color, #626a75);
font: inherit;
line-height: 1.2;
white-space: nowrap;
background: var(--c-subtle-surface-color, #f7f8fa);
border: 1px solid var(--c-control-border-color, #bfc5ce);
border-radius: var(--c-border-radius, 3px);
&.is-small {
height: 26px;
min-width: 26px;
padding: 3px 6px;
font-size: 12px;
}
&.is-large {
height: 34px;
min-width: 34px;
padding: 5px 9px;
font-size: 14px;
}
}
</style>

@ -0,0 +1,68 @@
<template>
<div class="c-input-group">
<slot />
</div>
</template>
<style scoped lang="scss">
.c-input-group {
display: flex;
width: 100%;
min-width: 0;
> :deep(*) {
margin-inline-start: -1px;
}
> :deep(:first-child) {
margin-inline-start: 0;
}
> :deep(.c-input),
> :deep(.c-password),
> :deep(.c-number-input) {
flex: 1 1 auto;
min-width: 0;
}
> :deep(.c-input),
> :deep(.c-button),
> :deep(.c-input-addon) {
border-radius: 0;
}
> :deep(:first-child.c-input),
> :deep(:first-child.c-button),
> :deep(:first-child.c-input-addon) {
border-start-start-radius: var(--c-border-radius, 3px);
border-end-start-radius: var(--c-border-radius, 3px);
}
> :deep(:last-child.c-input),
> :deep(:last-child.c-button),
> :deep(:last-child.c-input-addon) {
border-start-end-radius: var(--c-border-radius, 3px);
border-end-end-radius: var(--c-border-radius, 3px);
}
> :deep(.c-password:not(:first-child) .input),
> :deep(.c-number-input:not(:first-child) .decrement),
> :deep(.c-number-input:not(:first-child) .input:first-child) {
border-start-start-radius: 0;
border-end-start-radius: 0;
}
> :deep(.c-password:not(:last-child) .toggle),
> :deep(.c-password:not(:last-child) .input:last-child),
> :deep(.c-number-input:not(:last-child) .increment),
> :deep(.c-number-input:not(:last-child) .input:last-child) {
border-start-end-radius: 0;
border-end-end-radius: 0;
}
> :deep(:focus-within) {
position: relative;
z-index: 1;
}
}
</style>

@ -0,0 +1,118 @@
<script setup lang="ts">
import type { InputHTMLAttributes } from 'vue'
import { useFormControl } from '../form/useFormControl'
import type { CFormControlSize } from '../form/types'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
modelValue?: string | null
type?: InputHTMLAttributes['type']
size?: CFormControlSize
disabled?: boolean
readonly?: boolean
required?: boolean
invalid?: boolean
}>(),
{
modelValue: '',
type: 'text',
size: 'medium',
disabled: false,
readonly: false,
required: false,
invalid: false,
},
)
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const { controlId, describedBy, invalid, required } = useFormControl(props)
function updateValue(event: Event) {
emit('update:modelValue', (event.target as HTMLInputElement).value)
}
</script>
<template>
<input
v-bind="$attrs"
class="c-input"
:class="[`is-${size}`, { 'is-invalid': invalid }]"
:id="controlId"
:value="modelValue ?? ''"
:type="type"
:disabled="disabled"
:readonly="readonly"
:required="required"
:aria-invalid="invalid ? 'true' : undefined"
:aria-describedby="describedBy"
@input="updateValue"
/>
</template>
<style scoped lang="scss">
.c-input {
box-sizing: border-box;
width: 100%;
height: 30px;
min-width: 0;
padding: 4px 7px;
color: var(--c-text-color, #20242a);
font: inherit;
line-height: 1.2;
background: var(--c-input-background, #fff);
border: 1px solid var(--c-control-border-color, #bfc5ce);
border-radius: var(--c-border-radius, 3px);
&::placeholder {
color: var(--c-placeholder-color, #7a828d);
}
&:hover:not(:disabled):not(:read-only) {
border-color: var(--c-control-hover-border-color, #929aa6);
}
&:focus {
position: relative;
z-index: 1;
border-color: var(--c-focus-color, #3578c6);
outline: 1px solid var(--c-focus-color, #3578c6);
}
&:disabled {
color: var(--c-disabled-text-color, #8a9099);
cursor: not-allowed;
background: var(--c-disabled-background-color, #f1f3f5);
border-color: var(--c-disabled-border-color, #d5d9df);
}
&:read-only:not(:disabled) {
background: var(--c-readonly-background-color, #f7f8fa);
}
&.is-invalid {
border-color: var(--c-danger-color, #b42318);
&:focus {
outline-color: var(--c-danger-color, #b42318);
}
}
&.is-small {
height: 26px;
padding: 3px 6px;
font-size: 12px;
}
&.is-large {
height: 34px;
padding: 5px 8px;
font-size: 14px;
}
}
</style>

@ -0,0 +1,171 @@
<script setup lang="ts">
import { computed } from 'vue'
import CButton from '../button/CButton.vue'
import type { CFormControlSize } from '../form/types'
import CInput from '../input/CInput.vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
modelValue?: number | null
min?: number
max?: number
step?: number
size?: CFormControlSize
controls?: boolean
disabled?: boolean
readonly?: boolean
required?: boolean
invalid?: boolean
}>(),
{
modelValue: null,
min: undefined,
max: undefined,
step: 1,
size: 'medium',
controls: true,
disabled: false,
readonly: false,
required: false,
invalid: false,
},
)
const emit = defineEmits<{
'update:modelValue': [value: number | null]
}>()
const inputValue = computed(() => props.modelValue?.toString() ?? '')
const normalizedStep = computed(() =>
Number.isFinite(props.step) && props.step > 0 ? props.step : 1,
)
const canDecrement = computed(
() =>
!props.disabled &&
!props.readonly &&
(props.modelValue === null || props.min === undefined || props.modelValue > props.min),
)
const canIncrement = computed(
() =>
!props.disabled &&
!props.readonly &&
(props.modelValue === null || props.max === undefined || props.modelValue < props.max),
)
function updateValue(value: string) {
if (value === '') {
emit('update:modelValue', null)
return
}
const number = Number(value)
if (Number.isFinite(number)) emit('update:modelValue', number)
}
function clamp(value: number) {
let next = value
if (props.min !== undefined) next = Math.max(next, props.min)
if (props.max !== undefined) next = Math.min(next, props.max)
return Number(next.toPrecision(15))
}
function stepBy(direction: -1 | 1) {
if (direction < 0 && !canDecrement.value) return
if (direction > 0 && !canIncrement.value) return
if (props.modelValue === null) {
const initial = direction > 0 ? (props.min ?? 0) : (props.max ?? 0)
emit('update:modelValue', clamp(initial))
return
}
emit('update:modelValue', clamp(props.modelValue + normalizedStep.value * direction))
}
</script>
<template>
<div class="c-number-input" :class="{ 'has-controls': controls }">
<CButton
v-if="controls"
class="step decrement"
icon=""
:size="size"
:disabled="!canDecrement"
aria-label="Decrease value"
@click="stepBy(-1)"
/>
<CInput
v-bind="$attrs"
class="input"
:model-value="inputValue"
type="number"
:min="min"
:max="max"
:step="normalizedStep"
:size="size"
:disabled="disabled"
:readonly="readonly"
:required="required"
:invalid="invalid"
@update:model-value="updateValue"
/>
<CButton
v-if="controls"
class="step increment"
icon="+"
:size="size"
:disabled="!canIncrement"
aria-label="Increase value"
@click="stepBy(1)"
/>
</div>
</template>
<style scoped lang="scss">
.c-number-input {
display: flex;
width: 100%;
min-width: 0;
.input {
flex: 1 1 auto;
}
.step {
flex: none;
}
&.has-controls {
.input {
border-radius: 0;
appearance: textfield;
&::-webkit-inner-spin-button,
&::-webkit-outer-spin-button {
margin: 0;
appearance: none;
}
}
.decrement {
border-start-end-radius: 0;
border-end-end-radius: 0;
}
.increment {
margin-inline-start: -1px;
border-start-start-radius: 0;
border-end-start-radius: 0;
}
.input {
margin-inline-start: -1px;
}
}
}
</style>

@ -0,0 +1,107 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import CButton from '../button/CButton.vue'
import { useFormControl } from '../form/useFormControl'
import type { CFormControlSize } from '../form/types'
import CInput from '../input/CInput.vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
modelValue?: string | null
size?: CFormControlSize
disabled?: boolean
readonly?: boolean
required?: boolean
invalid?: boolean
revealable?: boolean
visible?: boolean
}>(),
{
modelValue: '',
size: 'medium',
disabled: false,
readonly: false,
required: false,
invalid: false,
revealable: true,
visible: undefined,
},
)
const emit = defineEmits<{
'update:modelValue': [value: string]
'update:visible': [visible: boolean]
}>()
const internalVisible = ref(false)
const isVisible = computed(() => props.visible ?? internalVisible.value)
const { controlId, describedBy, invalid, required } = useFormControl(props)
watch(
() => props.visible,
(visible) => {
if (visible !== undefined) internalVisible.value = visible
},
)
function toggleVisibility() {
const visible = !isVisible.value
internalVisible.value = visible
emit('update:visible', visible)
}
</script>
<template>
<div class="c-password" :class="[`is-${size}`, { 'is-invalid': invalid }]">
<CInput
v-bind="$attrs"
class="input"
:id="controlId"
:model-value="modelValue"
:type="isVisible ? 'text' : 'password'"
:size="size"
:disabled="disabled"
:readonly="readonly"
:required="required"
:invalid="invalid"
:aria-describedby="describedBy"
@update:model-value="emit('update:modelValue', $event)"
/>
<CButton
v-if="revealable"
class="toggle"
:size="size"
:icon="isVisible ? '⊘' : '◉'"
:disabled="disabled"
:aria-controls="controlId"
:aria-pressed="isVisible"
:aria-label="isVisible ? 'Hide password' : 'Show password'"
@click="toggleVisibility"
/>
</div>
</template>
<style scoped lang="scss">
.c-password {
display: flex;
width: 100%;
min-width: 0;
.input {
flex: 1 1 auto;
border-start-end-radius: 0;
border-end-end-radius: 0;
}
.toggle {
flex: none;
margin-inline-start: -1px;
border-start-start-radius: 0;
border-end-start-radius: 0;
}
}
</style>

@ -0,0 +1,150 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { CFormControlSize } from '../form/types'
import { useFormControl } from '../form/useFormControl'
import type { CRadioValue } from './types'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
modelValue?: CRadioValue | null
value: CRadioValue
size?: CFormControlSize
disabled?: boolean
required?: boolean
invalid?: boolean
}>(),
{
modelValue: null,
size: 'medium',
disabled: false,
required: false,
invalid: false,
},
)
const emit = defineEmits<{
'update:modelValue': [value: CRadioValue]
}>()
const { controlId, describedBy, invalid, required } = useFormControl(props)
const checked = computed(() => Object.is(props.modelValue, props.value))
</script>
<template>
<label class="c-radio" :class="[`is-${size}`, { 'is-disabled': disabled }]">
<input
v-bind="$attrs"
class="input"
type="radio"
:id="controlId"
:value="value"
:checked="checked"
:disabled="disabled"
:required="required"
:aria-invalid="invalid ? 'true' : undefined"
:aria-describedby="describedBy"
@change="emit('update:modelValue', value)"
/>
<span v-if="$slots.default" class="label"><slot /></span>
</label>
</template>
<style scoped lang="scss">
.c-radio {
display: inline-flex;
align-items: flex-start;
width: fit-content;
min-width: 0;
gap: 6px;
color: var(--c-text-color, #20242a);
font: inherit;
line-height: 1.35;
cursor: pointer;
.input {
box-sizing: border-box;
position: relative;
flex: none;
width: 16px;
height: 16px;
margin: 1px 0 0;
appearance: none;
cursor: inherit;
background: var(--c-input-background, #fff);
border: 1px solid var(--c-control-border-color, #bfc5ce);
border-radius: 50%;
&::after {
position: absolute;
top: 3px;
left: 3px;
display: none;
width: 8px;
height: 8px;
content: '';
background: var(--c-primary-color, #286aa6);
border-radius: 50%;
}
&:checked {
border-color: var(--c-primary-color, #286aa6);
&::after {
display: block;
}
}
&:focus-visible {
outline: 2px solid var(--c-focus-color, #3578c6);
outline-offset: 2px;
}
&[aria-invalid='true'] {
border-color: var(--c-danger-color, #b42318);
}
}
&.is-disabled {
color: var(--c-disabled-text-color, #8a9099);
cursor: not-allowed;
.input {
background: var(--c-disabled-background-color, #f1f3f5);
border-color: var(--c-disabled-border-color, #d5d9df);
}
}
&.is-small {
font-size: 12px;
.input {
width: 14px;
height: 14px;
&::after {
top: 3px;
left: 3px;
width: 6px;
height: 6px;
}
}
}
&.is-large {
font-size: 14px;
.input {
width: 18px;
height: 18px;
&::after {
top: 4px;
left: 4px;
}
}
}
}
</style>

@ -0,0 +1 @@
export type CRadioValue = string | number | boolean

@ -0,0 +1,217 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { CFormControlSize } from '../form/types'
import { useFormControl } from '../form/useFormControl'
import type {
CSelectKeyAccessor,
CSelectLabelAccessor,
CSelectOption,
CSelectValue,
CSelectValueAccessor,
} from './types'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
modelValue?: CSelectValue | null
options?: object[]
optionLabel?: CSelectLabelAccessor
optionValue?: CSelectValueAccessor
optionKey?: CSelectKeyAccessor
placeholder?: string
size?: CFormControlSize
disabled?: boolean
required?: boolean
invalid?: boolean
}>(),
{
modelValue: null,
options: () => [],
optionLabel: undefined,
optionValue: undefined,
optionKey: undefined,
placeholder: undefined,
size: 'medium',
disabled: false,
required: false,
invalid: false,
},
)
const emit = defineEmits<{
'update:modelValue': [value: CSelectValue | null]
}>()
const { controlId, describedBy, invalid, required } = useFormControl(props)
const objectKeys = new WeakMap<object, number>()
let nextObjectKey = 0
const selection = computed({
get: () => props.modelValue ?? null,
set: (value: CSelectValue | null) => emit('update:modelValue', value),
})
function readPath(option: object, path: string) {
return path.split('.').reduce<unknown>((value, key) => {
if (typeof value !== 'object' || value === null) return undefined
return (value as Record<string, unknown>)[key]
}, option)
}
function resolveLabel(option: object, accessor?: CSelectLabelAccessor) {
const value = accessor
? typeof accessor === 'function'
? accessor(option)
: readPath(option, accessor)
: readPath(option, 'label')
return value === undefined || value === null ? '' : String(value)
}
function resolveValue(option: object, accessor?: CSelectValueAccessor): CSelectValue {
if (!accessor) return option
return typeof accessor === 'function'
? accessor(option)
: (readPath(option, accessor) as CSelectValue)
}
function objectKey(value: object) {
let key = objectKeys.get(value)
if (key === undefined) {
key = nextObjectKey++
objectKeys.set(value, key)
}
return key
}
const visibleOptions = computed(() =>
props.options.flatMap((source, index) => {
if (props.optionLabel || props.optionValue) {
const record = source as Record<string, unknown>
if (record.hidden) return []
const value = resolveValue(source, props.optionValue)
const explicitKey = props.optionKey
? typeof props.optionKey === 'function'
? props.optionKey(source)
: readPath(source, props.optionKey)
: undefined
return [
{
key:
typeof explicitKey === 'string' || typeof explicitKey === 'number'
? explicitKey
: typeof value === 'object'
? `object:${objectKey(source)}`
: `mapped:${typeof value}:${String(value)}:${index}`,
label: resolveLabel(source, props.optionLabel),
value,
disabled: Boolean(record.disabled),
},
]
}
const option = source as CSelectOption
if (option.hidden) return []
const valueKey =
typeof option.value === 'object'
? `object:${objectKey(option.value)}`
: `${typeof option.value}:${String(option.value)}:${index}`
return [{ ...option, key: valueKey }]
}),
)
</script>
<template>
<select
v-model="selection"
v-bind="$attrs"
class="c-select"
:class="[
`is-${size}`,
{
'is-invalid': invalid,
'has-placeholder': Boolean(placeholder) && (modelValue ?? null) === null,
},
]"
:id="controlId"
:disabled="disabled"
:required="required"
:aria-invalid="invalid ? 'true' : undefined"
:aria-describedby="describedBy"
>
<option v-if="placeholder" :value="null" disabled>{{ placeholder }}</option>
<option
v-for="option in visibleOptions"
:key="option.key"
:value="option.value"
:disabled="option.disabled"
>
{{ option.label }}
</option>
<slot />
</select>
</template>
<style scoped lang="scss">
.c-select {
box-sizing: border-box;
width: 100%;
height: 30px;
min-width: 0;
padding: 4px 28px 4px 7px;
color: var(--c-text-color, #20242a);
font: inherit;
line-height: 1.2;
cursor: pointer;
background-color: var(--c-input-background, #fff);
border: 1px solid var(--c-control-border-color, #bfc5ce);
border-radius: var(--c-border-radius, 3px);
&:hover:not(:disabled) {
border-color: var(--c-control-hover-border-color, #929aa6);
}
&:focus {
position: relative;
z-index: 1;
border-color: var(--c-focus-color, #3578c6);
outline: 1px solid var(--c-focus-color, #3578c6);
}
&:disabled {
color: var(--c-disabled-text-color, #8a9099);
cursor: not-allowed;
background-color: var(--c-disabled-background-color, #f1f3f5);
border-color: var(--c-disabled-border-color, #d5d9df);
}
&.has-placeholder {
color: var(--c-placeholder-color, #7a828d);
}
&.is-invalid {
border-color: var(--c-danger-color, #b42318);
&:focus {
outline-color: var(--c-danger-color, #b42318);
}
}
&.is-small {
height: 26px;
padding-block: 3px;
font-size: 12px;
}
&.is-large {
height: 34px;
padding-block: 5px;
font-size: 14px;
}
}
</style>

@ -0,0 +1,13 @@
export type CSelectPrimitive = string | number | boolean
export type CSelectValue = CSelectPrimitive | object
export interface CSelectOption {
label: string
value: CSelectValue
disabled?: boolean
hidden?: boolean
}
export type CSelectLabelAccessor = string | ((option: object) => string)
export type CSelectKeyAccessor = string | ((option: object) => string | number)
export type CSelectValueAccessor = string | ((option: object) => CSelectValue)

@ -0,0 +1,113 @@
<script setup lang="ts">
import { useFormControl } from '../form/useFormControl'
import type { CFormControlSize } from '../form/types'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
modelValue?: string | null
size?: CFormControlSize
disabled?: boolean
readonly?: boolean
required?: boolean
invalid?: boolean
}>(),
{
modelValue: '',
size: 'medium',
disabled: false,
readonly: false,
required: false,
invalid: false,
},
)
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const { controlId, describedBy, invalid, required } = useFormControl(props)
function updateValue(event: Event) {
emit('update:modelValue', (event.target as HTMLTextAreaElement).value)
}
</script>
<template>
<textarea
v-bind="$attrs"
class="c-text-area"
:class="[`is-${size}`, { 'is-invalid': invalid }]"
:id="controlId"
:value="modelValue ?? ''"
:disabled="disabled"
:readonly="readonly"
:required="required"
:aria-invalid="invalid ? 'true' : undefined"
:aria-describedby="describedBy"
@input="updateValue"
/>
</template>
<style scoped lang="scss">
.c-text-area {
box-sizing: border-box;
display: block;
width: 100%;
min-width: 0;
min-height: 64px;
padding: 6px 7px;
color: var(--c-text-color, #20242a);
font: inherit;
line-height: 1.4;
resize: vertical;
background: var(--c-input-background, #fff);
border: 1px solid var(--c-control-border-color, #bfc5ce);
border-radius: var(--c-border-radius, 3px);
&::placeholder {
color: var(--c-placeholder-color, #7a828d);
}
&:hover:not(:disabled):not(:read-only) {
border-color: var(--c-control-hover-border-color, #929aa6);
}
&:focus {
border-color: var(--c-focus-color, #3578c6);
outline: 1px solid var(--c-focus-color, #3578c6);
}
&:disabled {
color: var(--c-disabled-text-color, #8a9099);
cursor: not-allowed;
background: var(--c-disabled-background-color, #f1f3f5);
border-color: var(--c-disabled-border-color, #d5d9df);
}
&:read-only:not(:disabled) {
background: var(--c-readonly-background-color, #f7f8fa);
}
&.is-invalid {
border-color: var(--c-danger-color, #b42318);
&:focus {
outline-color: var(--c-danger-color, #b42318);
}
}
&.is-small {
min-height: 52px;
padding: 4px 6px;
font-size: 12px;
}
&.is-large {
min-height: 76px;
padding: 7px 8px;
font-size: 14px;
}
}
</style>

@ -1,9 +1,19 @@
export { default as CAppBar } from './components/app-bar/CAppBar.vue'
export { default as CButton } from './components/button/CButton.vue'
export { default as CCheckbox } from './components/checkbox/CCheckbox.vue'
export { default as CFormField } from './components/form-field/CFormField.vue'
export { default as CIcon } from './components/icon/CIcon.vue'
export { default as CInputAddon } from './components/input-addon/CInputAddon.vue'
export { default as CInputGroup } from './components/input-group/CInputGroup.vue'
export { default as CInput } from './components/input/CInput.vue'
export { default as CMenu } from './components/menu/CMenu.vue'
export { default as CNumberInput } from './components/number-input/CNumberInput.vue'
export { default as CPassword } from './components/password/CPassword.vue'
export { default as CRadio } from './components/radio/CRadio.vue'
export { default as CSelect } from './components/select/CSelect.vue'
export { default as CSeparator } from './components/separator/CSeparator.vue'
export { default as CSideBar } from './components/side-bar/CSideBar.vue'
export { default as CTextArea } from './components/text-area/CTextArea.vue'
export type {
CMenuActionItem,
@ -15,6 +25,17 @@ export type {
CMenuSubmenuMode,
} from './components/menu/types'
export type { CButtonSize, CButtonType, CButtonVariant } from './components/button/types'
export type { CCheckboxModelValue, CCheckboxValue } from './components/checkbox/types'
export type { CFormControlSize } from './components/form/types'
export type { CRadioValue } from './components/radio/types'
export type {
CSelectKeyAccessor,
CSelectLabelAccessor,
CSelectOption,
CSelectPrimitive,
CSelectValue,
CSelectValueAccessor,
} from './components/select/types'
export type { CSeparatorOrientation } from './components/separator/types'
export type { CSideBarPlacement } from './components/side-bar/types'
export type {

@ -43,6 +43,70 @@ export function useTopNavigation() {
active: route.name === 'button',
command: () => void router.push({ name: 'button' }),
},
{
id: 'form-field',
label: 'Form Field',
icon: '▤',
active: route.name === 'form-field',
command: () => void router.push({ name: 'form-field' }),
},
{
id: 'input',
label: 'Input',
icon: '⌨',
active: route.name === 'input',
command: () => void router.push({ name: 'input' }),
},
{
id: 'number-input',
label: 'Number Input',
icon: '±',
active: route.name === 'number-input',
command: () => void router.push({ name: 'number-input' }),
},
{
id: 'input-group',
label: 'Input Group',
icon: '⊞',
active: route.name === 'input-group',
command: () => void router.push({ name: 'input-group' }),
},
{
id: 'text-area',
label: 'Text Area',
icon: '¶',
active: route.name === 'text-area',
command: () => void router.push({ name: 'text-area' }),
},
{
id: 'password',
label: 'Password',
icon: '🔒',
active: route.name === 'password',
command: () => void router.push({ name: 'password' }),
},
{
id: 'checkbox',
label: 'Checkbox',
icon: '☑',
active: route.name === 'checkbox',
command: () => void router.push({ name: 'checkbox' }),
},
{
id: 'radio',
label: 'Radio',
icon: '◉',
active: route.name === 'radio',
command: () => void router.push({ name: 'radio' }),
},
{
id: 'select',
label: 'Select',
icon: '▾',
active: route.name === 'select',
command: () => void router.push({ name: 'select' }),
},
{ type: 'separator' },
{
id: 'icon',
label: 'Icon',
@ -89,6 +153,70 @@ export function useComponentNavigation() {
active: route.name === 'button',
command: () => void router.push({ name: 'button' }),
},
{
id: 'form-field',
label: 'Form Field',
icon: '▤',
active: route.name === 'form-field',
command: () => void router.push({ name: 'form-field' }),
},
{
id: 'input',
label: 'Input',
icon: '⌨',
active: route.name === 'input',
command: () => void router.push({ name: 'input' }),
},
{
id: 'number-input',
label: 'Number Input',
icon: '±',
active: route.name === 'number-input',
command: () => void router.push({ name: 'number-input' }),
},
{
id: 'input-group',
label: 'Input Group',
icon: '⊞',
active: route.name === 'input-group',
command: () => void router.push({ name: 'input-group' }),
},
{
id: 'text-area',
label: 'Text Area',
icon: '¶',
active: route.name === 'text-area',
command: () => void router.push({ name: 'text-area' }),
},
{
id: 'password',
label: 'Password',
icon: '🔒',
active: route.name === 'password',
command: () => void router.push({ name: 'password' }),
},
{
id: 'checkbox',
label: 'Checkbox',
icon: '☑',
active: route.name === 'checkbox',
command: () => void router.push({ name: 'checkbox' }),
},
{
id: 'radio',
label: 'Radio',
icon: '◉',
active: route.name === 'radio',
command: () => void router.push({ name: 'radio' }),
},
{
id: 'select',
label: 'Select',
icon: '▾',
active: route.name === 'select',
command: () => void router.push({ name: 'select' }),
},
{ type: 'separator' },
{
id: 'icon',
label: 'Icon',

@ -0,0 +1,129 @@
<script setup lang="ts">
import { ref } from 'vue'
import { CCheckbox, CFormField, CSeparator } from '@/index'
import type { CCheckboxValue } from '@/index'
import CCodeBlock from '@/documentation/CCodeBlock.vue'
const enabled = ref<CCheckboxValue | CCheckboxValue[]>(true)
const permissions = ref<CCheckboxValue | CCheckboxValue[]>(['read', 'reports'])
const accepted = ref<CCheckboxValue | CCheckboxValue[]>(false)
const smallChecked = ref<CCheckboxValue | CCheckboxValue[]>(true)
const largeChecked = ref<CCheckboxValue | CCheckboxValue[]>(true)
const approval = ref<boolean | null>(false)
const basicUsage = `<CCheckbox v-model="enabled">Enable notifications</CCheckbox>`
const groupUsage = `<CCheckbox v-model="permissions" value="read">Read</CCheckbox>
<CCheckbox v-model="permissions" value="write">Write</CCheckbox>
<CCheckbox v-model="permissions" value="reports">Reports</CCheckbox>`
const valueUsage = `<CCheckbox
v-model="status"
true-value="enabled"
false-value="disabled"
>
Account enabled
</CCheckbox>`
const threeStateUsage = `<script setup lang="ts">
import { ref } from 'vue'
const approval = ref<boolean | null>(false)
<\/script>
<template>
<CCheckbox v-model="approval" three-state>
Approval state
</CCheckbox>
</template>`
</script>
<template>
<article class="form-page">
<header class="page-header">
<div><p class="category">Forms</p><h1>Checkbox</h1></div>
<p>
<code>CCheckbox</code> supports boolean choices, custom checked values, array groups, and
indeterminate presentation while retaining a native checkbox input.
</p>
</header>
<CSeparator />
<section class="section">
<h2>Boolean value</h2>
<div class="preview">
<CCheckbox v-model="enabled">Enable notifications</CCheckbox>
<span>Model: {{ enabled }}</span>
</div>
<CCodeBlock class="code-sample" :code="basicUsage" />
</section>
<section class="section">
<h2>Array group</h2>
<p>
When the model is an array, each checkbox adds or removes its <code>value</code>. This is
useful for permissions and independent feature selections.
</p>
<div class="preview">
<CCheckbox v-model="permissions" value="read">Read</CCheckbox>
<CCheckbox v-model="permissions" value="write">Write</CCheckbox>
<CCheckbox v-model="permissions" value="reports">Reports</CCheckbox>
<span>Selected: {{ permissions }}</span>
</div>
<CCodeBlock class="code-sample" :code="groupUsage" />
</section>
<section class="section">
<h2>States and sizes</h2>
<div class="preview">
<CCheckbox v-model="smallChecked" size="small">Small</CCheckbox>
<CCheckbox v-model="largeChecked" size="large">Large</CCheckbox>
<CCheckbox indeterminate>Partially selected</CCheckbox>
<CCheckbox disabled>Disabled</CCheckbox>
<CFormField error="You must accept the policy.">
<CCheckbox v-model="accepted">Accept policy</CCheckbox>
</CFormField>
</div>
</section>
<section class="section">
<h2>Custom values</h2>
<p>
<code>true-value</code> and <code>false-value</code> replace the default boolean values for
scalar models.
</p>
<CCodeBlock class="code-sample" :code="valueUsage" />
</section>
<section class="section">
<h2>Three-state checkbox</h2>
<p>
Add <code>three-state</code> to cycle through unchecked, checked, and indeterminate values.
The model sequence is <code>false true null false</code>, where <code>null</code>
represents the indeterminate state. Three-state behavior applies to scalar models, not
array groups.
</p>
<div class="preview">
<CCheckbox v-model="approval" three-state>Approval state</CCheckbox>
<span>Model: {{ approval === null ? 'null (indeterminate)' : approval }}</span>
</div>
<CCodeBlock class="code-sample" :code="threeStateUsage" />
</section>
<section class="section">
<h2>Properties</h2>
<dl class="property-list">
<div><dt><code>model-value</code></dt><dd>A scalar checkbox value or an array used by <code>v-model</code>.</dd></div>
<div><dt><code>value</code></dt><dd>Value added to or removed from an array model.</dd></div>
<div><dt><code>true-value</code></dt><dd>Scalar value emitted when checked. Defaults to true.</dd></div>
<div><dt><code>false-value</code></dt><dd>Scalar value emitted when unchecked. Defaults to false.</dd></div>
<div><dt><code>indeterminate</code></dt><dd>Displays a mixed state without changing the model by itself.</dd></div>
<div><dt><code>three-state</code></dt><dd>Cycles a scalar model through false, true, and null.</dd></div>
<div><dt><code>size</code></dt><dd><code>small</code>, <code>medium</code>, or <code>large</code>.</dd></div>
<div><dt><code>disabled</code></dt><dd>Disables interaction.</dd></div>
<div><dt><code>required</code></dt><dd>Applies native required validation.</dd></div>
<div><dt><code>invalid</code></dt><dd>Applies invalid styling and <code>aria-invalid</code>.</dd></div>
</dl>
</section>
</article>
</template>
<style scoped lang="scss" src="./form-demo.scss"></style>

@ -0,0 +1,79 @@
<script setup lang="ts">
import { ref } from 'vue'
import { CFormField, CInput, CSeparator } from '@/index'
import CCodeBlock from '@/documentation/CCodeBlock.vue'
const customer = ref('Contoso Ltd.')
const email = ref('invalid-address')
const basicUsage = `<CFormField
label="Customer"
hint="Enter the registered company name."
required
>
<CInput v-model="customer" />
</CFormField>`
const errorUsage = `<CFormField
label="Email"
error="Enter a valid email address."
>
<CInput v-model="email" type="email" />
</CFormField>`
</script>
<template>
<article class="form-page">
<header class="page-header">
<div><p class="category">Forms</p><h1>Form Field</h1></div>
<p>
<code>CFormField</code> consistently connects a label, hint, validation message, and
required state to a nested Concise UI form control.
</p>
</header>
<CSeparator />
<section class="section">
<h2>Label and hint</h2>
<p>
The field generates an input ID and accessible description automatically. Use
<code>for</code> only when a specific ID is required.
</p>
<div class="preview">
<CFormField label="Customer" hint="Enter the registered company name." required>
<CInput v-model="customer" />
</CFormField>
</div>
<CCodeBlock class="code-sample" :code="basicUsage" />
</section>
<section class="section">
<h2>Validation message</h2>
<p>
Providing <code>error</code> marks the nested control invalid, connects the message with
<code>aria-describedby</code>, and announces it as an alert.
</p>
<div class="preview">
<CFormField label="Email" error="Enter a valid email address.">
<CInput v-model="email" type="email" />
</CFormField>
</div>
<CCodeBlock class="code-sample" :code="errorUsage" />
</section>
<section class="section">
<h2>Properties</h2>
<dl class="property-list">
<div><dt><code>label</code></dt><dd>Text displayed above the control.</dd></div>
<div><dt><code>for</code></dt><dd>Optional control ID; generated automatically by default.</dd></div>
<div><dt><code>hint</code></dt><dd>Supporting text connected to the control.</dd></div>
<div><dt><code>error</code></dt><dd>Validation message that also marks the field invalid.</dd></div>
<div><dt><code>required</code></dt><dd>Shows a required marker and marks the nested control required.</dd></div>
<div><dt><code>invalid</code></dt><dd>Marks the nested control invalid without requiring an error string.</dd></div>
</dl>
</section>
</article>
</template>
<style scoped lang="scss" src="./form-demo.scss"></style>

@ -0,0 +1,75 @@
<script setup lang="ts">
import { ref } from 'vue'
import { CFormField, CInput, CSeparator } from '@/index'
import CCodeBlock from '@/documentation/CCodeBlock.vue'
const name = ref('Ada Lovelace')
const search = ref('')
const basicUsage = `<CInput v-model="name" placeholder="Full name" />
<CInput v-model="search" type="search" placeholder="Search records" />`
const sizeUsage = `<CInput size="small" placeholder="Small" />
<CInput size="medium" placeholder="Medium" />
<CInput size="large" placeholder="Large" />`
const stateUsage = `<CInput disabled model-value="Unavailable" />
<CInput readonly model-value="Read-only value" />
<CInput invalid model-value="Invalid value" />`
</script>
<template>
<article class="form-page">
<header class="page-header">
<div><p class="category">Forms</p><h1>Input</h1></div>
<p>
<code>CInput</code> is a compact native input with <code>v-model</code>, consistent sizing,
validation styling, and forwarded HTML attributes.
</p>
</header>
<CSeparator />
<section class="section">
<h2>Basic usage</h2>
<div class="preview">
<CFormField label="Full name"><CInput v-model="name" /></CFormField>
<CFormField label="Search"><CInput v-model="search" type="search" placeholder="Search records" /></CFormField>
</div>
<CCodeBlock class="code-sample" :code="basicUsage" />
</section>
<section class="section">
<h2>Sizes</h2>
<div class="preview-row">
<CInput size="small" placeholder="Small" aria-label="Small input" />
<CInput size="medium" placeholder="Medium" aria-label="Medium input" />
<CInput size="large" placeholder="Large" aria-label="Large input" />
</div>
<CCodeBlock class="code-sample" :code="sizeUsage" />
</section>
<section class="section">
<h2>States</h2>
<div class="preview">
<CInput disabled model-value="Unavailable" aria-label="Disabled input" />
<CInput readonly model-value="Read-only value" aria-label="Read-only input" />
<CInput invalid model-value="Invalid value" aria-label="Invalid input" />
</div>
<CCodeBlock class="code-sample" :code="stateUsage" />
</section>
<section class="section">
<h2>Properties</h2>
<dl class="property-list">
<div><dt><code>model-value</code></dt><dd>String value used by <code>v-model</code>.</dd></div>
<div><dt><code>type</code></dt><dd>Any native HTML input type. Defaults to <code>text</code>.</dd></div>
<div><dt><code>size</code></dt><dd><code>small</code>, <code>medium</code>, or <code>large</code>.</dd></div>
<div><dt><code>disabled</code></dt><dd>Prevents editing and focus.</dd></div>
<div><dt><code>readonly</code></dt><dd>Prevents editing while keeping the value focusable.</dd></div>
<div><dt><code>required</code></dt><dd>Applies native required validation.</dd></div>
<div><dt><code>invalid</code></dt><dd>Applies invalid styling and <code>aria-invalid</code>.</dd></div>
</dl>
</section>
</article>
</template>
<style scoped lang="scss" src="./form-demo.scss"></style>

@ -0,0 +1,123 @@
<script setup lang="ts">
import { ref } from 'vue'
import {
CButton,
CFormField,
CIcon,
CInput,
CInputAddon,
CInputGroup,
CNumberInput,
CPassword,
CSeparator,
} from '@/index'
import CCodeBlock from '@/documentation/CCodeBlock.vue'
const search = ref('')
const password = ref('warehouse-2026')
const price = ref<number | null>(49.95)
const lastAction = ref('No action selected')
const searchUsage = `<CFormField label="Search">
<CInputGroup>
<CInputAddon><CIcon></CIcon></CInputAddon>
<CInput v-model="search" />
<CButton variant="primary">Search</CButton>
</CInputGroup>
</CFormField>`
const compositionUsage = `<CInputGroup>
<CPassword v-model="password" />
<CButton>Generate</CButton>
</CInputGroup>
<CInputGroup>
<CInputAddon>$</CInputAddon>
<CNumberInput v-model="price" :controls="false" />
<CInputAddon>USD</CInputAddon>
</CInputGroup>`
</script>
<template>
<article class="form-page">
<header class="page-header">
<div><p class="category">Forms</p><h1>Input Group</h1></div>
<p>
<code>CInputGroup</code> joins inputs, addons, and buttons into one compact control without
adding component-specific prefix and suffix props.
</p>
</header>
<CSeparator />
<section class="section">
<h2>Icon and action</h2>
<p>
Place children in visual order. Use <code>CInputAddon</code> for non-interactive icons or
text, and a real <code>CButton</code> for actions.
</p>
<div class="preview">
<CFormField label="Search records">
<CInputGroup>
<CInputAddon><CIcon></CIcon></CInputAddon>
<CInput v-model="search" placeholder="Order or customer" />
<CButton variant="primary" @click="lastAction = `Searched for ${search || 'everything'}`">
Search
</CButton>
</CInputGroup>
</CFormField>
<span class="status" aria-live="polite">{{ lastAction }}</span>
</div>
<CCodeBlock class="code-sample" :code="searchUsage" />
</section>
<section class="section">
<h2>Compound controls</h2>
<p>
Password and number inputs keep their own internal controls. Additional addons and buttons
are placed outside them, and the group joins all outer borders automatically.
</p>
<div class="preview">
<CFormField label="Password">
<CInputGroup>
<CPassword v-model="password" />
<CButton @click="lastAction = 'Generated password'">Generate</CButton>
</CInputGroup>
</CFormField>
<CFormField label="Unit price">
<CInputGroup>
<CInputAddon>$</CInputAddon>
<CNumberInput v-model="price" :controls="false" />
<CInputAddon>USD</CInputAddon>
</CInputGroup>
</CFormField>
</div>
<CCodeBlock class="code-sample" :code="compositionUsage" />
</section>
<section class="section">
<h2>Responsibilities</h2>
<dl class="property-list">
<div>
<dt><code>CInputGroup</code></dt>
<dd>Arranges children horizontally, joins adjacent borders, and preserves focus stacking.</dd>
</div>
<div>
<dt><code>CInputAddon</code></dt>
<dd>Displays non-interactive text, units, or icons with a bordered neutral background.</dd>
</div>
<div>
<dt>Child order</dt>
<dd>Determines whether an addon or button appears before or after the input.</dd>
</div>
<div>
<dt>Sizes</dt>
<dd>Set the same size on the input, addon, and button when using a non-default size.</dd>
</div>
</dl>
</section>
</article>
</template>
<style scoped lang="scss" src="./form-demo.scss"></style>

@ -0,0 +1,142 @@
<script setup lang="ts">
import { ref } from 'vue'
import { CFormField, CNumberInput, CSeparator } from '@/index'
import CCodeBlock from '@/documentation/CCodeBlock.vue'
const quantity = ref<number | null>(12)
const price = ref<number | null>(49.95)
const smallValue = ref<number | null>(10)
const largeValue = ref<number | null>(10)
const invalidValue = ref<number | null>(10)
const modelUsage = `<script setup lang="ts">
import { ref } from 'vue'
const quantity = ref<number | null>(10)
<\/script>
<template>
<CNumberInput v-model="quantity" />
<!-- Equivalent explicit binding -->
<CNumberInput
:model-value="quantity"
@update:model-value="quantity = $event"
/>
</template>`
const basicUsage = `<CFormField label="Quantity" required>
<CNumberInput
v-model="quantity"
:min="0"
:max="100"
:step="1"
/>
</CFormField>`
const decimalUsage = `<CNumberInput v-model="price" :min="0" :step="0.05" />
<CNumberInput v-model="quantity" :controls="false" />`
</script>
<template>
<article class="form-page">
<header class="page-header">
<div><p class="category">Forms</p><h1>Number Input</h1></div>
<p>
<code>CNumberInput</code> provides numeric <code>v-model</code> values, native keyboard
entry, and optional compact step controls.
</p>
</header>
<CSeparator />
<section class="section">
<h2>Value binding</h2>
<p>
The model is a <code>number</code> or <code>null</code>. An empty input emits
<code>null</code>. Use <code>v-model</code> for an interactive control so typed values and
step-button changes are written back to application state.
</p>
<p>
Passing only <code>:model-value</code> is a one-way binding. In that case, handle
<code>@update:model-value</code> yourself; otherwise the component can emit changes but the
displayed model remains unchanged.
</p>
<CCodeBlock class="code-sample" :code="modelUsage" />
</section>
<section class="section">
<h2>Constraints and steps</h2>
<p>
Use <code>min</code>, <code>max</code>, and <code>step</code> to describe the permitted range.
The and + buttons change the current value by <code>step</code> and clamp their result to
the range. Manual input retains native browser validation and is not forcibly clamped while
the user is typing.
</p>
<div class="preview">
<CFormField label="Quantity" hint="Allowed range: 0100" required>
<CNumberInput v-model="quantity" :min="0" :max="100" :step="1" />
</CFormField>
<span>Current value: {{ quantity ?? 'empty' }}</span>
</div>
<CCodeBlock class="code-sample" :code="basicUsage" />
</section>
<section class="section">
<h2>Decimals and controls</h2>
<p>
Decimal steps are normalized to avoid common floating-point artifacts. Set
<code>:controls="false"</code> to remove the Concise UI and + buttons and use the browser's
native numeric control.
</p>
<div class="preview">
<CFormField label="Unit price"><CNumberInput v-model="price" :min="0" :step="0.05" /></CFormField>
<CFormField label="Without controls"><CNumberInput v-model="quantity" :controls="false" /></CFormField>
</div>
<CCodeBlock class="code-sample" :code="decimalUsage" />
</section>
<section class="section">
<h2>Sizes and states</h2>
<p>
Size changes the input and both step buttons together. Interactive examples use
<code>v-model</code>; disabled and read-only controls intentionally prevent step changes.
</p>
<div class="preview">
<CFormField label="Small (26px)">
<CNumberInput v-model="smallValue" size="small" />
</CFormField>
<CFormField label="Large (34px)">
<CNumberInput v-model="largeValue" size="large" />
</CFormField>
<CFormField label="Disabled">
<CNumberInput disabled :model-value="10" />
</CFormField>
<CFormField label="Read-only">
<CNumberInput readonly :model-value="10" />
</CFormField>
<CFormField label="Invalid">
<CNumberInput v-model="invalidValue" invalid />
</CFormField>
</div>
</section>
<section class="section">
<h2>Properties</h2>
<dl class="property-list">
<div><dt><code>model-value</code></dt><dd>Numeric value or <code>null</code>. Use <code>v-model</code> for two-way binding.</dd></div>
<div><dt><code>min</code></dt><dd>Optional lower constraint used by native validation and the decrement button.</dd></div>
<div><dt><code>max</code></dt><dd>Optional upper constraint used by native validation and the increment button.</dd></div>
<div><dt><code>step</code></dt><dd>Amount added or subtracted by the controls. Must be positive and defaults to <code>1</code>.</dd></div>
<div><dt><code>controls</code></dt><dd>Shows Concise UI decrement and increment buttons. Defaults to true.</dd></div>
<div><dt><code>size</code></dt><dd><code>small</code>, <code>medium</code>, or <code>large</code>.</dd></div>
<div><dt><code>disabled</code></dt><dd>Disables entry and step controls.</dd></div>
<div><dt><code>readonly</code></dt><dd>Prevents changes while keeping the input focusable.</dd></div>
<div><dt><code>required</code></dt><dd>Applies native required validation.</dd></div>
<div><dt><code>invalid</code></dt><dd>Applies invalid styling and <code>aria-invalid</code>.</dd></div>
</dl>
</section>
</article>
</template>
<style scoped lang="scss" src="./form-demo.scss"></style>

@ -0,0 +1,83 @@
<script setup lang="ts">
import { ref } from 'vue'
import { CFormField, CPassword, CSeparator } from '@/index'
import CCodeBlock from '@/documentation/CCodeBlock.vue'
const password = ref('warehouse-2026')
const passwordVisible = ref(false)
const basicUsage = `<CFormField label="Password" required>
<CPassword v-model="password" autocomplete="current-password" />
</CFormField>`
const visibilityUsage = `<CPassword
v-model="password"
v-model:visible="passwordVisible"
/>
<CPassword v-model="password" :revealable="false" />`
</script>
<template>
<article class="form-page">
<header class="page-header">
<div><p class="category">Forms</p><h1>Password</h1></div>
<p>
<code>CPassword</code> combines a native password input with an optional accessible
visibility control. Password values remain ordinary strings managed through
<code>v-model</code>.
</p>
</header>
<CSeparator />
<section class="section">
<h2>Basic usage</h2>
<div class="preview">
<CFormField label="Password" required>
<CPassword v-model="password" autocomplete="current-password" />
</CFormField>
</div>
<CCodeBlock class="code-sample" :code="basicUsage" />
</section>
<section class="section">
<h2>Visibility control</h2>
<p>
Visibility is managed internally by default. Use <code>v-model:visible</code> when the
application needs to observe or control it, or disable the toggle with
<code>:revealable="false"</code>.
</p>
<div class="preview">
<CPassword v-model="password" v-model:visible="passwordVisible" aria-label="Controlled password" />
<CPassword v-model="password" :revealable="false" aria-label="Password without reveal button" />
</div>
<CCodeBlock class="code-sample" :code="visibilityUsage" />
</section>
<section class="section">
<h2>Sizes and states</h2>
<div class="preview">
<CPassword size="small" model-value="secret" aria-label="Small password" />
<CPassword size="large" model-value="secret" aria-label="Large password" />
<CPassword disabled model-value="secret" aria-label="Disabled password" />
<CPassword invalid model-value="secret" aria-label="Invalid password" />
</div>
</section>
<section class="section">
<h2>Properties</h2>
<dl class="property-list">
<div><dt><code>model-value</code></dt><dd>Password string used by <code>v-model</code>.</dd></div>
<div><dt><code>visible</code></dt><dd>Optional controlled visibility state used by <code>v-model:visible</code>.</dd></div>
<div><dt><code>revealable</code></dt><dd>Shows the visibility button when true. Defaults to true.</dd></div>
<div><dt><code>size</code></dt><dd><code>small</code>, <code>medium</code>, or <code>large</code>.</dd></div>
<div><dt><code>disabled</code></dt><dd>Disables the input and visibility button.</dd></div>
<div><dt><code>readonly</code></dt><dd>Prevents editing while allowing visibility changes.</dd></div>
<div><dt><code>required</code></dt><dd>Applies native required validation.</dd></div>
<div><dt><code>invalid</code></dt><dd>Applies invalid styling and <code>aria-invalid</code>.</dd></div>
</dl>
</section>
</article>
</template>
<style scoped lang="scss" src="./form-demo.scss"></style>

@ -0,0 +1,86 @@
<script setup lang="ts">
import { ref } from 'vue'
import { CRadio, CSeparator } from '@/index'
import type { CRadioValue } from '@/index'
import CCodeBlock from '@/documentation/CCodeBlock.vue'
const shipping = ref<CRadioValue | null>('standard')
const groupUsage = `<fieldset>
<legend>Shipping method</legend>
<CRadio v-model="shipping" name="shipping" value="standard">Standard</CRadio>
<CRadio v-model="shipping" name="shipping" value="express">Express</CRadio>
<CRadio v-model="shipping" name="shipping" value="pickup">Pickup</CRadio>
</fieldset>`
</script>
<template>
<article class="form-page">
<header class="page-header">
<div><p class="category">Forms</p><h1>Radio</h1></div>
<p>
<code>CRadio</code> represents one mutually exclusive choice. Radios sharing a model and
native <code>name</code> form a keyboard-accessible group.
</p>
</header>
<CSeparator />
<section class="section">
<h2>Radio group</h2>
<p>
Use a native <code>fieldset</code> and <code>legend</code> to give the complete group an
accessible label. Each radio emits its own <code>value</code> when selected.
</p>
<div class="preview">
<fieldset class="radio-group">
<legend>Shipping method</legend>
<CRadio v-model="shipping" name="shipping" value="standard">Standard</CRadio>
<CRadio v-model="shipping" name="shipping" value="express">Express</CRadio>
<CRadio v-model="shipping" name="shipping" value="pickup">Pickup</CRadio>
</fieldset>
<span>Selected: {{ shipping }}</span>
</div>
<CCodeBlock class="code-sample" :code="groupUsage" />
</section>
<section class="section">
<h2>Sizes and states</h2>
<div class="preview">
<CRadio :model-value="1" :value="1" size="small" name="small-example">Small</CRadio>
<CRadio :model-value="1" :value="1" size="large" name="large-example">Large</CRadio>
<CRadio :model-value="false" :value="true" disabled name="disabled-example">Disabled</CRadio>
<CRadio :model-value="true" :value="true" invalid name="invalid-example">Invalid</CRadio>
</div>
</section>
<section class="section">
<h2>Properties</h2>
<dl class="property-list">
<div><dt><code>model-value</code></dt><dd>Currently selected group value, used by <code>v-model</code>.</dd></div>
<div><dt><code>value</code></dt><dd>Required value emitted when this radio is selected.</dd></div>
<div><dt><code>size</code></dt><dd><code>small</code>, <code>medium</code>, or <code>large</code>.</dd></div>
<div><dt><code>disabled</code></dt><dd>Prevents this option from being selected.</dd></div>
<div><dt><code>required</code></dt><dd>Applies native required validation.</dd></div>
<div><dt><code>invalid</code></dt><dd>Applies invalid styling and <code>aria-invalid</code>.</dd></div>
</dl>
</section>
</article>
</template>
<style scoped lang="scss">
@use './form-demo.scss';
.radio-group {
display: grid;
margin: 0;
padding: 8px 10px 10px;
gap: 7px;
border: 1px solid var(--c-border-color, #d5d9df);
legend {
padding: 0 4px;
font-weight: 600;
}
}
</style>

@ -0,0 +1,186 @@
<script setup lang="ts">
import { ref } from 'vue'
import { CFormField, CSelect, CSeparator } from '@/index'
import type { CSelectOption, CSelectValue } from '@/index'
import CCodeBlock from '@/documentation/CCodeBlock.vue'
const warehouse = ref<CSelectValue | null>('north')
const priority = ref<CSelectValue | null>(null)
const status = ref<CSelectValue | null>('active')
const products = [
{ id: 1, name: 'Book', code: 'BOO' },
{ id: 2, name: 'Stove', code: 'STV' },
]
const selectedProduct = ref<(typeof products)[number] | null>(products[0] ?? null)
const selectedProductId = ref<number | null>(1)
const warehouses: CSelectOption[] = [
{ label: 'North warehouse', value: 'north' },
{ label: 'Central warehouse', value: 'central' },
{ label: 'South warehouse', value: 'south', disabled: true },
]
const dataJavaScript = `const products = [
{ id: 1, name: 'Book', code: 'BOO' },
{ id: 2, name: 'Stove', code: 'STV' },
]
const statuses = [
{ label: 'Active', value: 'active' },
{ label: 'Inactive', value: 'inactive' },
]`
const statuses: CSelectOption[] = [
{ label: 'Active', value: 'active' },
{ label: 'Inactive', value: 'inactive' },
]
const dataUsage = `<CSelect
v-model="selectedProductId"
:options="products"
option-label="name"
option-value="id"
option-key="id"
/>
<!-- label and value are interpreted automatically -->
<CSelect
v-model="status"
:options="statuses"
/>`
const slotUsage = `<CSelect v-model="priority" placeholder="Select priority">
<option value="normal">Normal</option>
<option value="urgent">Urgent</option>
</CSelect>`
const objectJavaScript = `const products = [
{ id: 1, name: 'Book', code: 'BOO' },
{ id: 2, name: 'Stove', code: 'STV' },
]
const selectedProduct = ref(null)`
const objectUsage = `<CSelect
v-model="selectedProduct"
:options="products"
option-label="name"
option-key="id"
/>`
</script>
<template>
<article class="form-page">
<header class="page-header">
<div><p class="category">Forms</p><h1>Select</h1></div>
<p>
<code>CSelect</code> styles a native single-value select and supports either data-driven
options or ordinary HTML option slots.
</p>
</header>
<CSeparator />
<section class="section">
<h2>Object options</h2>
<p>
Pass raw objects with <code>option-label</code> to choose the displayed field. When
<code>option-value</code> is omitted, selecting an option binds the complete object. Use
<code>option-key</code> to provide stable rendering identity.
</p>
<div class="preview">
<CFormField label="Product">
<CSelect
v-model="selectedProduct"
:options="products"
option-label="name"
option-key="id"
/>
</CFormField>
<span>Selected object: {{ selectedProduct }}</span>
</div>
<CCodeBlock class="code-sample" :code="objectJavaScript" language="javascript" />
<CCodeBlock class="code-sample" :code="objectUsage" />
</section>
<section class="section">
<h2>Data-driven options</h2>
<p>
For raw objects, <code>option-label</code> selects the text shown to the user and
<code>option-value</code> selects the field written to <code>v-model</code>. Both accept a
property path or function. If <code>option-value</code> is omitted, the complete object is
used as shown above.
</p>
<p>
When every option already has <code>label</code> and <code>value</code> fields,
<code>CSelect</code> interprets those fields automaticallyno accessor props are needed.
These structured options can also include <code>disabled</code> and <code>hidden</code>.
</p>
<div class="preview">
<CFormField label="Product ID">
<CSelect
v-model="selectedProductId"
:options="products"
option-label="name"
option-value="id"
option-key="id"
/>
</CFormField>
<span>Mapped value: {{ selectedProductId }}</span>
<CFormField label="Status">
<CSelect v-model="status" :options="statuses" />
</CFormField>
<span>Automatic value: {{ status }}</span>
</div>
<CCodeBlock class="code-sample" :code="dataJavaScript" language="javascript" />
<CCodeBlock class="code-sample" :code="dataUsage" />
</section>
<section class="section">
<h2>Native option slot</h2>
<p>
Use the default slot when native <code>option</code> or <code>optgroup</code> markup is more
convenient. Data-driven and slotted options can also be combined.
</p>
<div class="preview">
<CFormField label="Priority">
<CSelect v-model="priority" placeholder="Select priority">
<option value="normal">Normal</option>
<option value="urgent">Urgent</option>
</CSelect>
</CFormField>
</div>
<CCodeBlock class="code-sample" :code="slotUsage" />
</section>
<section class="section">
<h2>Sizes and states</h2>
<div class="preview">
<CFormField label="Small (26px)"><CSelect v-model="warehouse" :options="warehouses" size="small" /></CFormField>
<CFormField label="Large (34px)"><CSelect v-model="warehouse" :options="warehouses" size="large" /></CFormField>
<CFormField label="Disabled"><CSelect :model-value="'north'" :options="warehouses" disabled /></CFormField>
<CFormField label="Invalid"><CSelect v-model="warehouse" :options="warehouses" invalid /></CFormField>
</div>
</section>
<section class="section">
<h2>Properties</h2>
<dl class="property-list">
<div><dt><code>model-value</code></dt><dd>Selected primitive, object, or <code>null</code>, used by <code>v-model</code>.</dd></div>
<div><dt><code>options</code></dt><dd>Array of <code>{ label, value }</code> records or raw objects.</dd></div>
<div><dt><code>option-label</code></dt><dd>Property path or function used to label raw object options.</dd></div>
<div><dt><code>option-value</code></dt><dd>Property path or function selecting the bound value. Omit it to bind the complete object.</dd></div>
<div><dt><code>option-key</code></dt><dd>Property path or function providing a stable string or number key for raw objects.</dd></div>
<div><dt><code>placeholder</code></dt><dd>Disabled initial option displayed while the model is null.</dd></div>
<div><dt><code>size</code></dt><dd><code>small</code>, <code>medium</code>, or <code>large</code>.</dd></div>
<div><dt><code>disabled</code></dt><dd>Disables selection and focus.</dd></div>
<div><dt><code>required</code></dt><dd>Applies native required validation.</dd></div>
<div><dt><code>invalid</code></dt><dd>Applies invalid styling and <code>aria-invalid</code>.</dd></div>
</dl>
</section>
</article>
</template>
<style scoped lang="scss" src="./form-demo.scss"></style>

@ -0,0 +1,72 @@
<script setup lang="ts">
import { ref } from 'vue'
import { CFormField, CSeparator, CTextArea } from '@/index'
import CCodeBlock from '@/documentation/CCodeBlock.vue'
const notes = ref('Deliver to receiving dock 3.')
const basicUsage = `<CFormField label="Delivery notes" hint="Include access or handling instructions.">
<CTextArea v-model="notes" rows="4" />
</CFormField>`
const stateUsage = `<CTextArea readonly model-value="Read-only notes" />
<CTextArea disabled model-value="Unavailable" />
<CTextArea invalid model-value="Review this value" />`
</script>
<template>
<article class="form-page">
<header class="page-header">
<div><p class="category">Forms</p><h1>Text Area</h1></div>
<p>
<code>CTextArea</code> provides compact multiline text entry while preserving native rows,
length constraints, selection, and resize behavior.
</p>
</header>
<CSeparator />
<section class="section">
<h2>Basic usage</h2>
<div class="preview">
<CFormField label="Delivery notes" hint="Include access or handling instructions.">
<CTextArea v-model="notes" rows="4" />
</CFormField>
</div>
<CCodeBlock class="code-sample" :code="basicUsage" />
</section>
<section class="section">
<h2>Sizes</h2>
<p>The size controls minimum height, padding, and typography. Native <code>rows</code> remains available.</p>
<div class="preview">
<CTextArea size="small" placeholder="Small" aria-label="Small text area" />
<CTextArea size="medium" placeholder="Medium" aria-label="Medium text area" />
<CTextArea size="large" placeholder="Large" aria-label="Large text area" />
</div>
</section>
<section class="section">
<h2>States</h2>
<div class="preview">
<CTextArea readonly model-value="Read-only notes" aria-label="Read-only notes" />
<CTextArea disabled model-value="Unavailable" aria-label="Disabled notes" />
<CTextArea invalid model-value="Review this value" aria-label="Invalid notes" />
</div>
<CCodeBlock class="code-sample" :code="stateUsage" />
</section>
<section class="section">
<h2>Properties</h2>
<dl class="property-list">
<div><dt><code>model-value</code></dt><dd>String value used by <code>v-model</code>.</dd></div>
<div><dt><code>size</code></dt><dd><code>small</code>, <code>medium</code>, or <code>large</code>.</dd></div>
<div><dt><code>disabled</code></dt><dd>Prevents editing and focus.</dd></div>
<div><dt><code>readonly</code></dt><dd>Prevents editing while keeping the value focusable.</dd></div>
<div><dt><code>required</code></dt><dd>Applies native required validation.</dd></div>
<div><dt><code>invalid</code></dt><dd>Applies invalid styling and <code>aria-invalid</code>.</dd></div>
</dl>
</section>
</article>
</template>
<style scoped lang="scss" src="./form-demo.scss"></style>

@ -0,0 +1,110 @@
.form-page {
width: min(100%, 900px);
margin: 0 auto;
.page-header {
display: grid;
grid-template-columns: minmax(220px, 0.7fr) minmax(320px, 1.3fr);
align-items: end;
padding-bottom: 20px;
gap: 32px;
> p {
margin-bottom: 0;
color: var(--c-muted-text-color, #626a75);
line-height: 1.5;
}
}
.category {
margin: 0 0 4px;
color: var(--c-primary-color, #2f6fad);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
h1,
h2,
p {
margin-top: 0;
}
h1 {
margin-bottom: 0;
font-size: 25px;
}
h2 {
margin-bottom: 5px;
font-size: 16px;
}
.section {
padding: 22px 0;
> p {
margin-bottom: 10px;
color: var(--c-muted-text-color, #626a75);
line-height: 1.5;
}
}
.preview,
.preview-row {
padding: 12px;
background: var(--c-surface-color, #fff);
border: 1px solid var(--c-border-color, #d5d9df);
}
.preview {
display: grid;
max-width: 520px;
gap: 12px;
}
.preview-row {
display: flex;
align-items: end;
gap: 12px;
> * {
flex: 1;
min-width: 0;
}
}
.code-sample {
margin-top: 8px;
}
.property-list {
margin: 0;
border: 1px solid var(--c-border-color, #d5d9df);
> div {
display: grid;
grid-template-columns: 120px minmax(0, 1fr);
+ div {
border-top: 1px solid var(--c-border-color, #d5d9df);
}
}
dt,
dd {
margin: 0;
padding: 8px 10px;
}
dt {
font-weight: 600;
background: var(--c-subtle-surface-color, #f7f8fa);
border-inline-end: 1px solid var(--c-border-color, #d5d9df);
}
dd {
line-height: 1.45;
}
}
}

@ -41,6 +41,51 @@ const router = createRouter({
name: 'button',
component: () => import('@/pages/components/Button.vue'),
},
{
path: 'form-field',
name: 'form-field',
component: () => import('@/pages/components/FormField.vue'),
},
{
path: 'input',
name: 'input',
component: () => import('@/pages/components/Input.vue'),
},
{
path: 'number-input',
name: 'number-input',
component: () => import('@/pages/components/NumberInput.vue'),
},
{
path: 'input-group',
name: 'input-group',
component: () => import('@/pages/components/InputGroup.vue'),
},
{
path: 'text-area',
name: 'text-area',
component: () => import('@/pages/components/TextArea.vue'),
},
{
path: 'password',
name: 'password',
component: () => import('@/pages/components/Password.vue'),
},
{
path: 'checkbox',
name: 'checkbox',
component: () => import('@/pages/components/Checkbox.vue'),
},
{
path: 'radio',
name: 'radio',
component: () => import('@/pages/components/Radio.vue'),
},
{
path: 'select',
name: 'select',
component: () => import('@/pages/components/Select.vue'),
},
{
path: 'icon',
name: 'icon',

Loading…
Cancel
Save