Basic Form Components
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,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>
|
||||
@ -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,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 automatically—no 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue