mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 17:23:25 +00:00
feat: unify phone apps with Sky UI
This commit is contained in:
@@ -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 />
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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>
|
||||
|
||||
Vendored
+21
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user