feat: unify phone apps with Sky UI

This commit is contained in:
Leon.Schmidt
2026-08-16 15:23:21 +02:00
parent 8d9cdb5d6b
commit 1071b4bde7
62 changed files with 5658 additions and 3349 deletions
+10 -3
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, type CSSProperties } from 'vue'
import { computed, useAttrs, type CSSProperties } from 'vue'
import { provideSkyPageScroll } from './page-scroll-context'
import { useSkyTheme } from './theme'
@@ -12,16 +12,18 @@ const props = withDefaults(
accentSoft?: string
component?: string
dark?: boolean
label: string
label?: string
}>(),
{
accent: '',
accentSoft: '',
component: 'main',
dark: undefined,
label: '',
},
)
const attrs = useAttrs()
const theme = useSkyTheme()
const pageScroll = provideSkyPageScroll()
const isDark = computed(() => props.dark ?? theme?.dark.value ?? false)
@@ -31,6 +33,11 @@ const effectiveAccent = computed(
const effectiveAccentSoft = computed(
() => props.accentSoft || theme?.accentSoft.value || '',
)
const accessibleLabel = computed(() => {
if (props.label) return props.label
const attribute = attrs['aria-label']
return typeof attribute === 'string' && attribute ? attribute : undefined
})
const accentStyle = computed(() =>
effectiveAccent.value || effectiveAccentSoft.value
@@ -60,7 +67,7 @@ const pageStyle = computed<CSSProperties>(
class="sky-app-page"
:class="{ 'sky-app-page--dark': isDark }"
:style="pageStyle"
:aria-label="label"
:aria-label="accessibleLabel"
>
<div class="sky-app-page__backdrop" aria-hidden="true"></div>
<slot />
+22
View File
@@ -60,6 +60,28 @@ describe('SkyNavbar', () => {
expect(html).toContain('Settings')
})
it('keeps migrated boolean sizing and inner hooks compatible', async () => {
const html = await renderToString(
createSSRApp({
render: () =>
h(
SkyNavbar,
{
component: 'nav',
innerClass: 'custom-inner',
large: true,
title: 'Clock',
},
{ right: () => h('button', 'Edit') },
),
}),
)
expect(html).toMatch(/^<nav/)
expect(html).toContain('sky-navbar--large')
expect(html).toContain('sky-navbar__inner custom-inner')
})
it('renders the extended navigation row only when it has controls', async () => {
const html = await renderToString(
createSSRApp({
+32 -7
View File
@@ -18,25 +18,38 @@ const props = withDefaults(
defineProps<{
backAppearance?: 'plain' | 'surface'
backLabel?: string
centerTitle?: boolean
colors?: Record<string, string>
component?: string
innerClass?: string
large?: boolean
medium?: boolean
outline?: boolean
showBack?: boolean
showBackText?: boolean
scrollEl?: HTMLElement | null
subnavbarClass?: string
subtitle?: string
title: string
title?: string
transparent?: boolean
variant?: 'compact' | 'large' | 'medium'
}>(),
{
backAppearance: 'plain',
backLabel: '',
centerTitle: true,
colors: undefined,
component: 'header',
innerClass: '',
large: false,
medium: false,
outline: false,
showBack: false,
showBackText: false,
subnavbarClass: '',
subtitle: '',
transparent: false,
title: '',
variant: 'compact',
},
)
@@ -49,12 +62,19 @@ provideSkyNavbar()
const slots = useSlots()
const pageScroll = useSkyPageScroll()
const resolvedVariant = computed(() => {
if (props.large) return 'large'
if (props.medium) return 'medium'
return props.variant
})
const accessibleBackLabel = computed(() => props.backLabel || props.title)
const hasExtendedTitle = computed(() => props.variant !== 'compact')
const hasExtendedTitle = computed(() => resolvedVariant.value !== 'compact')
const isCollapsible = computed(
() => hasExtendedTitle.value || props.transparent,
)
const titleHeight = computed(() => (props.variant === 'large' ? 52 : 44))
const titleHeight = computed(() =>
resolvedVariant.value === 'large' ? 52 : 44,
)
const effectiveScrollElement = computed(() =>
props.scrollEl === undefined
? (pageScroll?.element.value ?? null)
@@ -133,11 +153,12 @@ function hasNavigationRow(): boolean {
</script>
<template>
<header
<component
:is="component"
v-bind="$attrs"
class="sky-navbar"
:class="[
`sky-navbar--${variant}`,
`sky-navbar--${resolvedVariant}`,
{
'sky-navbar--outline': outline,
'sky-navbar--transparent': transparent,
@@ -150,7 +171,11 @@ function hasNavigationRow(): boolean {
<div class="sky-navbar__blur" aria-hidden="true" />
<div class="sky-navbar__background" aria-hidden="true" />
<div v-if="hasNavigationRow()" class="sky-navbar__inner">
<div
v-if="hasNavigationRow()"
class="sky-navbar__inner"
:class="innerClass"
>
<div v-if="hasLeftContent()" class="sky-navbar__left sky-glass-surface">
<slot name="left">
<button
@@ -219,5 +244,5 @@ function hasNavigationRow(): boolean {
>
<slot name="subnavbar" />
</div>
</header>
</component>
</template>
+15
View File
@@ -51,6 +51,21 @@ describe('SkyTabBar', () => {
expect(html).toContain('sky-tabbar--labels')
})
it('keeps migrated component and styling hooks on their intended layers', async () => {
const html = await renderToString(
createSSRApp(SkyTabBar, {
bgClass: 'custom-background',
component: 'footer',
innerClass: 'custom-inner',
label: 'Navigation',
}),
)
expect(html).toMatch(/^<footer/)
expect(html).toContain('sky-tabbar__background custom-background')
expect(html).toContain('sky-tabbar__inner custom-inner')
})
it('matches the Konsta iOS glass, density, and moving highlight contract', () => {
expect(foundation).toMatch(
/\.sky-tabbar\s*\{[^}]*--sky-tabbar-pane-height:\s*48px[^}]*padding-right:\s*calc\(var\(--sky-safe-area-right\) \+ 16px\)[^}]*padding-bottom:\s*calc\(var\(--sky-safe-area-bottom\) \+ 16px\)/s,
+21 -6
View File
@@ -6,6 +6,7 @@ import {
onMounted,
onUpdated,
ref,
useAttrs,
type CSSProperties,
} from 'vue'
@@ -15,24 +16,37 @@ defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
bgClass?: string
component?: string
floating?: boolean
icons?: boolean
label: string
innerClass?: string
label?: string
labels?: boolean
}>(),
{
bgClass: '',
component: 'nav',
floating: true,
icons: true,
innerClass: '',
label: '',
labels: true,
},
)
const attrs = useAttrs()
const links = ref<HTMLElement | null>(null)
const hasLinks = ref(false)
const pressed = ref(false)
const highlightWidth = ref('0%')
const highlightTransform = ref('translateX(0%)')
const transitionTimingFunction = ref('')
const accessibleLabel = computed(() => {
if (props.label) return props.label
const attribute = attrs['aria-label']
return typeof attribute === 'string' && attribute ? attribute : undefined
})
let activeIndex = 0
let newActiveIndex = 0
let touchRect: DOMRect | null = null
@@ -191,7 +205,8 @@ onBeforeUnmount(() => {
</script>
<template>
<nav
<component
:is="component"
v-bind="$attrs"
class="sky-tabbar"
:class="{
@@ -199,11 +214,11 @@ onBeforeUnmount(() => {
'sky-tabbar--icons': props.icons,
'sky-tabbar--labels': props.labels,
}"
:aria-label="props.label"
:aria-label="accessibleLabel"
>
<span class="sky-tabbar__blur" aria-hidden="true" />
<span class="sky-tabbar__background" aria-hidden="true" />
<div class="sky-tabbar__inner">
<span class="sky-tabbar__background" :class="bgClass" aria-hidden="true" />
<div class="sky-tabbar__inner" :class="innerClass">
<SkyGlass class="sky-tabbar__pane">
<div ref="links" class="sky-tabbar__links">
<slot />
@@ -220,5 +235,5 @@ onBeforeUnmount(() => {
</span>
</SkyGlass>
</div>
</nav>
</component>
</template>
+2 -2
View File
@@ -80,7 +80,7 @@
border: 1px solid transparent;
border-radius: 4px;
background: var(--sky-app-accent, #007aff);
color: #ffffff;
color: var(--sky-button-text, #ffffff);
font-size: 15px;
font-weight: 500;
line-height: 1.2;
@@ -1037,7 +1037,7 @@ label.sky-list-item__row {
min-width: 0;
flex: 1 1 auto;
overflow: hidden;
color: inherit;
color: var(--sky-list-item-title-color, inherit);
font-size: 17px;
font-weight: 400;
line-height: 24px;
+5 -1
View File
@@ -9,6 +9,7 @@ const props = withDefaults(
disabled?: boolean
href?: string
iconOnly?: boolean
linkProps?: Record<string, unknown>
type?: 'button' | 'reset' | 'submit'
}>(),
{
@@ -16,6 +17,7 @@ const props = withDefaults(
disabled: false,
href: undefined,
iconOnly: false,
linkProps: () => ({}),
type: 'button',
},
)
@@ -27,6 +29,7 @@ const emit = defineEmits<{
const elementProps = computed<Record<string, unknown>>(() => {
if (props.component === 'a') {
return {
...props.linkProps,
'aria-disabled': props.disabled || undefined,
href: props.disabled ? undefined : props.href,
tabindex: props.disabled ? -1 : 0,
@@ -34,8 +37,9 @@ const elementProps = computed<Record<string, unknown>>(() => {
}
return {
...props.linkProps,
disabled: props.disabled,
type: props.type,
type: props.linkProps.type ?? props.type,
}
})
@@ -5,18 +5,21 @@ defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
ariaLabel: string
ariaLabel?: string
component?: 'a' | 'button'
disabled?: boolean
href?: string
linkProps?: Record<string, unknown>
showText?: boolean
text?: string
type?: 'button' | 'reset' | 'submit'
}>(),
{
component: 'button',
ariaLabel: '',
disabled: false,
href: undefined,
linkProps: () => ({}),
showText: false,
text: '',
type: 'button',
@@ -28,7 +31,7 @@ const emit = defineEmits<{
}>()
const accessibleLabel = computed(
() => props.ariaLabel || (props.showText ? props.text : undefined),
() => props.ariaLabel || props.text || undefined,
)
const elementProps = computed<Record<string, unknown>>(() => {
const common = {
@@ -37,6 +40,7 @@ const elementProps = computed<Record<string, unknown>>(() => {
if (props.component === 'a') {
return {
...props.linkProps,
...common,
'aria-disabled': props.disabled || undefined,
href: props.disabled ? undefined : props.href,
@@ -45,9 +49,10 @@ const elementProps = computed<Record<string, unknown>>(() => {
}
return {
...props.linkProps,
...common,
disabled: props.disabled,
type: props.type,
type: props.linkProps.type ?? props.type,
}
})
+3 -2
View File
@@ -7,7 +7,7 @@ defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
clearLabel: string
clearLabel?: string
cancelLabel?: string
cancelButton?: boolean
clearButton?: boolean
@@ -29,6 +29,7 @@ const props = withDefaults(
cancelButton: false,
cancelLabel: '',
clearButton: true,
clearLabel: '',
component: 'div',
disableButton: false,
disableLabel: '',
@@ -167,7 +168,7 @@ function disable(event: MouseEvent): void {
v-if="clearButton && localValue"
class="sky-searchbar__clear"
type="button"
:aria-label="clearLabel"
:aria-label="clearLabel || 'Clear search'"
:disabled="disabled"
@pointerdown.prevent
@click="clear($event)"
+30 -6
View File
@@ -1,19 +1,27 @@
<script setup lang="ts">
import { computed } from 'vue'
defineOptions({ inheritAttrs: false })
withDefaults(
const props = withDefaults(
defineProps<{
active?: boolean
ariaLabel?: string
component?: 'a' | 'button'
disabled?: boolean
href?: string
label?: string
linkProps?: Record<string, unknown>
type?: 'button' | 'reset' | 'submit'
}>(),
{
active: false,
ariaLabel: '',
component: 'button',
disabled: false,
href: undefined,
label: '',
linkProps: () => ({}),
type: 'button',
},
)
@@ -21,17 +29,33 @@ withDefaults(
defineEmits<{
click: [event: MouseEvent]
}>()
const elementProps = computed<Record<string, unknown>>(() => {
if (props.component === '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 ?? props.type,
}
})
</script>
<template>
<button
v-bind="$attrs"
<component
:is="component"
v-bind="{ ...$attrs, ...elementProps }"
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">
@@ -41,5 +65,5 @@ defineEmits<{
<slot name="label">{{ label }}</slot>
</span>
<slot />
</button>
</component>
</template>
+21
View File
@@ -785,6 +785,27 @@
align-items: stretch;
}
.sky-tabbar__links > .sky-toolbar-pane {
width: 100%;
height: 100%;
min-width: 0;
display: flex;
align-items: stretch;
border-radius: inherit;
background: transparent;
box-shadow: none;
}
.sky-tabbar__links > .sky-toolbar-pane::after {
display: none;
}
.sky-tabbar__links > .sky-toolbar-pane > .sky-tab-button {
min-width: 0;
min-height: var(--sky-tabbar-pane-height);
flex: 1 1 0;
}
.sky-tabbar__highlight {
height: 100%;
position: absolute;