ENH - complete Sky UI Konsta parity

This commit is contained in:
DerEchteAlec
2026-08-13 19:06:05 +02:00
parent 5fca29eb90
commit 71f21b1e3e
90 changed files with 9098 additions and 676 deletions
+11 -2
View File
@@ -3,16 +3,25 @@ defineOptions({ inheritAttrs: false })
withDefaults(
defineProps<{
component?: string
small?: boolean
tone?: 'danger' | 'info' | 'neutral' | 'success' | 'warning'
}>(),
{
component: 'span',
small: false,
tone: 'neutral',
},
)
</script>
<template>
<span v-bind="$attrs" class="sky-badge" :class="`sky-badge--${tone}`">
<component
:is="component"
v-bind="$attrs"
class="sky-badge"
:class="[`sky-badge--${tone}`, { 'sky-badge--small': small }]"
>
<slot />
</span>
</component>
</template>
+7 -1
View File
@@ -3,13 +3,17 @@ defineOptions({ inheritAttrs: false })
withDefaults(
defineProps<{
component?: 'div' | 'section'
component?: 'article' | 'aside' | 'div' | 'section'
inset?: boolean
nested?: boolean
outline?: boolean
strong?: boolean
}>(),
{
component: 'div',
inset: false,
nested: false,
outline: false,
strong: false,
},
)
@@ -22,6 +26,8 @@ withDefaults(
class="sky-block"
:class="{
'sky-block--inset': inset,
'sky-block--nested': nested,
'sky-block--outline': outline,
'sky-block--strong': strong,
}"
>
@@ -0,0 +1,25 @@
<script setup lang="ts">
defineOptions({ inheritAttrs: false })
withDefaults(
defineProps<{
component?: 'div' | 'footer' | 'p'
inset?: boolean
}>(),
{
component: 'div',
inset: false,
},
)
</script>
<template>
<component
:is="component"
v-bind="$attrs"
class="sky-block-footer"
:class="{ 'sky-block-footer--inset': inset }"
>
<slot />
</component>
</template>
+2 -2
View File
@@ -3,11 +3,11 @@ defineOptions({ inheritAttrs: false })
withDefaults(
defineProps<{
component?: 'div' | 'header' | 'section'
component?: 'div' | 'header' | 'p' | 'section'
inset?: boolean
}>(),
{
component: 'header',
component: 'div',
inset: false,
},
)
+13 -1
View File
@@ -4,15 +4,27 @@ defineOptions({ inheritAttrs: false })
withDefaults(
defineProps<{
component?: 'div' | 'h2' | 'h3'
large?: boolean
medium?: boolean
}>(),
{
component: 'h2',
large: false,
medium: false,
},
)
</script>
<template>
<component :is="component" v-bind="$attrs" class="sky-block-title">
<component
:is="component"
v-bind="$attrs"
class="sky-block-title"
:class="{
'sky-block-title--large': large,
'sky-block-title--medium': medium && !large,
}"
>
<slot />
</component>
</template>
@@ -0,0 +1,26 @@
<script setup lang="ts">
defineOptions({ inheritAttrs: false })
withDefaults(
defineProps<{
ariaLabel?: string
component?: 'div' | 'nav' | 'span'
}>(),
{
ariaLabel: '',
component: 'nav',
},
)
</script>
<template>
<component
:is="component"
v-bind="$attrs"
class="sky-breadcrumbs"
:aria-label="ariaLabel || undefined"
:role="component === 'nav' ? undefined : 'navigation'"
>
<slot />
</component>
</template>
@@ -0,0 +1,77 @@
<script setup lang="ts">
import { computed } from 'vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
ariaControls?: string
ariaLabel: string
component?: 'a' | 'button'
disabled?: boolean
expanded?: boolean
href?: string
type?: 'button' | 'reset' | 'submit'
}>(),
{
ariaControls: '',
component: 'button',
disabled: false,
expanded: undefined,
href: undefined,
type: 'button',
},
)
const emit = defineEmits<{
click: [event: MouseEvent]
}>()
const elementProps = computed<Record<string, unknown>>(() => {
const common = {
'aria-controls': props.ariaControls || undefined,
'aria-expanded': props.expanded === undefined ? undefined : props.expanded,
'aria-label': props.ariaLabel || undefined,
}
if (props.component === 'a') {
return {
...common,
'aria-disabled': props.disabled || undefined,
href: props.disabled ? undefined : props.href,
tabindex: props.disabled ? -1 : undefined,
}
}
return {
...common,
disabled: props.disabled,
type: props.type,
}
})
function handleClick(event: MouseEvent): void {
if (props.disabled) {
event.preventDefault()
event.stopPropagation()
return
}
emit('click', event)
}
</script>
<template>
<component
:is="component"
v-bind="{ ...$attrs, ...elementProps }"
class="sky-breadcrumbs-collapsed"
:class="{ 'sky-breadcrumbs-collapsed--disabled': disabled }"
@click="handleClick"
>
<span class="sky-breadcrumbs-collapsed__dot" aria-hidden="true" />
<span class="sky-breadcrumbs-collapsed__dot" aria-hidden="true" />
<span class="sky-breadcrumbs-collapsed__dot" aria-hidden="true" />
<slot />
</component>
</template>
@@ -0,0 +1,79 @@
<script setup lang="ts">
import { computed } from 'vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
active?: boolean
component?: 'a' | 'button' | 'span'
disabled?: boolean
href?: string
type?: 'button' | 'reset' | 'submit'
}>(),
{
active: false,
component: 'span',
disabled: false,
href: undefined,
type: 'button',
},
)
const emit = defineEmits<{
click: [event: MouseEvent]
}>()
const elementProps = computed<Record<string, unknown>>(() => {
const common = {
'aria-current': props.active ? 'page' : undefined,
}
if (props.component === 'a') {
return {
...common,
'aria-disabled': props.disabled || undefined,
href: props.disabled ? undefined : props.href,
tabindex: props.disabled ? -1 : undefined,
}
}
if (props.component === 'button') {
return {
...common,
disabled: props.disabled,
type: props.type,
}
}
return {
...common,
'aria-disabled': props.disabled || undefined,
}
})
function handleClick(event: MouseEvent): void {
if (props.disabled) {
event.preventDefault()
event.stopPropagation()
return
}
emit('click', event)
}
</script>
<template>
<component
:is="component"
v-bind="{ ...$attrs, ...elementProps }"
class="sky-breadcrumbs-item"
:class="{
'sky-breadcrumbs-item--active': active,
'sky-breadcrumbs-item--disabled': disabled,
}"
@click="handleClick"
>
<slot />
</component>
</template>
@@ -0,0 +1,24 @@
<script setup lang="ts">
defineOptions({ inheritAttrs: false })
withDefaults(
defineProps<{
component?: 'span'
}>(),
{
component: 'span',
},
)
</script>
<template>
<component
:is="component"
v-bind="$attrs"
class="sky-breadcrumbs-separator"
aria-hidden="true"
>
<span class="sky-breadcrumbs-separator__icon" />
<slot />
</component>
</template>
+13 -1
View File
@@ -6,27 +6,35 @@ defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
block?: boolean
clear?: boolean
component?: 'a' | 'button'
disabled?: boolean
href?: string
iconOnly?: boolean
inline?: boolean
large?: boolean
outline?: boolean
raised?: boolean
rounded?: boolean
small?: boolean
tonal?: boolean
type?: 'button' | 'reset' | 'submit'
variant?: 'danger' | 'plain' | 'primary' | 'secondary'
}>(),
{
block: false,
clear: false,
component: 'button',
disabled: false,
href: undefined,
iconOnly: false,
inline: false,
large: false,
outline: false,
raised: false,
rounded: false,
small: false,
tonal: false,
type: 'button',
variant: 'primary',
},
@@ -71,11 +79,15 @@ function handleClick(event: MouseEvent): void {
`sky-button--${variant}`,
{
'sky-button--block': block,
'sky-button--clear': clear,
'sky-button--icon-only': iconOnly,
'sky-button--inline': inline,
'sky-button--large': large,
'sky-button--outline': outline,
'sky-button--raised': raised,
'sky-button--rounded': rounded,
'sky-button--small': small,
'sky-button--small': small && !large,
'sky-button--tonal': tonal,
},
]"
@click="handleClick"
+25 -11
View File
@@ -1,51 +1,65 @@
<script setup lang="ts">
import { useSlots } from 'vue'
defineOptions({ inheritAttrs: false })
withDefaults(
defineProps<{
component?: 'article' | 'div' | 'section'
component?: string
contentClass?: string
contentWrap?: boolean
contentWrapPadding?: string
footer?: number | string
footerDivider?: boolean
header?: number | string
headerDivider?: boolean
outline?: boolean
raised?: boolean
}>(),
{
component: 'div',
contentClass: '',
contentWrap: true,
contentWrapPadding: '',
footer: undefined,
footerDivider: false,
header: undefined,
headerDivider: false,
outline: false,
raised: false,
},
)
const slots = useSlots()
</script>
<template>
<component :is="component" v-bind="$attrs" class="sky-card">
<component
:is="component"
v-bind="$attrs"
class="sky-card"
:class="{
'sky-card--outline': outline,
'sky-card--raised': raised,
}"
>
<div
v-if="slots.header"
v-if="header !== undefined || $slots.header"
class="sky-card__header"
:class="{ 'sky-card__header--divider': headerDivider }"
>
<slot name="header" />
<slot name="header">{{ header }}</slot>
</div>
<div
v-if="contentWrap"
class="sky-card__content"
:class="contentWrapPadding"
:class="[contentWrapPadding, contentClass]"
>
<slot />
</div>
<slot v-else />
<div
v-if="slots.footer"
v-if="footer !== undefined || $slots.footer"
class="sky-card__footer"
:class="{ 'sky-card__footer--divider': footerDivider }"
>
<slot name="footer" />
<slot name="footer">{{ footer }}</slot>
</div>
</component>
</template>
@@ -0,0 +1,58 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { createSSRApp, h } from 'vue'
import { renderToString } from 'vue/server-renderer'
import { describe, expect, it } from 'vitest'
import SkyCheckbox from './SkyCheckbox.vue'
describe('SkyCheckbox', () => {
it('keeps its native state and accessible label wiring', async () => {
const html = await renderToString(
createSSRApp({
render: () =>
h(
SkyCheckbox,
{
indeterminate: true,
modelValue: true,
name: 'selection',
required: true,
value: 'all',
},
{ default: () => 'Select all' },
),
}),
)
expect(html).toContain('type="checkbox"')
expect(html).toContain('checked')
expect(html).toContain('required')
expect(html).toContain('name="selection"')
expect(html).toContain('value="all"')
expect(html).toContain('aria-labelledby=')
expect(html).toContain('sky-checkbox--checked')
expect(html).toContain('sky-checkbox--indeterminate')
expect(html).toContain('sky-checkbox__indeterminate')
expect(html).toContain('Select all')
})
it('matches the Konsta iOS checkbox colors without inventing a glass hold state', () => {
const uiDirectory = fileURLToPath(new URL('..', import.meta.url))
const controls = readFileSync(`${uiDirectory}/controls.css`, 'utf8')
const checkboxStyles = controls.slice(
controls.indexOf('.sky-checkbox {'),
controls.indexOf('.sky-fab {'),
)
expect(checkboxStyles).toContain(
'border: 1px solid var(--sky-subtle, rgba(0, 0, 0, 0.3))',
)
expect(checkboxStyles).toMatch(
/\.sky-checkbox__indeterminate\s*\{[\s\S]*?width: 75%/,
)
expect(checkboxStyles).not.toContain('--sky-shadow-glass')
expect(checkboxStyles).not.toContain('scale(1.4)')
})
})
+129
View File
@@ -0,0 +1,129 @@
<script setup lang="ts">
import { computed, nextTick, ref, useId, watchEffect } from 'vue'
defineOptions({ inheritAttrs: false })
type CheckboxValue = number | string
const props = withDefaults(
defineProps<{
ariaDescribedby?: string
ariaLabel?: string
ariaLabelledby?: string
checked?: boolean
component?: 'div' | 'label' | 'span'
disabled?: boolean
id?: string
indeterminate?: boolean
modelValue?: boolean
name?: string
readonly?: boolean
required?: boolean
value?: CheckboxValue
}>(),
{
ariaDescribedby: '',
ariaLabel: '',
ariaLabelledby: '',
checked: false,
component: 'label',
disabled: false,
id: undefined,
indeterminate: false,
modelValue: undefined,
name: undefined,
readonly: false,
required: false,
value: undefined,
},
)
const emit = defineEmits<{
change: [event: Event]
'update:modelValue': [value: boolean]
}>()
const generatedId = useId()
const input = ref<HTMLInputElement | null>(null)
const inputId = computed(() => props.id || generatedId)
const isChecked = computed(() => props.modelValue ?? props.checked)
const isMarked = computed(() => isChecked.value || props.indeterminate)
const labelId = computed(() => `${inputId.value}-label`)
watchEffect(() => {
if (input.value) input.value.indeterminate = props.indeterminate
})
function resyncIndeterminate(): void {
void nextTick(() => {
if (input.value) input.value.indeterminate = props.indeterminate
})
}
function handleChange(event: Event): void {
if (!(event.target instanceof HTMLInputElement)) return
if (props.readonly) {
event.target.checked = isChecked.value
resyncIndeterminate()
return
}
emit('update:modelValue', event.target.checked)
emit('change', event)
resyncIndeterminate()
}
function handleContainerClick(event: MouseEvent): void {
if (props.disabled || props.readonly) {
event.preventDefault()
event.stopPropagation()
return
}
if (props.component !== 'label' && event.target !== input.value) {
input.value?.click()
}
}
</script>
<template>
<component
:is="component"
v-bind="$attrs"
class="sky-checkbox"
:class="{
'sky-checkbox--checked': isMarked,
'sky-checkbox--disabled': disabled,
'sky-checkbox--indeterminate': indeterminate,
'sky-checkbox--readonly': readonly,
}"
@click="handleContainerClick"
>
<input
:id="inputId"
ref="input"
class="sky-checkbox__input"
type="checkbox"
:aria-describedby="ariaDescribedby || undefined"
:aria-label="ariaLabel || undefined"
:aria-labelledby="
ariaLabelledby || (!ariaLabel && $slots.default ? labelId : undefined)
"
:aria-readonly="readonly || undefined"
:checked="isChecked"
:disabled="disabled"
:name="name"
:required="required"
:value="value"
@change="handleChange"
/>
<span class="sky-checkbox__mark" aria-hidden="true">
<span v-if="indeterminate" class="sky-checkbox__indeterminate" />
<span v-else class="sky-checkbox__check" />
</span>
<span v-if="$slots.default" :id="labelId" class="sky-checkbox__label">
<slot />
</span>
</component>
</template>
+51 -7
View File
@@ -5,16 +5,22 @@ defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
component?: 'a' | 'button' | 'span'
component?: 'a' | 'button' | 'div' | 'span'
deleteButton?: boolean
deleteLabel?: string
disabled?: boolean
href?: string
outline?: boolean
selected?: boolean
type?: 'button' | 'reset' | 'submit'
}>(),
{
component: 'button',
deleteButton: false,
deleteLabel: '',
disabled: false,
href: undefined,
outline: false,
selected: undefined,
type: 'button',
},
@@ -22,14 +28,24 @@ const props = withDefaults(
const emit = defineEmits<{
click: [event: MouseEvent]
delete: [event: KeyboardEvent | MouseEvent]
}>()
const showDelete = computed(
() => props.deleteButton && Boolean(props.deleteLabel),
)
const effectiveComponent = computed(() =>
showDelete.value && (props.component === 'a' || props.component === 'button')
? 'span'
: props.component,
)
const elementProps = computed<Record<string, unknown>>(() => {
if (props.component === 'button') {
if (effectiveComponent.value === 'button') {
return { disabled: props.disabled, type: props.type }
}
if (props.component === 'a') {
if (effectiveComponent.value === 'a') {
return {
'aria-disabled': props.disabled || undefined,
href: props.disabled ? undefined : props.href,
@@ -37,7 +53,7 @@ const elementProps = computed<Record<string, unknown>>(() => {
}
}
return {}
return { 'aria-disabled': props.disabled || undefined }
})
function handleClick(event: MouseEvent): void {
@@ -49,19 +65,47 @@ function handleClick(event: MouseEvent): void {
emit('click', event)
}
function handleDelete(event: KeyboardEvent | MouseEvent): void {
if (props.disabled) return
emit('delete', event)
}
</script>
<template>
<component
:is="component"
:is="effectiveComponent"
v-bind="{ ...$attrs, ...elementProps }"
class="sky-chip"
:class="{ 'sky-chip--selected': selected }"
:class="{
'sky-chip--outline': outline,
'sky-chip--selected': selected,
'sky-chip--with-delete': showDelete,
}"
:aria-pressed="
component === 'button' && selected !== undefined ? selected : undefined
effectiveComponent === 'button' && selected !== undefined
? selected
: undefined
"
@click="handleClick"
>
<span v-if="$slots.media" class="sky-chip__media">
<slot name="media" />
</span>
<slot />
<span
v-if="showDelete"
class="sky-chip__delete"
role="button"
:tabindex="disabled ? -1 : 0"
:aria-label="deleteLabel"
@click.stop="handleDelete"
@keydown.enter.prevent.stop="handleDelete"
@keydown.space.prevent.stop="handleDelete"
>
<slot name="delete">
<span class="sky-chip__delete-icon" aria-hidden="true" />
</slot>
</span>
</component>
</template>
+84
View File
@@ -0,0 +1,84 @@
<script setup lang="ts">
import { computed, useSlots } from 'vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
ariaLabel?: string
component?: 'a' | 'button'
disabled?: boolean
href?: string
text?: string
textPosition?: 'after' | 'before'
type?: 'button' | 'reset' | 'submit'
}>(),
{
ariaLabel: '',
component: 'button',
disabled: false,
href: undefined,
text: '',
textPosition: 'after',
type: 'button',
},
)
const emit = defineEmits<{
click: [event: MouseEvent]
}>()
const slots = useSlots()
const hasText = computed(() => Boolean(props.text || slots.text))
const elementProps = computed<Record<string, unknown>>(() => {
if (props.component === 'a') {
return {
'aria-disabled': props.disabled || undefined,
'aria-label': props.ariaLabel || undefined,
href: props.disabled ? undefined : props.href,
tabindex: props.disabled ? -1 : undefined,
}
}
return {
'aria-label': props.ariaLabel || undefined,
disabled: props.disabled,
type: props.type,
}
})
function handleClick(event: MouseEvent): void {
if (props.disabled) {
event.preventDefault()
event.stopPropagation()
return
}
emit('click', event)
}
</script>
<template>
<component
:is="component"
v-bind="{ ...$attrs, ...elementProps }"
class="sky-fab"
:class="{
'sky-fab--disabled': disabled,
'sky-fab--icon-only': !hasText,
'sky-fab--with-text': hasText,
}"
@click="handleClick"
>
<span v-if="hasText && textPosition === 'before'" class="sky-fab__text">
{{ text }}<slot name="text" />
</span>
<span v-if="$slots.icon" class="sky-fab__icon">
<slot name="icon" />
</span>
<span v-if="hasText && textPosition === 'after'" class="sky-fab__text">
{{ text }}<slot name="text" />
</span>
<slot />
</component>
</template>
+172
View File
@@ -0,0 +1,172 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
component?:
| 'a'
| 'article'
| 'aside'
| 'button'
| 'div'
| 'label'
| 'section'
| 'span'
disabled?: boolean
highlight?: boolean
hoverHighlight?: boolean
href?: string
type?: 'button' | 'reset' | 'submit'
}>(),
{
component: 'div',
disabled: false,
highlight: true,
hoverHighlight: true,
href: undefined,
type: 'button',
},
)
const emit = defineEmits<{
click: [event: MouseEvent]
}>()
const root = ref<HTMLElement | null>(null)
const highlightVisible = ref(false)
const touchHighlight = ref(false)
const highlightX = ref('50%')
const highlightY = ref('50%')
const touchScale = ref('1.05')
const capturedPointerId = ref<number | null>(null)
const highlightStyle = computed(() => ({
'--sky-glass-highlight-x': highlightX.value,
'--sky-glass-highlight-y': highlightY.value,
'--sky-glass-touch-scale': touchScale.value,
}))
const elementProps = computed<Record<string, unknown>>(() => {
if (props.component === 'a') {
return {
'aria-disabled': props.disabled || undefined,
href: props.disabled ? undefined : props.href,
tabindex: props.disabled ? -1 : undefined,
}
}
if (props.component === 'button') {
return {
disabled: props.disabled,
type: props.type,
}
}
return {
'aria-disabled': props.disabled || undefined,
}
})
function handleClick(event: MouseEvent): void {
if (props.disabled) {
event.preventDefault()
event.stopPropagation()
return
}
emit('click', event)
}
function updateHighlightPosition(event: PointerEvent): void {
if (
props.disabled ||
!props.highlight ||
!props.hoverHighlight ||
event.pointerType !== 'mouse'
) {
return
}
const bounds = root.value?.getBoundingClientRect()
if (!bounds) return
highlightX.value = `${Math.max(0, Math.min(bounds.width, event.clientX - bounds.left))}px`
highlightY.value = `${Math.max(0, Math.min(bounds.height, event.clientY - bounds.top))}px`
highlightVisible.value = true
}
function releasePointerCapture(): void {
if (
root.value &&
capturedPointerId.value !== null &&
typeof root.value.hasPointerCapture === 'function' &&
root.value.hasPointerCapture(capturedPointerId.value) &&
typeof root.value.releasePointerCapture === 'function'
) {
root.value.releasePointerCapture(capturedPointerId.value)
}
capturedPointerId.value = null
}
function clearPointerState(): void {
releasePointerCapture()
highlightVisible.value = false
touchHighlight.value = false
}
function handlePointerDown(event: PointerEvent): void {
if (props.disabled || !props.highlight) return
if (event.pointerType === 'touch' || event.pointerType === 'pen') {
const bounds = root.value?.getBoundingClientRect()
touchScale.value =
bounds && bounds.width <= 60 && bounds.height <= 60 ? '1.25' : '1.05'
capturedPointerId.value = event.pointerId
if (typeof root.value?.setPointerCapture === 'function') {
root.value.setPointerCapture(event.pointerId)
}
touchHighlight.value = true
} else {
updateHighlightPosition(event)
}
}
function handlePointerEnd(): void {
clearPointerState()
}
watch(
() => [props.disabled, props.highlight] as const,
([disabled, highlight]) => {
if (disabled || !highlight) clearPointerState()
},
)
onBeforeUnmount(clearPointerState)
</script>
<template>
<component
:is="component"
ref="root"
v-bind="{ ...$attrs, ...elementProps }"
class="sky-glass"
:class="{
'sky-glass--disabled': disabled,
'sky-glass--highlight': highlight,
'sky-glass--highlight-visible': highlightVisible,
'sky-glass--interactive': component === 'a' || component === 'button',
'sky-glass--touch-highlight': touchHighlight,
}"
:style="highlightStyle"
@click="handleClick"
@lostpointercapture="handlePointerEnd"
@pointercancel="handlePointerEnd"
@pointerdown="handlePointerDown"
@pointerleave="handlePointerEnd"
@pointermove="updateHighlightPosition"
@pointerup="handlePointerEnd"
>
<slot />
</component>
</template>
+9
View File
@@ -5,17 +5,23 @@ withDefaults(
defineProps<{
component?: 'div' | 'ol' | 'ul'
density?: 'compact' | 'regular'
dividers?: boolean
flush?: boolean
inset?: boolean
menu?: boolean
nested?: boolean
outline?: boolean
strong?: boolean
}>(),
{
component: 'ul',
density: 'regular',
dividers: true,
flush: false,
inset: false,
menu: false,
nested: false,
outline: false,
strong: false,
},
)
@@ -28,7 +34,10 @@ withDefaults(
class="sky-list"
:class="{
'sky-list--inset': inset,
'sky-list--dividers': dividers,
'sky-list--menu': menu,
'sky-list--nested': nested,
'sky-list--outline': outline,
'sky-list--strong': strong,
'sky-list--compact': density === 'compact',
'sky-list--flush': flush,
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { computed } from 'vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
ariaLabel?: string
component?: 'div' | 'li'
disabled?: boolean
href?: string
linkComponent?: 'a' | 'button'
linkProps?: Record<string, unknown>
target?: string
type?: 'button' | 'reset' | 'submit'
value?: number | string
variant?: 'danger' | 'default'
}>(),
{
ariaLabel: '',
component: 'li',
disabled: false,
href: undefined,
linkComponent: undefined,
linkProps: () => ({}),
target: undefined,
type: 'button',
value: undefined,
variant: 'default',
},
)
const emit = defineEmits<{
click: [event: MouseEvent]
}>()
const actionComponent = computed(
() => props.linkComponent ?? (props.href !== undefined ? 'a' : 'button'),
)
const actionProps = computed<Record<string, unknown>>(() => {
if (actionComponent.value === 'a') {
return {
...props.linkProps,
'aria-disabled': props.disabled || undefined,
'aria-label': props.ariaLabel || undefined,
href: props.disabled ? undefined : props.href,
tabindex: props.disabled ? -1 : undefined,
target: props.target,
}
}
return {
...props.linkProps,
'aria-label': props.ariaLabel || undefined,
disabled: props.disabled,
type: props.type,
value: props.value,
}
})
function handleClick(event: MouseEvent): void {
if (props.disabled) {
event.preventDefault()
event.stopPropagation()
return
}
emit('click', event)
}
</script>
<template>
<component
:is="component"
v-bind="$attrs"
class="sky-list-button"
:class="[
`sky-list-button--${variant}`,
{ 'sky-list-button--disabled': disabled },
]"
>
<component
:is="actionComponent"
v-bind="actionProps"
class="sky-list-button__action"
@click="handleClick"
>
<slot />
</component>
</component>
</template>
+44
View File
@@ -0,0 +1,44 @@
<script setup lang="ts">
import SkyList from './SkyList.vue'
defineOptions({ inheritAttrs: false })
withDefaults(
defineProps<{
density?: 'compact' | 'regular'
dividers?: boolean
flush?: boolean
inset?: boolean
menuList?: boolean
outline?: boolean
strong?: boolean
}>(),
{
density: 'regular',
dividers: true,
flush: false,
inset: false,
menuList: false,
outline: false,
strong: false,
},
)
</script>
<template>
<li v-bind="$attrs" class="sky-list-group">
<SkyList
class="sky-list-group__list"
:density="density"
:dividers="dividers"
:flush="flush"
:inset="inset"
:menu="menuList"
nested
:outline="outline"
:strong="strong"
>
<slot />
</SkyList>
</li>
</template>
+135 -24
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, useSlots } from 'vue'
defineOptions({ inheritAttrs: false })
@@ -7,28 +7,62 @@ const props = withDefaults(
defineProps<{
after?: number | string
ariaLabel?: string
active?: boolean
chevron?: boolean
component?: 'div' | 'li'
contacts?: boolean
contentClass?: string
disabled?: boolean
dividers?: boolean
footer?: string
groupTitle?: boolean
header?: string
href?: string
href?: boolean | string
innerClass?: string
label?: boolean
link?: boolean
linkComponent?: 'a' | 'button'
linkProps?: Record<string, unknown>
media?: number | string
mediaClass?: string
menu?: boolean
strongTitle?: boolean | 'auto'
subtitle?: string
target?: string
text?: string
title?: string
titleFontSizeIos?: string
titleWrapClass?: string
}>(),
{
after: undefined,
ariaLabel: '',
active: false,
chevron: true,
component: 'li',
contacts: false,
contentClass: '',
disabled: false,
dividers: undefined,
footer: '',
groupTitle: false,
header: '',
href: undefined,
innerClass: '',
label: false,
link: false,
linkComponent: 'button',
linkComponent: undefined,
linkProps: () => ({}),
media: undefined,
mediaClass: '',
menu: false,
strongTitle: false,
subtitle: '',
target: undefined,
text: '',
title: '',
titleFontSizeIos: '',
titleWrapClass: '',
},
)
@@ -36,20 +70,44 @@ const emit = defineEmits<{
click: [event: MouseEvent]
}>()
const slots = useSlots()
const resolvedStrongTitle = computed(
() =>
props.strongTitle === true ||
(props.strongTitle === 'auto' &&
Boolean(props.title || slots.title) &&
Boolean(props.subtitle || slots.subtitle || props.text || slots.text)),
)
const hasHref = computed(
() => typeof props.href === 'string' || props.href === true,
)
const isLink = computed(() => props.link || hasHref.value)
const effectiveLinkComponent = computed(
() => props.linkComponent ?? (hasHref.value ? 'a' : 'button'),
)
const rowComponent = computed(() => {
if (props.link) return props.linkComponent
if (isLink.value) return effectiveLinkComponent.value
if (props.label) return 'label'
return 'div'
})
const rowProps = computed<Record<string, unknown>>(() => {
if (!props.link) return {}
if (!isLink.value) return {}
if (effectiveLinkComponent.value === 'a') {
const href =
typeof props.href === 'string'
? props.href
: props.href === true
? ''
: undefined
if (props.linkComponent === 'a') {
return {
...props.linkProps,
'aria-disabled': props.disabled || undefined,
href: props.disabled ? undefined : props.href,
href: props.disabled ? undefined : href,
target: props.target,
tabindex: props.disabled ? -1 : undefined,
}
}
@@ -73,49 +131,102 @@ function handleClick(event: MouseEvent): void {
</script>
<template>
<li
<component
:is="component"
v-if="groupTitle"
v-bind="$attrs"
class="sky-list-item sky-list-item--group-title"
:role="component === 'div' ? 'listitem' : undefined"
>
<slot name="title">{{ title }}</slot>
<slot />
</component>
<component
:is="component"
v-else
v-bind="$attrs"
class="sky-list-item"
:class="{
'sky-list-item--disabled': disabled,
'sky-list-item--active': active,
'sky-list-item--contacts': contacts,
'sky-list-item--dividers': dividers === true,
'sky-list-item--no-dividers': dividers === false,
'sky-list-item--label': label,
'sky-list-item--link': link,
'sky-list-item--link': isLink,
'sky-list-item--menu': menu,
}"
:role="component === 'div' ? 'listitem' : undefined"
>
<component
:is="rowComponent"
v-bind="rowProps"
class="sky-list-item__row"
:class="contentClass"
:aria-label="ariaLabel || undefined"
@click="handleClick"
>
<span v-if="$slots.media" class="sky-list-item__media">
<slot name="media" />
<span
v-if="media !== undefined || $slots.media"
class="sky-list-item__media"
:class="mediaClass"
>
<slot name="media">{{ media }}</slot>
</span>
<span class="sky-list-item__content">
<span class="sky-list-item__content" :class="innerClass">
<small v-if="header || $slots.header" class="sky-list-item__header">
<slot name="header">{{ header }}</slot>
</small>
<strong v-if="title || $slots.title" class="sky-list-item__title">
<slot name="title">{{ title }}</slot>
</strong>
<span
v-if="
title ||
$slots.title ||
after !== undefined ||
$slots.after ||
(isLink && chevron && !menu)
"
class="sky-list-item__title-wrap"
:class="titleWrapClass"
>
<strong
v-if="title || $slots.title"
class="sky-list-item__title"
:class="[
titleFontSizeIos,
{ 'sky-list-item__title--strong': resolvedStrongTitle },
]"
>
<slot name="title">{{ title }}</slot>
</strong>
<span
v-if="after !== undefined || $slots.after"
class="sky-list-item__after"
>
<slot name="after">{{ after }}</slot>
</span>
<span
v-if="isLink && chevron && !menu"
class="sky-list-item__chevron"
aria-hidden="true"
/>
</span>
<span
v-if="subtitle || $slots.subtitle"
class="sky-list-item__subtitle"
>
<slot name="subtitle">{{ subtitle }}</slot>
</span>
<span v-if="text || $slots.text" class="sky-list-item__text">
<slot name="text">{{ text }}</slot>
</span>
<small v-if="footer || $slots.footer" class="sky-list-item__footer">
<slot name="footer">{{ footer }}</slot>
</small>
<slot name="inner" />
<slot />
</span>
<span
v-if="after !== undefined || $slots.after"
class="sky-list-item__after"
>
<slot name="after">{{ after }}</slot>
</span>
<span v-if="link" class="sky-list-item__chevron" aria-hidden="true" />
<slot name="content" />
</component>
</li>
</component>
</template>
+49
View File
@@ -0,0 +1,49 @@
<script setup lang="ts">
import SkyList from './SkyList.vue'
defineOptions({ inheritAttrs: false })
withDefaults(
defineProps<{
ariaLabel?: string
component?: 'div' | 'ol' | 'ul'
density?: 'compact' | 'regular'
dividers?: boolean
flush?: boolean
inset?: boolean
nested?: boolean
outline?: boolean
strong?: boolean
}>(),
{
ariaLabel: '',
component: 'ul',
density: 'regular',
dividers: true,
flush: false,
inset: false,
nested: false,
outline: false,
strong: false,
},
)
</script>
<template>
<SkyList
v-bind="$attrs"
class="sky-menu-list"
:aria-label="ariaLabel || undefined"
:component="component"
:density="density"
:dividers="dividers"
:flush="flush"
:inset="inset"
menu
:nested="nested"
:outline="outline"
:strong="strong"
>
<slot />
</SkyList>
</template>
@@ -0,0 +1,124 @@
<script setup lang="ts">
import { computed } from 'vue'
import SkyListItem from './SkyListItem.vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
active?: boolean
after?: number | string
ariaLabel?: string
contentClass?: string
disabled?: boolean
footer?: string
header?: string
href?: boolean | string
innerClass?: string
linkComponent?: 'a' | 'button'
linkProps?: Record<string, unknown>
media?: number | string
mediaClass?: string
subtitle?: string
target?: string
text?: string
title?: string
titleFontSizeIos?: string
titleWrapClass?: string
}>(),
{
active: false,
after: undefined,
ariaLabel: '',
contentClass: '',
disabled: false,
footer: '',
header: '',
href: undefined,
innerClass: '',
linkComponent: undefined,
linkProps: () => ({}),
media: undefined,
mediaClass: '',
subtitle: '',
target: undefined,
text: '',
title: '',
titleFontSizeIos: '',
titleWrapClass: '',
},
)
const emit = defineEmits<{
click: [event: MouseEvent]
}>()
const actionComponent = computed(
() =>
props.linkComponent ??
(typeof props.href === 'string' || props.href === true ? 'a' : 'button'),
)
const actionProps = computed<Record<string, unknown>>(() => ({
...props.linkProps,
'aria-current': props.active ? 'page' : undefined,
}))
</script>
<template>
<SkyListItem
v-bind="$attrs"
class="sky-menu-list-item"
:class="{ 'sky-menu-list-item--active': active }"
:after="after"
:aria-label="ariaLabel"
:content-class="contentClass"
:disabled="disabled"
:footer="footer"
:header="header"
:href="href"
:inner-class="innerClass"
link
:link-component="actionComponent"
:link-props="actionProps"
:media="media"
:media-class="mediaClass"
menu
:subtitle="subtitle"
:target="target"
:text="text"
:title="title"
:title-font-size-ios="titleFontSizeIos"
:title-wrap-class="titleWrapClass"
@click="emit('click', $event)"
>
<slot />
<template v-if="$slots.media" #media>
<slot name="media" />
</template>
<template v-if="$slots.header" #header>
<slot name="header" />
</template>
<template v-if="$slots.title" #title>
<slot name="title" />
</template>
<template v-if="$slots.subtitle" #subtitle>
<slot name="subtitle" />
</template>
<template v-if="$slots.text" #text>
<slot name="text" />
</template>
<template v-if="$slots.footer" #footer>
<slot name="footer" />
</template>
<template v-if="$slots.after" #after>
<slot name="after" />
</template>
<template v-if="$slots.inner" #inner>
<slot name="inner" />
</template>
<template v-if="$slots.content" #content>
<slot name="content" />
</template>
</SkyListItem>
</template>
@@ -0,0 +1,84 @@
<script setup lang="ts">
import { computed } from 'vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
ariaLabel: string
component?: 'a' | 'button'
disabled?: boolean
href?: string
showText?: boolean
text?: string
type?: 'button' | 'reset' | 'submit'
}>(),
{
component: 'button',
disabled: false,
href: undefined,
showText: false,
text: '',
type: 'button',
},
)
const emit = defineEmits<{
click: [event: MouseEvent]
}>()
const accessibleLabel = computed(
() => props.ariaLabel || (props.showText ? props.text : undefined),
)
const elementProps = computed<Record<string, unknown>>(() => {
const common = {
'aria-label': accessibleLabel.value || undefined,
}
if (props.component === 'a') {
return {
...common,
'aria-disabled': props.disabled || undefined,
href: props.disabled ? undefined : props.href,
tabindex: props.disabled ? -1 : undefined,
}
}
return {
...common,
disabled: props.disabled,
type: props.type,
}
})
function handleClick(event: MouseEvent): void {
if (props.disabled) {
event.preventDefault()
event.stopPropagation()
return
}
emit('click', event)
}
</script>
<template>
<component
:is="component"
v-bind="{ ...$attrs, ...elementProps }"
class="sky-navbar-back-link"
:class="{
'sky-navbar-back-link--disabled': disabled,
'sky-navbar-back-link--with-text': showText,
}"
@click="handleClick"
>
<span class="sky-navbar-back-link__icon" aria-hidden="true">
<span class="sky-navbar-back-link__chevron" />
</span>
<span v-if="showText && text" class="sky-navbar-back-link__text">
{{ text }}
</span>
<slot />
</component>
</template>
+20
View File
@@ -1,3 +1,6 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { createSSRApp } from 'vue'
import { renderToString } from 'vue/server-renderer'
import { describe, expect, it } from 'vitest'
@@ -41,4 +44,21 @@ describe('SkyRange', () => {
expect(html).toContain('Volume')
expect(html).toContain('aria-label="Radio volume"')
})
it('keeps the Konsta glass hold state on enabled range thumbs', () => {
const uiDirectory = fileURLToPath(new URL('..', import.meta.url))
const controls = readFileSync(`${uiDirectory}/controls.css`, 'utf8')
const tokens = readFileSync(`${uiDirectory}/tokens.css`, 'utf8')
expect(controls).toMatch(
/\.sky-range__input:not\(:disabled\):active::-webkit-slider-thumb\s*\{[\s\S]*?--sky-range-hold-background[\s\S]*?--sky-glass-thumb-active-background[\s\S]*?--sky-range-hold-shadow[\s\S]*?--sky-shadow-thumb[\s\S]*?--sky-shadow-glass-thumb[\s\S]*?--sky-shadow-glass-thumb-glow[\s\S]*?--sky-hold-thumb-scale, 1\.4/,
)
expect(controls).toMatch(
/\.sky-range__input:not\(:disabled\):focus-visible:active::-webkit-slider-thumb/,
)
expect(controls).not.toContain(
'.sky-range__input:active::-webkit-slider-runnable-track',
)
expect(tokens).toContain('--sky-shadow-glass-thumb-glow')
})
})
+124 -34
View File
@@ -1,13 +1,23 @@
<script setup lang="ts">
import { computed, ref, useId, watch } from 'vue'
import { computed, ref, useId, watch, type StyleValue } from 'vue'
import SkyGlass from './SkyGlass.vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
clearLabel: string
cancelLabel?: string
cancelButton?: boolean
clearButton?: boolean
component?: 'div' | 'form'
disableButton?: boolean
disableLabel?: string
disabled?: boolean
id?: string
inputId?: string
inputStyle?: StyleValue
label?: string
modelValue?: string
name?: string
@@ -16,7 +26,15 @@ const props = withDefaults(
}>(),
{
disabled: false,
cancelButton: false,
cancelLabel: '',
clearButton: true,
component: 'div',
disableButton: false,
disableLabel: '',
id: undefined,
inputId: undefined,
inputStyle: undefined,
label: '',
modelValue: undefined,
name: undefined,
@@ -27,18 +45,33 @@ const props = withDefaults(
const emit = defineEmits<{
blur: [event: FocusEvent]
'blur-capture': [event: FocusEvent]
cancel: []
change: [event: Event]
clear: []
clear: [event?: MouseEvent]
disable: [event: MouseEvent]
focus: [event: FocusEvent]
'focus-capture': [event: FocusEvent]
input: [event: Event]
'update:modelValue': [value: string]
}>()
const generatedId = useId()
const inputId = computed(() => props.id || generatedId)
const resolvedInputId = computed(() => props.inputId || props.id || generatedId)
const input = ref<HTMLInputElement | null>(null)
const effectiveValue = computed(() => props.modelValue ?? props.value ?? '')
const localValue = ref(effectiveValue.value)
const composing = ref(false)
const focused = ref(false)
const showCancel = computed(
() => props.cancelButton && Boolean(props.cancelLabel),
)
const disableAccessibleLabel = computed(
() => props.disableLabel || props.cancelLabel || props.label,
)
const showDisable = computed(
() => props.disableButton && Boolean(disableAccessibleLabel.value),
)
watch(effectiveValue, (value) => {
if (!composing.value) localValue.value = value
@@ -58,50 +91,107 @@ function handleCompositionEnd(event: CompositionEvent): void {
emit('update:modelValue', event.target.value)
}
function clear(): void {
function clear(event?: MouseEvent): void {
if (props.disabled) return
localValue.value = ''
emit('update:modelValue', '')
emit('clear')
emit('clear', event)
}
function handleFocus(event: FocusEvent): void {
focused.value = true
emit('focus', event)
}
function handleBlur(event: FocusEvent): void {
focused.value = false
emit('blur', event)
}
function cancel(): void {
if (props.disabled) return
clear()
emit('cancel')
}
function disable(event: MouseEvent): void {
if (props.disabled) return
input.value?.blur()
clear(event)
emit('disable', event)
}
</script>
<template>
<div
<component
:is="component"
v-bind="$attrs"
class="sky-searchbar"
:class="{ 'sky-searchbar--disabled': disabled }"
:class="{
'sky-searchbar--disabled': disabled,
'sky-searchbar--focused': focused,
'sky-searchbar--with-cancel': showCancel,
'sky-searchbar--with-disable': showDisable,
}"
@blur.capture="emit('blur-capture', $event)"
@focus.capture="emit('focus-capture', $event)"
>
<label v-if="label" class="sky-visually-hidden" :for="inputId">
{{ label }}
</label>
<span class="sky-searchbar__icon" aria-hidden="true" />
<input
:id="inputId"
class="sky-searchbar__input"
type="search"
:aria-label="label || placeholder || undefined"
autocomplete="off"
:disabled="disabled"
:name="name"
:placeholder="placeholder"
:value="localValue"
@blur="emit('blur', $event)"
@change="emit('change', $event)"
@compositionend="handleCompositionEnd"
@compositionstart="composing = true"
@focus="emit('focus', $event)"
@input="handleInput"
/>
<SkyGlass :highlight="false" class="sky-searchbar__control">
<label v-if="label" class="sky-visually-hidden" :for="resolvedInputId">
{{ label }}
</label>
<span class="sky-searchbar__icon" aria-hidden="true" />
<input
:id="resolvedInputId"
ref="input"
class="sky-searchbar__input"
:style="inputStyle"
type="search"
:aria-label="label || placeholder || undefined"
autocomplete="off"
:disabled="disabled"
:name="name"
:placeholder="placeholder"
:value="localValue"
@blur="handleBlur"
@change="emit('change', $event)"
@compositionend="handleCompositionEnd"
@compositionstart="composing = true"
@focus="handleFocus"
@input="handleInput"
/>
<button
v-if="clearButton && localValue"
class="sky-searchbar__clear"
type="button"
:aria-label="clearLabel"
:disabled="disabled"
@pointerdown.prevent
@click="clear($event)"
>
<span aria-hidden="true" />
</button>
</SkyGlass>
<button
v-if="localValue"
class="sky-searchbar__clear"
v-if="showCancel"
class="sky-searchbar__cancel"
type="button"
:aria-label="clearLabel"
:disabled="disabled"
@click="clear"
@pointerdown.prevent
@click="cancel"
>
<span aria-hidden="true" />
{{ cancelLabel }}
</button>
</div>
<button
v-if="showDisable"
class="sky-searchbar__disable sky-searchbar__cancel"
type="button"
:aria-label="disableAccessibleLabel"
:disabled="disabled"
@pointerdown.prevent
@click="disable"
>
<span class="sky-searchbar__disable-icon" aria-hidden="true" />
</button>
</component>
</template>
@@ -0,0 +1,140 @@
import { readFileSync } from 'node:fs'
import { createSSRApp, h } from 'vue'
import { renderToString } from 'vue/server-renderer'
import { describe, expect, it } from 'vitest'
import SkySegmented from './SkySegmented.vue'
import SkySegmentedButton from './SkySegmentedButton.vue'
const controls = readFileSync(
new URL('../controls.css', import.meta.url),
'utf8',
)
async function renderNavigation(activeIndex = 1): Promise<string> {
return renderToString(
createSSRApp({
render: () =>
h(
SkySegmented,
{
activeIndex,
ariaLabel: 'App Store',
itemCount: 3,
navigation: true,
rounded: true,
strong: true,
},
{
default: () =>
['Apps', 'Games', 'Search'].map((label, index) =>
h(
SkySegmentedButton,
{
active: activeIndex === index,
'aria-label': label,
},
() => label,
),
),
},
),
}),
)
}
describe('SkySegmented navigation', () => {
it('renders the Konsta iOS glass stack and one moving highlight', async () => {
const html = await renderNavigation()
expect(html).toContain('sky-glass')
expect(html).toContain('sky-glass--highlight')
expect(html).toContain('sky-segmented--navigation')
expect(html).toContain('role="group"')
expect(html).toContain('aria-label="App Store"')
expect(html.match(/<button/g)).toHaveLength(3)
expect(html.match(/aria-pressed="true"/g)).toHaveLength(1)
expect(html).toContain('sky-segmented__highlight')
expect(html).toContain('width:calc(33.3333% - 5.3333px)')
expect(html).toContain('--sky-segmented-indicator-offset:calc(100% + 4px)')
})
it('allows callers to disable only the interactive Glass highlight', async () => {
const html = await renderToString(
createSSRApp({
render: () =>
h(
SkySegmented,
{
ariaLabel: 'Static glass',
glassHighlight: false,
itemCount: 1,
navigation: true,
},
{
default: () =>
h(SkySegmentedButton, { active: true }, () => 'One'),
},
),
}),
)
expect(html).toContain('sky-glass')
expect(html).not.toContain('sky-glass--highlight')
})
it('locks the 56px container, 48px controls and reduced motion', () => {
expect(controls).toMatch(
/\.sky-glass\.sky-segmented--navigation\s*\{[^}]*min-height:\s*56px[^}]*gap:\s*4px[^}]*padding:\s*4px/s,
)
expect(controls).toMatch(
/\.sky-segmented--navigation \.sky-segmented-button\s*\{[^}]*min-height:\s*48px/s,
)
expect(controls).toMatch(
/\.sky-segmented__highlight\s*\{[^}]*top:\s*4px[^}]*bottom:\s*4px[^}]*background:\s*#e5e5ea/s,
)
expect(controls).toMatch(
/\.sky-app-page--dark \.sky-segmented__highlight\s*\{[^}]*background:\s*#2c2c2e/s,
)
expect(controls).toMatch(
/@media \(prefers-reduced-motion: reduce\)[\s\S]*\.sky-segmented__highlight,[\s\S]*transition-duration:\s*0\.01ms/,
)
})
it('lets subnavbar search controls fill the available Konsta row', () => {
expect(controls).toMatch(
/\.sky-searchbar\s*\{[^}]*width:\s*100%[^}]*flex:\s*1 1 auto/s,
)
})
it('calculates the same sliding pill for full-width five-item navigation', async () => {
const html = await renderToString(
createSSRApp({
render: () =>
h(
SkySegmented,
{
activeIndex: 4,
ariaLabel: 'Five tabs',
itemCount: 5,
navigation: true,
},
{
default: () =>
Array.from({ length: 5 }, (_, index) =>
h(
SkySegmentedButton,
{ active: index === 4 },
() => `${index}`,
),
),
},
),
}),
)
expect(html).toContain('width:calc(20% - 4.8px)')
expect(html).toContain('--sky-segmented-indicator-offset:calc(400% + 16px)')
})
})
+64 -3
View File
@@ -1,23 +1,84 @@
<script setup lang="ts">
import { computed, type CSSProperties } from 'vue'
import { useSkyNavbar } from '../navbar-context'
import SkyGlass from './SkyGlass.vue'
defineOptions({ inheritAttrs: false })
withDefaults(
const props = withDefaults(
defineProps<{
activeIndex?: number
ariaLabel?: string
glassHighlight?: boolean
itemCount?: number
navigation?: boolean
outline?: boolean
raised?: boolean
rounded?: boolean
strong?: boolean
}>(),
{
activeIndex: 0,
ariaLabel: '',
glassHighlight: true,
itemCount: 0,
navigation: undefined,
outline: false,
raised: false,
rounded: false,
strong: true,
},
)
const insideNavbar = useSkyNavbar()
const isNavigation = computed(() => props.navigation ?? insideNavbar)
const rootComponent = computed(() => (isNavigation.value ? SkyGlass : 'div'))
const indicatorStyle = computed<CSSProperties | undefined>(() => {
const itemCount = Number.isFinite(props.itemCount)
? Math.max(0, Math.floor(props.itemCount))
: 0
if (!isNavigation.value || !props.strong || itemCount === 0) return undefined
const requestedIndex = Number.isFinite(props.activeIndex)
? Math.floor(props.activeIndex)
: 0
const activeIndex = Math.max(0, Math.min(itemCount - 1, requestedIndex))
const widthPercentage = Number((100 / itemCount).toFixed(4))
const spacing = Number(((8 + (itemCount - 1) * 4) / itemCount).toFixed(4))
const percentageOffset = activeIndex * 100
const pixelOffset = activeIndex * 4
return {
'--sky-segmented-indicator-offset': `calc(${percentageOffset}% + ${pixelOffset}px)`,
'--sky-segmented-indicator-offset-rtl': `calc(-${percentageOffset}% - ${pixelOffset}px)`,
width: `calc(${widthPercentage}% - ${spacing}px)`,
}
})
</script>
<template>
<div
<component
:is="rootComponent"
v-bind="$attrs"
:highlight="isNavigation ? glassHighlight : undefined"
class="sky-segmented"
:class="{
'sky-segmented--navigation': isNavigation,
'sky-segmented--outline': outline,
'sky-segmented--raised': raised,
'sky-segmented--rounded': rounded,
'sky-segmented--strong': strong,
}"
role="group"
:aria-label="ariaLabel || undefined"
>
<slot />
</div>
<span
v-if="indicatorStyle"
class="sky-segmented__highlight"
:style="indicatorStyle"
aria-hidden="true"
/>
</component>
</template>
@@ -5,11 +5,13 @@ withDefaults(
defineProps<{
active?: boolean
disabled?: boolean
tab?: boolean
type?: 'button' | 'reset' | 'submit'
}>(),
{
active: false,
disabled: false,
tab: false,
type: 'button',
},
)
@@ -24,7 +26,9 @@ defineEmits<{
v-bind="$attrs"
class="sky-segmented-button"
:class="{ 'sky-segmented-button--active': active }"
:aria-pressed="active"
:role="tab ? 'tab' : undefined"
:aria-pressed="tab ? undefined : active"
:aria-selected="tab ? active : undefined"
:disabled="disabled"
:type="type"
@click="$emit('click', $event)"
+3 -1
View File
@@ -28,5 +28,7 @@ const spinnerStyle = computed<CSSProperties>(() => {
:aria-hidden="label ? undefined : true"
:aria-label="label || undefined"
:role="label ? 'status' : undefined"
/>
>
<i v-for="index in 8" :key="index" aria-hidden="true" />
</span>
</template>
+241
View File
@@ -0,0 +1,241 @@
<script setup lang="ts">
import { computed, ref, useId, watch } from 'vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
buttonsOnly?: boolean
component?: 'div' | 'span'
decrementLabel: string
disabled?: boolean
id?: string
incrementLabel: string
input?: boolean
inputDisabled?: boolean
inputLabel?: string
inputPlaceholder?: string
inputReadonly?: boolean
inputType?: 'number' | 'text'
large?: boolean
max?: number
min?: number
modelValue?: number
name?: string
outline?: boolean
raised?: boolean
rounded?: boolean
small?: boolean
step?: number
value?: number
}>(),
{
buttonsOnly: false,
component: 'span',
disabled: false,
id: undefined,
input: false,
inputDisabled: false,
inputLabel: '',
inputPlaceholder: '',
inputReadonly: false,
inputType: 'number',
large: false,
max: undefined,
min: undefined,
modelValue: undefined,
name: undefined,
outline: false,
raised: false,
rounded: false,
small: false,
step: 1,
value: 0,
},
)
const emit = defineEmits<{
blur: [event: FocusEvent]
change: [event: Event]
focus: [event: FocusEvent]
input: [event: Event]
minus: [event: MouseEvent]
plus: [event: MouseEvent]
'update:modelValue': [value: number]
}>()
const generatedId = useId()
const composing = ref(false)
const inputId = computed(() => props.id || generatedId)
const internalValue = ref(props.modelValue ?? props.value)
const currentValue = computed(() => props.modelValue ?? internalValue.value)
const effectiveStep = computed(() =>
Number.isFinite(props.step) && props.step > 0 ? props.step : 1,
)
const decrementDisabled = computed(
() =>
props.disabled ||
(props.min !== undefined && currentValue.value <= props.min),
)
const incrementDisabled = computed(
() =>
props.disabled ||
(props.max !== undefined && currentValue.value >= props.max),
)
watch(
() => props.value,
(value) => {
if (props.modelValue === undefined) internalValue.value = value
},
)
watch(
() => props.modelValue,
(value) => {
if (value !== undefined) internalValue.value = value
},
)
function normalizeValue(value: number): number {
let nextValue = value
if (props.min !== undefined) nextValue = Math.max(props.min, nextValue)
if (props.max !== undefined) nextValue = Math.min(props.max, nextValue)
return nextValue
}
function updateValue(value: number): void {
const nextValue = normalizeValue(value)
if (props.modelValue === undefined) internalValue.value = nextValue
emit('update:modelValue', nextValue)
}
function decrement(event: MouseEvent): void {
if (decrementDisabled.value) return
updateValue(currentValue.value - effectiveStep.value)
emit('minus', event)
}
function increment(event: MouseEvent): void {
if (incrementDisabled.value) return
updateValue(currentValue.value + effectiveStep.value)
emit('plus', event)
}
function inputValue(event: Event): number | undefined {
if (!(event.target instanceof HTMLInputElement)) return undefined
const value =
props.inputType === 'number'
? event.target.valueAsNumber
: Number(event.target.value)
return Number.isFinite(value) ? value : undefined
}
function handleInput(event: Event): void {
if (composing.value) {
emit('input', event)
return
}
const value = inputValue(event)
if (value !== undefined) updateValue(value)
emit('input', event)
}
function handleCompositionEnd(event: CompositionEvent): void {
composing.value = false
const value = inputValue(event)
if (value !== undefined) updateValue(value)
}
function handleChange(event: Event): void {
const value = inputValue(event)
if (value !== undefined) {
updateValue(value)
} else if (event.target instanceof HTMLInputElement) {
event.target.value = String(currentValue.value)
}
emit('change', event)
}
</script>
<template>
<component
:is="component"
v-bind="$attrs"
class="sky-stepper"
:class="{
'sky-stepper--buttons-only': buttonsOnly,
'sky-stepper--disabled': disabled,
'sky-stepper--input': input,
'sky-stepper--large': large,
'sky-stepper--outline': outline,
'sky-stepper--raised': raised,
'sky-stepper--rounded': rounded,
'sky-stepper--small': small,
}"
>
<button
class="sky-stepper__button sky-stepper__decrement"
type="button"
:aria-controls="input && !buttonsOnly ? inputId : undefined"
:aria-label="decrementLabel || undefined"
:disabled="decrementDisabled"
@click="decrement"
>
<slot name="decrement">
<span
class="sky-stepper__minus"
:aria-hidden="decrementLabel ? true : undefined"
>&minus;</span
>
</slot>
</button>
<input
v-if="input && !buttonsOnly"
:id="inputId"
class="sky-stepper__value sky-stepper__input"
:aria-label="inputLabel || undefined"
:disabled="disabled || inputDisabled"
:max="max"
:min="min"
:name="name"
:placeholder="inputPlaceholder"
:readonly="inputReadonly"
:step="effectiveStep"
:type="inputType"
:value="currentValue"
@blur="emit('blur', $event)"
@change="handleChange"
@compositionend="handleCompositionEnd"
@compositionstart="composing = true"
@focus="emit('focus', $event)"
@input="handleInput"
/>
<span
v-else-if="!buttonsOnly"
class="sky-stepper__value"
aria-live="polite"
>
<slot name="value" :value="currentValue">{{ currentValue }}</slot>
</span>
<button
class="sky-stepper__button sky-stepper__increment"
type="button"
:aria-controls="input && !buttonsOnly ? inputId : undefined"
:aria-label="incrementLabel || undefined"
:disabled="incrementDisabled"
@click="increment"
>
<slot name="increment">
<span
class="sky-stepper__plus"
:aria-hidden="incrementLabel ? true : undefined"
>+</span
>
</slot>
</button>
</component>
</template>
+35
View File
@@ -0,0 +1,35 @@
<script setup lang="ts">
defineOptions({ inheritAttrs: false })
withDefaults(
defineProps<{
ariaLabel?: string
caption?: string
compact?: boolean
striped?: boolean
}>(),
{
ariaLabel: '',
caption: '',
compact: false,
striped: false,
},
)
</script>
<template>
<table
v-bind="$attrs"
class="sky-table"
:class="{
'sky-table--compact': compact,
'sky-table--striped': striped,
}"
:aria-label="ariaLabel || undefined"
>
<caption v-if="caption || $slots.caption" class="sky-table__caption">
<slot name="caption">{{ caption }}</slot>
</caption>
<slot />
</table>
</template>
@@ -0,0 +1,9 @@
<script setup lang="ts">
defineOptions({ inheritAttrs: false })
</script>
<template>
<tbody v-bind="$attrs" class="sky-table-body">
<slot />
</tbody>
</template>
+53
View File
@@ -0,0 +1,53 @@
<script setup lang="ts">
import { computed } from 'vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
align?: 'center' | 'end' | 'start'
colspan?: number | string
component?: 'td' | 'th'
header?: boolean
nowrap?: boolean
numeric?: boolean
rowspan?: number | string
scope?: 'col' | 'colgroup' | 'row' | 'rowgroup'
}>(),
{
align: 'start',
colspan: undefined,
component: undefined,
header: false,
nowrap: false,
numeric: false,
rowspan: undefined,
scope: undefined,
},
)
const cellComponent = computed(
() => props.component ?? (props.header ? 'th' : 'td'),
)
</script>
<template>
<component
:is="cellComponent"
v-bind="$attrs"
class="sky-table-cell"
:class="[
`sky-table-cell--${align}`,
{
'sky-table-cell--header': header || cellComponent === 'th',
'sky-table-cell--nowrap': nowrap,
'sky-table-cell--numeric': numeric,
},
]"
:colspan="colspan"
:rowspan="rowspan"
:scope="cellComponent === 'th' ? scope : undefined"
>
<slot />
</component>
</template>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
defineOptions({ inheritAttrs: false })
withDefaults(
defineProps<{
sticky?: boolean
}>(),
{
sticky: false,
},
)
</script>
<template>
<thead
v-bind="$attrs"
class="sky-table-head"
:class="{ 'sky-table-head--sticky': sticky }"
>
<slot />
</thead>
</template>
+28
View File
@@ -0,0 +1,28 @@
<script setup lang="ts">
defineOptions({ inheritAttrs: false })
withDefaults(
defineProps<{
header?: boolean
selected?: boolean
}>(),
{
header: false,
selected: false,
},
)
</script>
<template>
<tr
v-bind="$attrs"
class="sky-table-row"
:class="{
'sky-table-row--header': header,
'sky-table-row--selected': selected,
}"
:aria-selected="selected || undefined"
>
<slot />
</tr>
</template>
@@ -0,0 +1,96 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { createSSRApp, h } from 'vue'
import { renderToString } from 'vue/server-renderer'
import { describe, expect, it } from 'vitest'
import SkyToggle from './SkyToggle.vue'
describe('SkyToggle', () => {
it('renders the complete Konsta iOS glass layer stack', async () => {
const html = await renderToString(
createSSRApp({
render: () =>
h(SkyToggle, {
ariaLabel: 'Airplane mode',
component: 'div',
modelValue: true,
name: 'airplane-mode',
value: 'enabled',
}),
}),
)
expect(html).toContain('<div')
expect(html).toContain('role="switch"')
expect(html).toContain('aria-label="Airplane mode"')
expect(html).toContain('checked')
expect(html).toContain('name="airplane-mode"')
expect(html).toContain('value="enabled"')
expect(html).toContain('sky-toggle--checked')
expect(html).toContain('sky-toggle__thumb-side')
expect(html).toContain('sky-toggle__thumb-bg')
expect(html).toContain('sky-toggle__thumb-shadow')
expect(html).toContain('sky-toggle__thumb-wrap')
expect(html).toContain('sky-toggle__thumb')
})
it('keeps disabled and readonly switches native and inert', async () => {
const html = await renderToString(
createSSRApp({
render: () =>
h(SkyToggle, {
ariaLabel: 'Locked setting',
disabled: true,
readonly: true,
}),
}),
)
expect(html).toContain('sky-toggle--disabled')
expect(html).toContain('sky-toggle--readonly')
expect(html).toContain('disabled')
expect(html).toContain('readonly')
expect(html).toContain('aria-readonly="true"')
})
it('binds the complete enabled-only Konsta hold effect in CSS', () => {
const uiDirectory = fileURLToPath(new URL('..', import.meta.url))
const controls = readFileSync(`${uiDirectory}/controls.css`, 'utf8')
const tokens = readFileSync(`${uiDirectory}/tokens.css`, 'utf8')
expect(controls).toMatch(
/\.sky-toggle:not\(\.sky-toggle--disabled\):not\(\.sky-toggle--readonly\):active[\s\S]*?\.sky-toggle__thumb-bg[\s\S]*?--sky-toggle-hold-track-scale, 0\.75/,
)
expect(controls).toMatch(
/\.sky-toggle:not\(\.sky-toggle--disabled\):not\(\.sky-toggle--readonly\):active[\s\S]*?\.sky-toggle__thumb-wrap[\s\S]*?--sky-toggle-hold-background, transparent[\s\S]*?--sky-hold-thumb-scale, 1\.4/,
)
expect(controls).toContain('var(--sky-toggle-glow-spread, 10px)')
expect(controls).toContain('var(--sky-app-accent, #007aff)')
expect(controls).toContain('var(--sky-toggle-glow-opacity, 0.75)')
expect(controls).toContain('var(--sky-shadow-glass-thumb)')
expect(controls).toContain("[dir='rtl'] .sky-toggle--checked")
expect(controls).toContain(".sky-toggle--checked[dir='rtl']")
expect(controls).toMatch(
/\.sky-toggle--checked\[dir='rtl'\] \.sky-toggle__thumb-side\s*\{\s*transform: none/,
)
expect(tokens).toContain('--sky-toggle-track: #f1f1f5')
expect(tokens).toContain('--sky-toggle-track: #444447')
expect(tokens).toContain('--sky-toggle-glow-opacity: 0.75')
expect(tokens).toContain('--sky-toggle-glow-opacity: 1')
const reducedMotion = controls.slice(
controls.indexOf('@media (prefers-reduced-motion: reduce)'),
controls.indexOf(
'@supports',
controls.indexOf('@media (prefers-reduced-motion: reduce)'),
),
)
expect(reducedMotion).toContain('.sky-toggle__thumb-side')
expect(reducedMotion).toContain('.sky-toggle__thumb-bg')
expect(reducedMotion).toContain('.sky-toggle__thumb-shadow')
expect(reducedMotion).toContain('.sky-toggle__thumb-wrap')
expect(reducedMotion).toContain('transform: translateX(-22px)')
})
})
+55 -6
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, nextTick, ref, useId } from 'vue'
defineOptions({ inheritAttrs: false })
@@ -9,20 +9,26 @@ const props = withDefaults(
ariaDescribedby?: string
ariaLabelledby?: string
checked?: boolean
component?: 'div' | 'label' | 'span'
disabled?: boolean
id?: string
modelValue?: boolean
name?: string
readonly?: boolean
value?: number | string
}>(),
{
ariaLabel: '',
ariaDescribedby: '',
ariaLabelledby: '',
checked: false,
component: 'label',
disabled: false,
id: undefined,
modelValue: undefined,
name: undefined,
readonly: false,
value: undefined,
},
)
@@ -31,42 +37,85 @@ const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const generatedId = useId()
const isChecked = computed(() => props.modelValue ?? props.checked)
const input = ref<HTMLInputElement | null>(null)
const labelId = computed(() => `${props.id || generatedId}-label`)
function restoreReadonlyState(): void {
void nextTick(() => {
if (input.value) input.value.checked = isChecked.value
})
}
function handleChange(event: Event): void {
if (!(event.target instanceof HTMLInputElement)) return
if (props.readonly) {
event.target.checked = isChecked.value
restoreReadonlyState()
return
}
emit('update:modelValue', event.target.checked)
emit('change', event)
}
function handleContainerClick(event: MouseEvent): void {
if (props.disabled || props.readonly) {
event.preventDefault()
event.stopPropagation()
restoreReadonlyState()
return
}
if (props.component !== 'label' && event.target !== input.value) {
input.value?.click()
}
}
</script>
<template>
<label
<component
:is="component"
v-bind="$attrs"
class="sky-toggle"
:class="{
'sky-toggle--checked': isChecked,
'sky-toggle--disabled': disabled,
'sky-toggle--readonly': readonly,
}"
@click="handleContainerClick"
>
<input
:id="id"
ref="input"
class="sky-toggle__input"
type="checkbox"
:aria-describedby="ariaDescribedby || undefined"
role="switch"
:aria-label="ariaLabel || undefined"
:aria-labelledby="ariaLabelledby || undefined"
:aria-labelledby="
ariaLabelledby || (!ariaLabel && $slots.default ? labelId : undefined)
"
:aria-readonly="readonly || undefined"
:checked="isChecked"
:disabled="disabled"
:name="name"
:readonly="readonly"
:value="value"
@change="handleChange"
/>
<span class="sky-toggle__track" aria-hidden="true">
<span class="sky-toggle__thumb" />
<span class="sky-toggle__thumb-side" />
<span class="sky-toggle__thumb-bg" />
<span class="sky-toggle__thumb-shadow" />
<span class="sky-toggle__thumb-wrap">
<span class="sky-toggle__thumb" />
</span>
</span>
<span v-if="$slots.default" class="sky-toggle__label">
<span v-if="$slots.default" :id="labelId" class="sky-toggle__label">
<slot />
</span>
</label>
</component>
</template>
+51
View File
@@ -0,0 +1,51 @@
<script setup lang="ts">
defineOptions({ inheritAttrs: false })
withDefaults(
defineProps<{
ariaLabel?: string
bgClass?: string
component?: 'div' | 'footer' | 'header' | 'nav'
innerClass?: string
outline?: boolean
tabbar?: boolean
tabbarIcons?: boolean
tabbarLabels?: boolean
top?: boolean
}>(),
{
ariaLabel: '',
bgClass: '',
component: 'div',
innerClass: '',
outline: false,
tabbar: false,
tabbarIcons: false,
tabbarLabels: false,
top: false,
},
)
</script>
<template>
<component
:is="component"
v-bind="$attrs"
class="sky-toolbar"
:class="{
'sky-toolbar--outline': outline,
'sky-toolbar--tabbar': tabbar,
'sky-toolbar--tabbar-icons': tabbarIcons,
'sky-toolbar--tabbar-labels': tabbarLabels,
'sky-toolbar--top': top,
}"
:aria-label="ariaLabel || undefined"
:aria-orientation="tabbar ? undefined : 'horizontal'"
:role="tabbar ? 'tablist' : 'toolbar'"
>
<span class="sky-toolbar__background" :class="bgClass" aria-hidden="true" />
<div class="sky-toolbar__inner" :class="innerClass">
<slot />
</div>
</component>
</template>
+20
View File
@@ -1,15 +1,28 @@
export { default as SkyBadge } from './SkyBadge.vue'
export { default as SkyBlock } from './SkyBlock.vue'
export { default as SkyBlockFooter } from './SkyBlockFooter.vue'
export { default as SkyBlockHeader } from './SkyBlockHeader.vue'
export { default as SkyBlockTitle } from './SkyBlockTitle.vue'
export { default as SkyBreadcrumbs } from './SkyBreadcrumbs.vue'
export { default as SkyBreadcrumbsCollapsed } from './SkyBreadcrumbsCollapsed.vue'
export { default as SkyBreadcrumbsItem } from './SkyBreadcrumbsItem.vue'
export { default as SkyBreadcrumbsSeparator } from './SkyBreadcrumbsSeparator.vue'
export { default as SkyButton } from './SkyButton.vue'
export { default as SkyCard } from './SkyCard.vue'
export { default as SkyCheckbox } from './SkyCheckbox.vue'
export { default as SkyChip } from './SkyChip.vue'
export { default as SkyFab } from './SkyFab.vue'
export { default as SkyField } from './SkyField.vue'
export { default as SkyGlass } from './SkyGlass.vue'
export { default as SkyIcon } from './SkyIcon.vue'
export { default as SkyLink } from './SkyLink.vue'
export { default as SkyList } from './SkyList.vue'
export { default as SkyListButton } from './SkyListButton.vue'
export { default as SkyListGroup } from './SkyListGroup.vue'
export { default as SkyListItem } from './SkyListItem.vue'
export { default as SkyMenuList } from './SkyMenuList.vue'
export { default as SkyMenuListItem } from './SkyMenuListItem.vue'
export { default as SkyNavbarBackLink } from './SkyNavbarBackLink.vue'
export { default as SkyProgress } from './SkyProgress.vue'
export { default as SkyRadio } from './SkyRadio.vue'
export { default as SkyRange } from './SkyRange.vue'
@@ -18,7 +31,14 @@ export { default as SkySegmented } from './SkySegmented.vue'
export { default as SkySegmentedButton } from './SkySegmentedButton.vue'
export { default as SkySpinner } from './SkySpinner.vue'
export { default as SkyStatusCard } from './SkyStatusCard.vue'
export { default as SkyStepper } from './SkyStepper.vue'
export { default as SkySurface } from './SkySurface.vue'
export { default as SkyTabButton } from './SkyTabButton.vue'
export { default as SkyTable } from './SkyTable.vue'
export { default as SkyTableBody } from './SkyTableBody.vue'
export { default as SkyTableCell } from './SkyTableCell.vue'
export { default as SkyTableHead } from './SkyTableHead.vue'
export { default as SkyTableRow } from './SkyTableRow.vue'
export { default as SkyToggle } from './SkyToggle.vue'
export { default as SkyToolbar } from './SkyToolbar.vue'
export { default as SkyToolbarPane } from './SkyToolbarPane.vue'