mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 09:13:24 +00:00
ADD - introduce the first-party Sky UI system
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
]),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
Vendored
+449
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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')
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user