ADD - introduce the first-party Sky UI system

This commit is contained in:
DerEchteAlec
2026-08-13 03:08:43 +02:00
parent 6d25423f45
commit 6b508adbc9
72 changed files with 6045 additions and 0 deletions
+5
View File
@@ -4,5 +4,10 @@ import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import './assets/main.css'
import './ui/tokens.css'
import './ui/foundation.css'
import './ui/controls.css'
import './ui/settings.css'
import './ui/overlays.css'
createApp(App).use(createPinia()).use(router).mount('#app')
+43
View File
@@ -0,0 +1,43 @@
<script setup lang="ts">
import { computed } from 'vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
accent?: string
accentSoft?: string
dark?: boolean
label: string
}>(),
{
accent: '',
accentSoft: '',
dark: false,
},
)
const accentStyle = computed(() =>
props.accent || props.accentSoft
? {
...(props.accent ? { '--sky-app-accent': props.accent } : {}),
...(props.accentSoft
? { '--sky-app-accent-soft': props.accentSoft }
: {}),
}
: undefined,
)
</script>
<template>
<main
v-bind="$attrs"
class="sky-app-page"
:class="{ 'sky-app-page--dark': dark }"
:style="accentStyle"
:aria-label="label"
>
<div class="sky-app-page__backdrop" aria-hidden="true"></div>
<slot />
</main>
</template>
+41
View File
@@ -0,0 +1,41 @@
<script setup lang="ts">
defineOptions({ inheritAttrs: false })
withDefaults(
defineProps<{
body?: string
compact?: boolean
title?: string
tone?: 'danger' | 'neutral'
}>(),
{
body: '',
compact: false,
title: '',
tone: 'neutral',
},
)
</script>
<template>
<section
v-bind="$attrs"
class="sky-empty-state"
:class="[
`sky-empty-state--${tone}`,
{ 'sky-empty-state--compact': compact },
]"
:role="tone === 'danger' ? 'alert' : 'status'"
:aria-live="tone === 'danger' ? 'assertive' : 'polite'"
aria-atomic="true"
>
<span v-if="$slots.icon" class="sky-empty-state__icon">
<slot name="icon" />
</span>
<strong v-if="title" class="sky-empty-state__title">{{ title }}</strong>
<p v-if="body" class="sky-empty-state__body">{{ body }}</p>
<div v-if="$slots.actions" class="sky-empty-state__actions">
<slot name="actions" />
</div>
</section>
</template>
+37
View File
@@ -0,0 +1,37 @@
import { createSSRApp } from 'vue'
import { renderToString } from 'vue/server-renderer'
import { describe, expect, it } from 'vitest'
import SkyInfiniteLoader from '@/ui/SkyInfiniteLoader.vue'
function renderLoader(error: boolean | string): Promise<string> {
const app = createSSRApp(SkyInfiniteLoader, {
error,
hasMore: false,
loading: false,
loadingLabel: 'Loading more companies',
loadKey: null,
retryLabel: 'Try Again',
})
return renderToString(app)
}
describe('SkyInfiniteLoader error rendering', () => {
it('does not Boolean-cast a bound empty error string to true', async () => {
const html = await renderLoader('')
expect(html).not.toContain('sky-infinite-loader__retry')
expect(html).not.toContain('Try Again')
})
it.each([true, 'append_failed'])(
'renders retry for error %j',
async (error) => {
const html = await renderLoader(error)
expect(html).toContain('sky-infinite-loader__retry')
expect(html).toContain('Try Again')
},
)
})
+143
View File
@@ -0,0 +1,143 @@
<script setup lang="ts">
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
type PropType,
ref,
watch,
} from 'vue'
import SkyLink from './controls/SkyLink.vue'
import SkySpinner from './controls/SkySpinner.vue'
const OBSERVER_ROOT_MARGIN = '0px 0px 140px 0px'
const props = defineProps({
error: {
default: false,
type: [String, Boolean] as PropType<string | boolean>,
},
hasMore: { required: true, type: Boolean },
loading: { required: true, type: Boolean },
loadingLabel: { required: true, type: String },
loadKey: {
default: null,
type: [String, Number] as PropType<string | number | null | undefined>,
},
retryLabel: { required: true, type: String },
})
const emit = defineEmits<{
load: []
retry: []
}>()
const sentinel = ref<HTMLElement | null>(null)
const hasError = computed(() => Boolean(props.error))
const isHidden = computed(
() => !props.hasMore && !props.loading && !hasError.value,
)
const isVisibleState = computed(() => props.loading || hasError.value)
let observer: IntersectionObserver | null = null
let observedElement: HTMLElement | null = null
let refreshGeneration = 0
let hasRequestedKey = false
let lastRequestedKey: string | number | null | undefined
function requestNextPage(): void {
if (!props.hasMore || props.loading || hasError.value) return
if (hasRequestedKey && Object.is(lastRequestedKey, props.loadKey)) return
hasRequestedKey = true
lastRequestedKey = props.loadKey
emit('load')
}
function handleIntersections(entries: IntersectionObserverEntry[]): void {
const entry = entries.at(-1)
if (entry?.isIntersecting) requestNextPage()
}
function refreshObservation(): void {
if (!observer || !sentinel.value) return
// Re-observing requests a fresh post-layout intersection. A cached `true`
// from before new rows were appended could otherwise fetch one page too far.
if (observedElement) observer.unobserve(observedElement)
observedElement = null
if (!props.hasMore) return
observedElement = sentinel.value
observer.observe(observedElement)
}
async function scheduleObservationRefresh(): Promise<void> {
const generation = ++refreshGeneration
await nextTick()
if (generation !== refreshGeneration) return
refreshObservation()
}
function retry(): void {
if (props.loading || !hasError.value) return
emit('retry')
}
watch(
() => [props.hasMore, props.loading, props.error, props.loadKey] as const,
([hasMore]) => {
if (!hasMore) {
hasRequestedKey = false
lastRequestedKey = undefined
}
void scheduleObservationRefresh()
},
{ flush: 'post' },
)
onMounted(() => {
if (typeof IntersectionObserver === 'undefined' || !sentinel.value) return
const scrollRoot = sentinel.value.closest<HTMLElement>('.sky-scroll-area')
observer = new IntersectionObserver(handleIntersections, {
root: scrollRoot,
rootMargin: OBSERVER_ROOT_MARGIN,
threshold: 0,
})
refreshObservation()
})
onBeforeUnmount(() => {
refreshGeneration += 1
observer?.disconnect()
observer = null
observedElement = null
})
</script>
<template>
<div
ref="sentinel"
class="sky-infinite-loader"
:class="{ 'sky-infinite-loader--visible': isVisibleState }"
:hidden="isHidden"
aria-live="polite"
:aria-busy="loading || undefined"
>
<SkySpinner v-if="loading" :label="loadingLabel" :size="18" />
<SkyLink
v-else-if="hasError"
class="sky-infinite-loader__retry"
type="button"
@click="retry"
>
{{ retryLabel }}
</SkyLink>
</div>
</template>
+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
defineOptions({ inheritAttrs: false })
withDefaults(
defineProps<{
inset?: boolean
strong?: boolean
}>(),
{
inset: true,
strong: true,
},
)
</script>
<template>
<ul
v-bind="$attrs"
class="sky-list-card"
:class="{
'sky-list-card--inset': inset,
'sky-list-card--strong': strong,
}"
>
<slot />
</ul>
</template>
+61
View File
@@ -0,0 +1,61 @@
import { readFileSync } from 'node:fs'
import { createSSRApp } from 'vue'
import { renderToString } from 'vue/server-renderer'
import { describe, expect, it } from 'vitest'
import SkyNavbar from '@/ui/SkyNavbar.vue'
const foundationStyles = readFileSync(
new URL('./foundation.css', import.meta.url),
'utf8',
)
describe('SkyNavbar', () => {
it('keeps the compact centered header as the default', async () => {
const html = await renderToString(
createSSRApp(SkyNavbar, { title: 'Account' }),
)
expect(html).toContain('sky-navbar--compact')
expect(html).toContain('<h1 class="sky-navbar__title">Account</h1>')
})
it('exposes the large-title header without changing heading semantics', async () => {
const html = await renderToString(
createSSRApp(SkyNavbar, {
title: 'Settings',
variant: 'large',
}),
)
expect(html).toContain('sky-navbar--large')
expect(html).toContain('<h1 class="sky-navbar__title">Settings</h1>')
})
it('does not reserve a second navigation row for the large title', () => {
const largeNavbarRule = foundationStyles.match(
/\.sky-navbar--large\s*\{(?<declarations>[^}]*)\}/,
)?.groups?.declarations
expect(largeNavbarRule).toBeDefined()
expect(largeNavbarRule).toContain(
'grid-template-rows: var(--sky-navbar-large-title-height)',
)
expect(largeNavbarRule).not.toContain('var(--sky-navbar-height)')
})
it('exposes the optional surface back affordance for detail screens', async () => {
const html = await renderToString(
createSSRApp(SkyNavbar, {
backAppearance: 'surface',
backLabel: 'Back to Settings',
showBack: true,
title: 'Account',
}),
)
expect(html).toContain('sky-navbar__back--surface')
expect(html).toContain('aria-label="Back to Settings"')
})
})
+58
View File
@@ -0,0 +1,58 @@
<script setup lang="ts">
import { ChevronLeft } from 'lucide-vue-next'
import { computed } from 'vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
backAppearance?: 'plain' | 'surface'
backLabel?: string
showBack?: boolean
showBackText?: boolean
title: string
variant?: 'compact' | 'large'
}>(),
{
backAppearance: 'plain',
backLabel: '',
showBack: false,
showBackText: false,
variant: 'compact',
},
)
const emit = defineEmits<{
back: []
}>()
const accessibleBackLabel = computed(() => props.backLabel || props.title)
</script>
<template>
<header v-bind="$attrs" class="sky-navbar" :class="`sky-navbar--${variant}`">
<div class="sky-navbar__left">
<slot name="left">
<button
v-if="showBack"
type="button"
class="sky-navbar__back"
:class="`sky-navbar__back--${backAppearance}`"
:aria-label="accessibleBackLabel"
@click="emit('back')"
>
<ChevronLeft :size="26" :stroke-width="2" aria-hidden="true" />
<span v-if="showBackText" class="sky-navbar__back-label">
{{ backLabel }}
</span>
</button>
</slot>
</div>
<h1 class="sky-navbar__title">{{ title }}</h1>
<div class="sky-navbar__right">
<slot name="right" />
</div>
</header>
</template>
+25
View File
@@ -0,0 +1,25 @@
<script setup lang="ts">
defineOptions({ inheritAttrs: false })
withDefaults(
defineProps<{
as?: string
withTabbar?: boolean
}>(),
{
as: 'section',
withTabbar: false,
},
)
</script>
<template>
<component
:is="as"
v-bind="$attrs"
class="sky-scroll-area"
:class="{ 'sky-scroll-area--tabbar': withTabbar }"
>
<slot />
</component>
</template>
+176
View File
@@ -0,0 +1,176 @@
<script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue'
import { resolveScrollRailWheel } from '@/utils/scrollRail'
defineOptions({ inheritAttrs: false })
withDefaults(
defineProps<{
as?: string
label?: string
}>(),
{
as: 'div',
label: '',
},
)
const DRAG_THRESHOLD_PX = 5
const isDragging = ref(false)
let activePointerId: number | null = null
let dragStartClientX = 0
let dragStartScrollLeft = 0
let renderedScale = 1
let suppressNextClick = false
let clickResetTimer: ReturnType<typeof setTimeout> | undefined
function resetPointerState() {
activePointerId = null
isDragging.value = false
}
function scheduleClickReset() {
if (clickResetTimer !== undefined) {
clearTimeout(clickResetTimer)
}
clickResetTimer = setTimeout(() => {
suppressNextClick = false
clickResetTimer = undefined
}, 0)
}
function handlePointerDown(event: PointerEvent) {
if (event.pointerType === 'touch' || !event.isPrimary || event.button !== 0) {
return
}
const rail = event.currentTarget as HTMLElement
const renderedWidth = rail.getBoundingClientRect().width
activePointerId = event.pointerId
dragStartClientX = event.clientX
dragStartScrollLeft = rail.scrollLeft
renderedScale =
rail.clientWidth > 0 && renderedWidth > 0
? renderedWidth / rail.clientWidth
: 1
suppressNextClick = false
}
function handlePointerMove(event: PointerEvent) {
if (event.pointerId !== activePointerId) {
return
}
if ((event.buttons & 1) === 0) {
resetPointerState()
return
}
const rail = event.currentTarget as HTMLElement
const deltaX = (event.clientX - dragStartClientX) / renderedScale
if (!isDragging.value && Math.abs(deltaX) < DRAG_THRESHOLD_PX) {
return
}
if (!isDragging.value) {
isDragging.value = true
rail.setPointerCapture(event.pointerId)
}
event.preventDefault()
rail.scrollLeft = dragStartScrollLeft - deltaX
}
function finishPointer(event: PointerEvent) {
if (event.pointerId !== activePointerId) {
return
}
const rail = event.currentTarget as HTMLElement
const dragged = isDragging.value
if (rail.hasPointerCapture(event.pointerId)) {
rail.releasePointerCapture(event.pointerId)
}
resetPointerState()
if (dragged) {
suppressNextClick = true
scheduleClickReset()
}
}
function handlePointerLeave(event: PointerEvent) {
if (event.pointerId === activePointerId && !isDragging.value) {
resetPointerState()
}
}
function handleLostPointerCapture(event: PointerEvent) {
if (event.pointerId === activePointerId) {
resetPointerState()
}
}
function handleClickCapture(event: MouseEvent) {
if (!suppressNextClick) {
return
}
suppressNextClick = false
event.preventDefault()
event.stopPropagation()
}
function handleWheel(event: WheelEvent) {
if (event.ctrlKey) return
const rail = event.currentTarget as HTMLElement
const result = resolveScrollRailWheel({
clientWidth: rail.clientWidth,
deltaMode: event.deltaMode,
deltaX: event.deltaX,
deltaY: event.deltaY,
scrollLeft: rail.scrollLeft,
scrollWidth: rail.scrollWidth,
})
if (!result.consumed) return
rail.scrollLeft = result.scrollLeft
event.preventDefault()
}
onBeforeUnmount(() => {
if (clickResetTimer !== undefined) {
clearTimeout(clickResetTimer)
}
})
</script>
<template>
<component
:is="as"
v-bind="$attrs"
class="sky-scroll-rail"
:class="{ 'sky-scroll-rail--dragging': isDragging }"
role="region"
:aria-label="label || undefined"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@pointerup="finishPointer"
@pointercancel="finishPointer"
@pointerleave="handlePointerLeave"
@lostpointercapture="handleLostPointerCapture"
@click.capture="handleClickCapture"
@wheel="handleWheel"
>
<slot />
</component>
</template>
+14
View File
@@ -0,0 +1,14 @@
<script setup lang="ts">
defineOptions({ inheritAttrs: false })
defineProps<{
title: string
}>()
</script>
<template>
<section v-bind="$attrs" class="sky-section">
<h2 class="sky-section__title">{{ title }}</h2>
<slot />
</section>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
defineOptions({ inheritAttrs: false })
defineProps<{
label: string
}>()
</script>
<template>
<nav v-bind="$attrs" class="sky-tabbar" :aria-label="label">
<div class="sky-tabbar__inner">
<div class="sky-tabbar__pane">
<slot />
</div>
</div>
</nav>
</template>
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
<script setup lang="ts">
defineOptions({ inheritAttrs: false })
withDefaults(
defineProps<{
tone?: 'danger' | 'info' | 'neutral' | 'success' | 'warning'
}>(),
{
tone: 'neutral',
},
)
</script>
<template>
<span v-bind="$attrs" class="sky-badge" :class="`sky-badge--${tone}`">
<slot />
</span>
</template>
+30
View File
@@ -0,0 +1,30 @@
<script setup lang="ts">
defineOptions({ inheritAttrs: false })
withDefaults(
defineProps<{
component?: 'div' | 'section'
inset?: boolean
strong?: boolean
}>(),
{
component: 'div',
inset: false,
strong: false,
},
)
</script>
<template>
<component
:is="component"
v-bind="$attrs"
class="sky-block"
:class="{
'sky-block--inset': inset,
'sky-block--strong': strong,
}"
>
<slot />
</component>
</template>
@@ -0,0 +1,18 @@
<script setup lang="ts">
defineOptions({ inheritAttrs: false })
withDefaults(
defineProps<{
component?: 'div' | 'h2' | 'h3'
}>(),
{
component: 'h2',
},
)
</script>
<template>
<component :is="component" v-bind="$attrs" class="sky-block-title">
<slot />
</component>
</template>
+82
View File
@@ -0,0 +1,82 @@
<script setup lang="ts">
import { computed } from 'vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
block?: boolean
component?: 'a' | 'button'
disabled?: boolean
href?: string
iconOnly?: boolean
large?: boolean
outline?: boolean
rounded?: boolean
type?: 'button' | 'reset' | 'submit'
variant?: 'danger' | 'plain' | 'primary' | 'secondary'
}>(),
{
block: false,
component: 'button',
disabled: false,
href: undefined,
iconOnly: false,
large: false,
outline: false,
rounded: false,
type: 'button',
variant: 'primary',
},
)
const emit = defineEmits<{
click: [event: MouseEvent]
}>()
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,
}
}
return {
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-button"
:class="[
`sky-button--${variant}`,
{
'sky-button--block': block,
'sky-button--icon-only': iconOnly,
'sky-button--large': large,
'sky-button--outline': outline,
'sky-button--rounded': rounded,
},
]"
@click="handleClick"
>
<slot />
</component>
</template>
+23
View File
@@ -0,0 +1,23 @@
<script setup lang="ts">
defineOptions({ inheritAttrs: false })
withDefaults(
defineProps<{
component?: 'article' | 'div' | 'section'
contentWrap?: boolean
}>(),
{
component: 'div',
contentWrap: true,
},
)
</script>
<template>
<component :is="component" v-bind="$attrs" class="sky-card">
<div v-if="contentWrap" class="sky-card__content">
<slot />
</div>
<slot v-else />
</component>
</template>
+67
View File
@@ -0,0 +1,67 @@
<script setup lang="ts">
import { computed } from 'vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
component?: 'a' | 'button' | 'span'
disabled?: boolean
href?: string
selected?: boolean
type?: 'button' | 'reset' | 'submit'
}>(),
{
component: 'button',
disabled: false,
href: undefined,
selected: undefined,
type: 'button',
},
)
const emit = defineEmits<{
click: [event: MouseEvent]
}>()
const elementProps = computed<Record<string, unknown>>(() => {
if (props.component === 'button') {
return { disabled: props.disabled, type: props.type }
}
if (props.component === 'a') {
return {
'aria-disabled': props.disabled || undefined,
href: props.disabled ? undefined : props.href,
tabindex: props.disabled ? -1 : undefined,
}
}
return {}
})
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-chip"
:class="{ 'sky-chip--selected': selected }"
:aria-pressed="
component === 'button' && selected !== undefined ? selected : undefined
"
@click="handleClick"
>
<slot />
</component>
</template>
+115
View File
@@ -0,0 +1,115 @@
import { createSSRApp, h } from 'vue'
import { renderToString } from 'vue/server-renderer'
import { describe, expect, it } from 'vitest'
import SkyField from '@/ui/controls/SkyField.vue'
describe('SkyField numeric constraints', () => {
it('forwards min, max, and step to the native input', async () => {
const app = createSSRApp(SkyField, {
ariaLabel: 'Primary frequency',
max: 999.9,
min: 0.1,
step: 0.1,
type: 'number',
})
const html = await renderToString(app)
const input = html.match(/<input[^>]+>/)?.[0] ?? ''
expect(input).toContain('aria-label="Primary frequency"')
expect(input).toContain('max="999.9"')
expect(input).toContain('min="0.1"')
expect(input).toContain('step="0.1"')
})
it('exposes the inline settings layout without changing input semantics', async () => {
const app = createSSRApp(SkyField, {
label: 'Service number',
layout: 'inline',
modelValue: '231',
})
const html = await renderToString(app)
expect(html).toContain('sky-field--inline')
expect(html).toMatch(/<label[^>]+for="([^"]+)"/)
expect(html).toContain('value="231"')
})
it('marks fields with leading media so labels and controls share alignment', async () => {
const app = createSSRApp({
render: () =>
h(
SkyField,
{ label: 'Primary frequency' },
{ leading: () => h('span', 'icon') },
),
})
const html = await renderToString(app)
expect(html).toContain('sky-field--has-leading')
expect(html).toContain('sky-field__leading')
})
it('forwards text-entry hints to the native input', async () => {
const app = createSSRApp(SkyField, {
autocapitalize: 'none',
autocomplete: 'off',
autocorrect: 'off',
pattern: '[A-Z0-9]+',
spellcheck: false,
type: 'text',
})
const html = await renderToString(app)
const input = html.match(/<input[^>]+>/)?.[0] ?? ''
expect(input).toContain('autocapitalize="none"')
expect(input).toContain('autocomplete="off"')
expect(input).toContain('autocorrect="off"')
expect(input).toContain('pattern="[A-Z0-9]+"')
expect(input).toContain('spellcheck="false"')
})
it('renders an explicitly labelled clear control only for a non-empty value', async () => {
const app = createSSRApp(SkyField, {
clearButton: true,
clearLabel: 'Clear email',
modelValue: 'alex@example.com',
})
const html = await renderToString(app)
expect(html).toContain('class="sky-field__clear"')
expect(html).toContain('type="button"')
expect(html).toContain('aria-label="Clear email"')
})
it('does not expose an unnamed clear control', async () => {
const app = createSSRApp(SkyField, {
clearButton: true,
modelValue: 'alex@example.com',
})
const html = await renderToString(app)
expect(html).not.toContain('sky-field__clear')
})
it('declares its accessible clear event without changing existing events', () => {
const component = SkyField as unknown as { emits: string[] }
expect(component.emits).toEqual(
expect.arrayContaining([
'blur',
'change',
'clear',
'focus',
'input',
'update:modelValue',
]),
)
})
})
+262
View File
@@ -0,0 +1,262 @@
<script setup lang="ts">
import { computed, useId, useSlots, type CSSProperties } from 'vue'
import { useComposedFieldValue } from '@/ui/controls/useComposedFieldValue'
defineOptions({ inheritAttrs: false })
type FieldValue = number | string
const props = withDefaults(
defineProps<{
ariaLabel?: string
autocapitalize?:
| 'characters'
| 'none'
| 'off'
| 'on'
| 'sentences'
| 'words'
autocomplete?: string
autocorrect?: 'off' | 'on'
clearButton?: boolean
clearLabel?: string
disabled?: boolean
error?: string
help?: string
id?: string
inputMode?:
| 'decimal'
| 'email'
| 'none'
| 'numeric'
| 'search'
| 'tel'
| 'text'
| 'url'
inputStyle?: CSSProperties
label?: string
layout?: 'inline' | 'stacked'
max?: number | string
maxlength?: number | string
min?: number | string
modelValue?: FieldValue
name?: string
outline?: boolean
pattern?: string
placeholder?: string
readonly?: boolean
required?: boolean
rows?: number
spellcheck?: boolean
step?: number | string
type?:
| 'datetime-local'
| 'email'
| 'number'
| 'password'
| 'search'
| 'tel'
| 'text'
| 'textarea'
| 'time'
| 'url'
value?: FieldValue
}>(),
{
ariaLabel: '',
autocapitalize: undefined,
autocomplete: undefined,
autocorrect: undefined,
clearButton: false,
clearLabel: '',
disabled: false,
error: '',
help: '',
id: undefined,
inputMode: undefined,
inputStyle: undefined,
label: '',
layout: 'stacked',
max: undefined,
maxlength: undefined,
min: undefined,
modelValue: undefined,
name: undefined,
outline: false,
pattern: undefined,
placeholder: '',
readonly: false,
required: false,
rows: 3,
spellcheck: undefined,
step: undefined,
type: 'text',
value: undefined,
},
)
const emit = defineEmits<{
blur: [event: FocusEvent]
change: [event: Event]
clear: []
focus: [event: FocusEvent]
input: [event: Event]
'update:modelValue': [value: string]
}>()
const generatedId = useId()
const slots = useSlots()
const inputId = computed(() => props.id || generatedId)
const helpId = computed(() => `${inputId.value}-help`)
const errorId = computed(() => `${inputId.value}-error`)
const effectiveValue = computed(() => props.modelValue ?? props.value ?? '')
const {
clear: clearValue,
endComposition,
input,
localValue,
startComposition,
} = useComposedFieldValue(effectiveValue, (value) =>
emit('update:modelValue', value),
)
const hasClearButton = computed(
() =>
props.clearButton &&
Boolean(props.clearLabel) &&
localValue.value.length > 0,
)
const describedBy = computed(() => {
const ids: string[] = []
if (props.help) ids.push(helpId.value)
if (props.error) ids.push(errorId.value)
return ids.length ? ids.join(' ') : undefined
})
function fieldValue(event: Event): string {
const target = event.target
if (
!(target instanceof HTMLInputElement) &&
!(target instanceof HTMLTextAreaElement)
) {
return ''
}
return target.value
}
function handleInput(event: Event): void {
const value = fieldValue(event)
input(value)
emit('input', event)
}
function handleCompositionEnd(event: CompositionEvent): void {
endComposition(fieldValue(event))
}
function clear(): void {
if (props.disabled || props.readonly) return
clearValue()
emit('clear')
}
</script>
<template>
<li
v-bind="$attrs"
class="sky-field"
:class="{
'sky-field--disabled': disabled,
'sky-field--error': Boolean(error),
'sky-field--inline': layout === 'inline',
'sky-field--outline': outline,
'sky-field--has-leading': Boolean(slots.leading),
}"
>
<label v-if="label" class="sky-field__label" :for="inputId">
{{ label }}
</label>
<div class="sky-field__control">
<span v-if="$slots.leading" class="sky-field__leading">
<slot name="leading" />
</span>
<textarea
v-if="type === 'textarea'"
:id="inputId"
class="sky-field__input sky-field__textarea"
:aria-describedby="describedBy"
:aria-invalid="error ? true : undefined"
:aria-label="ariaLabel || undefined"
:autocapitalize="autocapitalize"
:autocorrect="autocorrect"
:disabled="disabled"
:maxlength="maxlength"
:name="name"
:placeholder="placeholder"
:readonly="readonly"
:required="required"
:rows="rows"
:spellcheck="spellcheck"
:style="inputStyle"
:value="localValue"
@blur="emit('blur', $event)"
@change="emit('change', $event)"
@compositionend="handleCompositionEnd"
@compositionstart="startComposition"
@focus="emit('focus', $event)"
@input="handleInput"
/>
<input
v-else
:id="inputId"
class="sky-field__input"
:aria-describedby="describedBy"
:aria-invalid="error ? true : undefined"
:aria-label="ariaLabel || undefined"
:autocapitalize="autocapitalize"
:autocomplete="autocomplete"
:autocorrect="autocorrect"
:disabled="disabled"
:inputmode="inputMode"
:max="max"
:maxlength="maxlength"
:min="min"
:name="name"
:pattern="pattern"
:placeholder="placeholder"
:readonly="readonly"
:required="required"
:step="step"
:spellcheck="spellcheck"
:style="inputStyle"
:type="type"
:value="localValue"
@blur="emit('blur', $event)"
@change="emit('change', $event)"
@compositionend="handleCompositionEnd"
@compositionstart="startComposition"
@focus="emit('focus', $event)"
@input="handleInput"
/>
<span v-if="$slots.trailing" class="sky-field__trailing">
<slot name="trailing" />
</span>
<button
v-if="hasClearButton"
class="sky-field__clear"
type="button"
:aria-label="clearLabel"
:disabled="disabled || readonly"
@pointerdown.prevent
@click="clear"
>
<span aria-hidden="true" />
</button>
</div>
<small v-if="help" :id="helpId" class="sky-field__help">{{ help }}</small>
<small v-if="error" :id="errorId" class="sky-field__error" role="alert">
{{ error }}
</small>
</li>
</template>
+35
View File
@@ -0,0 +1,35 @@
<script setup lang="ts">
import { computed, type CSSProperties } from 'vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
label?: string
size?: number | string
}>(),
{
label: '',
size: undefined,
},
)
const iconStyle = computed<CSSProperties | undefined>(() => {
if (props.size === undefined) return undefined
const size = typeof props.size === 'number' ? `${props.size}px` : props.size
return { height: size, width: size }
})
</script>
<template>
<span
v-bind="$attrs"
class="sky-icon"
:style="iconStyle"
:aria-hidden="label ? undefined : true"
:aria-label="label || undefined"
:role="label ? 'img' : undefined"
>
<slot />
</span>
</template>
+63
View File
@@ -0,0 +1,63 @@
<script setup lang="ts">
import { computed } from 'vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
component?: 'a' | 'button'
disabled?: boolean
href?: string
iconOnly?: boolean
type?: 'button' | 'reset' | 'submit'
}>(),
{
component: 'button',
disabled: false,
href: undefined,
iconOnly: false,
type: 'button',
},
)
const emit = defineEmits<{
click: [event: MouseEvent]
}>()
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,
}
}
return {
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-link"
:class="{ 'sky-link--icon-only': iconOnly }"
@click="handleClick"
>
<slot />
</component>
</template>
+23
View File
@@ -0,0 +1,23 @@
import { createSSRApp } from 'vue'
import { renderToString } from 'vue/server-renderer'
import { describe, expect, it } from 'vitest'
import SkyList from '@/ui/controls/SkyList.vue'
describe('SkyList', () => {
it('exposes compact flush grouping as a reusable list contract', async () => {
const app = createSSRApp(SkyList, {
density: 'compact',
flush: true,
inset: true,
strong: true,
})
const html = await renderToString(app)
expect(html).toContain('sky-list--compact')
expect(html).toContain('sky-list--flush')
expect(html).toContain('sky-list--inset')
expect(html).toContain('sky-list--strong')
})
})
+40
View File
@@ -0,0 +1,40 @@
<script setup lang="ts">
defineOptions({ inheritAttrs: false })
withDefaults(
defineProps<{
component?: 'div' | 'ol' | 'ul'
density?: 'compact' | 'regular'
flush?: boolean
inset?: boolean
nested?: boolean
strong?: boolean
}>(),
{
component: 'ul',
density: 'regular',
flush: false,
inset: false,
nested: false,
strong: false,
},
)
</script>
<template>
<component
:is="component"
v-bind="$attrs"
class="sky-list"
:class="{
'sky-list--inset': inset,
'sky-list--nested': nested,
'sky-list--strong': strong,
'sky-list--compact': density === 'compact',
'sky-list--flush': flush,
}"
:role="component === 'div' ? 'list' : undefined"
>
<slot />
</component>
</template>
+121
View File
@@ -0,0 +1,121 @@
<script setup lang="ts">
import { computed } from 'vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
after?: number | string
ariaLabel?: string
disabled?: boolean
header?: string
href?: string
label?: boolean
link?: boolean
linkComponent?: 'a' | 'button'
linkProps?: Record<string, unknown>
subtitle?: string
title?: string
}>(),
{
after: undefined,
ariaLabel: '',
disabled: false,
header: '',
href: undefined,
label: false,
link: false,
linkComponent: 'button',
linkProps: () => ({}),
subtitle: '',
title: '',
},
)
const emit = defineEmits<{
click: [event: MouseEvent]
}>()
const rowComponent = computed(() => {
if (props.link) return props.linkComponent
if (props.label) return 'label'
return 'div'
})
const rowProps = computed<Record<string, unknown>>(() => {
if (!props.link) return {}
if (props.linkComponent === 'a') {
return {
...props.linkProps,
'aria-disabled': props.disabled || undefined,
href: props.disabled ? undefined : props.href,
tabindex: props.disabled ? -1 : undefined,
}
}
return {
...props.linkProps,
disabled: props.disabled,
type: props.linkProps.type ?? 'button',
}
})
function handleClick(event: MouseEvent): void {
if (props.disabled) {
event.preventDefault()
event.stopPropagation()
return
}
emit('click', event)
}
</script>
<template>
<li
v-bind="$attrs"
class="sky-list-item"
:class="{
'sky-list-item--disabled': disabled,
'sky-list-item--label': label,
'sky-list-item--link': link,
}"
>
<component
:is="rowComponent"
v-bind="rowProps"
class="sky-list-item__row"
:aria-label="ariaLabel || undefined"
@click="handleClick"
>
<span v-if="$slots.media" class="sky-list-item__media">
<slot name="media" />
</span>
<span class="sky-list-item__content">
<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="subtitle || $slots.subtitle"
class="sky-list-item__subtitle"
>
<slot name="subtitle">{{ subtitle }}</slot>
</span>
<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" />
</component>
</li>
</template>
+38
View File
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { computed } from 'vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
label?: string
progress?: number
}>(),
{
label: '',
progress: 0,
},
)
const normalizedProgress = computed(() =>
Math.min(
1,
Math.max(0, Number.isFinite(props.progress) ? props.progress : 0),
),
)
const percentage = computed(() => Math.round(normalizedProgress.value * 100))
</script>
<template>
<div
v-bind="$attrs"
class="sky-progress"
role="progressbar"
:aria-label="label || undefined"
aria-valuemin="0"
aria-valuemax="100"
:aria-valuenow="percentage"
>
<span class="sky-progress__value" :style="{ width: `${percentage}%` }" />
</div>
</template>
+86
View File
@@ -0,0 +1,86 @@
<script setup lang="ts">
import { computed, ref, useId } from 'vue'
defineOptions({ inheritAttrs: false })
type RadioValue = boolean | number | string
const props = withDefaults(
defineProps<{
ariaLabel?: string
checked?: boolean
disabled?: boolean
modelValue?: RadioValue
name?: string
value: RadioValue
}>(),
{
ariaLabel: '',
checked: false,
disabled: false,
modelValue: undefined,
name: undefined,
},
)
const emit = defineEmits<{
change: [event: Event]
'update:modelValue': [value: RadioValue]
}>()
const input = ref<HTMLInputElement | null>(null)
const labelId = useId()
const isChecked = computed(() =>
props.modelValue === undefined
? props.checked
: props.modelValue === props.value,
)
function handleChange(event: Event): void {
if (!(event.target instanceof HTMLInputElement) || !event.target.checked) {
return
}
emit('update:modelValue', props.value)
emit('change', event)
}
function handleClick(event: MouseEvent): void {
if (
props.disabled ||
event.target === input.value ||
event.defaultPrevented
) {
return
}
input.value?.click()
}
</script>
<template>
<span
v-bind="$attrs"
class="sky-radio"
:class="{
'sky-radio--checked': isChecked,
'sky-radio--disabled': disabled,
}"
@click="handleClick"
>
<input
ref="input"
class="sky-radio__input"
type="radio"
:aria-label="ariaLabel || undefined"
:aria-labelledby="!ariaLabel && $slots.default ? labelId : undefined"
:checked="isChecked"
:disabled="disabled"
:name="name"
:value="value"
@change="handleChange"
/>
<span class="sky-radio__mark" aria-hidden="true" />
<span v-if="$slots.default" :id="labelId" class="sky-radio__label">
<slot />
</span>
</span>
</template>
+44
View File
@@ -0,0 +1,44 @@
import { createSSRApp } from 'vue'
import { renderToString } from 'vue/server-renderer'
import { describe, expect, it } from 'vitest'
import SkyRange from '@/ui/controls/SkyRange.vue'
describe('SkyRange', () => {
it('renders a native accessible range with the configured progress', async () => {
const app = createSSRApp(SkyRange, {
ariaLabel: 'Radio volume',
ariaValueText: '25%',
max: 100,
min: 0,
modelValue: 25,
step: 1,
})
const html = await renderToString(app)
expect(html).toContain('type="range"')
expect(html).toContain('aria-label="Radio volume"')
expect(html).toContain('aria-valuetext="25%"')
expect(html).toContain('min="0"')
expect(html).toContain('max="100"')
expect(html).toContain('step="1"')
expect(html).toContain('--sky-range-progress:25%')
})
it('integrates an optional caption without replacing the accessible name', async () => {
const app = createSSRApp(SkyRange, {
ariaLabel: 'Radio volume',
caption: 'Volume',
modelValue: 64,
})
const html = await renderToString(app)
expect(html).toContain('sky-range--captioned')
expect(html).toContain('class="sky-range__caption"')
expect(html).toContain('aria-hidden="true"')
expect(html).toContain('Volume')
expect(html).toContain('aria-label="Radio volume"')
})
})
+102
View File
@@ -0,0 +1,102 @@
<script setup lang="ts">
import { computed, useId, type CSSProperties } from 'vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
ariaLabel?: string
ariaValueText?: string
caption?: string
disabled?: boolean
id?: string
max?: number
min?: number
modelValue?: number
name?: string
step?: number
value?: number
}>(),
{
ariaLabel: '',
ariaValueText: '',
caption: '',
disabled: false,
id: undefined,
max: 100,
min: 0,
modelValue: undefined,
name: undefined,
step: 1,
value: undefined,
},
)
const emit = defineEmits<{
change: [event: Event]
input: [event: Event]
'update:modelValue': [value: number]
}>()
const generatedId = useId()
const inputId = computed(() => props.id || generatedId)
const effectiveValue = computed(
() => props.modelValue ?? props.value ?? props.min,
)
const normalizedProgress = computed(() => {
const span = props.max - props.min
if (span <= 0) return 0
return Math.min(
100,
Math.max(0, ((effectiveValue.value - props.min) / span) * 100),
)
})
const rangeStyle = computed<CSSProperties>(() => ({
'--sky-range-progress': `${normalizedProgress.value}%`,
}))
function handleInput(event: Event): void {
if (!(event.target instanceof HTMLInputElement)) return
emit('update:modelValue', event.target.valueAsNumber)
emit('input', event)
}
</script>
<template>
<label
v-bind="$attrs"
class="sky-range"
:class="{
'sky-range--captioned': Boolean(caption || $slots.caption),
'sky-range--disabled': disabled,
}"
:for="inputId"
>
<span
v-if="caption || $slots.caption"
class="sky-range__caption"
aria-hidden="true"
>
<slot name="caption">{{ caption }}</slot>
</span>
<input
:id="inputId"
class="sky-range__input"
type="range"
:aria-label="ariaLabel || undefined"
:aria-valuetext="ariaValueText || undefined"
:disabled="disabled"
:max="max"
:min="min"
:name="name"
:step="step"
:style="rangeStyle"
:value="effectiveValue"
@change="emit('change', $event)"
@input="handleInput"
/>
<span v-if="$slots.default" class="sky-range__label">
<slot />
</span>
</label>
</template>
+107
View File
@@ -0,0 +1,107 @@
<script setup lang="ts">
import { computed, ref, useId, watch } from 'vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
clearLabel: string
disabled?: boolean
id?: string
label?: string
modelValue?: string
name?: string
placeholder?: string
value?: string
}>(),
{
disabled: false,
id: undefined,
label: '',
modelValue: undefined,
name: undefined,
placeholder: '',
value: undefined,
},
)
const emit = defineEmits<{
blur: [event: FocusEvent]
change: [event: Event]
clear: []
focus: [event: FocusEvent]
input: [event: Event]
'update:modelValue': [value: string]
}>()
const generatedId = useId()
const inputId = computed(() => props.id || generatedId)
const effectiveValue = computed(() => props.modelValue ?? props.value ?? '')
const localValue = ref(effectiveValue.value)
const composing = ref(false)
watch(effectiveValue, (value) => {
if (!composing.value) localValue.value = value
})
function handleInput(event: Event): void {
if (!(event.target instanceof HTMLInputElement)) return
localValue.value = event.target.value
emit('update:modelValue', event.target.value)
emit('input', event)
}
function handleCompositionEnd(event: CompositionEvent): void {
composing.value = false
if (!(event.target instanceof HTMLInputElement)) return
localValue.value = event.target.value
emit('update:modelValue', event.target.value)
}
function clear(): void {
if (props.disabled) return
localValue.value = ''
emit('update:modelValue', '')
emit('clear')
}
</script>
<template>
<div
v-bind="$attrs"
class="sky-searchbar"
:class="{ 'sky-searchbar--disabled': disabled }"
>
<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"
/>
<button
v-if="localValue"
class="sky-searchbar__clear"
type="button"
:aria-label="clearLabel"
:disabled="disabled"
@click="clear"
>
<span aria-hidden="true" />
</button>
</div>
</template>
+23
View File
@@ -0,0 +1,23 @@
<script setup lang="ts">
defineOptions({ inheritAttrs: false })
withDefaults(
defineProps<{
ariaLabel?: string
}>(),
{
ariaLabel: '',
},
)
</script>
<template>
<div
v-bind="$attrs"
class="sky-segmented"
role="group"
:aria-label="ariaLabel || undefined"
>
<slot />
</div>
</template>
@@ -0,0 +1,34 @@
<script setup lang="ts">
defineOptions({ inheritAttrs: false })
withDefaults(
defineProps<{
active?: boolean
disabled?: boolean
type?: 'button' | 'reset' | 'submit'
}>(),
{
active: false,
disabled: false,
type: 'button',
},
)
defineEmits<{
click: [event: MouseEvent]
}>()
</script>
<template>
<button
v-bind="$attrs"
class="sky-segmented-button"
:class="{ 'sky-segmented-button--active': active }"
:aria-pressed="active"
:disabled="disabled"
:type="type"
@click="$emit('click', $event)"
>
<slot />
</button>
</template>
+32
View File
@@ -0,0 +1,32 @@
<script setup lang="ts">
import { computed, type CSSProperties } from 'vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
label?: string
size?: number | string
}>(),
{
label: '',
size: 20,
},
)
const spinnerStyle = computed<CSSProperties>(() => {
const size = typeof props.size === 'number' ? `${props.size}px` : props.size
return { height: size, width: size }
})
</script>
<template>
<span
v-bind="$attrs"
class="sky-spinner"
:style="spinnerStyle"
:aria-hidden="label ? undefined : true"
:aria-label="label || undefined"
:role="label ? 'status' : undefined"
/>
</template>
@@ -0,0 +1,34 @@
import { createSSRApp, h } from 'vue'
import { renderToString } from 'vue/server-renderer'
import { describe, expect, it } from 'vitest'
import SkyStatusCard from '@/ui/controls/SkyStatusCard.vue'
describe('SkyStatusCard', () => {
it('renders a textual live status with a semantic tone and indicator', async () => {
const app = createSSRApp({
render: () =>
h(
SkyStatusCard,
{
indicator: true,
ariaLive: 'polite',
subtitle: 'Yaca',
title: 'Connected to 120.5 MHz',
tone: 'success',
},
{ icon: () => h('svg', { 'aria-hidden': 'true' }) },
),
})
const html = await renderToString(app)
expect(html).toContain('role="status"')
expect(html).toContain('aria-live="polite"')
expect(html).toContain('aria-atomic="true"')
expect(html).toContain('sky-status-card--success')
expect(html).toContain('sky-status-card__indicator')
expect(html).toContain('Connected to 120.5 MHz')
expect(html).toContain('Yaca')
})
})
@@ -0,0 +1,58 @@
<script setup lang="ts">
import SkyCard from './SkyCard.vue'
defineOptions({ inheritAttrs: false })
withDefaults(
defineProps<{
ariaLive?: 'assertive' | 'off' | 'polite'
indicator?: boolean
subtitle?: string
title: string
tone?: 'accent' | 'danger' | 'neutral' | 'success' | 'warning'
}>(),
{
ariaLive: 'off',
indicator: false,
subtitle: '',
tone: 'neutral',
},
)
</script>
<template>
<SkyCard
v-bind="$attrs"
:content-wrap="false"
class="sky-status-card"
:class="`sky-status-card--${tone}`"
:role="ariaLive === 'off' ? undefined : 'status'"
:aria-atomic="ariaLive === 'off' ? undefined : true"
:aria-live="ariaLive === 'off' ? undefined : ariaLive"
>
<span v-if="$slots.icon" class="sky-status-card__icon">
<slot name="icon" />
</span>
<span class="sky-status-card__copy">
<strong class="sky-status-card__title">
<slot name="title">{{ title }}</slot>
</strong>
<small
v-if="subtitle || $slots.subtitle"
class="sky-status-card__subtitle"
>
<slot name="subtitle">{{ subtitle }}</slot>
</small>
</span>
<span v-if="$slots.trailing" class="sky-status-card__trailing">
<slot name="trailing" />
</span>
<span
v-else-if="indicator"
class="sky-status-card__indicator"
aria-hidden="true"
/>
</SkyCard>
</template>
+25
View File
@@ -0,0 +1,25 @@
<script setup lang="ts">
defineOptions({ inheritAttrs: false })
withDefaults(
defineProps<{
component?: 'div' | 'section'
highlight?: boolean
}>(),
{
component: 'div',
highlight: false,
},
)
</script>
<template>
<component
:is="component"
v-bind="$attrs"
class="sky-surface"
:class="{ 'sky-surface--highlight': highlight }"
>
<slot />
</component>
</template>
+45
View File
@@ -0,0 +1,45 @@
<script setup lang="ts">
defineOptions({ inheritAttrs: false })
withDefaults(
defineProps<{
active?: boolean
ariaLabel?: string
disabled?: boolean
label?: string
type?: 'button' | 'reset' | 'submit'
}>(),
{
active: false,
ariaLabel: '',
disabled: false,
label: '',
type: 'button',
},
)
defineEmits<{
click: [event: MouseEvent]
}>()
</script>
<template>
<button
v-bind="$attrs"
class="sky-tab-button"
:class="{ 'sky-tab-button--active': active }"
:aria-current="active ? 'page' : undefined"
:aria-label="ariaLabel || undefined"
:disabled="disabled"
:type="type"
@click="$emit('click', $event)"
>
<span v-if="$slots.icon" class="sky-tab-button__icon">
<slot name="icon" />
</span>
<span v-if="label || $slots.label" class="sky-tab-button__label">
<slot name="label">{{ label }}</slot>
</span>
<slot />
</button>
</template>
+72
View File
@@ -0,0 +1,72 @@
<script setup lang="ts">
import { computed } from 'vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
ariaLabel?: string
ariaDescribedby?: string
ariaLabelledby?: string
checked?: boolean
disabled?: boolean
id?: string
modelValue?: boolean
name?: string
}>(),
{
ariaLabel: '',
ariaDescribedby: '',
ariaLabelledby: '',
checked: false,
disabled: false,
id: undefined,
modelValue: undefined,
name: undefined,
},
)
const emit = defineEmits<{
change: [event: Event]
'update:modelValue': [value: boolean]
}>()
const isChecked = computed(() => props.modelValue ?? props.checked)
function handleChange(event: Event): void {
if (!(event.target instanceof HTMLInputElement)) return
emit('update:modelValue', event.target.checked)
emit('change', event)
}
</script>
<template>
<label
v-bind="$attrs"
class="sky-toggle"
:class="{
'sky-toggle--checked': isChecked,
'sky-toggle--disabled': disabled,
}"
>
<input
:id="id"
class="sky-toggle__input"
type="checkbox"
:aria-describedby="ariaDescribedby || undefined"
role="switch"
:aria-label="ariaLabel || undefined"
:aria-labelledby="ariaLabelledby || undefined"
:checked="isChecked"
:disabled="disabled"
:name="name"
@change="handleChange"
/>
<span class="sky-toggle__track" aria-hidden="true">
<span class="sky-toggle__thumb" />
</span>
<span v-if="$slots.default" class="sky-toggle__label">
<slot />
</span>
</label>
</template>
@@ -0,0 +1,18 @@
<script setup lang="ts">
defineOptions({ inheritAttrs: false })
withDefaults(
defineProps<{
component?: 'div' | 'nav'
}>(),
{
component: 'div',
},
)
</script>
<template>
<component :is="component" v-bind="$attrs" class="sky-toolbar-pane">
<slot />
</component>
</template>
+23
View File
@@ -0,0 +1,23 @@
export { default as SkyBadge } from './SkyBadge.vue'
export { default as SkyBlock } from './SkyBlock.vue'
export { default as SkyBlockTitle } from './SkyBlockTitle.vue'
export { default as SkyButton } from './SkyButton.vue'
export { default as SkyCard } from './SkyCard.vue'
export { default as SkyChip } from './SkyChip.vue'
export { default as SkyField } from './SkyField.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 SkyListItem } from './SkyListItem.vue'
export { default as SkyProgress } from './SkyProgress.vue'
export { default as SkyRadio } from './SkyRadio.vue'
export { default as SkyRange } from './SkyRange.vue'
export { default as SkySearchbar } from './SkySearchbar.vue'
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 SkySurface } from './SkySurface.vue'
export { default as SkyTabButton } from './SkyTabButton.vue'
export { default as SkyToggle } from './SkyToggle.vue'
export { default as SkyToolbarPane } from './SkyToolbarPane.vue'
@@ -0,0 +1,49 @@
import { computed, nextTick, ref } from 'vue'
import { describe, expect, it } from 'vitest'
import { useComposedFieldValue } from '@/ui/controls/useComposedFieldValue'
describe('useComposedFieldValue', () => {
it('does not overwrite active IME composition from external state', async () => {
const externalValue = ref('before')
const updates: string[] = []
const field = useComposedFieldValue(
computed(() => externalValue.value),
(value) => updates.push(value),
)
field.startComposition()
field.input('composing')
externalValue.value = 'external'
await nextTick()
expect(field.localValue.value).toBe('composing')
field.endComposition('completed')
expect(field.localValue.value).toBe('completed')
expect(updates).toEqual(['composing', 'completed'])
})
it('resumes controlled updates after composition and clears explicitly', async () => {
const externalValue = ref('before')
const updates: string[] = []
const field = useComposedFieldValue(
computed(() => externalValue.value),
(value) => updates.push(value),
)
field.startComposition()
field.endComposition('completed')
externalValue.value = 'after'
await nextTick()
expect(field.localValue.value).toBe('after')
field.clear()
expect(field.composing.value).toBe(false)
expect(field.localValue.value).toBe('')
expect(updates.at(-1)).toBe('')
})
})
@@ -0,0 +1,46 @@
import { ref, watch, type ComputedRef, type Ref } from 'vue'
type FieldValue = number | string
export interface ComposedFieldValue {
clear: () => void
composing: Ref<boolean>
endComposition: (value: string) => void
input: (value: string) => void
localValue: Ref<string>
startComposition: () => void
}
export function useComposedFieldValue(
effectiveValue: ComputedRef<FieldValue>,
update: (value: string) => void,
): ComposedFieldValue {
const localValue = ref(String(effectiveValue.value))
const composing = ref(false)
watch(effectiveValue, (value) => {
if (!composing.value) localValue.value = String(value)
})
function setValue(value: string): void {
localValue.value = value
update(value)
}
return {
clear: () => {
composing.value = false
setValue('')
},
composing,
endComposition: (value) => {
composing.value = false
setValue(value)
},
input: setValue,
localValue,
startComposition: () => {
composing.value = true
},
}
}
+449
View File
@@ -0,0 +1,449 @@
.sky-app-page {
position: relative;
isolation: isolate;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--sky-bg);
color: var(--sky-text);
}
.sky-app-page__backdrop {
position: absolute;
z-index: -1;
inset: 0;
background: var(--sky-bg);
pointer-events: none;
}
.sky-navbar {
position: relative;
z-index: 10;
box-sizing: border-box;
min-width: 0;
min-height: calc(var(--sky-safe-area-top) + var(--sky-navbar-height));
padding: var(--sky-safe-area-top) var(--sky-page-gutter) 12px;
display: grid;
grid-template-columns:
minmax(var(--sky-touch-target), 1fr)
minmax(0, 2fr)
minmax(var(--sky-touch-target), 1fr);
align-items: center;
flex: none;
color: var(--sky-text);
}
.sky-navbar__left,
.sky-navbar__right {
min-width: 0;
min-height: var(--sky-navbar-height);
display: flex;
align-items: center;
}
.sky-navbar__left {
justify-content: flex-start;
}
.sky-navbar__right {
justify-content: flex-end;
}
.sky-navbar__title {
min-width: 0;
margin: 0;
padding: 0 var(--sky-space-1);
overflow: hidden;
color: var(--sky-text);
font-size: var(--sky-font-title);
font-weight: 650;
line-height: var(--sky-navbar-height);
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.sky-navbar__back {
box-sizing: border-box;
max-width: 100%;
min-width: var(--sky-touch-target);
min-height: var(--sky-touch-target);
margin: 0;
padding: 0 var(--sky-space-1);
display: inline-flex;
align-items: center;
justify-content: flex-start;
gap: var(--sky-space-1);
border: 0;
border-radius: var(--sky-radius-control);
appearance: none;
color: var(--sky-app-accent);
background: transparent;
font: inherit;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.sky-navbar__back-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sky-navbar button,
.sky-navbar a {
min-width: var(--sky-touch-target);
min-height: var(--sky-touch-target);
}
.sky-navbar button:active,
.sky-navbar a:active {
background: var(--sky-app-accent-soft);
}
.sky-navbar__back--surface {
width: var(--sky-touch-target);
max-width: var(--sky-touch-target);
padding: 0;
justify-content: center;
border: 1px solid var(--sky-hairline);
border-radius: 50%;
background: var(--sky-surface);
color: var(--sky-text);
box-shadow: 0 1px 3px rgb(0 0 0 / 24%);
}
.sky-navbar--large {
min-height: calc(
var(--sky-safe-area-top) + var(--sky-navbar-large-title-height)
);
padding-right: var(--sky-page-gutter);
padding-bottom: 12px;
padding-left: var(--sky-page-gutter);
grid-template-columns: minmax(0, 1fr) auto;
grid-template-rows: var(--sky-navbar-large-title-height);
grid-template-areas: 'title right';
align-items: end;
}
.sky-navbar--large .sky-navbar__left {
display: none;
}
.sky-navbar--large .sky-navbar__title {
grid-area: title;
padding: 0;
font-size: var(--sky-font-large-title);
font-weight: 700;
line-height: 36px;
text-align: left;
}
.sky-navbar--large .sky-navbar__right {
grid-area: right;
}
.sky-scroll-area {
position: relative;
z-index: 1;
box-sizing: border-box;
min-width: 0;
min-height: 0;
padding: var(--sky-page-space) var(--sky-page-gutter)
calc(var(--sky-safe-area-bottom) + var(--sky-page-space));
flex: 1 1 auto;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
touch-action: pan-y;
scrollbar-width: none;
}
.sky-scroll-area--tabbar {
padding-bottom: calc(
var(--sky-safe-area-bottom) + var(--sky-tabbar-height) + var(--sky-space-3)
);
}
.sky-scroll-area::-webkit-scrollbar {
display: none;
}
.sky-infinite-loader {
box-sizing: border-box;
width: 100%;
height: 1px;
min-width: 0;
margin: 0;
display: grid;
place-items: center;
color: var(--sky-app-accent);
}
.sky-infinite-loader[hidden] {
display: none;
}
.sky-infinite-loader--visible {
height: auto;
min-height: var(--sky-touch-target);
margin: var(--sky-space-1) 0;
}
.sky-infinite-loader__retry.sky-link {
min-height: var(--sky-touch-target);
padding: 0 var(--sky-space-2);
border: 0;
border-radius: 0;
background: transparent;
color: var(--sky-app-accent);
font-weight: 600;
}
.sky-infinite-loader__retry.sky-link:active:not(:disabled) {
background: transparent;
opacity: 0.62;
}
.sky-scroll-rail {
box-sizing: border-box;
width: 100%;
min-width: 0;
padding: calc(var(--sky-space-1) / 2) 0;
display: flex;
align-items: center;
gap: var(--sky-space-2);
overflow-x: auto;
overflow-y: hidden;
overscroll-behavior-x: contain;
touch-action: pan-x;
scrollbar-width: none;
cursor: grab;
}
.sky-scroll-rail > * {
flex: 0 0 auto;
}
.sky-scroll-rail::-webkit-scrollbar {
width: 0;
height: 0;
display: none;
}
.sky-scroll-rail--dragging,
.sky-scroll-rail--dragging * {
user-select: none;
cursor: grabbing;
}
.sky-section {
min-width: 0;
margin: 0 0 var(--sky-space-4);
}
.sky-section__title {
margin: var(--sky-space-5) var(--sky-space-1) var(--sky-space-2);
padding: 0;
color: var(--sky-muted);
font-size: var(--sky-font-body);
font-weight: 600;
line-height: var(--sky-space-5);
}
.sky-list-card {
min-width: 0;
margin: 0;
padding: 0;
overflow: hidden;
color: var(--sky-text);
list-style: none;
}
.sky-list-card--inset {
border: 1px solid var(--sky-hairline);
border-radius: var(--sky-radius-card);
}
.sky-list-card--strong {
background: var(--sky-surface);
}
.sky-list-card > li {
min-width: 0;
color: var(--sky-text);
}
.sky-list-card > li + li {
border-top: 1px solid var(--sky-hairline);
}
.sky-list-card button,
.sky-list-card a,
.sky-list-card label {
min-height: var(--sky-touch-target);
}
.sky-empty-state {
box-sizing: border-box;
min-height: 240px;
margin: var(--sky-space-3) 0;
padding: var(--sky-space-6) var(--sky-space-5);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--sky-space-2);
border: 1px solid var(--sky-hairline);
border-radius: var(--sky-radius-card);
background: var(--sky-surface);
color: var(--sky-muted);
text-align: center;
}
.sky-empty-state--compact {
min-height: 150px;
padding: var(--sky-space-5);
}
.sky-empty-state--danger .sky-empty-state__icon {
color: var(--sky-danger);
}
.sky-empty-state__icon {
min-height: 36px;
display: grid;
place-items: center;
color: var(--sky-muted);
}
.sky-empty-state__title {
color: var(--sky-text);
font-size: var(--sky-font-title);
font-weight: 650;
line-height: 21px;
}
.sky-empty-state__body {
max-width: 270px;
margin: 0;
color: var(--sky-muted);
font-size: 13px;
line-height: 18px;
}
.sky-empty-state__actions {
width: 100%;
margin-top: var(--sky-space-1);
}
.sky-empty-state__actions button,
.sky-empty-state__actions a {
width: 100%;
min-height: var(--sky-touch-target);
}
.sky-tabbar {
position: absolute;
z-index: 20;
right: 0;
bottom: 0;
left: 0;
box-sizing: border-box;
min-height: calc(var(--sky-tabbar-height) + var(--sky-safe-area-bottom));
padding-bottom: var(--sky-safe-area-bottom);
border-top: 1px solid var(--sky-hairline);
background: var(--sky-surface);
color: var(--sky-text);
}
.sky-tabbar__inner,
.sky-tabbar__pane {
width: 100%;
min-width: 0;
min-height: var(--sky-tabbar-height);
}
.sky-tabbar__pane {
display: flex;
align-items: stretch;
}
.sky-tabbar__pane > button,
.sky-tabbar__pane > a {
box-sizing: border-box;
min-width: 0;
min-height: var(--sky-tabbar-height);
margin: 0;
padding: var(--sky-space-1) var(--sky-space-2);
display: flex;
align-items: center;
justify-content: center;
flex: 1 1 0;
overflow: hidden;
border: 0;
border-radius: 0;
appearance: none;
color: inherit;
background: transparent;
font: inherit;
text-decoration: none;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.sky-tabbar__pane > button:active,
.sky-tabbar__pane > a:active {
background: var(--sky-app-accent-soft);
}
.sky-tabbar__pane > button > span,
.sky-tabbar__pane > a > span {
min-width: 0;
max-width: 100%;
}
.sky-tabbar__pane > button > span > span:last-child,
.sky-tabbar__pane > a > span > span:last-child {
max-width: 100%;
overflow: hidden;
font-size: 11px;
font-weight: 600;
line-height: 13px;
text-overflow: ellipsis;
}
.sky-navbar button:focus-visible,
.sky-navbar a:focus-visible,
.sky-list-card button:focus-visible,
.sky-list-card a:focus-visible,
.sky-list-card input:focus-visible,
.sky-list-card select:focus-visible,
.sky-list-card textarea:focus-visible,
.sky-empty-state button:focus-visible,
.sky-empty-state a:focus-visible,
.sky-tabbar button:focus-visible,
.sky-tabbar a:focus-visible {
outline: 2px solid var(--sky-app-accent);
outline-offset: -2px;
}
@media (prefers-reduced-motion: reduce) {
.sky-scroll-rail {
scroll-behavior: auto !important;
}
.sky-app-page *,
.sky-app-page *::before,
.sky-app-page *::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}
+12
View File
@@ -0,0 +1,12 @@
export { default as SkyAppPage } from './SkyAppPage.vue'
export { default as SkyEmptyState } from './SkyEmptyState.vue'
export { default as SkyInfiniteLoader } from './SkyInfiniteLoader.vue'
export { default as SkyListCard } from './SkyListCard.vue'
export { default as SkyNavbar } from './SkyNavbar.vue'
export { default as SkyScrollArea } from './SkyScrollArea.vue'
export { default as SkyScrollRail } from './SkyScrollRail.vue'
export { default as SkySection } from './SkySection.vue'
export { default as SkyTabBar } from './SkyTabBar.vue'
export * from './controls'
export * from './overlays'
export * from './settings'
+266
View File
@@ -0,0 +1,266 @@
.sky-sheet,
.sky-action-sheet,
.sky-dialog {
position: absolute;
z-index: var(--sky-overlay-layer, 80);
inset: 0;
}
.sky-overlay-backdrop {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: 0;
padding: 0;
background: rgba(0, 0, 0, 0.48);
}
.sky-sheet__panel,
.sky-action-sheet__panel {
position: absolute;
right: 0;
bottom: 0;
left: 0;
max-height: 88%;
overflow: auto;
border: 1px solid var(--sky-hairline);
border-bottom: 0;
border-radius: var(--sky-radius-sheet) var(--sky-radius-sheet) 0 0;
background: var(--sky-surface);
color: var(--sky-text);
box-shadow: 0 -10px 30px rgba(0, 0, 0, 0.22);
}
.sky-action-sheet__panel {
padding: var(--sky-space-2) var(--sky-page-gutter)
calc(var(--sky-safe-area-bottom) + var(--sky-space-2));
border: 0;
background: transparent;
box-shadow: none;
}
.sky-action-group {
overflow: hidden;
margin-top: var(--sky-space-2);
border: 1px solid var(--sky-hairline);
border-radius: var(--sky-radius-card);
background: var(--sky-surface);
}
.sky-action-button {
width: 100%;
min-height: 52px;
border: 0;
border-bottom: 1px solid var(--sky-hairline);
padding: 10px 16px;
color: var(--sky-app-accent);
background: transparent;
font: inherit;
font-size: 16px;
}
.sky-action-button:last-child {
border-bottom: 0;
}
.sky-action-button--bold {
font-weight: 700;
}
.sky-dialog {
display: grid;
place-items: center;
padding: var(--sky-page-gutter);
}
.sky-dialog__panel {
position: relative;
z-index: 1;
width: min(280px, 90%);
overflow: hidden;
border: 1px solid var(--sky-hairline);
border-radius: var(--sky-radius-card);
background: var(--sky-surface);
color: var(--sky-text);
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.3);
}
.sky-sheet__panel:focus,
.sky-action-sheet__panel:focus,
.sky-dialog__panel:focus {
outline: none;
}
.sky-dialog__content {
padding: 20px 18px 16px;
text-align: center;
}
.sky-dialog__content h2 {
margin: 0 0 6px;
font-size: 17px;
line-height: 22px;
}
.sky-dialog__content p {
margin: 0;
color: var(--sky-muted);
font-size: 13px;
line-height: 18px;
}
.sky-dialog__buttons {
display: flex;
border-top: 1px solid var(--sky-hairline);
}
.sky-dialog-button {
min-width: 0;
min-height: 48px;
flex: 1;
border: 0;
border-right: 1px solid var(--sky-hairline);
color: var(--sky-app-accent);
background: transparent;
font: inherit;
}
.sky-dialog-button:last-child {
border-right: 0;
}
.sky-dialog-button--strong {
font-weight: 700;
}
.sky-toast {
position: absolute;
z-index: 100;
right: 50%;
max-width: calc(100% - 40px);
border: 1px solid var(--sky-hairline);
border-radius: 999px;
padding: 10px 16px;
color: var(--sky-text);
background: var(--sky-surface);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.26);
font-size: 13px;
transform: translateX(50%);
}
.sky-toast--top {
top: calc(var(--sky-safe-area-top) + var(--sky-space-4));
}
.sky-toast--center {
top: 50%;
transform: translate(50%, -50%);
}
.sky-toast--bottom {
bottom: calc(var(--sky-safe-area-bottom) + var(--sky-space-5));
}
.sky-messages {
display: flex;
flex-direction: column;
gap: var(--sky-space-2);
padding: var(--sky-space-2) 0;
}
.sky-messages-title {
margin: 8px 0;
color: var(--sky-muted);
font-size: 12px;
text-align: center;
}
.sky-message {
max-width: 82%;
align-self: flex-start;
}
.sky-message--sent {
align-self: flex-end;
}
.sky-message__name,
.sky-message time {
display: block;
margin: 0 6px 3px;
color: var(--sky-muted);
font-size: 10px;
}
.sky-message p {
margin: 0;
border-radius: 16px 16px 16px 5px;
padding: 9px 12px;
background: var(--sky-surface-muted);
color: var(--sky-text);
font-size: 13px;
line-height: 18px;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.sky-message--sent p {
border-radius: 16px 16px 5px;
background: var(--sky-app-accent);
color: #ffffff;
}
.sky-message time {
margin-top: 3px;
}
.sky-message--sent time {
text-align: right;
}
.sky-messagebar {
display: flex;
align-items: flex-end;
gap: var(--sky-space-2);
border-top: 1px solid var(--sky-hairline);
padding: 8px var(--sky-page-gutter)
calc(var(--sky-safe-area-bottom) + var(--sky-space-2));
background: var(--sky-surface);
}
.sky-messagebar textarea {
min-width: 0;
min-height: 40px;
max-height: 100px;
flex: 1;
resize: none;
border: 1px solid var(--sky-hairline);
border-radius: 18px;
padding: 9px 12px;
color: var(--sky-text);
background: var(--sky-surface-muted);
font: inherit;
font-size: 14px;
line-height: 20px;
}
.sky-messagebar__right {
min-width: var(--sky-touch-target);
min-height: var(--sky-touch-target);
display: grid;
place-items: center;
}
.sky-action-button:focus-visible,
.sky-dialog-button:focus-visible,
.sky-messagebar textarea:focus-visible {
outline: 2px solid var(--sky-app-accent);
outline-offset: -2px;
}
.sky-action-button:disabled,
.sky-dialog-button:disabled,
.sky-messagebar textarea:disabled {
opacity: 0.45;
}
@@ -0,0 +1,17 @@
<script setup lang="ts">
withDefaults(defineProps<{ bold?: boolean; disabled?: boolean }>(), {
bold: false,
disabled: false,
})
</script>
<template>
<button
type="button"
class="sky-action-button"
:class="{ 'sky-action-button--bold': bold }"
:disabled="disabled"
>
<slot />
</button>
</template>
@@ -0,0 +1,3 @@
<template>
<div class="sky-action-group"><slot /></div>
</template>
@@ -0,0 +1,81 @@
<script setup lang="ts">
import { nextTick, ref, toRef, watch } from 'vue'
import { useOverlayFocusTrap } from './useOverlayFocusTrap'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
ariaDescribedby?: string
ariaLabel?: string
ariaLabelledby?: string
ariaModal?: boolean | 'false' | 'true'
label?: string
opened: boolean
role?: 'alertdialog' | 'dialog' | 'none' | 'presentation'
tabindex?: number | string
}>(),
{
ariaModal: true,
tabindex: -1,
},
)
const emit = defineEmits<{
backdropclick: []
escape: [event: KeyboardEvent]
}>()
const root = ref<HTMLElement | null>(null)
const panel = ref<HTMLElement | null>(null)
const inferredRole = ref<'alertdialog' | 'dialog' | 'none' | 'presentation'>()
watch(
[toRef(props, 'opened'), toRef(props, 'role')],
async ([opened]) => {
inferredRole.value = undefined
if (!opened) return
await nextTick()
if (!props.opened || !panel.value) return
const nestedDialog = panel.value.querySelector(
'[role="dialog"], [role="alertdialog"]',
)
inferredRole.value = props.role ?? (nestedDialog ? undefined : 'dialog')
},
{ immediate: true, flush: 'post' },
)
useOverlayFocusTrap({
onEscape: (event) => emit('escape', event),
opened: toRef(props, 'opened'),
panel,
root,
})
</script>
<template>
<div v-if="opened" ref="root" v-bind="$attrs" class="sky-action-sheet">
<div
class="sky-overlay-backdrop"
aria-hidden="true"
@click="emit('backdropclick')"
></div>
<div
ref="panel"
class="sky-action-sheet__panel"
:role="inferredRole"
:aria-modal="
inferredRole === 'dialog' || inferredRole === 'alertdialog'
? ariaModal
: undefined
"
:aria-label="inferredRole ? ariaLabel || label || undefined : undefined"
:aria-labelledby="inferredRole ? ariaLabelledby : undefined"
:aria-describedby="inferredRole ? ariaDescribedby : undefined"
:tabindex="tabindex"
>
<slot />
</div>
</div>
</template>
+64
View File
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { ref, toRef, useId } from 'vue'
import { useOverlayFocusTrap } from './useOverlayFocusTrap'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
ariaModal?: boolean | 'false' | 'true'
content?: string
opened: boolean
role?: 'alertdialog' | 'dialog'
tabindex?: number | string
title: string
}>(),
{
ariaModal: true,
role: 'dialog',
tabindex: -1,
},
)
const emit = defineEmits<{
backdropclick: []
escape: [event: KeyboardEvent]
}>()
const titleId = useId()
const contentId = useId()
const root = ref<HTMLElement | null>(null)
const panel = ref<HTMLElement | null>(null)
useOverlayFocusTrap({
onEscape: (event) => emit('escape', event),
opened: toRef(props, 'opened'),
panel,
root,
})
</script>
<template>
<div v-if="opened" ref="root" v-bind="$attrs" class="sky-dialog">
<div
class="sky-overlay-backdrop"
aria-hidden="true"
@click="emit('backdropclick')"
></div>
<section
ref="panel"
class="sky-dialog__panel"
:role="role"
:aria-modal="ariaModal"
:aria-labelledby="titleId"
:aria-describedby="content ? contentId : undefined"
:tabindex="tabindex"
>
<div class="sky-dialog__content">
<h2 :id="titleId">{{ title }}</h2>
<p v-if="content" :id="contentId">{{ content }}</p>
<slot />
</div>
<div class="sky-dialog__buttons"><slot name="buttons" /></div>
</section>
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
withDefaults(defineProps<{ disabled?: boolean; strong?: boolean }>(), {
disabled: false,
strong: false,
})
</script>
<template>
<button
type="button"
class="sky-dialog-button"
:class="{ 'sky-dialog-button--strong': strong }"
:disabled="disabled"
>
<slot />
</button>
</template>
+19
View File
@@ -0,0 +1,19 @@
<script setup lang="ts">
withDefaults(
defineProps<{
name?: string
text: string
textFooter?: string
type?: 'received' | 'sent'
}>(),
{ name: '', textFooter: '', type: 'received' },
)
</script>
<template>
<article class="sky-message" :class="`sky-message--${type}`">
<small v-if="name" class="sky-message__name">{{ name }}</small>
<p>{{ text }}</p>
<time v-if="textFooter">{{ textFooter }}</time>
</article>
</template>
@@ -0,0 +1,29 @@
<script setup lang="ts">
defineOptions({ inheritAttrs: false })
withDefaults(
defineProps<{ disabled?: boolean; placeholder?: string; value?: string }>(),
{ disabled: false, placeholder: '', value: '' },
)
defineEmits<{
input: [event: Event]
keydown: [event: KeyboardEvent]
}>()
</script>
<template>
<div v-bind="$attrs" class="sky-messagebar">
<textarea
:disabled="disabled"
:placeholder="placeholder"
:value="value"
rows="1"
@input="$emit('input', $event)"
@keydown="$emit('keydown', $event)"
></textarea>
<div v-if="$slots.right" class="sky-messagebar__right">
<slot name="right" />
</div>
</div>
</template>
+3
View File
@@ -0,0 +1,3 @@
<template>
<div class="sky-messages"><slot /></div>
</template>
@@ -0,0 +1,3 @@
<template>
<p class="sky-messages-title"><slot /></p>
</template>
+80
View File
@@ -0,0 +1,80 @@
<script setup lang="ts">
import { nextTick, ref, toRef, watch } from 'vue'
import { useOverlayFocusTrap } from './useOverlayFocusTrap'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
ariaDescribedby?: string
ariaLabel?: string
ariaLabelledby?: string
ariaModal?: boolean | 'false' | 'true'
opened: boolean
role?: 'alertdialog' | 'dialog' | 'none' | 'presentation'
tabindex?: number | string
}>(),
{
ariaModal: true,
tabindex: -1,
},
)
const emit = defineEmits<{
backdropclick: []
escape: [event: KeyboardEvent]
}>()
const root = ref<HTMLElement | null>(null)
const panel = ref<HTMLElement | null>(null)
const inferredRole = ref<'alertdialog' | 'dialog' | 'none' | 'presentation'>()
watch(
[toRef(props, 'opened'), toRef(props, 'role')],
async ([opened]) => {
inferredRole.value = undefined
if (!opened) return
await nextTick()
if (!props.opened || !panel.value) return
const nestedDialog = panel.value.querySelector(
'[role="dialog"], [role="alertdialog"]',
)
inferredRole.value = props.role ?? (nestedDialog ? undefined : 'dialog')
},
{ immediate: true, flush: 'post' },
)
useOverlayFocusTrap({
onEscape: (event) => emit('escape', event),
opened: toRef(props, 'opened'),
panel,
root,
})
</script>
<template>
<div v-if="opened" ref="root" v-bind="$attrs" class="sky-sheet">
<div
class="sky-overlay-backdrop"
aria-hidden="true"
@click="emit('backdropclick')"
></div>
<div
ref="panel"
class="sky-sheet__panel"
:role="inferredRole"
:aria-modal="
inferredRole === 'dialog' || inferredRole === 'alertdialog'
? ariaModal
: undefined
"
:aria-label="inferredRole ? ariaLabel : undefined"
:aria-labelledby="inferredRole ? ariaLabelledby : undefined"
:aria-describedby="inferredRole ? ariaDescribedby : undefined"
:tabindex="tabindex"
>
<slot />
</div>
</div>
</template>
+21
View File
@@ -0,0 +1,21 @@
<script setup lang="ts">
defineOptions({ inheritAttrs: false })
withDefaults(
defineProps<{ opened: boolean; position?: 'bottom' | 'center' | 'top' }>(),
{ position: 'bottom' },
)
</script>
<template>
<div
v-if="opened"
v-bind="$attrs"
class="sky-toast"
:class="`sky-toast--${position}`"
role="status"
aria-live="polite"
>
<slot />
</div>
</template>
+11
View File
@@ -0,0 +1,11 @@
export { default as SkyActionButton } from './SkyActionButton.vue'
export { default as SkyActionGroup } from './SkyActionGroup.vue'
export { default as SkyActionSheet } from './SkyActionSheet.vue'
export { default as SkyDialog } from './SkyDialog.vue'
export { default as SkyDialogButton } from './SkyDialogButton.vue'
export { default as SkyMessage } from './SkyMessage.vue'
export { default as SkyMessagebar } from './SkyMessagebar.vue'
export { default as SkyMessages } from './SkyMessages.vue'
export { default as SkyMessagesTitle } from './SkyMessagesTitle.vue'
export { default as SkySheet } from './SkySheet.vue'
export { default as SkyToast } from './SkyToast.vue'
@@ -0,0 +1,323 @@
import { nextTick, onBeforeUnmount, watch, type Ref } from 'vue'
interface OverlayStackEntry {
hadInertAttribute: boolean
id: symbol
onEscape: (event: KeyboardEvent) => void
panel: HTMLElement
previousAriaHidden: string | null
previousFocus: HTMLElement | null
root: HTMLElement
}
interface OverlayFocusTrapOptions {
onEscape: (event: KeyboardEvent) => void
opened: Readonly<Ref<boolean>>
panel: Readonly<Ref<HTMLElement | null>>
root: Readonly<Ref<HTMLElement | null>>
}
const focusableSelector = [
'a[href]',
'area[href]',
'button:not([disabled])',
'input:not([disabled]):not([type="hidden"])',
'select:not([disabled])',
'textarea:not([disabled])',
'iframe',
'object',
'embed',
'[contenteditable]:not([contenteditable="false"])',
'[tabindex]',
].join(',')
const overlayStack: OverlayStackEntry[] = []
let documentListenerAttached = false
function isVisible(element: HTMLElement): boolean {
const style = window.getComputedStyle(element)
return (
style.display !== 'none' &&
style.visibility !== 'hidden' &&
element.getClientRects().length > 0 &&
!element.closest('[aria-hidden="true"], [inert]')
)
}
function canReceiveFocus(element: HTMLElement): boolean {
return (
element.isConnected &&
!element.matches(':disabled') &&
!element.hasAttribute('hidden') &&
isVisible(element)
)
}
function getTabbableElements(root: HTMLElement): HTMLElement[] {
return Array.from(
root.querySelectorAll<HTMLElement>(focusableSelector),
).filter((element) => element.tabIndex >= 0 && canReceiveFocus(element))
}
function focusElement(element: HTMLElement | null): boolean {
if (!element || !canReceiveFocus(element)) return false
element.focus({ preventScroll: true })
return document.activeElement === element
}
function focusFirstElement(entry: OverlayStackEntry): void {
const preferred = entry.root.querySelector<HTMLElement>(
'[data-sky-autofocus], [autofocus]',
)
if (focusElement(preferred)) return
const firstTabbable = getTabbableElements(entry.root)[0]
if (focusElement(firstTabbable)) return
focusElement(entry.panel)
}
function trapTabKey(entry: OverlayStackEntry, event: KeyboardEvent): void {
const tabbable = getTabbableElements(entry.root)
if (tabbable.length === 0) {
event.preventDefault()
event.stopPropagation()
focusElement(entry.panel)
return
}
const activeElement = document.activeElement
const first = tabbable[0]
const last = tabbable[tabbable.length - 1]
const focusIsInside =
activeElement instanceof Node && entry.root.contains(activeElement)
if (!focusIsInside) {
event.preventDefault()
event.stopPropagation()
focusElement(event.shiftKey ? last : first)
return
}
const activeIndex = tabbable.indexOf(activeElement as HTMLElement)
if (activeIndex < 0) {
event.preventDefault()
event.stopPropagation()
focusElement(event.shiftKey ? last : first)
return
}
if (event.shiftKey && activeElement === first) {
event.preventDefault()
event.stopPropagation()
focusElement(last)
return
}
if (!event.shiftKey && activeElement === last) {
event.preventDefault()
event.stopPropagation()
focusElement(first)
}
}
function restoreOverlayAccessibility(entry: OverlayStackEntry): void {
entry.root.style.removeProperty('--sky-overlay-layer')
if (entry.previousAriaHidden === null) {
entry.root.removeAttribute('aria-hidden')
} else {
entry.root.setAttribute('aria-hidden', entry.previousAriaHidden)
}
if (entry.hadInertAttribute) {
entry.root.setAttribute('inert', '')
} else {
entry.root.removeAttribute('inert')
}
}
function syncOverlayStack(): void {
const topIndex = overlayStack.length - 1
overlayStack.forEach((entry, index) => {
entry.root.style.setProperty('--sky-overlay-layer', String(80 + index))
if (index === topIndex) {
if (entry.previousAriaHidden === null) {
entry.root.removeAttribute('aria-hidden')
} else {
entry.root.setAttribute('aria-hidden', entry.previousAriaHidden)
}
if (entry.hadInertAttribute) {
entry.root.setAttribute('inert', '')
} else {
entry.root.removeAttribute('inert')
}
return
}
entry.root.setAttribute('aria-hidden', 'true')
entry.root.setAttribute('inert', '')
})
}
function handleDocumentKeydown(event: KeyboardEvent): void {
const topOverlay = overlayStack[overlayStack.length - 1]
if (!topOverlay) return
if (event.key === 'Escape') {
if (event.isComposing) return
event.preventDefault()
event.stopPropagation()
topOverlay.onEscape(event)
return
}
if (event.key === 'Tab') trapTabKey(topOverlay, event)
}
function syncDocumentListener(): void {
if (typeof document === 'undefined') return
const shouldAttach = overlayStack.length > 0
if (shouldAttach && !documentListenerAttached) {
document.addEventListener('keydown', handleDocumentKeydown, true)
documentListenerAttached = true
return
}
if (!shouldAttach && documentListenerAttached) {
document.removeEventListener('keydown', handleDocumentKeydown, true)
documentListenerAttached = false
}
}
function currentFocus(): HTMLElement | null {
const activeElement = document.activeElement
if (
!(activeElement instanceof HTMLElement) ||
activeElement === document.body ||
activeElement === document.documentElement
) {
return null
}
return activeElement
}
function registerOverlay(
root: HTMLElement,
panel: HTMLElement,
onEscape: (event: KeyboardEvent) => void,
): () => void {
const entry: OverlayStackEntry = {
hadInertAttribute: root.hasAttribute('inert'),
id: Symbol('sky-overlay'),
onEscape,
panel,
previousAriaHidden: root.getAttribute('aria-hidden'),
previousFocus: currentFocus(),
root,
}
overlayStack.push(entry)
syncDocumentListener()
focusFirstElement(entry)
syncOverlayStack()
let registered = true
return () => {
if (!registered) return
registered = false
const index = overlayStack.findIndex(
(candidate) => candidate.id === entry.id,
)
if (index < 0) return
const wasTopOverlay = index === overlayStack.length - 1
const overlayAbove = overlayStack[index + 1]
overlayStack.splice(index, 1)
restoreOverlayAccessibility(entry)
syncOverlayStack()
syncDocumentListener()
if (!wasTopOverlay) {
if (
overlayAbove &&
entry.previousFocus &&
overlayAbove.previousFocus &&
entry.root.contains(overlayAbove.previousFocus)
) {
overlayAbove.previousFocus = entry.previousFocus
}
return
}
const nextTopOverlay = overlayStack[overlayStack.length - 1]
if (nextTopOverlay) {
if (
entry.previousFocus &&
nextTopOverlay.root.contains(entry.previousFocus) &&
focusElement(entry.previousFocus)
) {
return
}
focusFirstElement(nextTopOverlay)
return
}
focusElement(entry.previousFocus)
}
}
export function useOverlayFocusTrap(options: OverlayFocusTrapOptions): void {
let activation = 0
let unregister: (() => void) | undefined
const stopWatching = watch(
options.opened,
async (opened) => {
activation += 1
const currentActivation = activation
if (!opened) {
unregister?.()
unregister = undefined
return
}
await nextTick()
if (currentActivation !== activation || !options.opened.value) return
const root = options.root.value
const panel = options.panel.value
if (!root || !panel) return
unregister?.()
unregister = registerOverlay(root, panel, options.onEscape)
},
{ immediate: true, flush: 'post' },
)
onBeforeUnmount(() => {
activation += 1
stopWatching()
unregister?.()
unregister = undefined
})
}
+263
View File
@@ -0,0 +1,263 @@
.sky-settings-group {
min-width: 0;
margin: 0 0 var(--sky-space-5);
}
.sky-settings-group__title {
margin: var(--sky-space-5) var(--sky-space-3) 7px;
padding: 0;
color: var(--sky-muted);
font-size: 13px;
font-weight: 600;
line-height: 18px;
}
.sky-settings-group__list {
min-width: 0;
margin: 0;
padding: 0;
overflow: hidden;
border: 0;
border-radius: var(--sky-radius-card);
background: var(--sky-surface);
color: var(--sky-text);
list-style: none;
}
.sky-settings-group__footer {
margin: 7px var(--sky-space-3) 0;
color: var(--sky-muted);
font-size: 12px;
line-height: 16px;
}
.sky-settings-row,
.sky-settings-range-row,
.sky-settings-group__list > .sky-field {
position: relative;
min-width: 0;
margin: 0;
padding: 0;
background: transparent;
list-style: none;
}
.sky-settings-group__list > li + li::before {
position: absolute;
z-index: 2;
top: 0;
right: 14px;
left: 14px;
height: 1px;
content: '';
background: var(--sky-hairline);
pointer-events: none;
}
.sky-settings-group__list
> .sky-settings-row--has-media
+ .sky-settings-row::before,
.sky-settings-group__list
> .sky-settings-row
+ .sky-settings-row--has-media::before {
left: 50px;
}
.sky-settings-row__frame,
.sky-settings-range-row__frame {
box-sizing: border-box;
width: 100%;
min-width: 0;
min-height: 52px;
margin: 0;
padding: 8px 14px;
display: flex;
align-items: center;
gap: 11px;
border: 0;
border-radius: 0;
appearance: none;
background: transparent;
color: var(--sky-text);
font: inherit;
text-align: left;
-webkit-tap-highlight-color: transparent;
}
button.sky-settings-row__frame {
cursor: pointer;
}
button.sky-settings-row__frame:active:not(:disabled) {
background: var(--sky-surface-muted);
}
button.sky-settings-row__frame:focus-visible {
outline: 2px solid var(--sky-app-accent);
outline-offset: -2px;
}
.sky-settings-row--toggle .sky-settings-row__frame {
padding-top: var(--sky-space-1);
padding-bottom: var(--sky-space-1);
}
.sky-settings-row__leading,
.sky-settings-row__accessory,
.sky-settings-row__trailing {
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
}
.sky-settings-row__leading {
width: 25px;
color: var(--sky-app-accent);
}
.sky-settings-row__content {
min-width: 0;
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 2px;
}
.sky-settings-row__title {
min-width: 0;
color: inherit;
font-size: 15px;
font-weight: 450;
line-height: 20px;
overflow-wrap: anywhere;
}
label.sky-settings-row__title {
cursor: pointer;
}
.sky-settings-row__description {
color: var(--sky-muted);
font-size: 12px;
line-height: 16px;
}
.sky-settings-row__accessory {
min-width: 0;
gap: 4px;
color: var(--sky-muted);
}
.sky-settings-row__value {
max-width: 126px;
overflow: hidden;
font-size: 14px;
line-height: 20px;
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
}
.sky-settings-row__chevron {
opacity: 0.62;
}
.sky-settings-row__check {
color: var(--sky-app-accent);
}
.sky-settings-row--accent .sky-settings-row__title,
.sky-settings-row--action.sky-settings-row--default .sky-settings-row__title {
color: var(--sky-app-accent);
}
.sky-settings-row--danger .sky-settings-row__title {
color: var(--sky-danger);
}
.sky-settings-row--disabled,
.sky-settings-row--pending,
.sky-settings-range-row--disabled {
opacity: 0.46;
}
.sky-settings-range-row__frame {
padding-top: var(--sky-space-1);
padding-bottom: var(--sky-space-1);
}
.sky-settings-group--compact .sky-settings-row__frame,
.sky-settings-group--compact .sky-settings-range-row__frame {
min-height: 48px;
padding-top: 6px;
padding-bottom: 6px;
}
.sky-settings-range-row .sky-range {
width: 100%;
}
.sky-settings-icon {
width: 28px;
height: 28px;
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: 7px;
background: var(--sky-settings-icon-color, var(--sky-app-accent));
color: #ffffff;
box-shadow:
inset 0 1px 0 rgb(255 255 255 / 35%),
0 1px 2px rgb(0 0 0 / 25%);
}
.sky-settings-icon > svg {
width: 17px;
height: 17px;
display: block;
}
.sky-settings-group__list > .sky-field {
min-height: 52px;
padding: var(--sky-space-1) 14px;
}
.sky-settings-group__list > .sky-field .sky-field__label {
font-size: 13px;
line-height: 18px;
}
.sky-settings-group__list > .sky-field .sky-field__input {
font-size: 15px;
}
.sky-settings-group__list > .sky-field--inline {
display: grid;
grid-template-columns: minmax(0, 42%) minmax(0, 58%);
align-items: center;
column-gap: 12px;
}
.sky-settings-group__list > .sky-field--inline .sky-field__label {
margin: 0;
color: var(--sky-text);
font-size: 15px;
font-weight: 450;
line-height: 20px;
}
.sky-settings-group__list > .sky-field--inline .sky-field__control {
justify-content: flex-end;
}
.sky-settings-group__list > .sky-field--inline .sky-field__input {
text-align: right;
}
.sky-settings-group__list > .sky-field--inline .sky-field__help,
.sky-settings-group__list > .sky-field--inline .sky-field__error {
grid-column: 1 / -1;
}
@@ -0,0 +1,52 @@
import { createSSRApp, h } from 'vue'
import { renderToString } from 'vue/server-renderer'
import { describe, expect, it } from 'vitest'
import SkySettingsGroup from '@/ui/settings/SkySettingsGroup.vue'
import SkySettingsRow from '@/ui/settings/SkySettingsRow.vue'
describe('SkySettingsGroup', () => {
it('connects its heading and footer to the settings section', async () => {
const app = createSSRApp({
render: () =>
h(
SkySettingsGroup,
{
footer: 'Used when reconnecting to the channel.',
title: 'Connection',
},
{
default: () =>
h(SkySettingsRow, {
kind: 'value',
title: 'Status',
value: 'Ready',
}),
},
),
})
const html = await renderToString(app)
const titleId = html.match(/<h2 id="([^"]+)"/)?.[1]
const footerId = html.match(/<p id="([^"]+)"/)?.[1]
expect(titleId).toBeTruthy()
expect(footerId).toBeTruthy()
expect(html).toContain(`aria-labelledby="${titleId}"`)
expect(html).toContain(`aria-describedby="${footerId}"`)
expect(html).toContain('<ul class="sky-settings-group__list">')
expect(html).not.toContain('sky-list--inset')
})
it('uses an explicit accessible name when no heading is shown', async () => {
const app = createSSRApp(SkySettingsGroup, {
ariaLabel: 'Profile actions',
})
const html = await renderToString(app)
expect(html).toContain('aria-label="Profile actions"')
expect(html).not.toContain('<h2')
expect(html).not.toContain('sky-settings-group__footer')
})
})
@@ -0,0 +1,50 @@
<script setup lang="ts">
import { computed, useId, useSlots } from 'vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
ariaLabel?: string
density?: 'compact' | 'regular'
footer?: string
title?: string
}>(),
{
ariaLabel: '',
density: 'regular',
footer: '',
title: '',
},
)
const slots = useSlots()
const generatedId = useId()
const titleId = `${generatedId}-title`
const footerId = `${generatedId}-footer`
const hasTitle = computed(() => Boolean(props.title || slots.header))
const hasFooter = computed(() => Boolean(props.footer || slots.footer))
</script>
<template>
<section
v-bind="$attrs"
class="sky-settings-group"
:class="`sky-settings-group--${density}`"
:aria-label="!hasTitle && ariaLabel ? ariaLabel : undefined"
:aria-labelledby="hasTitle ? titleId : undefined"
:aria-describedby="hasFooter ? footerId : undefined"
>
<h2 v-if="hasTitle" :id="titleId" class="sky-settings-group__title">
<slot name="header">{{ title }}</slot>
</h2>
<ul class="sky-settings-group__list">
<slot />
</ul>
<p v-if="hasFooter" :id="footerId" class="sky-settings-group__footer">
<slot name="footer">{{ footer }}</slot>
</p>
</section>
</template>
@@ -0,0 +1,37 @@
import { createSSRApp, h } from 'vue'
import { renderToString } from 'vue/server-renderer'
import { describe, expect, it } from 'vitest'
import SkySettingsIcon from '@/ui/settings/SkySettingsIcon.vue'
describe('SkySettingsIcon', () => {
it('renders its icon slot as a decorative colored tile by default', async () => {
const app = createSSRApp({
render: () =>
h(
SkySettingsIcon,
{ color: '#007aff' },
{ default: () => h('svg', { class: 'wifi-icon' }) },
),
})
const html = await renderToString(app)
expect(html).toContain('class="sky-settings-icon"')
expect(html).toContain('--sky-settings-icon-color:#007aff')
expect(html).toContain('aria-hidden="true"')
expect(html).toContain('wifi-icon')
})
it('can expose a meaningful icon when it is not redundant with row copy', async () => {
const app = createSSRApp(SkySettingsIcon, {
ariaLabel: 'Connected network',
})
const html = await renderToString(app)
expect(html).toContain('role="img"')
expect(html).toContain('aria-label="Connected network"')
expect(html).not.toContain('aria-hidden="true"')
})
})
@@ -0,0 +1,30 @@
<script setup lang="ts">
import { computed, type CSSProperties } from 'vue'
const props = withDefaults(
defineProps<{
ariaLabel?: string
color?: string
}>(),
{
ariaLabel: '',
color: 'var(--sky-app-accent)',
},
)
const iconStyle = computed<CSSProperties>(() => ({
'--sky-settings-icon-color': props.color,
}))
</script>
<template>
<span
class="sky-settings-icon"
:style="iconStyle"
:role="ariaLabel ? 'img' : undefined"
:aria-label="ariaLabel || undefined"
:aria-hidden="ariaLabel ? undefined : true"
>
<slot />
</span>
</template>
@@ -0,0 +1,64 @@
import { createSSRApp } from 'vue'
import { renderToString } from 'vue/server-renderer'
import { describe, expect, it } from 'vitest'
import SkySettingsRangeRow from '@/ui/settings/SkySettingsRangeRow.vue'
describe('SkySettingsRangeRow', () => {
it('renders a semantic list row and forwards the native range contract', async () => {
const app = createSSRApp(SkySettingsRangeRow, {
ariaValueText: '75 percent',
max: 100,
min: 25,
modelValue: 75,
step: 5,
title: 'Brightness',
valueLabel: '75%',
})
const html = await renderToString(app)
expect(html).toContain('<li')
expect(html).toContain('class="sky-settings-range-row"')
expect(html).toContain('type="range"')
expect(html).toContain('aria-label="Brightness"')
expect(html).toContain('aria-valuetext="75 percent"')
expect(html).toContain('min="25"')
expect(html).toContain('max="100"')
expect(html).toContain('step="5"')
expect(html).toContain('75%')
})
it('uses the effective numeric value as its default visible label', async () => {
const app = createSSRApp(SkySettingsRangeRow, {
title: 'Scale',
value: 1.25,
})
const html = await renderToString(app)
expect(html).toMatch(/class="sky-range__label">.*1\.25.*<\/span>/)
expect(html).not.toContain('aria-valuetext=')
})
it('uses the formatted visible label as the accessible value fallback', async () => {
const app = createSSRApp(SkySettingsRangeRow, {
modelValue: 0.75,
title: 'Scale',
valueLabel: '75%',
})
const html = await renderToString(app)
expect(html).toContain('aria-valuetext="75%"')
expect(html).toMatch(/class="sky-range__label">.*75%.*<\/span>/)
})
it('exposes input, change, and numeric model update events', () => {
const component = SkySettingsRangeRow as unknown as { emits: string[] }
expect(component.emits).toEqual(
expect.arrayContaining(['change', 'input', 'update:modelValue']),
)
})
})
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { computed } from 'vue'
import SkyRange from '@/ui/controls/SkyRange.vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
ariaValueText?: string
disabled?: boolean
max?: number
min?: number
modelValue?: number
step?: number
title: string
value?: number
valueLabel?: string
}>(),
{
ariaValueText: '',
disabled: false,
max: 100,
min: 0,
modelValue: undefined,
step: 1,
value: undefined,
valueLabel: undefined,
},
)
const emit = defineEmits<{
change: [event: Event]
input: [event: Event]
'update:modelValue': [value: number]
}>()
const effectiveValue = computed(
() => props.modelValue ?? props.value ?? props.min,
)
const visibleValue = computed(
() => props.valueLabel ?? String(effectiveValue.value),
)
const accessibleValue = computed(
() => props.ariaValueText || props.valueLabel || '',
)
</script>
<template>
<li
v-bind="$attrs"
class="sky-settings-range-row"
:class="{ 'sky-settings-range-row--disabled': disabled }"
>
<div class="sky-settings-range-row__frame">
<SkyRange
:aria-label="title"
:aria-value-text="accessibleValue"
:caption="title"
:disabled="disabled"
:max="max"
:min="min"
:model-value="effectiveValue"
:step="step"
@change="emit('change', $event)"
@input="emit('input', $event)"
@update:model-value="emit('update:modelValue', $event)"
>
{{ visibleValue }}
</SkyRange>
</div>
</li>
</template>
@@ -0,0 +1,136 @@
import { createSSRApp, h } from 'vue'
import { renderToString } from 'vue/server-renderer'
import { describe, expect, it } from 'vitest'
import SkySettingsRow from '@/ui/settings/SkySettingsRow.vue'
async function renderRow(props: Record<string, unknown>): Promise<string> {
return renderToString(createSSRApp(SkySettingsRow, props))
}
describe('SkySettingsRow', () => {
it('renders navigation as a full-row button with value and chevron', async () => {
const html = await renderRow({
kind: 'navigation',
title: 'Private address',
value: 'Static',
})
expect(html).toContain('<button')
expect(html).toContain('type="button"')
expect(html).toContain('sky-settings-row__value')
expect(html).toContain('sky-settings-row__chevron')
expect(html).toContain('Static')
})
it('marks a selected choice without adding a chevron', async () => {
const html = await renderRow({
kind: 'choice',
selected: true,
title: 'System',
})
expect(html).toContain('aria-pressed="true"')
expect(html).toContain('sky-settings-row__check')
expect(html).not.toContain('sky-settings-row__chevron')
})
it('labels the native switch from the row copy', async () => {
const html = await renderRow({
description: 'Reconnect after restarting the phone.',
kind: 'toggle',
modelValue: true,
title: 'Automatic rejoin',
})
const titleId = html.match(/<label id="([^"]+)"/)?.[1]
const descriptionId = html.match(/<span id="([^"]+)"/)?.[1]
const input = html.match(/<input[^>]+role="switch"[^>]*>/)?.[0] ?? ''
expect(titleId).toBeTruthy()
expect(descriptionId).toBeTruthy()
expect(input).toContain(`aria-labelledby="${titleId}"`)
expect(input).toContain(`aria-describedby="${descriptionId}"`)
expect(input).toContain('checked')
})
it('keeps the labelled switch mounted and disabled while pending', async () => {
const html = await renderRow({
kind: 'toggle',
modelValue: true,
pending: true,
title: 'Automatic rejoin',
})
const input = html.match(/<input[^>]+role="switch"[^>]*>/)?.[0] ?? ''
expect(html).toContain('sky-spinner')
expect(input).toContain('disabled')
expect(input).toContain('aria-labelledby=')
})
it('renders destructive actions as buttons with the danger tone', async () => {
const html = await renderRow({
kind: 'action',
title: 'Remove',
tone: 'danger',
})
expect(html).toContain('<button')
expect(html).toContain('sky-settings-row--danger')
expect(html).not.toContain('sky-settings-row__chevron')
})
it('keeps value and chevron when navigation supplies custom trailing content', async () => {
const app = createSSRApp({
render: () =>
h(
SkySettingsRow,
{
kind: 'navigation',
title: 'Wi-Fi',
value: 'Connected',
},
{
trailing: () => h('span', { class: 'network-strength' }, 'Strong'),
},
),
})
const html = await renderToString(app)
expect(html).toContain('Connected')
expect(html).toContain('network-strength')
expect(html).toContain('sky-settings-row__chevron')
const labelledBy = html.match(/aria-labelledby="([^"]+)"/)?.[1] ?? ''
const valueId = html.match(
/<span id="([^"]+)" class="sky-settings-row__value"/,
)?.[1]
const trailingId = html.match(
/<span id="([^"]+)" class="sky-settings-row__trailing"/,
)?.[1]
expect(valueId).toBeTruthy()
expect(trailingId).toBeTruthy()
expect(labelledBy).toContain(valueId ?? '')
expect(labelledBy).toContain(trailingId ?? '')
})
it.each(['custom', 'value'] as const)(
'renders the trailing slot for %s rows',
async (kind) => {
const app = createSSRApp({
render: () =>
h(
SkySettingsRow,
{ kind, title: 'Status' },
{
trailing: () => h('span', { class: 'status-accessory' }, 'Ready'),
},
),
})
const html = await renderToString(app)
expect(html).toContain('status-accessory')
expect(html).toContain('Ready')
},
)
})
+213
View File
@@ -0,0 +1,213 @@
<script setup lang="ts">
import { Check, ChevronRight } from 'lucide-vue-next'
import { computed, useId, useSlots } from 'vue'
import SkySpinner from '@/ui/controls/SkySpinner.vue'
import SkyToggle from '@/ui/controls/SkyToggle.vue'
defineOptions({ inheritAttrs: false })
type SettingsRowKind =
| 'action'
| 'choice'
| 'custom'
| 'navigation'
| 'toggle'
| 'value'
const props = withDefaults(
defineProps<{
ariaLabel?: string
description?: string
disabled?: boolean
kind?: SettingsRowKind
modelValue?: boolean
name?: string
pending?: boolean
selected?: boolean
title: string
tone?: 'accent' | 'danger' | 'default'
value?: number | string
}>(),
{
ariaLabel: '',
description: '',
disabled: false,
kind: 'value',
modelValue: false,
name: undefined,
pending: false,
selected: false,
tone: 'default',
value: undefined,
},
)
const emit = defineEmits<{
activate: [event: MouseEvent]
'update:modelValue': [value: boolean]
}>()
const slots = useSlots()
const generatedId = useId()
const titleId = `${generatedId}-title`
const descriptionId = `${generatedId}-description`
const toggleId = `${generatedId}-toggle`
const valueId = `${generatedId}-value`
const trailingId = `${generatedId}-trailing`
const isInteractive = computed(() =>
['action', 'choice', 'navigation'].includes(props.kind),
)
const rowComponent = computed(() => (isInteractive.value ? 'button' : 'div'))
const hasDescription = computed(() =>
Boolean(props.description || slots.description),
)
const hasValue = computed(
() => props.value !== undefined && String(props.value).length > 0,
)
const hasFreeTrailing = computed(
() =>
Boolean(slots.trailing) &&
['custom', 'navigation', 'value'].includes(props.kind),
)
const labelledBy = computed(() => {
if (props.ariaLabel) return undefined
const ids = [titleId]
if (hasValue.value) ids.push(valueId)
if (hasFreeTrailing.value) ids.push(trailingId)
return ids.join(' ')
})
function handleActivate(event: MouseEvent): void {
if (props.disabled || props.pending || !isInteractive.value) return
emit('activate', event)
}
function updateToggle(value: boolean): void {
if (props.disabled || props.pending) return
emit('update:modelValue', value)
}
</script>
<template>
<li
v-bind="$attrs"
class="sky-settings-row"
:class="[
`sky-settings-row--${kind}`,
`sky-settings-row--${tone}`,
{
'sky-settings-row--disabled': disabled,
'sky-settings-row--has-media': Boolean($slots.leading),
'sky-settings-row--pending': pending,
'sky-settings-row--selected': selected,
},
]"
>
<div
v-if="kind === 'toggle'"
class="sky-settings-row__frame"
:aria-busy="pending || undefined"
>
<span v-if="$slots.leading" class="sky-settings-row__leading">
<slot name="leading" />
</span>
<span class="sky-settings-row__content">
<label :id="titleId" class="sky-settings-row__title" :for="toggleId">
<slot name="title">{{ title }}</slot>
</label>
<span
v-if="hasDescription"
:id="descriptionId"
class="sky-settings-row__description"
>
<slot name="description">{{ description }}</slot>
</span>
</span>
<span class="sky-settings-row__accessory">
<SkySpinner v-if="pending" :size="18" />
<SkyToggle
:id="toggleId"
:model-value="modelValue"
:name="name"
:disabled="disabled || pending"
:aria-labelledby="titleId"
:aria-describedby="hasDescription ? descriptionId : undefined"
@update:model-value="updateToggle"
/>
</span>
</div>
<component
:is="rowComponent"
v-else
class="sky-settings-row__frame"
:type="isInteractive ? 'button' : undefined"
:disabled="isInteractive ? disabled || pending : undefined"
:aria-label="ariaLabel || undefined"
:aria-labelledby="labelledBy"
:aria-describedby="hasDescription ? descriptionId : undefined"
:aria-pressed="kind === 'choice' ? selected : undefined"
:aria-busy="pending || undefined"
@click="handleActivate"
>
<span v-if="$slots.leading" class="sky-settings-row__leading">
<slot name="leading" />
</span>
<span class="sky-settings-row__content">
<span :id="titleId" class="sky-settings-row__title">
<slot name="title">{{ title }}</slot>
</span>
<span
v-if="hasDescription"
:id="descriptionId"
class="sky-settings-row__description"
>
<slot name="description">{{ description }}</slot>
</span>
</span>
<span
v-if="
pending ||
hasValue ||
hasFreeTrailing ||
kind === 'choice' ||
kind === 'navigation'
"
class="sky-settings-row__accessory"
>
<SkySpinner v-if="pending" :size="18" />
<template v-else>
<span v-if="hasValue" :id="valueId" class="sky-settings-row__value">
<slot name="value">{{ value }}</slot>
</span>
<span
v-if="hasFreeTrailing"
:id="trailingId"
class="sky-settings-row__trailing"
>
<slot name="trailing" />
</span>
<Check
v-if="kind === 'choice' && selected"
class="sky-settings-row__check"
:size="20"
:stroke-width="2.25"
aria-hidden="true"
/>
<ChevronRight
v-if="kind === 'navigation'"
class="sky-settings-row__chevron"
:size="18"
:stroke-width="2"
aria-hidden="true"
/>
</template>
</span>
</component>
</li>
</template>
+4
View File
@@ -0,0 +1,4 @@
export { default as SkySettingsGroup } from './SkySettingsGroup.vue'
export { default as SkySettingsIcon } from './SkySettingsIcon.vue'
export { default as SkySettingsRangeRow } from './SkySettingsRangeRow.vue'
export { default as SkySettingsRow } from './SkySettingsRow.vue'
+56
View File
@@ -0,0 +1,56 @@
:root {
--sky-safe-area-top: 58px;
--sky-safe-area-bottom: 25px;
--sky-page-gutter: 14px;
--sky-page-space: 12px;
--sky-navbar-height: 44px;
--sky-navbar-large-title-height: 52px;
--sky-tabbar-height: 64px;
--sky-touch-target: 44px;
--sky-space-1: 4px;
--sky-space-2: 8px;
--sky-space-3: 12px;
--sky-space-4: 16px;
--sky-space-5: 20px;
--sky-space-6: 24px;
--sky-radius-control: 12px;
--sky-radius-card: 16px;
--sky-radius-sheet: 24px;
--sky-font-caption: 12px;
--sky-font-body: 15px;
--sky-font-title: 17px;
--sky-font-large-title: 28px;
}
.sky-app-page {
--sky-app-accent: #3b82f6;
--sky-app-accent-soft: rgba(59, 130, 246, 0.14);
--sky-bg: #f2f4f7;
--sky-surface: #ffffff;
--sky-surface-muted: #e6e9ee;
--sky-text: #111827;
--sky-muted: #64748b;
--sky-hairline: rgba(15, 23, 42, 0.1);
--sky-danger: #dc2626;
--sky-danger-soft: rgba(220, 38, 38, 0.14);
--sky-success: #059669;
--sky-success-soft: rgba(5, 150, 105, 0.14);
--sky-warning: #b45309;
--sky-warning-soft: rgba(245, 158, 11, 0.16);
}
.sky-app-page--dark {
--sky-app-accent-soft: rgba(96, 165, 250, 0.17);
--sky-bg: #08080a;
--sky-surface: #1c1c1e;
--sky-surface-muted: #2c2c2e;
--sky-text: #f5f5f7;
--sky-muted: #98989f;
--sky-hairline: rgba(255, 255, 255, 0.1);
--sky-danger: #ff453a;
--sky-danger-soft: rgba(255, 69, 58, 0.16);
--sky-success: #30d158;
--sky-success-soft: rgba(48, 209, 88, 0.14);
--sky-warning: #ffd60a;
--sky-warning-soft: rgba(255, 214, 10, 0.16);
}
+90
View File
@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest'
import {
resolveScrollRailWheel,
SCROLL_RAIL_DELTA_MODE_LINE,
SCROLL_RAIL_DELTA_MODE_PAGE,
SCROLL_RAIL_DELTA_MODE_PIXEL,
SCROLL_RAIL_LINE_HEIGHT,
} from '@/utils/scrollRail'
const baseInput = {
clientWidth: 300,
deltaMode: SCROLL_RAIL_DELTA_MODE_PIXEL,
deltaX: 0,
deltaY: 0,
scrollLeft: 100,
scrollWidth: 700,
}
describe('horizontal scroll rail wheel behavior', () => {
it('maps a vertical mouse wheel to horizontal movement', () => {
expect(resolveScrollRailWheel({ ...baseInput, deltaY: 120 })).toEqual({
consumed: true,
scrollLeft: 220,
})
expect(resolveScrollRailWheel({ ...baseInput, deltaY: -60 })).toEqual({
consumed: true,
scrollLeft: 40,
})
})
it('uses the dominant trackpad axis without adding both deltas', () => {
expect(
resolveScrollRailWheel({ ...baseInput, deltaX: 80, deltaY: 12 }),
).toEqual({ consumed: true, scrollLeft: 180 })
})
it('normalizes line and page wheel deltas', () => {
expect(
resolveScrollRailWheel({
...baseInput,
deltaMode: SCROLL_RAIL_DELTA_MODE_LINE,
deltaY: 2,
}).scrollLeft,
).toBe(100 + 2 * SCROLL_RAIL_LINE_HEIGHT)
expect(
resolveScrollRailWheel({
...baseInput,
deltaMode: SCROLL_RAIL_DELTA_MODE_PAGE,
deltaY: 1,
}).scrollLeft,
).toBe(400)
})
it('clamps movement and releases the wheel at either edge', () => {
expect(resolveScrollRailWheel({ ...baseInput, deltaY: -500 })).toEqual({
consumed: true,
scrollLeft: 0,
})
expect(
resolveScrollRailWheel({ ...baseInput, scrollLeft: 0, deltaY: -40 }),
).toEqual({ consumed: false, scrollLeft: 0 })
expect(
resolveScrollRailWheel({
...baseInput,
scrollLeft: 400,
deltaY: 40,
}),
).toEqual({ consumed: false, scrollLeft: 400 })
expect(
resolveScrollRailWheel({
...baseInput,
scrollLeft: 400.75,
deltaY: 40,
}),
).toEqual({ consumed: false, scrollLeft: 400.75 })
})
it('does not consume the wheel when the rail has no overflow', () => {
expect(
resolveScrollRailWheel({
...baseInput,
clientWidth: 300,
scrollLeft: 0,
scrollWidth: 300,
deltaY: 80,
}),
).toEqual({ consumed: false, scrollLeft: 0 })
})
})
+52
View File
@@ -0,0 +1,52 @@
export const SCROLL_RAIL_DELTA_MODE_PIXEL = 0
export const SCROLL_RAIL_DELTA_MODE_LINE = 1
export const SCROLL_RAIL_DELTA_MODE_PAGE = 2
export const SCROLL_RAIL_LINE_HEIGHT = 16
export interface ScrollRailWheelInput {
clientWidth: number
deltaMode: number
deltaX: number
deltaY: number
scrollLeft: number
scrollWidth: number
}
export interface ScrollRailWheelResult {
consumed: boolean
scrollLeft: number
}
export function resolveScrollRailWheel({
clientWidth,
deltaMode,
deltaX,
deltaY,
scrollLeft,
scrollWidth,
}: ScrollRailWheelInput): ScrollRailWheelResult {
const maxScrollLeft = Math.max(0, scrollWidth - clientWidth)
if (maxScrollLeft === 0) {
return { consumed: false, scrollLeft: 0 }
}
const boundedScrollLeft = Math.min(maxScrollLeft, Math.max(0, scrollLeft))
let delta = Math.abs(deltaY) >= Math.abs(deltaX) ? deltaY : deltaX
if (deltaMode === SCROLL_RAIL_DELTA_MODE_LINE) {
delta *= SCROLL_RAIL_LINE_HEIGHT
} else if (deltaMode === SCROLL_RAIL_DELTA_MODE_PAGE) {
delta *= clientWidth
}
const nextScrollLeft = Math.min(
maxScrollLeft,
Math.max(0, boundedScrollLeft + delta),
)
const consumed = Math.abs(nextScrollLeft - boundedScrollLeft) >= 0.5
return {
consumed,
scrollLeft: consumed ? nextScrollLeft : scrollLeft,
}
}