mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 17:23:25 +00:00
ENH - complete Sky UI Konsta parity
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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)')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)')
|
||||
})
|
||||
})
|
||||
@@ -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)"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>−</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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user