mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-28 22:01:40 +00:00
ENH - align Sky UI catalog with Konsta
This commit is contained in:
Vendored
+7
-1
@@ -1,6 +1,12 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module 'framework7-icons/vue/vue/*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
|
||||
const component: DefineComponent
|
||||
export default component
|
||||
}
|
||||
|
||||
interface Window {
|
||||
GetParentResourceName?: () => string
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"dompurify": "^3.4.13",
|
||||
"emoji-picker-element-data": "^1.8.0",
|
||||
"fix-webm-duration": "^1.0.6",
|
||||
"framework7-icons": "^5.0.5",
|
||||
"konsta": "~5.2.0",
|
||||
"lucide-vue-next": "^0.525.0",
|
||||
"pinia": "^3.0.3",
|
||||
|
||||
Generated
+9
@@ -35,6 +35,9 @@ importers:
|
||||
fix-webm-duration:
|
||||
specifier: ^1.0.6
|
||||
version: 1.0.6
|
||||
framework7-icons:
|
||||
specifier: ^5.0.5
|
||||
version: 5.0.5
|
||||
konsta:
|
||||
specifier: ~5.2.0
|
||||
version: 5.2.0
|
||||
@@ -1589,6 +1592,10 @@ packages:
|
||||
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
framework7-icons@5.0.5:
|
||||
resolution: {integrity: sha512-bvHMLyujV9TFuudehd3ORZ/EvNp19Ir3ckVzYAOf3MkLymHba/9oHLsgopCh0x5UsrYZUpkrE+fd7ggj5y4wRw==}
|
||||
engines: {node: '>= 0.10.0'}
|
||||
|
||||
fresh@2.0.0:
|
||||
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -4085,6 +4092,8 @@ snapshots:
|
||||
|
||||
forwarded@0.2.0: {}
|
||||
|
||||
framework7-icons@5.0.5: {}
|
||||
|
||||
fresh@2.0.0: {}
|
||||
|
||||
fsevents@2.3.3:
|
||||
|
||||
@@ -70,6 +70,7 @@ import type { EasyShareEvent } from '@/types/easyshare'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
import { formatTimer } from '@/utils/clock'
|
||||
import { parsePhonePreferences } from '@/utils/preferences'
|
||||
import { getHairlinePixelStyle } from '@/utils/rendering'
|
||||
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
|
||||
import SpringboardView from '@/views/SpringboardView.vue'
|
||||
|
||||
@@ -317,6 +318,7 @@ const setupRequired = computed(
|
||||
)
|
||||
const systemColorScheme = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const viewportScale = ref(getViewportScale())
|
||||
const browserDevicePixelRatio = ref(window.devicePixelRatio || 1)
|
||||
const phoneBaseZoom = computed(
|
||||
() =>
|
||||
viewportScale.value *
|
||||
@@ -347,6 +349,7 @@ const phoneZoom = computed(() => {
|
||||
)
|
||||
})
|
||||
const phoneResolutionStyle = computed<CSSProperties>(() => ({
|
||||
...getHairlinePixelStyle(phoneZoom.value, browserDevicePixelRatio.value),
|
||||
'--phone-edge-gap': `${24 * viewportScale.value}px`,
|
||||
'--phone-stack-gap': `${16 * viewportScale.value}px`,
|
||||
'--phone-zoom': phoneZoom.value,
|
||||
@@ -1077,6 +1080,7 @@ function onSystemColorSchemeChange(event: MediaQueryListEvent): void {
|
||||
|
||||
function updateViewportScale(): void {
|
||||
viewportScale.value = getViewportScale()
|
||||
browserDevicePixelRatio.value = window.devicePixelRatio || 1
|
||||
}
|
||||
|
||||
function completeUnlock(): void {
|
||||
@@ -1401,6 +1405,7 @@ onBeforeUnmount(() => {
|
||||
v-for="notification in notifications.devicePreviews"
|
||||
:key="notification.device?.imei"
|
||||
:notification="notification"
|
||||
:device-pixel-ratio="browserDevicePixelRatio"
|
||||
:zoom="
|
||||
phoneBaseZoom *
|
||||
((notification.device?.preferences.settings.phoneScale ?? 100) /
|
||||
@@ -1424,7 +1429,7 @@ onBeforeUnmount(() => {
|
||||
<div
|
||||
class="phone-screen"
|
||||
:class="{
|
||||
'phone-screen--app': isAppRoute,
|
||||
'phone-screen--app': isAppRoute || isDevelopmentRoute,
|
||||
'phone-app--light': !phone.isDarkMode,
|
||||
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
|
||||
}"
|
||||
|
||||
@@ -21,4 +21,11 @@ describe('browser development preview contract', () => {
|
||||
)
|
||||
expect(source).toContain('else loadUnlockedPhoneData()')
|
||||
})
|
||||
|
||||
it('keeps hairlines at one rendered device pixel through phone zoom', () => {
|
||||
expect(source).toContain(
|
||||
'...getHairlinePixelStyle(phoneZoom.value, browserDevicePixelRatio.value)',
|
||||
)
|
||||
expect(source).toContain(':device-pixel-ratio="browserDevicePixelRatio"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -357,9 +357,12 @@ button {
|
||||
.phone-app--performance .sky-ui-provider,
|
||||
.phone-app--performance .sky-app,
|
||||
.phone-app--performance .sky-app-page {
|
||||
--sky-glass: var(--phone-performance-glass);
|
||||
--sky-glass-solid: var(--phone-performance-glass);
|
||||
/* Resolve Glass from the nearest Sky theme instead of the outer phone theme. */
|
||||
--sky-glass: var(--sky-glass-solid);
|
||||
--sky-navbar-glass: var(--sky-bg);
|
||||
--sky-shadow-glass: none;
|
||||
--sky-shadow-glass-fab: none;
|
||||
--sky-shadow-glass-fab-outer: none;
|
||||
--sky-hold-thumb-scale: 1;
|
||||
--sky-range-hold-background: #ffffff;
|
||||
--sky-range-hold-shadow: var(--sky-shadow-thumb);
|
||||
@@ -378,28 +381,42 @@ button {
|
||||
backdrop-filter: none !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
}
|
||||
.phone-app--performance .k-glass {
|
||||
background-color: var(--phone-performance-glass) !important;
|
||||
scale: 1 !important;
|
||||
.phone-app--performance .k-glass,
|
||||
.phone-app--performance
|
||||
.k-range
|
||||
[class~='group-has-[input:active]/range:scale-[1.4]'],
|
||||
.phone-app--performance .k-toggle [class~='group-active:scale-[0.75]'],
|
||||
.phone-app--performance .k-toggle [class~='group-active:scale-[1.4]'] {
|
||||
--tw-scale-x: 1 !important;
|
||||
--tw-scale-y: 1 !important;
|
||||
--tw-scale-z: 1 !important;
|
||||
transform:
|
||||
translate3d(
|
||||
var(--tw-translate-x, 0),
|
||||
var(--tw-translate-y, 0),
|
||||
0
|
||||
)
|
||||
scale(1) !important;
|
||||
}
|
||||
.phone-app--performance .k-glass > [class~='rounded-[inherit]'] {
|
||||
display: none !important;
|
||||
.phone-app--performance .k-glass {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
/* Konsta's default background utility is the solid-surface opt-in marker.
|
||||
Custom color utilities replace it, so semantic Glass colors stay intact. */
|
||||
.phone-app--performance
|
||||
.k-glass:where([class~='bg-ios-light-glass']) {
|
||||
background: var(--phone-performance-glass) !important;
|
||||
}
|
||||
.phone-app--performance
|
||||
.k-range
|
||||
[class~='group-has-[input:active]/range:scale-[1.4]'] {
|
||||
background-color: #ffffff !important;
|
||||
scale: 1 !important;
|
||||
}
|
||||
.phone-app--performance
|
||||
.k-range
|
||||
[class~='group-has-[input:active]/range:opacity-100'] {
|
||||
opacity: 0 !important;
|
||||
}
|
||||
.phone-app--performance .k-toggle [class~='group-active:scale-[0.75]'],
|
||||
.phone-app--performance .k-toggle [class~='group-active:scale-[1.4]'] {
|
||||
scale: 1 !important;
|
||||
}
|
||||
.phone-app--performance .k-toggle [class~='group-active:bg-transparent'] {
|
||||
background-color: #ffffff !important;
|
||||
}
|
||||
@@ -412,16 +429,20 @@ button {
|
||||
.phone-app--performance .sky-glass--touch-highlight {
|
||||
transform: none;
|
||||
}
|
||||
.phone-app--performance .sky-glass,
|
||||
.phone-app--performance .sky-navbar__back--surface {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.phone-app--performance .sky-segmented--navigation {
|
||||
background: var(--phone-performance-glass);
|
||||
background: var(--sky-glass-solid);
|
||||
box-shadow: none;
|
||||
}
|
||||
.phone-app--performance .sky-segmented--navbar {
|
||||
background: var(--phone-performance-glass);
|
||||
background: var(--sky-glass-solid);
|
||||
box-shadow: none;
|
||||
}
|
||||
.phone-app--performance .sky-tabbar__pane {
|
||||
background: var(--phone-performance-glass);
|
||||
background: var(--sky-glass-solid);
|
||||
box-shadow: none;
|
||||
}
|
||||
.phone-app--performance .control-center__backdrop {
|
||||
@@ -461,6 +482,22 @@ button {
|
||||
.phone-app--performance .banking-app__aurora {
|
||||
display: none;
|
||||
}
|
||||
.phone-app--performance .phone-effect--expensive-shadow {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.phone-app--performance .phone-effect--decorative {
|
||||
display: none !important;
|
||||
}
|
||||
.phone-app--performance .phone-effect--filtered-media {
|
||||
filter: none !important;
|
||||
}
|
||||
.phone-app--performance .phone-effect--solid-surface {
|
||||
background: var(
|
||||
--phone-effect-solid-background,
|
||||
var(--phone-performance-glass)
|
||||
) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Chromium 103 cannot resolve color-mix() when the mixed color is a variable. */
|
||||
@supports not (color: color-mix(in srgb, white, black)) {
|
||||
|
||||
@@ -7,8 +7,10 @@ import PhoneNotifications from '@/components/PhoneNotifications.vue'
|
||||
import { PHONE_FRAME_IMAGES } from '@/config/appearance'
|
||||
import type { PhoneNotification } from '@/stores/notifications'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import { getHairlinePixelStyle } from '@/utils/rendering'
|
||||
|
||||
const props = defineProps<{
|
||||
devicePixelRatio: number
|
||||
notification: PhoneNotification
|
||||
zoom: number
|
||||
}>()
|
||||
@@ -28,7 +30,10 @@ const isDarkMode = computed(() => {
|
||||
const frameImage = computed(
|
||||
() => PHONE_FRAME_IMAGES[preferences.value.settings.frame],
|
||||
)
|
||||
const wrapperStyle = computed<CSSProperties>(() => ({ zoom: props.zoom }))
|
||||
const wrapperStyle = computed<CSSProperties>(() => ({
|
||||
...getHairlinePixelStyle(props.zoom, props.devicePixelRatio),
|
||||
zoom: props.zoom,
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -21,10 +21,7 @@ import { useAppStoreStore } from '@/stores/app-store'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { BuiltinPhoneAppId } from '@/types/apps'
|
||||
import { SkyButton, SkyField, SkySpinner } from '@/ui'
|
||||
import {
|
||||
filterMailAddressInput,
|
||||
normalizeMailAddress,
|
||||
} from '@/utils/mail'
|
||||
import { filterMailAddressInput, normalizeMailAddress } from '@/utils/mail'
|
||||
import {
|
||||
PHONE_SETUP_LAST_STEP,
|
||||
WALLPAPER_IDS,
|
||||
@@ -264,9 +261,17 @@ function finish(): void {
|
||||
</div>
|
||||
<footer class="setup-welcome__footer">
|
||||
<div class="setup-welcome__privacy">
|
||||
<span><ShieldCheck :size="14" />{{ phone.t('Setup.welcome.private') }}</span>
|
||||
<span
|
||||
><ShieldCheck :size="14" />{{
|
||||
phone.t('Setup.welcome.private')
|
||||
}}</span
|
||||
>
|
||||
<i aria-hidden="true"></i>
|
||||
<span><Sparkles :size="14" />{{ phone.t('Setup.welcome.personal') }}</span>
|
||||
<span
|
||||
><Sparkles :size="14" />{{
|
||||
phone.t('Setup.welcome.personal')
|
||||
}}</span
|
||||
>
|
||||
</div>
|
||||
<SkyButton class="setup-assistant__primary" @click="continueSetup">
|
||||
{{ phone.t('Setup.getStarted') }}
|
||||
@@ -323,9 +328,23 @@ function finish(): void {
|
||||
</p>
|
||||
<Transition name="setup-account-copy" mode="out-in">
|
||||
<div :key="accountMode" class="setup-cloud-heading">
|
||||
<h1>{{ phone.t(accountMode === 'login' ? 'Setup.cloud.signInTitle' : 'Setup.cloud.createTitle') }}</h1>
|
||||
<h1>
|
||||
{{
|
||||
phone.t(
|
||||
accountMode === 'login'
|
||||
? 'Setup.cloud.signInTitle'
|
||||
: 'Setup.cloud.createTitle',
|
||||
)
|
||||
}}
|
||||
</h1>
|
||||
<p class="setup-assistant__lead">
|
||||
{{ phone.t(accountMode === 'login' ? 'Setup.cloud.signInBody' : 'Setup.cloud.createBody') }}
|
||||
{{
|
||||
phone.t(
|
||||
accountMode === 'login'
|
||||
? 'Setup.cloud.signInBody'
|
||||
: 'Setup.cloud.createBody',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</Transition>
|
||||
@@ -333,7 +352,10 @@ function finish(): void {
|
||||
<div class="setup-assistant__selector" role="group">
|
||||
<span
|
||||
class="setup-assistant__selector-indicator"
|
||||
:class="{ 'setup-assistant__selector-indicator--register': accountMode === 'register' }"
|
||||
:class="{
|
||||
'setup-assistant__selector-indicator--register':
|
||||
accountMode === 'register',
|
||||
}"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<button
|
||||
@@ -356,7 +378,10 @@ function finish(): void {
|
||||
<span>{{ accountInitial }}</span>
|
||||
<div>
|
||||
<small>{{ phone.t('Setup.cloud.accountPreview') }}</small>
|
||||
<strong>{{ email || phone.t('Setup.cloud.addressPlaceholder') }}<em>@ifruit.com</em></strong>
|
||||
<strong
|
||||
>{{ email || phone.t('Setup.cloud.addressPlaceholder')
|
||||
}}<em>@ifruit.com</em></strong
|
||||
>
|
||||
</div>
|
||||
<Check v-if="normalizedAccountEmail" :size="17" />
|
||||
</div>
|
||||
@@ -382,7 +407,9 @@ function finish(): void {
|
||||
v-model="password"
|
||||
type="password"
|
||||
:label="phone.t('Setup.cloud.password')"
|
||||
:autocomplete="accountMode === 'login' ? 'current-password' : 'new-password'"
|
||||
:autocomplete="
|
||||
accountMode === 'login' ? 'current-password' : 'new-password'
|
||||
"
|
||||
/>
|
||||
<Transition name="setup-account-field">
|
||||
<SkyField
|
||||
@@ -397,7 +424,10 @@ function finish(): void {
|
||||
</div>
|
||||
|
||||
<Transition name="setup-account-strength">
|
||||
<div v-if="accountMode === 'register'" class="setup-cloud-strength">
|
||||
<div
|
||||
v-if="accountMode === 'register'"
|
||||
class="setup-cloud-strength"
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
v-for="level in 4"
|
||||
@@ -405,7 +435,9 @@ function finish(): void {
|
||||
:class="{ active: level <= passwordStrength }"
|
||||
></span>
|
||||
</div>
|
||||
<small>{{ phone.t(`Setup.cloud.strength${passwordStrength}`) }}</small>
|
||||
<small>{{
|
||||
phone.t(`Setup.cloud.strength${passwordStrength}`)
|
||||
}}</small>
|
||||
</div>
|
||||
</Transition>
|
||||
<p v-if="accountError" class="setup-assistant__error" role="alert">
|
||||
@@ -418,7 +450,13 @@ function finish(): void {
|
||||
>
|
||||
<SkySpinner v-if="accountBusy" />
|
||||
<Transition v-else name="setup-account-action" mode="out-in">
|
||||
<span :key="accountMode">{{ phone.t(accountMode === 'login' ? 'Setup.cloud.signInAction' : 'Setup.cloud.createAction') }}</span>
|
||||
<span :key="accountMode">{{
|
||||
phone.t(
|
||||
accountMode === 'login'
|
||||
? 'Setup.cloud.signInAction'
|
||||
: 'Setup.cloud.createAction',
|
||||
)
|
||||
}}</span>
|
||||
</Transition>
|
||||
</SkyButton>
|
||||
<div class="setup-cloud-security-note">
|
||||
@@ -476,9 +514,17 @@ function finish(): void {
|
||||
>
|
||||
<span>
|
||||
<b>{{ length }}</b>
|
||||
<small>{{ phone.t(`Setup.security.${length === 4 ? 'fourDigit' : 'sixDigit'}`) }}</small>
|
||||
<small>{{
|
||||
phone.t(
|
||||
`Setup.security.${length === 4 ? 'fourDigit' : 'sixDigit'}`,
|
||||
)
|
||||
}}</small>
|
||||
</span>
|
||||
<Check v-if="passcodeLength === length" :size="16" :stroke-width="3" />
|
||||
<Check
|
||||
v-if="passcodeLength === length"
|
||||
:size="16"
|
||||
:stroke-width="3"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div class="setup-assistant__notice">
|
||||
@@ -488,7 +534,11 @@ function finish(): void {
|
||||
<SkyButton
|
||||
class="setup-assistant__primary"
|
||||
@click="passcodeStage = 'create'"
|
||||
>{{ phone.t('Setup.security.createSelected', { count: String(passcodeLength) }) }}</SkyButton
|
||||
>{{
|
||||
phone.t('Setup.security.createSelected', {
|
||||
count: String(passcodeLength),
|
||||
})
|
||||
}}</SkyButton
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -757,7 +807,13 @@ function finish(): void {
|
||||
: 'Setup.security.confirm',
|
||||
)
|
||||
"
|
||||
:subtitle="phone.t(passcodeLength === 4 ? 'Setup.security.fourDigitHint' : 'Setup.security.sixDigitHint')"
|
||||
:subtitle="
|
||||
phone.t(
|
||||
passcodeLength === 4
|
||||
? 'Setup.security.fourDigitHint'
|
||||
: 'Setup.security.sixDigitHint',
|
||||
)
|
||||
"
|
||||
@cancel="passcodeStage = null"
|
||||
@complete="submitPasscode"
|
||||
/>
|
||||
@@ -987,7 +1043,13 @@ function finish(): void {
|
||||
bottom: 44px;
|
||||
left: 12px;
|
||||
background:
|
||||
linear-gradient(90deg, transparent, rgb(55 122 255 / 13%) 28%, rgb(121 91 255 / 11%) 72%, transparent),
|
||||
linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgb(55 122 255 / 13%) 28%,
|
||||
rgb(121 91 255 / 11%) 72%,
|
||||
transparent
|
||||
),
|
||||
linear-gradient(180deg, transparent, rgb(39 126 255 / 9%) 48%, transparent);
|
||||
filter: blur(22px);
|
||||
content: '';
|
||||
@@ -1023,8 +1085,12 @@ function finish(): void {
|
||||
transform: translateY(12px) scale(0.94);
|
||||
animation: setup-welcome-language 9s cubic-bezier(0.22, 1, 0.36, 1) infinite;
|
||||
}
|
||||
.setup-welcome__greetings span:nth-child(2) { animation-delay: 3s; }
|
||||
.setup-welcome__greetings span:nth-child(3) { animation-delay: 6s; }
|
||||
.setup-welcome__greetings span:nth-child(2) {
|
||||
animation-delay: 3s;
|
||||
}
|
||||
.setup-welcome__greetings span:nth-child(3) {
|
||||
animation-delay: 6s;
|
||||
}
|
||||
.setup-welcome__signature {
|
||||
position: absolute;
|
||||
bottom: 66px;
|
||||
@@ -1072,7 +1138,11 @@ function finish(): void {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
border-top: 1px solid rgb(255 255 255 / 7%);
|
||||
background: linear-gradient(180deg, rgb(7 10 20 / 30%), rgb(4 7 13 / 94%) 28%);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgb(7 10 20 / 30%),
|
||||
rgb(4 7 13 / 94%) 28%
|
||||
);
|
||||
backdrop-filter: blur(24px);
|
||||
}
|
||||
.setup-welcome__privacy {
|
||||
@@ -1109,13 +1179,34 @@ function finish(): void {
|
||||
background: rgb(255 255 255 / 92%);
|
||||
}
|
||||
@keyframes setup-welcome-language {
|
||||
0% { opacity: 0; transform: translateY(12px) scale(0.94); filter: blur(5px); }
|
||||
8%, 27% { opacity: 1; transform: translateY(0) scale(1); filter: blur(0); }
|
||||
34%, 100% { opacity: 0; transform: translateY(-10px) scale(1.03); filter: blur(4px); }
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(12px) scale(0.94);
|
||||
filter: blur(5px);
|
||||
}
|
||||
8%,
|
||||
27% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
filter: blur(0);
|
||||
}
|
||||
34%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px) scale(1.03);
|
||||
filter: blur(4px);
|
||||
}
|
||||
}
|
||||
@keyframes setup-welcome-breathe {
|
||||
0%, 100% { transform: scaleX(0.92); opacity: 0.58; }
|
||||
50% { transform: scaleX(1.04); opacity: 1; }
|
||||
0%,
|
||||
100% {
|
||||
transform: scaleX(0.92);
|
||||
opacity: 0.58;
|
||||
}
|
||||
50% {
|
||||
transform: scaleX(1.04);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.setup-connectivity-card {
|
||||
position: relative;
|
||||
@@ -1195,7 +1286,9 @@ function finish(): void {
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.setup-assistant--step-2 .setup-assistant__eyebrow { margin-top: 8px; }
|
||||
.setup-assistant--step-2 .setup-assistant__eyebrow {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.setup-cloud-hero {
|
||||
position: relative;
|
||||
display: grid;
|
||||
@@ -1223,7 +1316,9 @@ function finish(): void {
|
||||
content: '';
|
||||
transform: rotate(58deg);
|
||||
}
|
||||
.setup-cloud-hero__orbit::after { transform: rotate(-58deg); }
|
||||
.setup-cloud-hero__orbit::after {
|
||||
transform: rotate(-58deg);
|
||||
}
|
||||
.setup-cloud-hero__glow {
|
||||
position: absolute;
|
||||
inset: 13px;
|
||||
@@ -1231,7 +1326,10 @@ function finish(): void {
|
||||
background: rgb(38 125 255 / 22%);
|
||||
box-shadow: 0 0 35px 10px rgb(39 115 255 / 22%);
|
||||
}
|
||||
.setup-cloud-hero svg { position: relative; filter: drop-shadow(0 6px 12px rgb(25 102 255 / 42%)); }
|
||||
.setup-cloud-hero svg {
|
||||
position: relative;
|
||||
filter: drop-shadow(0 6px 12px rgb(25 102 255 / 42%));
|
||||
}
|
||||
.setup-cloud-hero i {
|
||||
position: absolute;
|
||||
width: 3px;
|
||||
@@ -1240,10 +1338,22 @@ function finish(): void {
|
||||
background: #8ac1ff;
|
||||
box-shadow: 0 0 8px #479eff;
|
||||
}
|
||||
.setup-cloud-hero i:nth-of-type(1) { top: 2px; left: 23px; }
|
||||
.setup-cloud-hero i:nth-of-type(2) { right: -1px; bottom: 25px; }
|
||||
.setup-cloud-hero i:nth-of-type(3) { bottom: 3px; left: 11px; }
|
||||
.setup-cloud-form { margin-top: 11px; gap: 7px; }
|
||||
.setup-cloud-hero i:nth-of-type(1) {
|
||||
top: 2px;
|
||||
left: 23px;
|
||||
}
|
||||
.setup-cloud-hero i:nth-of-type(2) {
|
||||
right: -1px;
|
||||
bottom: 25px;
|
||||
}
|
||||
.setup-cloud-hero i:nth-of-type(3) {
|
||||
bottom: 3px;
|
||||
left: 11px;
|
||||
}
|
||||
.setup-cloud-form {
|
||||
margin-top: 11px;
|
||||
gap: 7px;
|
||||
}
|
||||
.setup-cloud-identity {
|
||||
display: flex;
|
||||
min-height: 47px;
|
||||
@@ -1253,7 +1363,11 @@ function finish(): void {
|
||||
gap: 10px;
|
||||
border: 1px solid rgb(113 170 255 / 12%);
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, rgb(49 115 213 / 14%), rgb(255 255 255 / 4%));
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgb(49 115 213 / 14%),
|
||||
rgb(255 255 255 / 4%)
|
||||
);
|
||||
text-align: left;
|
||||
}
|
||||
.setup-cloud-identity > span {
|
||||
@@ -1264,20 +1378,45 @@ function finish(): void {
|
||||
border: 1px solid rgb(255 255 255 / 16%);
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(145deg, #398ff6, #6d5bea);
|
||||
box-shadow: inset 0 1px rgb(255 255 255 / 28%), 0 8px 18px rgb(27 91 208 / 20%);
|
||||
box-shadow:
|
||||
inset 0 1px rgb(255 255 255 / 28%),
|
||||
0 8px 18px rgb(27 91 208 / 20%);
|
||||
color: white;
|
||||
font-size: 15px;
|
||||
font-style: normal;
|
||||
font-weight: 750;
|
||||
place-items: center;
|
||||
}
|
||||
.setup-cloud-identity div { min-width: 0; flex: 1; }
|
||||
.setup-cloud-identity div {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.setup-cloud-identity small,
|
||||
.setup-cloud-identity strong { display: block; }
|
||||
.setup-cloud-identity small { color: rgb(255 255 255 / 43%); font-size: 8px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
|
||||
.setup-cloud-identity strong { margin-top: 2px; overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.setup-cloud-identity em { color: #8dbdff; font-style: normal; font-weight: 600; }
|
||||
.setup-cloud-identity > svg { color: #67d99d; }
|
||||
.setup-cloud-identity strong {
|
||||
display: block;
|
||||
}
|
||||
.setup-cloud-identity small {
|
||||
color: rgb(255 255 255 / 43%);
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.setup-cloud-identity strong {
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.setup-cloud-identity em {
|
||||
color: #8dbdff;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
}
|
||||
.setup-cloud-identity > svg {
|
||||
color: #67d99d;
|
||||
}
|
||||
.setup-cloud-fields {
|
||||
overflow: hidden;
|
||||
border: 1px solid rgb(255 255 255 / 9%);
|
||||
@@ -1304,17 +1443,47 @@ function finish(): void {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
.setup-cloud-fields :deep(.sky-field) { border: 0; border-radius: 0; background: transparent; }
|
||||
.setup-cloud-fields :deep(.sky-field + .sky-field) { border-top: 1px solid rgb(255 255 255 / 8%); }
|
||||
.setup-cloud-fields :deep(.sky-field) { padding: 3px 12px; }
|
||||
.setup-cloud-fields :deep(.sky-field) {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.setup-cloud-fields :deep(.sky-field__inner) {
|
||||
padding: 0;
|
||||
}
|
||||
.setup-cloud-fields :deep(.sky-field + .sky-field) {
|
||||
border-top: 1px solid rgb(255 255 255 / 8%);
|
||||
}
|
||||
.setup-cloud-fields :deep(.sky-field) {
|
||||
padding: 3px 12px;
|
||||
}
|
||||
.setup-cloud-fields :deep(.sky-field__control),
|
||||
.setup-cloud-fields :deep(.sky-field__input) { min-height: 43px; }
|
||||
.setup-cloud-fields :deep(.sky-field--floating-label .sky-field__control) { min-height: 52px; }
|
||||
.setup-cloud-fields :deep(.sky-field--floating-label .sky-field__label) { top: 6px; left: 12px; }
|
||||
.setup-cloud-fields :deep(.sky-field__label) { color: rgb(255 255 255 / 52%); }
|
||||
.setup-cloud-fields :deep(.sky-field__input) { color: #ffffff; caret-color: #5ba9ff; }
|
||||
.setup-cloud-fields :deep(.sky-field__input::placeholder) { color: rgb(255 255 255 / 24%); }
|
||||
.setup-cloud-suffix { color: #82b8ff; font-size: 11px; font-weight: 650; white-space: nowrap; }
|
||||
.setup-cloud-fields :deep(.sky-field__input) {
|
||||
min-height: 43px;
|
||||
}
|
||||
.setup-cloud-fields :deep(.sky-field--floating-label .sky-field__control) {
|
||||
min-height: 52px;
|
||||
}
|
||||
.setup-cloud-fields :deep(.sky-field--floating-label .sky-field__label) {
|
||||
top: 6px;
|
||||
left: 12px;
|
||||
}
|
||||
.setup-cloud-fields :deep(.sky-field__label) {
|
||||
color: rgb(255 255 255 / 52%);
|
||||
}
|
||||
.setup-cloud-fields :deep(.sky-field__input) {
|
||||
color: #ffffff;
|
||||
caret-color: #5ba9ff;
|
||||
}
|
||||
.setup-cloud-fields :deep(.sky-field__input::placeholder) {
|
||||
color: rgb(255 255 255 / 24%);
|
||||
}
|
||||
.setup-cloud-suffix {
|
||||
color: #82b8ff;
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.setup-cloud-strength {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1322,9 +1491,21 @@ function finish(): void {
|
||||
color: rgb(255 255 255 / 42%);
|
||||
font-size: 9px;
|
||||
}
|
||||
.setup-cloud-strength > div { display: flex; flex: 1; gap: 4px; }
|
||||
.setup-cloud-strength span { height: 3px; flex: 1; border-radius: 99px; background: rgb(255 255 255 / 10%); }
|
||||
.setup-cloud-strength span.active { background: linear-gradient(90deg, #318bff, #66c0ff); box-shadow: 0 0 7px rgb(49 139 255 / 34%); }
|
||||
.setup-cloud-strength > div {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 4px;
|
||||
}
|
||||
.setup-cloud-strength span {
|
||||
height: 3px;
|
||||
flex: 1;
|
||||
border-radius: 99px;
|
||||
background: rgb(255 255 255 / 10%);
|
||||
}
|
||||
.setup-cloud-strength span.active {
|
||||
background: linear-gradient(90deg, #318bff, #66c0ff);
|
||||
box-shadow: 0 0 7px rgb(49 139 255 / 34%);
|
||||
}
|
||||
.setup-account-strength-enter-active,
|
||||
.setup-account-strength-leave-active {
|
||||
transition:
|
||||
@@ -1344,9 +1525,17 @@ function finish(): void {
|
||||
color: rgb(199 215 238 / 46%);
|
||||
font-size: 9px;
|
||||
}
|
||||
.setup-assistant--step-2 .setup-assistant__primary { min-height: 46px; }
|
||||
.setup-assistant--step-2 .setup-assistant__later { padding: 7px; }
|
||||
@keyframes setup-cloud-orbit { to { transform: rotate(360deg); } }
|
||||
.setup-assistant--step-2 .setup-assistant__primary {
|
||||
min-height: 46px;
|
||||
}
|
||||
.setup-assistant--step-2 .setup-assistant__later {
|
||||
padding: 7px;
|
||||
}
|
||||
@keyframes setup-cloud-orbit {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.setup-assistant__selector {
|
||||
position: relative;
|
||||
display: grid;
|
||||
@@ -1474,25 +1663,47 @@ function finish(): void {
|
||||
color: #ffffff;
|
||||
background: rgb(255 255 255 / 5%);
|
||||
text-align: left;
|
||||
transition: border-color 160ms ease, background 160ms ease, transform 120ms ease;
|
||||
transition:
|
||||
border-color 160ms ease,
|
||||
background 160ms ease,
|
||||
transform 120ms ease;
|
||||
}
|
||||
.setup-passcode-length button:active {
|
||||
transform: scale(0.975);
|
||||
}
|
||||
.setup-passcode-length button:active { transform: scale(0.975); }
|
||||
.setup-passcode-length button.selected {
|
||||
border-color: rgb(94 172 255 / 72%);
|
||||
background: linear-gradient(145deg, rgb(42 132 245 / 23%), rgb(64 96 210 / 10%));
|
||||
box-shadow: inset 0 1px rgb(255 255 255 / 9%), 0 9px 22px rgb(23 91 193 / 12%);
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
rgb(42 132 245 / 23%),
|
||||
rgb(64 96 210 / 10%)
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 1px rgb(255 255 255 / 9%),
|
||||
0 9px 22px rgb(23 91 193 / 12%);
|
||||
}
|
||||
.setup-passcode-length button > span,
|
||||
.setup-passcode-length b,
|
||||
.setup-passcode-length small { display: block; }
|
||||
.setup-passcode-length b { font-size: 18px; line-height: 1; }
|
||||
.setup-passcode-length small { margin-top: 4px; color: rgb(255 255 255 / 48%); font-size: 9px; }
|
||||
.setup-passcode-length small {
|
||||
display: block;
|
||||
}
|
||||
.setup-passcode-length b {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
.setup-passcode-length small {
|
||||
margin-top: 4px;
|
||||
color: rgb(255 255 255 / 48%);
|
||||
font-size: 9px;
|
||||
}
|
||||
.setup-passcode-length svg {
|
||||
padding: 3px;
|
||||
border-radius: 50%;
|
||||
background: #318cf5;
|
||||
}
|
||||
.setup-assistant--step-3 .setup-assistant__notice { margin: 12px 0; }
|
||||
.setup-assistant--step-3 .setup-assistant__notice {
|
||||
margin: 12px 0;
|
||||
}
|
||||
.setup-choice-grid {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
@@ -1857,4 +2068,54 @@ function finish(): void {
|
||||
opacity: 0;
|
||||
transform: translateX(16px);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.setup-assistant,
|
||||
.setup-assistant *,
|
||||
.setup-assistant *::before,
|
||||
.setup-assistant *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
|
||||
.setup-assistant__aurora,
|
||||
.setup-welcome__hero::before,
|
||||
.setup-cloud-hero__orbit {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.setup-welcome__greetings span {
|
||||
opacity: 0;
|
||||
animation: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.setup-welcome__greetings span:first-child {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.setup-forward-enter-active,
|
||||
.setup-forward-leave-active,
|
||||
.setup-back-enter-active,
|
||||
.setup-back-leave-active {
|
||||
transition-duration: 0.01ms;
|
||||
}
|
||||
|
||||
.setup-forward-enter-from,
|
||||
.setup-forward-leave-to,
|
||||
.setup-back-enter-from,
|
||||
.setup-back-leave-to,
|
||||
.setup-account-field-enter-from,
|
||||
.setup-account-field-leave-to,
|
||||
.setup-account-strength-enter-from,
|
||||
.setup-account-strength-leave-to,
|
||||
.setup-account-copy-enter-from,
|
||||
.setup-account-copy-leave-to,
|
||||
.setup-account-action-enter-from,
|
||||
.setup-account-action-leave-to,
|
||||
.setup-passcode-length button:active {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const appSource = readFileSync(new URL('../App.vue', import.meta.url), 'utf8')
|
||||
const kitchenSinkSource = readFileSync(
|
||||
new URL('../views/development/SkyUiKitchenSinkView.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const indicatorsSource = readFileSync(
|
||||
new URL('./PhoneStatusIndicators.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const styles = readFileSync(
|
||||
new URL('../assets/main.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('phone status bar theme contract', () => {
|
||||
it('treats the Sky UI development catalog as app content', () => {
|
||||
expect(appSource).toContain(
|
||||
"'phone-screen--app': isAppRoute || isDevelopmentRoute",
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the Kitchen Sink toggle and phone shell on one theme source', () => {
|
||||
expect(kitchenSinkSource).toMatch(
|
||||
/const dark = computed\(\{[\s\S]*?get: \(\) => phone\.isDarkMode,[\s\S]*?phone\.setPreference\('appearanceMode', value \? 'dark' : 'light'\)/,
|
||||
)
|
||||
})
|
||||
|
||||
it('uses black in light app content and white in dark content', () => {
|
||||
expect(styles).toMatch(/\.phone-status-bar\s*\{[^}]*color:\s*#fff;/s)
|
||||
expect(styles).toMatch(
|
||||
/\.phone-screen--app \.phone-app--light \.phone-status-bar\s*\{\s*color:\s*#000;/,
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps every status indicator bound to the inherited foreground', () => {
|
||||
expect(indicatorsSource).toMatch(/<Plane[\s\S]*?fill="currentColor"/)
|
||||
expect(indicatorsSource).toMatch(
|
||||
/\.phone-status-indicators__signal\s*\{[^}]*fill:\s*currentColor;/s,
|
||||
)
|
||||
expect(indicatorsSource).toMatch(
|
||||
/\.phone-status-indicators__wifi\s*\{[^}]*stroke:\s*currentColor;/s,
|
||||
)
|
||||
expect(indicatorsSource).toMatch(
|
||||
/\.phone-status-indicators__wifi circle\s*\{[^}]*fill:\s*currentColor;/s,
|
||||
)
|
||||
expect(indicatorsSource).toMatch(
|
||||
/\.phone-status-indicators__battery\s*\{[^}]*fill:\s*currentColor;/s,
|
||||
)
|
||||
expect(indicatorsSource).toMatch(
|
||||
/\.phone-status-indicators__battery rect:first-child\s*\{[^}]*stroke:\s*currentColor;/s,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -323,4 +323,23 @@ onBeforeUnmount(() => {
|
||||
transform: scale(0.86);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.radio-hud__icon {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.radio-hud-member-enter-active,
|
||||
.radio-hud-member-leave-active {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.radio-hud-member-enter-from,
|
||||
.radio-hud-member-leave-to,
|
||||
.radio-hud[data-horizontal='left'] .radio-hud-member-enter-from,
|
||||
.radio-hud[data-horizontal='left'] .radio-hud-member-leave-to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -341,6 +341,7 @@ onBeforeUnmount(() => {
|
||||
:class="[
|
||||
`home-widget--${instance.kind}`,
|
||||
{
|
||||
'phone-effect--solid-surface': instance.kind !== 'weather',
|
||||
'home-widget--music-empty':
|
||||
instance.kind === 'music' && !music.current.value,
|
||||
},
|
||||
@@ -641,6 +642,7 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.home-widget {
|
||||
--phone-effect-solid-background: #19191b;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
@@ -1403,10 +1405,6 @@ onBeforeUnmount(() => {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
:global(.phone-app--performance) .home-widget:not(.home-widget--weather) {
|
||||
background-color: #19191b;
|
||||
}
|
||||
|
||||
@keyframes widget-wobble {
|
||||
from {
|
||||
transform: rotate(-0.7deg) translateY(-0.5px);
|
||||
|
||||
@@ -186,11 +186,17 @@ const canSubmit = computed(() => {
|
||||
height: 46px;
|
||||
grid-row: 1 / span 2;
|
||||
place-items: center;
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--auth-accent, #ffd63e) 46%, transparent);
|
||||
border: 1px solid rgba(255, 214, 62, 0.46);
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--auth-accent, #ffd63e) 46%,
|
||||
transparent
|
||||
);
|
||||
border-radius: 15px;
|
||||
color: var(--auth-accent, var(--yellow, #ffd63e));
|
||||
background: rgba(255, 214, 62, 0.14);
|
||||
background: color-mix(in srgb, var(--auth-accent, #ffd63e) 14%, transparent);
|
||||
box-shadow: 0 10px 28px rgba(255, 214, 62, 0.16);
|
||||
box-shadow: 0 10px 28px
|
||||
color-mix(in srgb, var(--auth-accent, #ffd63e) 16%, transparent);
|
||||
}
|
||||
@@ -221,6 +227,7 @@ const canSubmit = computed(() => {
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 24px;
|
||||
background: var(--panel, #20262c);
|
||||
background: color-mix(in srgb, var(--panel, #20262c) 90%, transparent);
|
||||
box-shadow: 0 22px 50px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(22px) saturate(1.15);
|
||||
@@ -232,6 +239,7 @@ const canSubmit = computed(() => {
|
||||
width: 170px;
|
||||
height: 150px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 214, 62, 0.16);
|
||||
background: color-mix(in srgb, var(--auth-accent, #ffd63e) 16%, transparent);
|
||||
filter: blur(38px);
|
||||
content: '';
|
||||
@@ -279,10 +287,15 @@ const canSubmit = computed(() => {
|
||||
height: 66px;
|
||||
flex: none;
|
||||
place-items: center;
|
||||
border: 2px solid
|
||||
color-mix(in srgb, var(--auth-accent, #ffd63e) 58%, transparent);
|
||||
border: 2px solid rgba(255, 214, 62, 0.58);
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--auth-accent, #ffd63e) 58%,
|
||||
transparent
|
||||
);
|
||||
border-radius: 50%;
|
||||
color: var(--auth-accent, var(--yellow, #ffd63e));
|
||||
background: var(--panel, #20262c);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--auth-accent, #ffd63e) 10%,
|
||||
@@ -344,6 +357,7 @@ const canSubmit = computed(() => {
|
||||
place-items: center;
|
||||
border-radius: 11px;
|
||||
color: var(--auth-accent, #ffd63e);
|
||||
background: rgba(255, 214, 62, 0.13);
|
||||
background: color-mix(in srgb, var(--auth-accent, #ffd63e) 13%, transparent);
|
||||
}
|
||||
.app-profile-auth__identity div {
|
||||
@@ -379,6 +393,7 @@ const canSubmit = computed(() => {
|
||||
color: inherit !important;
|
||||
}
|
||||
.app-profile-auth__fields :deep(.text-xs > div) {
|
||||
background: var(--panel, #20262c) !important;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--panel, #20262c) 94%,
|
||||
@@ -404,6 +419,7 @@ const canSubmit = computed(() => {
|
||||
padding: 0 17px;
|
||||
color: #fff !important;
|
||||
background: var(--auth-accent, var(--yellow, #ffd63e)) !important;
|
||||
box-shadow: 0 10px 26px rgba(255, 214, 62, 0.25);
|
||||
box-shadow: 0 10px 26px
|
||||
color-mix(in srgb, var(--auth-accent, #ffd63e) 25%, transparent);
|
||||
font-weight: 750;
|
||||
|
||||
@@ -10,12 +10,14 @@ const props = withDefaults(
|
||||
defineProps<{
|
||||
accent?: string
|
||||
accentSoft?: string
|
||||
component?: string
|
||||
dark?: boolean
|
||||
label: string
|
||||
}>(),
|
||||
{
|
||||
accent: '',
|
||||
accentSoft: '',
|
||||
component: 'main',
|
||||
dark: undefined,
|
||||
},
|
||||
)
|
||||
@@ -52,7 +54,8 @@ const pageStyle = computed<CSSProperties>(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main
|
||||
<component
|
||||
:is="component"
|
||||
v-bind="$attrs"
|
||||
class="sky-app-page"
|
||||
:class="{ 'sky-app-page--dark': isDark }"
|
||||
@@ -61,5 +64,5 @@ const pageStyle = computed<CSSProperties>(
|
||||
>
|
||||
<div class="sky-app-page__backdrop" aria-hidden="true"></div>
|
||||
<slot />
|
||||
</main>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
@@ -14,6 +14,10 @@ const foundationStyles = readFileSync(
|
||||
new URL('./foundation.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const controlsStyles = readFileSync(
|
||||
new URL('./controls.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const appPageSource = readFileSync(
|
||||
new URL('./SkyAppPage.vue', import.meta.url),
|
||||
'utf8',
|
||||
@@ -84,7 +88,7 @@ describe('SkyNavbar', () => {
|
||||
)?.groups?.declarations
|
||||
|
||||
expect(noNavigationRule).toBeDefined()
|
||||
expect(noNavigationRule).toContain('var(--sky-safe-area-top)')
|
||||
expect(noNavigationRule).toContain('var(--sky-navbar-safe-area-top)')
|
||||
expect(noNavigationRule).toContain('var(--sky-navbar-height)')
|
||||
expect(noNavigationRule).toContain('var(--sky-space-3)')
|
||||
expect(navigationRowRule).toBeDefined()
|
||||
@@ -211,6 +215,40 @@ describe('SkyNavbar', () => {
|
||||
|
||||
expect(html).toContain('sky-navbar__back--surface')
|
||||
expect(html).toContain('aria-label="Back to Settings"')
|
||||
expect(html).toContain('class="sky-navbar__back-icon"')
|
||||
expect(html).toContain('viewBox="0 0 12 20"')
|
||||
expect(html).not.toContain('lucide-chevron-left')
|
||||
})
|
||||
|
||||
it('keeps the Konsta glass wrappers and centers the exact iOS back icon', () => {
|
||||
expect(navbarSource).toContain('class="sky-navbar__left sky-glass-surface"')
|
||||
expect(navbarSource).toContain(
|
||||
'class="sky-navbar__right sky-glass-surface"',
|
||||
)
|
||||
expect(foundationStyles).toMatch(
|
||||
/\.sky-navbar__back\s*\{[^}]*padding:\s*0 16px;[^}]*justify-content:\s*center/s,
|
||||
)
|
||||
expect(foundationStyles).toMatch(
|
||||
/\.sky-navbar__back-icon\s*\{[^}]*width:\s*12px;[^}]*height:\s*20px;[^}]*display:\s*block;[^}]*fill:\s*currentColor/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('inherits the Konsta iOS foreground for navbar and toolbar actions', () => {
|
||||
expect(foundationStyles).toMatch(
|
||||
/\.sky-navbar__back\s*\{[^}]*color:\s*inherit;/s,
|
||||
)
|
||||
expect(controlsStyles).toMatch(
|
||||
/\.sky-navbar \.sky-link,\s*\.sky-navbar \.sky-navbar-back-link,\s*\.sky-toolbar \.sky-link\s*\{[^}]*color:\s*inherit;/s,
|
||||
)
|
||||
expect(controlsStyles).toMatch(
|
||||
/\.sky-link\s*\{[^}]*color:\s*var\(--sky-app-accent, #007aff\);/s,
|
||||
)
|
||||
expect(controlsStyles).toMatch(
|
||||
/\.sky-navbar-back-link:active:not\(:disabled\)\s*\{[^}]*opacity:\s*0\.5;[^}]*transition-duration:\s*0ms;/s,
|
||||
)
|
||||
expect(controlsStyles).not.toMatch(
|
||||
/\.sky-navbar-back-link:active:not\(:disabled\)\s*\{[^}]*--sky-app-accent-soft/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('renders the Konsta 56px subnavbar and custom class hook', async () => {
|
||||
@@ -238,6 +276,9 @@ describe('SkyNavbar', () => {
|
||||
})
|
||||
|
||||
it('uses the Konsta navbar glass heights with and without subnavbar', () => {
|
||||
const navbarRule = foundationStyles.match(
|
||||
/(?:^|\n)\.sky-navbar\s*\{(?<declarations>[^}]*)\}/,
|
||||
)?.groups?.declarations
|
||||
const baseEffectRule = foundationStyles.match(
|
||||
/\.sky-navbar__blur,\s*\.sky-navbar__background\s*\{(?<declarations>[^}]*)\}/,
|
||||
)?.groups?.declarations
|
||||
@@ -245,10 +286,13 @@ describe('SkyNavbar', () => {
|
||||
/\.sky-navbar--with-subnavbar \.sky-navbar__blur,\s*\.sky-navbar--with-subnavbar \.sky-navbar__background\s*\{(?<declarations>[^}]*)\}/,
|
||||
)?.groups?.declarations
|
||||
|
||||
expect(baseEffectRule).toContain('var(--sky-safe-area-top)')
|
||||
expect(navbarRule).toContain(
|
||||
'--sky-navbar-safe-area-top: max(16px, var(--sky-safe-area-top))',
|
||||
)
|
||||
expect(baseEffectRule).toContain('var(--sky-navbar-safe-area-top)')
|
||||
expect(baseEffectRule).toContain('var(--sky-navbar-height)')
|
||||
expect(baseEffectRule).toContain('+ 16px')
|
||||
expect(subnavbarEffectRule).toContain('var(--sky-safe-area-top)')
|
||||
expect(subnavbarEffectRule).toContain('var(--sky-navbar-safe-area-top)')
|
||||
expect(subnavbarEffectRule).toContain('var(--sky-navbar-height)')
|
||||
expect(subnavbarEffectRule).toContain('+ 70px + 16px')
|
||||
expect(foundationStyles).toMatch(
|
||||
@@ -270,7 +314,12 @@ describe('SkyNavbar', () => {
|
||||
subnavbar: () =>
|
||||
h(
|
||||
SkySegmented,
|
||||
{ activeIndex: 0, ariaLabel: 'Store sections', itemCount: 2 },
|
||||
{
|
||||
activeIndex: 0,
|
||||
ariaLabel: 'Store sections',
|
||||
itemCount: 2,
|
||||
strong: true,
|
||||
},
|
||||
{
|
||||
default: () => [
|
||||
h(SkySegmentedButton, { active: true }, () => 'Apps'),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronLeft } from 'lucide-vue-next'
|
||||
import {
|
||||
computed,
|
||||
onBeforeUnmount,
|
||||
@@ -152,7 +151,7 @@ function hasNavigationRow(): boolean {
|
||||
<div class="sky-navbar__background" aria-hidden="true" />
|
||||
|
||||
<div v-if="hasNavigationRow()" class="sky-navbar__inner">
|
||||
<div v-if="hasLeftContent()" class="sky-navbar__left">
|
||||
<div v-if="hasLeftContent()" class="sky-navbar__left sky-glass-surface">
|
||||
<slot name="left">
|
||||
<button
|
||||
v-if="showBack"
|
||||
@@ -162,7 +161,15 @@ function hasNavigationRow(): boolean {
|
||||
:aria-label="accessibleBackLabel"
|
||||
@click="emit('back')"
|
||||
>
|
||||
<ChevronLeft :size="26" :stroke-width="2" aria-hidden="true" />
|
||||
<svg
|
||||
class="sky-navbar__back-icon"
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 12 20"
|
||||
>
|
||||
<path
|
||||
d="M10.6737904,1.29289322 C11.0342516,1.65335447 11.0619794,2.22054978 10.7569738,2.61281627 L10.6737458,2.70706222 L3.76756619,9.61235263 C3.5939889,9.78590804 3.57468677,10.0553312 3.70967055,10.2502079 L3.76753111,10.3194689 L10.673816,17.2262348 C11.0643303,17.6167774 11.0643188,18.2499456 10.6737904,18.640474 C10.2832661,19.0309983 9.65010112,19.0309983 9.25957683,18.640474 L1.29289322,10.6737904 C0.902368927,10.2832661 0.902368927,9.65010112 1.29289322,9.25957683 L9.25957683,1.29289322 C9.62006079,0.932409257 10.1872918,0.904679722 10.5795831,1.20970461 L10.6737904,1.29289322 Z"
|
||||
/>
|
||||
</svg>
|
||||
<span v-if="showBackText" class="sky-navbar__back-label">
|
||||
{{ backLabel }}
|
||||
</span>
|
||||
@@ -179,7 +186,7 @@ function hasNavigationRow(): boolean {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="hasRightContent()" class="sky-navbar__right">
|
||||
<div v-if="hasRightContent()" class="sky-navbar__right sky-glass-surface">
|
||||
<slot name="right" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,10 +8,12 @@ defineOptions({ inheritAttrs: false })
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
as?: string
|
||||
padded?: boolean
|
||||
withTabbar?: boolean
|
||||
}>(),
|
||||
{
|
||||
as: 'section',
|
||||
padded: false,
|
||||
withTabbar: false,
|
||||
},
|
||||
)
|
||||
@@ -38,7 +40,10 @@ onBeforeUnmount(() => unregister?.())
|
||||
ref="root"
|
||||
v-bind="$attrs"
|
||||
class="sky-scroll-area"
|
||||
:class="{ 'sky-scroll-area--tabbar': withTabbar }"
|
||||
:class="{
|
||||
'sky-scroll-area--padded': padded,
|
||||
'sky-scroll-area--tabbar': withTabbar,
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
</component>
|
||||
|
||||
@@ -65,7 +65,16 @@ describe('SkyTabBar', () => {
|
||||
/\.sky-tab-button__icon\s*\{[^}]*width:\s*28px[^}]*height:\s*28px/s,
|
||||
)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-tab-button__label\s*\{[^}]*font-size:\s*12px[^}]*font-weight:\s*500/s,
|
||||
/\.sky-tab-button__label\s*\{[^}]*font-size:\s*16px[^}]*font-weight:\s*400[^}]*line-height:\s*24px/s,
|
||||
)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-tabbar \.sky-tab-button__icon \+ \.sky-tab-button__label\s*\{[^}]*font-size:\s*12px[^}]*font-weight:\s*500[^}]*line-height:\s*16px/s,
|
||||
)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-tabbar \.sky-tab-button\s*\{[^}]*color:\s*var\(--sky-text,\s*#000000\)/s,
|
||||
)
|
||||
expect(foundation).not.toContain(
|
||||
'.sky-tabbar__links > button > span > span:last-child',
|
||||
)
|
||||
expect(source).toContain("attributeFilter: ['class']")
|
||||
expect(source).toContain(
|
||||
@@ -75,7 +84,7 @@ describe('SkyTabBar', () => {
|
||||
|
||||
it('keeps Performance solid while Ultimate retains shared Glass', () => {
|
||||
expect(mainCss).toMatch(
|
||||
/\.phone-app--performance \.sky-tabbar__pane\s*\{[^}]*background:\s*var\(--phone-performance-glass\)[^}]*box-shadow:\s*none/s,
|
||||
/\.phone-app--performance \.sky-tabbar__pane\s*\{[^}]*background:\s*var\(--sky-glass-solid\)[^}]*box-shadow:\s*none/s,
|
||||
)
|
||||
expect(source).toContain('<SkyGlass class="sky-tabbar__pane">')
|
||||
})
|
||||
|
||||
@@ -40,6 +40,7 @@ const konstaIosParity = {
|
||||
List: 'SkyList',
|
||||
ListButton: 'SkyListButton',
|
||||
ListGroup: 'SkyListGroup',
|
||||
ListGroupInner: 'SkyListGroupInner',
|
||||
ListInput: 'SkyField',
|
||||
ListItem: 'SkyListItem',
|
||||
MenuList: 'SkyMenuList',
|
||||
@@ -57,7 +58,6 @@ const konstaIosParity = {
|
||||
Popup: 'SkyPopup',
|
||||
Preloader: 'SkySpinner',
|
||||
Progressbar: 'SkyProgress',
|
||||
Provider: 'SkyProvider',
|
||||
Radio: 'SkyRadio',
|
||||
Range: 'SkyRange',
|
||||
Searchbar: 'SkySearchbar',
|
||||
@@ -143,6 +143,12 @@ describe('Sky UI public catalog', () => {
|
||||
expect(missingIndexes).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps SkyProvider as a Sky extension outside the Konsta catalog', () => {
|
||||
expect(readFileSync(rootIndexPath, 'utf8')).toContain(
|
||||
"export { default as SkyProvider } from './SkyProvider.vue'",
|
||||
)
|
||||
})
|
||||
|
||||
it('exports every Sky component from its family index exactly once', () => {
|
||||
const failures: string[] = []
|
||||
|
||||
|
||||
@@ -5,7 +5,16 @@ import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const uiDirectory = fileURLToPath(new URL('.', import.meta.url))
|
||||
const frontendDirectory = fileURLToPath(new URL('..', import.meta.url))
|
||||
const mainSource = readFileSync(new URL('../main.ts', import.meta.url), 'utf8')
|
||||
const mainCssSource = readFileSync(
|
||||
new URL('../assets/main.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const appProfileAuthSource = readFileSync(
|
||||
new URL('../components/account/AppProfileAuth.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
interface StyleSource {
|
||||
content: string
|
||||
@@ -108,6 +117,52 @@ describe('Sky UI FiveM CEF contracts', () => {
|
||||
expect(violations).toEqual([])
|
||||
})
|
||||
|
||||
it('does not make production component styling depend on :has()', () => {
|
||||
const offenders = walkFiles(frontendDirectory)
|
||||
.filter(
|
||||
(path) =>
|
||||
/\.(?:css|vue)$/.test(path) &&
|
||||
!/\.(?:test|spec)\.(?:ts|tsx)$/.test(path),
|
||||
)
|
||||
.filter((path) => /:has\s*\(/i.test(readFileSync(path, 'utf8')))
|
||||
.map((path) => relative(frontendDirectory, path).replace(/\\/g, '/'))
|
||||
|
||||
expect(offenders).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps variable profile color mixes behind Chrome 103 fallbacks', () => {
|
||||
expect(appProfileAuthSource).toMatch(
|
||||
/\.app-profile-auth__card\s*\{[^}]*background:\s*var\(--panel, #20262c\);[^}]*background:\s*color-mix\(/s,
|
||||
)
|
||||
expect(appProfileAuthSource).toMatch(
|
||||
/\.app-profile-auth__mark\s*\{[^}]*background:\s*rgba\(255, 214, 62, 0\.14\);[^}]*background:\s*color-mix\(/s,
|
||||
)
|
||||
expect(appProfileAuthSource).toMatch(
|
||||
/\.app-profile-auth__avatar\s*\{[^}]*background:\s*var\(--panel, #20262c\);[^}]*background:\s*color-mix\(/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps Performance hold transforms on the Chrome 103 syntax floor', () => {
|
||||
const performanceStart = mainCssSource.indexOf(
|
||||
'/* FiveM CEF blur-free glass fallback.',
|
||||
)
|
||||
const performanceEnd = mainCssSource.indexOf(
|
||||
'/* Chromium 103 cannot resolve color-mix()',
|
||||
performanceStart,
|
||||
)
|
||||
const performanceCss = mainCssSource.slice(
|
||||
performanceStart,
|
||||
performanceEnd,
|
||||
)
|
||||
|
||||
expect(performanceStart).toBeGreaterThanOrEqual(0)
|
||||
expect(performanceEnd).toBeGreaterThan(performanceStart)
|
||||
expect(performanceCss).not.toMatch(/(?:^|[;{])\s*scale\s*:/m)
|
||||
expect(performanceCss).toMatch(
|
||||
/transform:\s*translate3d\([\s\S]*?var\(--tw-translate-x, 0\)[\s\S]*?var\(--tw-translate-y, 0\)[\s\S]*?scale\(1\) !important;/,
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps optional glass blur behind a solid CEF fallback', () => {
|
||||
const blurDeclaration = /(?:^|[;{])\s*(?:-webkit-)?backdrop-filter\s*:/im
|
||||
const blurSources = uiStyleSources().filter(({ content }) =>
|
||||
|
||||
@@ -9,13 +9,19 @@ import {
|
||||
SkyActionButton,
|
||||
SkyActionGroup,
|
||||
SkyBadge,
|
||||
SkyButton,
|
||||
SkyCard,
|
||||
SkyChip,
|
||||
SkyDialog,
|
||||
SkyField,
|
||||
SkyListItem,
|
||||
SkyMessage,
|
||||
SkyMessagebar,
|
||||
SkyMenuList,
|
||||
SkyMenuListItem,
|
||||
SkySearchbar,
|
||||
SkyScrollArea,
|
||||
SkySpinner,
|
||||
SkyToast,
|
||||
} from '@/ui'
|
||||
|
||||
@@ -30,6 +36,60 @@ async function render(
|
||||
}
|
||||
|
||||
describe('Sky UI Konsta 5.3 parity contracts', () => {
|
||||
it('keeps Page content edge-to-edge unless padded compatibility is explicit', async () => {
|
||||
const edgeToEdge = await render(
|
||||
SkyScrollArea,
|
||||
{},
|
||||
{ default: () => 'Content' },
|
||||
)
|
||||
const padded = await render(
|
||||
SkyScrollArea,
|
||||
{ padded: true },
|
||||
{ default: () => 'Content' },
|
||||
)
|
||||
|
||||
expect(edgeToEdge).toContain('class="sky-scroll-area"')
|
||||
expect(edgeToEdge).not.toContain('sky-scroll-area--padded')
|
||||
expect(padded).toContain('sky-scroll-area--padded')
|
||||
})
|
||||
|
||||
it('keeps Field styling on its root while forwarding native attrs to the control', async () => {
|
||||
const html = await render(SkyField, {
|
||||
class: 'native-input-class',
|
||||
'data-native': 'yes',
|
||||
inputId: 'native-input',
|
||||
placeholder: 'Name',
|
||||
})
|
||||
|
||||
const rootTag = html.match(/^<li[^>]*>/)?.[0]
|
||||
const inputTag = html.match(/<input[^>]*>/)?.[0]
|
||||
|
||||
expect(rootTag).toMatch(/class="[^"]*\bsky-field\b/)
|
||||
expect(rootTag).toContain('native-input-class')
|
||||
expect(rootTag).not.toContain('sky-field--error')
|
||||
expect(rootTag).not.toContain('data-native')
|
||||
expect(inputTag).not.toContain('native-input-class')
|
||||
expect(inputTag).toContain('data-native="yes"')
|
||||
})
|
||||
|
||||
it('uses Konsta iOS defaults for Badge, Button, Messagebar, Message and Spinner', async () => {
|
||||
const [badge, button, messagebar, message, spinner] = await Promise.all([
|
||||
render(SkyBadge),
|
||||
render(SkyButton, {}, { default: () => 'Continue' }),
|
||||
render(SkyMessagebar),
|
||||
render(SkyMessage, {}, { default: () => 'Sent' }),
|
||||
render(SkySpinner),
|
||||
])
|
||||
|
||||
expect(badge).toContain('sky-badge--primary')
|
||||
expect(button).not.toContain('sky-button--inline')
|
||||
expect(messagebar).not.toContain('sky-messagebar--outline')
|
||||
expect(message).toMatch(/^<div[^>]*sky-message--sent/)
|
||||
expect(spinner).toContain('width:32px')
|
||||
expect(spinner.match(/<path/g)).toHaveLength(8)
|
||||
expect(spinner).not.toContain('<i')
|
||||
})
|
||||
|
||||
it('keeps the main ListItem migration hooks and slot geometry', async () => {
|
||||
const html = await render(
|
||||
SkyListItem,
|
||||
@@ -48,6 +108,7 @@ describe('Sky UI Konsta 5.3 parity contracts', () => {
|
||||
},
|
||||
{
|
||||
content: () => h('span', { class: 'custom-content-slot' }, 'Content'),
|
||||
default: () => h('span', { class: 'custom-default-slot' }, 'Default'),
|
||||
inner: () => h('span', { class: 'custom-inner-slot' }, 'Inner'),
|
||||
},
|
||||
)
|
||||
@@ -61,7 +122,11 @@ describe('Sky UI Konsta 5.3 parity contracts', () => {
|
||||
expect(html).toContain('custom-inner')
|
||||
expect(html).toContain('custom-media')
|
||||
expect(html).toContain('custom-content-slot')
|
||||
expect(html.indexOf('custom-default-slot')).toBeGreaterThan(
|
||||
html.indexOf('</a>'),
|
||||
)
|
||||
expect(html).toContain('custom-inner-slot')
|
||||
expect(html).toContain('<div class="sky-list-item__title')
|
||||
})
|
||||
|
||||
it('marks MenuList and forwards inherited MenuListItem content', async () => {
|
||||
@@ -181,6 +246,62 @@ describe('Sky UI Konsta 5.3 parity contracts', () => {
|
||||
expect(combined).toMatch(/@supports[\s\S]*backdrop-filter/)
|
||||
expect(combined).toContain('sky-glass--highlight-visible')
|
||||
expect(combined).toContain('sky-glass--touch-highlight')
|
||||
expect(combined).toContain('sky-glass-surface')
|
||||
expect(combined).toContain('var(--sky-navbar-glass, var(--sky-bg))')
|
||||
})
|
||||
|
||||
it('locks visible iOS geometry while keeping touch expansion invisible', () => {
|
||||
const uiDirectory = fileURLToPath(new URL('.', import.meta.url))
|
||||
const controls = readFileSync(`${uiDirectory}/controls.css`, 'utf8')
|
||||
const overlays = readFileSync(`${uiDirectory}/overlays.css`, 'utf8')
|
||||
|
||||
expect(controls).toMatch(
|
||||
/\.sky-button\s*\{[^}]*height:\s*34px[^}]*min-height:\s*34px[^}]*padding:\s*4px 8px/s,
|
||||
)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-button--small\s*\{[^}]*height:\s*28px[^}]*min-height:\s*28px[^}]*padding-inline:\s*8px/s,
|
||||
)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-button::before\s*\{[^}]*width:\s*max\(100%, var\(--sky-touch-target, 44px\)\)[^}]*inset-block:\s*-5px/s,
|
||||
)
|
||||
expect(controls).not.toMatch(
|
||||
/\.sky-button:active:not\(:disabled\)\s*\{[^}]*transform:/s,
|
||||
)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-checkbox__mark\s*\{[^}]*width:\s*22px[^}]*height:\s*22px/s,
|
||||
)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-list-item__title-wrap\s*\{[^}]*min-height:\s*28px/s,
|
||||
)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-list-item__row\s*\{[^}]*gap:\s*0[^}]*padding:\s*0 0 0 calc\(var\(--sky-safe-area-left\) \+ 16px\)/s,
|
||||
)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-list-item__media\s*\{[^}]*margin-right:\s*16px[^}]*padding:\s*8px 0/s,
|
||||
)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-list-item__content\s*\{[^}]*padding:\s*12px calc\(var\(--sky-safe-area-right\) \+ 16px\) 12px 0/s,
|
||||
)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-block-title \+ \.sky-block-header,[\s\S]*?\.sky-block-title \+ \.sky-block-footer,[\s\S]*?\{[\s\S]*?margin-top:\s*8px;/,
|
||||
)
|
||||
expect(controls).toContain(
|
||||
'.sky-list-item--dividers:not(.sky-list-item--menu)',
|
||||
)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-radio__mark\s*\{[^}]*width:\s*22px[^}]*height:\s*22px/s,
|
||||
)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-toggle__track\s*\{[^}]*width:\s*64px[^}]*height:\s*28px/s,
|
||||
)
|
||||
expect(controls).toMatch(/\.sky-range__input\s*\{[^}]*height:\s*28px/s)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-spinner__svg\s*\{[^}]*animation:\s*sky-spinner-spin 1s steps\(8, end\) infinite/s,
|
||||
)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-list-item--group-title\.sky-list-item--contacts\s*\{[^}]*background:\s*var\(--sky-list-group-title-contacts-background[^}]*color:\s*var\(--sky-list-group-title-contacts-text/s,
|
||||
)
|
||||
expect(overlays).toMatch(/\.sky-action-button\s*\{[^}]*font-size:\s*20px/s)
|
||||
expect(overlays).toMatch(/\.sky-messages\s*\{[^}]*margin-bottom:\s*48px/s)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,17 +20,44 @@ describe('Sky UI graphics modes', () => {
|
||||
fileURLToPath(new URL('./controls.css', import.meta.url)),
|
||||
'utf8',
|
||||
)
|
||||
const controlCenterSource = readFileSync(
|
||||
fileURLToPath(new URL('../components/PhoneControlCenter.vue', import.meta.url)),
|
||||
'utf8',
|
||||
)
|
||||
const appModeConsumers = [
|
||||
'../components/SpringboardWidget.vue',
|
||||
'../views/apps/AppStoreApp.vue',
|
||||
'../views/apps/AppStoreDetail.vue',
|
||||
'../views/apps/MusicApp.vue',
|
||||
]
|
||||
.map((path) =>
|
||||
readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8'),
|
||||
)
|
||||
.join('\n')
|
||||
|
||||
it('keeps Performance glass solid and blur-free', () => {
|
||||
expect(performanceCss).toContain(
|
||||
'--sky-glass: var(--phone-performance-glass)',
|
||||
)
|
||||
expect(performanceCss).toContain(
|
||||
expect(performanceCss).toContain('--sky-glass: var(--sky-glass-solid)')
|
||||
expect(performanceCss).not.toContain(
|
||||
'--sky-glass-solid: var(--phone-performance-glass)',
|
||||
)
|
||||
expect(performanceCss).toContain('--sky-navbar-glass: var(--sky-bg)')
|
||||
expect(performanceCss).toContain('--sky-shadow-glass: none')
|
||||
expect(performanceCss).toContain('backdrop-filter: none !important')
|
||||
expect(performanceCss).toContain('-webkit-backdrop-filter: none !important')
|
||||
expect(performanceCss).toMatch(
|
||||
/\.phone-app--performance \.k-glass\s*\{[^}]*box-shadow:\s*none !important[^}]*\}/s,
|
||||
)
|
||||
expect(performanceCss).not.toMatch(
|
||||
/\.phone-app--performance \.k-glass\s*\{[^}]*background(?:-color)?:/s,
|
||||
)
|
||||
expect(performanceCss).toMatch(
|
||||
/\.k-glass:where\(\[class~='bg-ios-light-glass'\]\)\s*\{[^}]*background:\s*var\(--phone-performance-glass\) !important/s,
|
||||
)
|
||||
expect(performanceCss).not.toContain("[class~='dark:bg-ios-dark-glass']")
|
||||
expect(controlCenterSource).toContain("bgIos: 'bg-[#5e5ce6]'")
|
||||
expect(controlCenterSource).not.toContain(
|
||||
"bgIos: 'bg-ios-light-glass bg-[#5e5ce6]'",
|
||||
)
|
||||
})
|
||||
|
||||
it('removes Range and Toggle hold glass only inside Performance mode', () => {
|
||||
@@ -49,12 +76,17 @@ describe('Sky UI graphics modes', () => {
|
||||
expect(performanceCss).toContain(
|
||||
'.phone-app--performance .sky-glass--highlight-visible::after',
|
||||
)
|
||||
expect(performanceCss).toContain("[class~='rounded-[inherit]']")
|
||||
expect(performanceCss).not.toContain("[class~='rounded-[inherit]']")
|
||||
expect(performanceCss).toContain(
|
||||
"[class~='group-has-[input:active]/range:scale-[1.4]']",
|
||||
)
|
||||
expect(performanceCss).toContain("[class~='group-active:scale-[1.4]']")
|
||||
expect(performanceCss).toContain("[class~='group-active:opacity-100']")
|
||||
expect(performanceCss).not.toMatch(/(?:^|[;{])\s*scale\s*:/m)
|
||||
expect(performanceCss).toContain('--tw-scale-x: 1 !important')
|
||||
expect(performanceCss).toContain('var(--tw-translate-x, 0)')
|
||||
expect(performanceCss).toContain('var(--tw-translate-y, 0)')
|
||||
expect(performanceCss).toContain('scale(1) !important')
|
||||
})
|
||||
|
||||
it('does not globally disable functional motion or focus styling', () => {
|
||||
@@ -70,16 +102,34 @@ describe('Sky UI graphics modes', () => {
|
||||
|
||||
it('keeps pill navigation solid in Performance and glass in Ultimate', () => {
|
||||
expect(performanceCss).toMatch(
|
||||
/\.phone-app--performance \.sky-segmented--navigation\s*\{[^}]*background:\s*var\(--phone-performance-glass\)[^}]*box-shadow:\s*none/s,
|
||||
/\.phone-app--performance \.sky-segmented--navigation\s*\{[^}]*background:\s*var\(--sky-glass-solid\)[^}]*box-shadow:\s*none/s,
|
||||
)
|
||||
expect(performanceCss).toMatch(
|
||||
/\.phone-app--performance \.sky-segmented--navbar\s*\{[^}]*background:\s*var\(--phone-performance-glass\)[^}]*box-shadow:\s*none/s,
|
||||
/\.phone-app--performance \.sky-segmented--navbar\s*\{[^}]*background:\s*var\(--sky-glass-solid\)[^}]*box-shadow:\s*none/s,
|
||||
)
|
||||
expect(controlsCss).toMatch(
|
||||
/\.sky-glass\s*\{[^}]*background:\s*var\(--sky-glass-solid[^}]*box-shadow:\s*var\(--sky-shadow-glass\)/s,
|
||||
)
|
||||
expect(controlsCss).toMatch(
|
||||
/@supports[\s\S]*?\.sky-glass\s*\{[^}]*backdrop-filter:\s*blur\(16px\)[^}]*\}[\s\S]*?\.sky-glass\s*\{[^}]*background:\s*var\(--sky-glass,/,
|
||||
expect(controlsCss).toContain('@supports (')
|
||||
expect(controlsCss).toContain('backdrop-filter: blur(16px)')
|
||||
expect(controlsCss).toContain(
|
||||
'background: var(--sky-glass, rgba(255, 255, 255, 0.75))',
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps app-specific effect choices behind shared semantic markers', () => {
|
||||
expect(appModeConsumers).not.toContain(':global(.phone-app--performance)')
|
||||
expect(appModeConsumers).not.toContain(
|
||||
'[`phone-app--${phone.preferences.settings.graphicsMode}`]',
|
||||
)
|
||||
expect(appModeConsumers).toContain('phone-effect--expensive-shadow')
|
||||
expect(appModeConsumers).toContain('phone-effect--decorative')
|
||||
expect(appModeConsumers).toContain('phone-effect--solid-surface')
|
||||
expect(performanceCss).toContain(
|
||||
'.phone-app--performance .phone-effect--expensive-shadow',
|
||||
)
|
||||
expect(performanceCss).toMatch(
|
||||
/\.phone-app--performance \.phone-effect--solid-surface\s*\{[^}]*background:\s*var\(/s,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
+683
-331
File diff suppressed because it is too large
Load Diff
@@ -5,12 +5,12 @@ withDefaults(
|
||||
defineProps<{
|
||||
component?: string
|
||||
small?: boolean
|
||||
tone?: 'danger' | 'info' | 'neutral' | 'success' | 'warning'
|
||||
tone?: 'danger' | 'info' | 'neutral' | 'primary' | 'success' | 'warning'
|
||||
}>(),
|
||||
{
|
||||
component: 'span',
|
||||
small: false,
|
||||
tone: 'neutral',
|
||||
tone: 'primary',
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -48,7 +48,7 @@ describe('SkyButton', () => {
|
||||
expect(buttonStyles).not.toContain('--sky-app-accent-shade')
|
||||
})
|
||||
|
||||
it('keeps outline hover border, surface, and text in one color state', () => {
|
||||
it('keeps outline text accented through hover and pressed states', () => {
|
||||
const uiDirectory = fileURLToPath(new URL('..', import.meta.url))
|
||||
const controls = readFileSync(`${uiDirectory}/controls.css`, 'utf8')
|
||||
const buttonStyles = controls.slice(
|
||||
@@ -56,11 +56,12 @@ describe('SkyButton', () => {
|
||||
controls.indexOf('.sky-badge'),
|
||||
)
|
||||
|
||||
expect(buttonStyles).not.toContain('.sky-button--outline:hover')
|
||||
expect(buttonStyles).toMatch(
|
||||
/@media \(hover: hover\) and \(pointer: fine\)[\s\S]*?\.sky-button--outline:hover:not\(:disabled\)\s*\{[\s\S]*?border-color:\s*var\(--sky-app-accent, #007aff\);[\s\S]*?background:\s*var\(--sky-app-accent, #007aff\);[\s\S]*?color:\s*#ffffff;/,
|
||||
/\.sky-button--outline:active:not\(:disabled\)\s*\{[\s\S]*?background:\s*var\(--sky-app-accent-soft, rgba\(0, 122, 255, 0\.15\)\);[\s\S]*?color:\s*var\(--sky-app-accent, #007aff\);[\s\S]*?filter:\s*none;/,
|
||||
)
|
||||
expect(buttonStyles).toMatch(
|
||||
/\.sky-button--danger\.sky-button--outline:hover:not\(:disabled\)\s*\{[\s\S]*?border-color:\s*var\(--sky-danger, #dc2626\);[\s\S]*?background:\s*var\(--sky-danger, #dc2626\);[\s\S]*?color:\s*#ffffff;/,
|
||||
/\.sky-button--danger\.sky-button--outline:active:not\(:disabled\)\s*\{[\s\S]*?background:\s*var\(--sky-danger-soft, rgba\(220, 38, 38, 0\.14\)\);[\s\S]*?color:\s*var\(--sky-danger, #dc2626\);/,
|
||||
)
|
||||
expect(buttonStyles).toContain(
|
||||
'color var(--sky-transition-fast, 100ms) ease,',
|
||||
|
||||
@@ -39,7 +39,7 @@ describe('SkyField', () => {
|
||||
expect(html).toContain('value="231"')
|
||||
})
|
||||
|
||||
it('marks fields with leading media so labels and controls share alignment', async () => {
|
||||
it('keeps the leading alias on the canonical Konsta media column', async () => {
|
||||
const app = createSSRApp({
|
||||
render: () =>
|
||||
h(
|
||||
@@ -52,7 +52,28 @@ describe('SkyField', () => {
|
||||
const html = await renderToString(app)
|
||||
|
||||
expect(html).toContain('sky-field--has-leading')
|
||||
expect(html).toContain('sky-field__leading')
|
||||
expect(html).toContain('sky-field--has-media')
|
||||
expect(html).toContain('class="sky-field__media"')
|
||||
expect(html.indexOf('sky-field__media')).toBeLessThan(
|
||||
html.indexOf('sky-field__inner'),
|
||||
)
|
||||
})
|
||||
|
||||
it('renders canonical media, inner, label, and control nesting', async () => {
|
||||
const app = createSSRApp({
|
||||
render: () =>
|
||||
h(
|
||||
SkyField,
|
||||
{ label: 'Name' },
|
||||
{ media: () => h('img', { alt: 'icon', src: '/demo.png' }) },
|
||||
),
|
||||
})
|
||||
|
||||
const html = await renderToString(app)
|
||||
|
||||
expect(html).toMatch(
|
||||
/<span class="sky-field__media">[\s\S]*?<\/span><div class="sky-field__inner"><label[\s\S]*?<div class="sky-field__control">/,
|
||||
)
|
||||
})
|
||||
|
||||
it('forwards text-entry hints to the native input', async () => {
|
||||
@@ -118,15 +139,31 @@ describe('SkyField', () => {
|
||||
|
||||
expect(select).toContain('class="sky-field__input sky-field__select"')
|
||||
expect(select).toContain('aria-label="Gender"')
|
||||
expect(select).toContain('placeholder="Please choose..."')
|
||||
expect(select).toContain('value="male"')
|
||||
expect(html).toContain(
|
||||
'<option disabled value="">Please choose...</option>',
|
||||
)
|
||||
expect(html).not.toContain('>Please choose...</option>')
|
||||
expect(html).toContain('<option value="male">Male</option>')
|
||||
expect(html).toContain('<option disabled value="0">Unavailable</option>')
|
||||
expect(html).toContain('<option value="custom">Custom</option>')
|
||||
})
|
||||
|
||||
it('renders the exact Konsta dropdown triangle', async () => {
|
||||
const app = createSSRApp(SkyField, {
|
||||
dropdown: true,
|
||||
options: [{ label: 'Male', value: 'Male' }],
|
||||
type: 'select',
|
||||
})
|
||||
|
||||
const html = await renderToString(app)
|
||||
|
||||
expect(html).toContain(
|
||||
'<svg class="sky-field__dropdown" xmlns="http://www.w3.org/2000/svg" width="8" height="5" viewBox="0 0 8 5" fill="currentColor" aria-hidden="true" focusable="false">',
|
||||
)
|
||||
expect(html).toContain(
|
||||
'<polygon fill-rule="evenodd" points="0 0 8 0 4 5"></polygon>',
|
||||
)
|
||||
})
|
||||
|
||||
it('raises floating labels only after the field has a value', async () => {
|
||||
const emptyApp = createSSRApp(SkyField, {
|
||||
floatingLabel: true,
|
||||
@@ -149,17 +186,25 @@ describe('SkyField', () => {
|
||||
expect(valuedHtml).toContain('sky-field--floating-raised')
|
||||
})
|
||||
|
||||
it('keeps floating-label controls touch-sized and reduced-motion aware', () => {
|
||||
it('locks Konsta input, outline, floating-label, and reduced-motion geometry', () => {
|
||||
const controls = readFileSync(
|
||||
new URL('../controls.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
expect(controls).toMatch(
|
||||
/\.sky-field__input\s*\{[^}]*min-height:\s*var\(--sky-touch-target, 44px\)/s,
|
||||
/\.sky-field__input\s*\{[^}]*height:\s*40px;[^}]*min-height:\s*40px;[^}]*font-size:\s*16px;[^}]*line-height:\s*24px/s,
|
||||
)
|
||||
expect(controls).toMatch(/\.sky-field__media,[\s\S]*?padding:\s*8px 0;/)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-field__inner\s*\{[^}]*padding:\s*12px calc\(16px \+ var\(--sky-safe-area-right, 0px\)\) 12px 0/s,
|
||||
)
|
||||
expect(controls).toMatch(/\.sky-field--outline\s*\{[^}]*margin:\s*16px 0;/s)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-field--floating-label:not\(\.sky-field--inline\) \.sky-field__label\s*\{[^}]*transform:\s*translateY\(16px\) scale\(1\.33\)/s,
|
||||
)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-field--floating-label:not\(\.sky-field--inline\) \.sky-field__control\s*\{[^}]*min-height:\s*58px;[^}]*padding-top:\s*14px/s,
|
||||
/\.sky-field--floating-label\.sky-field--outline:not\([\s\S]*?\.sky-field__label\s*\{[^}]*transform:\s*translateY\(24px\) scale\(1\.33\)/s,
|
||||
)
|
||||
expect(controls).toMatch(
|
||||
/@media \(prefers-reduced-motion: reduce\)\s*\{[^}]*\.sky-field__label/s,
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, useId, useSlots, type CSSProperties } from 'vue'
|
||||
import {
|
||||
computed,
|
||||
ref,
|
||||
useAttrs,
|
||||
useId,
|
||||
useSlots,
|
||||
type CSSProperties,
|
||||
} from 'vue'
|
||||
|
||||
import { useComposedFieldValue } from '@/ui/controls/useComposedFieldValue'
|
||||
|
||||
@@ -15,6 +22,7 @@ type FieldOption = {
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
ariaLabel?: string
|
||||
accept?: number | string
|
||||
autocapitalize?:
|
||||
| 'characters'
|
||||
| 'none'
|
||||
@@ -24,13 +32,20 @@ const props = withDefaults(
|
||||
| 'words'
|
||||
autocomplete?: string
|
||||
autocorrect?: 'off' | 'on'
|
||||
autofocus?: boolean
|
||||
autosave?: string
|
||||
clearButton?: boolean
|
||||
clearLabel?: string
|
||||
component?: string
|
||||
disabled?: boolean
|
||||
error?: string
|
||||
error?: boolean | string
|
||||
dropdown?: boolean
|
||||
floatingLabel?: boolean
|
||||
help?: string
|
||||
id?: string
|
||||
info?: string
|
||||
inputClass?: string
|
||||
inputId?: string
|
||||
inputMode?:
|
||||
| 'decimal'
|
||||
| 'email'
|
||||
@@ -40,11 +55,21 @@ const props = withDefaults(
|
||||
| 'tel'
|
||||
| 'text'
|
||||
| 'url'
|
||||
inputStyle?: CSSProperties
|
||||
inputmode?:
|
||||
| 'decimal'
|
||||
| 'email'
|
||||
| 'none'
|
||||
| 'numeric'
|
||||
| 'search'
|
||||
| 'tel'
|
||||
| 'text'
|
||||
| 'url'
|
||||
inputStyle?: CSSProperties | string
|
||||
label?: string
|
||||
layout?: 'inline' | 'stacked'
|
||||
max?: number | string
|
||||
maxlength?: number | string
|
||||
minlength?: number | string
|
||||
min?: number | string
|
||||
modelValue?: FieldValue
|
||||
name?: string
|
||||
@@ -55,8 +80,10 @@ const props = withDefaults(
|
||||
readonly?: boolean
|
||||
required?: boolean
|
||||
rows?: number
|
||||
spellcheck?: boolean
|
||||
size?: number | string
|
||||
spellcheck?: boolean | 'false' | 'true'
|
||||
step?: number | string
|
||||
tabindex?: number | string
|
||||
type?:
|
||||
| 'date'
|
||||
| 'datetime-local'
|
||||
@@ -73,23 +100,33 @@ const props = withDefaults(
|
||||
value?: FieldValue
|
||||
}>(),
|
||||
{
|
||||
accept: undefined,
|
||||
ariaLabel: '',
|
||||
autocapitalize: undefined,
|
||||
autocomplete: undefined,
|
||||
autocorrect: undefined,
|
||||
autofocus: false,
|
||||
autosave: undefined,
|
||||
clearButton: false,
|
||||
clearLabel: '',
|
||||
component: 'li',
|
||||
disabled: false,
|
||||
error: '',
|
||||
error: false,
|
||||
dropdown: false,
|
||||
floatingLabel: false,
|
||||
help: '',
|
||||
id: undefined,
|
||||
info: '',
|
||||
inputClass: '',
|
||||
inputId: undefined,
|
||||
inputMode: undefined,
|
||||
inputmode: undefined,
|
||||
inputStyle: undefined,
|
||||
label: '',
|
||||
layout: 'stacked',
|
||||
max: undefined,
|
||||
maxlength: undefined,
|
||||
minlength: undefined,
|
||||
min: undefined,
|
||||
modelValue: undefined,
|
||||
name: undefined,
|
||||
@@ -100,8 +137,10 @@ const props = withDefaults(
|
||||
readonly: false,
|
||||
required: false,
|
||||
rows: 3,
|
||||
size: undefined,
|
||||
spellcheck: undefined,
|
||||
step: undefined,
|
||||
tabindex: undefined,
|
||||
type: 'text',
|
||||
value: undefined,
|
||||
},
|
||||
@@ -117,11 +156,21 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const generatedId = useId()
|
||||
const attrs = useAttrs()
|
||||
const slots = useSlots()
|
||||
const isFocused = ref(false)
|
||||
const inputId = computed(() => props.id || generatedId)
|
||||
const helpId = computed(() => `${inputId.value}-help`)
|
||||
const errorId = computed(() => `${inputId.value}-error`)
|
||||
const hasMedia = computed(() => Boolean(slots.media || slots.leading))
|
||||
const resolvedInputId = computed(() => props.inputId || props.id || generatedId)
|
||||
const helpId = computed(() => `${resolvedInputId.value}-help`)
|
||||
const errorId = computed(() => `${resolvedInputId.value}-error`)
|
||||
const infoId = computed(() => `${resolvedInputId.value}-info`)
|
||||
const nativeAttrs = computed(() =>
|
||||
Object.fromEntries(
|
||||
Object.entries(attrs).filter(
|
||||
([name]) => name !== 'class' && name !== 'style',
|
||||
),
|
||||
),
|
||||
)
|
||||
const effectiveValue = computed(() => props.modelValue ?? props.value ?? '')
|
||||
const {
|
||||
clear: clearValue,
|
||||
@@ -140,15 +189,18 @@ const hasClearButton = computed(
|
||||
)
|
||||
const isFloatingRaised = computed(
|
||||
() =>
|
||||
Boolean(props.label) &&
|
||||
Boolean(props.label || slots.label) &&
|
||||
props.floatingLabel &&
|
||||
(isFocused.value || localValue.value.length > 0),
|
||||
)
|
||||
|
||||
const describedBy = computed(() => {
|
||||
const ids: string[] = []
|
||||
if (props.help) ids.push(helpId.value)
|
||||
if (props.error) ids.push(errorId.value)
|
||||
if (props.help || slots.help) ids.push(helpId.value)
|
||||
if ((props.info && !props.error) || slots.info) ids.push(infoId.value)
|
||||
if ((props.error && props.error !== true) || slots.error) {
|
||||
ids.push(errorId.value)
|
||||
}
|
||||
return ids.length ? ids.join(' ') : undefined
|
||||
})
|
||||
|
||||
@@ -192,132 +244,195 @@ function clear(): void {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li
|
||||
v-bind="$attrs"
|
||||
<component
|
||||
:is="component"
|
||||
class="sky-field"
|
||||
:class="{
|
||||
'sky-field--disabled': disabled,
|
||||
'sky-field--error': Boolean(error),
|
||||
'sky-field--floating-label': Boolean(label) && floatingLabel,
|
||||
'sky-field--floating-raised': isFloatingRaised,
|
||||
'sky-field--inline': layout === 'inline',
|
||||
'sky-field--outline': outline,
|
||||
'sky-field--has-leading': Boolean(slots.leading),
|
||||
}"
|
||||
:class="[
|
||||
attrs.class,
|
||||
{
|
||||
'sky-field--disabled': disabled,
|
||||
'sky-field--error': Boolean(error),
|
||||
'sky-field--floating-label':
|
||||
Boolean(label || $slots.label) && floatingLabel,
|
||||
'sky-field--floating-raised': isFloatingRaised,
|
||||
'sky-field--has-label': Boolean(label || slots.label),
|
||||
'sky-field--inline': layout === 'inline',
|
||||
'sky-field--outline': outline,
|
||||
'sky-field--has-leading': Boolean(slots.leading),
|
||||
'sky-field--has-media': hasMedia,
|
||||
},
|
||||
]"
|
||||
:style="attrs.style"
|
||||
>
|
||||
<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="handleBlur"
|
||||
@change="emit('change', $event)"
|
||||
@compositionend="handleCompositionEnd"
|
||||
@compositionstart="startComposition"
|
||||
@focus="handleFocus"
|
||||
@input="handleInput"
|
||||
/>
|
||||
<select
|
||||
v-else-if="type === 'select'"
|
||||
:id="inputId"
|
||||
class="sky-field__input sky-field__select"
|
||||
:aria-describedby="describedBy"
|
||||
:aria-invalid="error ? true : undefined"
|
||||
:aria-label="ariaLabel || undefined"
|
||||
:disabled="disabled"
|
||||
:name="name"
|
||||
:required="required"
|
||||
:style="inputStyle"
|
||||
:value="localValue"
|
||||
@blur="handleBlur"
|
||||
@change="emit('change', $event)"
|
||||
@focus="handleFocus"
|
||||
@input="handleInput"
|
||||
<span v-if="hasMedia" class="sky-field__media">
|
||||
<slot v-if="$slots.media" name="media" />
|
||||
<slot v-else name="leading" />
|
||||
</span>
|
||||
<div class="sky-field__inner">
|
||||
<label
|
||||
v-if="label || $slots.label"
|
||||
class="sky-field__label"
|
||||
:for="resolvedInputId"
|
||||
>
|
||||
<option v-if="placeholder" disabled value="">
|
||||
{{ placeholder }}
|
||||
</option>
|
||||
<option
|
||||
v-for="(option, index) in options"
|
||||
:key="`${typeof option.value}:${String(option.value)}:${index}`"
|
||||
:disabled="option.disabled"
|
||||
:value="option.value"
|
||||
<span class="sky-field__label-text"
|
||||
>{{ label }}<slot name="label"
|
||||
/></span>
|
||||
</label>
|
||||
<div class="sky-field__control">
|
||||
<slot v-if="$slots.input" name="input" />
|
||||
<textarea
|
||||
v-else-if="type === 'textarea'"
|
||||
v-bind="nativeAttrs"
|
||||
:id="resolvedInputId"
|
||||
class="sky-field__input sky-field__textarea"
|
||||
:class="inputClass"
|
||||
:aria-describedby="describedBy"
|
||||
:aria-invalid="error ? true : undefined"
|
||||
:aria-label="ariaLabel || undefined"
|
||||
:autocapitalize="autocapitalize"
|
||||
:autocorrect="autocorrect"
|
||||
:autofocus="autofocus"
|
||||
:autosave="autosave"
|
||||
:disabled="disabled"
|
||||
:maxlength="maxlength"
|
||||
:minlength="minlength"
|
||||
:name="name"
|
||||
:placeholder="placeholder"
|
||||
:readonly="readonly"
|
||||
:required="required"
|
||||
:rows="rows"
|
||||
:spellcheck="spellcheck"
|
||||
:tabindex="tabindex"
|
||||
:style="inputStyle"
|
||||
:value="localValue"
|
||||
@blur="handleBlur"
|
||||
@change="emit('change', $event)"
|
||||
@compositionend="handleCompositionEnd"
|
||||
@compositionstart="startComposition"
|
||||
@focus="handleFocus"
|
||||
@input="handleInput"
|
||||
/>
|
||||
<select
|
||||
v-else-if="type === 'select'"
|
||||
v-bind="nativeAttrs"
|
||||
:id="resolvedInputId"
|
||||
class="sky-field__input sky-field__select"
|
||||
:class="inputClass"
|
||||
:aria-describedby="describedBy"
|
||||
:aria-invalid="error ? true : undefined"
|
||||
:aria-label="ariaLabel || undefined"
|
||||
:disabled="disabled"
|
||||
:autofocus="autofocus"
|
||||
:name="name"
|
||||
:placeholder="placeholder"
|
||||
:required="required"
|
||||
:size="size === undefined ? undefined : String(size)"
|
||||
:style="inputStyle"
|
||||
:value="localValue"
|
||||
:tabindex="tabindex"
|
||||
@blur="handleBlur"
|
||||
@change="emit('change', $event)"
|
||||
@focus="handleFocus"
|
||||
@input="handleInput"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
<slot />
|
||||
</select>
|
||||
<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="handleBlur"
|
||||
@change="emit('change', $event)"
|
||||
@compositionend="handleCompositionEnd"
|
||||
@compositionstart="startComposition"
|
||||
@focus="handleFocus"
|
||||
@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"
|
||||
<option
|
||||
v-for="(option, index) in options"
|
||||
:key="`${typeof option.value}:${String(option.value)}:${index}`"
|
||||
:disabled="option.disabled"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
<slot />
|
||||
</select>
|
||||
<input
|
||||
v-else
|
||||
v-bind="nativeAttrs"
|
||||
:id="resolvedInputId"
|
||||
class="sky-field__input"
|
||||
:class="inputClass"
|
||||
:aria-describedby="describedBy"
|
||||
:aria-invalid="error ? true : undefined"
|
||||
:aria-label="ariaLabel || undefined"
|
||||
:autocapitalize="autocapitalize"
|
||||
:accept="accept === undefined ? undefined : String(accept)"
|
||||
:autocomplete="autocomplete"
|
||||
:autocorrect="autocorrect"
|
||||
:autofocus="autofocus"
|
||||
:autosave="autosave"
|
||||
:disabled="disabled"
|
||||
:inputmode="inputMode ?? inputmode"
|
||||
:max="max"
|
||||
:maxlength="maxlength"
|
||||
:minlength="minlength"
|
||||
:min="min"
|
||||
:name="name"
|
||||
:pattern="pattern"
|
||||
:placeholder="placeholder"
|
||||
:readonly="readonly"
|
||||
:required="required"
|
||||
:size="size"
|
||||
:step="step"
|
||||
:tabindex="tabindex"
|
||||
:spellcheck="spellcheck"
|
||||
:style="inputStyle"
|
||||
:type="type"
|
||||
:value="localValue"
|
||||
@blur="handleBlur"
|
||||
@change="emit('change', $event)"
|
||||
@compositionend="handleCompositionEnd"
|
||||
@compositionstart="startComposition"
|
||||
@focus="handleFocus"
|
||||
@input="handleInput"
|
||||
/>
|
||||
<span v-if="$slots.trailing" class="sky-field__trailing">
|
||||
<slot name="trailing" />
|
||||
</span>
|
||||
<svg
|
||||
v-if="dropdown"
|
||||
class="sky-field__dropdown"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="8"
|
||||
height="5"
|
||||
viewBox="0 0 8 5"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<polygon fill-rule="evenodd" points="0 0 8 0 4 5" />
|
||||
</svg>
|
||||
<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 || $slots.help" :id="helpId" class="sky-field__help">
|
||||
{{ help }}<slot name="help" />
|
||||
</small>
|
||||
<small
|
||||
v-if="(error && error !== true) || $slots.error"
|
||||
:id="errorId"
|
||||
class="sky-field__error"
|
||||
role="alert"
|
||||
>
|
||||
<span aria-hidden="true" />
|
||||
</button>
|
||||
{{ error === true ? '' : error }}<slot name="error" />
|
||||
</small>
|
||||
<small
|
||||
v-if="(info && !error) || $slots.info"
|
||||
:id="infoId"
|
||||
class="sky-field__info"
|
||||
>
|
||||
{{ info }}<slot name="info" />
|
||||
</small>
|
||||
</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>
|
||||
<span v-if="outline" class="sky-field__border" aria-hidden="true" />
|
||||
<slot v-if="type !== 'select' && !$slots.input" />
|
||||
</component>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { createSSRApp, h } from 'vue'
|
||||
import { renderToString } from 'vue/server-renderer'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import SkyIcon from './SkyIcon.vue'
|
||||
|
||||
describe('SkyIcon', () => {
|
||||
it('uses the Konsta i element default and renders a colored badge', async () => {
|
||||
const html = await renderToString(
|
||||
createSSRApp({
|
||||
render: () =>
|
||||
h(
|
||||
SkyIcon,
|
||||
{
|
||||
badge: 5,
|
||||
badgeColors: { bg: '#ef4444', text: '#ffffff' },
|
||||
size: 28,
|
||||
},
|
||||
{ default: () => h('svg') },
|
||||
),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(html).toContain('<i')
|
||||
expect(html).toContain('class="sky-icon"')
|
||||
expect(html).toContain('height:28px')
|
||||
expect(html).toContain('width:28px')
|
||||
expect(html).toContain('aria-hidden="true"')
|
||||
expect(html).toContain('sky-icon__badge')
|
||||
expect(html).toContain('background:#ef4444')
|
||||
expect(html).toContain('color:#ffffff')
|
||||
expect(html).toMatch(/sky-icon__badge[\s\S]*?5[\s\S]*?<\/span>/)
|
||||
})
|
||||
|
||||
it('supports a custom component and a badge slot without a badge prop', async () => {
|
||||
const html = await renderToString(
|
||||
createSSRApp({
|
||||
render: () =>
|
||||
h(
|
||||
SkyIcon,
|
||||
{ component: 'span', label: 'Inbox' },
|
||||
{
|
||||
badge: () => 'NEW',
|
||||
default: () => h('svg'),
|
||||
},
|
||||
),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(html).toContain('<span')
|
||||
expect(html).toContain('aria-label="Inbox"')
|
||||
expect(html).toContain('role="img"')
|
||||
expect(html).not.toContain('aria-hidden="true"')
|
||||
expect(html).toContain('sky-icon__badge')
|
||||
expect(html).toMatch(/sky-icon__badge[\s\S]*?NEW[\s\S]*?<\/span>/)
|
||||
})
|
||||
})
|
||||
@@ -1,28 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, type CSSProperties } from 'vue'
|
||||
import { computed, type CSSProperties, useSlots } from 'vue'
|
||||
|
||||
import SkyBadge from './SkyBadge.vue'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
badge?: number | string
|
||||
badgeColors?: {
|
||||
bg?: string
|
||||
text?: string
|
||||
}
|
||||
component?: string
|
||||
label?: string
|
||||
size?: number | string
|
||||
}>(),
|
||||
{
|
||||
badge: undefined,
|
||||
badgeColors: undefined,
|
||||
component: 'i',
|
||||
label: '',
|
||||
size: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
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 }
|
||||
})
|
||||
|
||||
const badgeStyle = computed<CSSProperties | undefined>(() => {
|
||||
if (!props.badgeColors?.bg && !props.badgeColors?.text) return undefined
|
||||
return {
|
||||
background: props.badgeColors.bg,
|
||||
color: props.badgeColors.text,
|
||||
}
|
||||
})
|
||||
|
||||
const hasBadge = computed(
|
||||
() =>
|
||||
(props.badge !== undefined && props.badge !== null) || Boolean(slots.badge),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
<component
|
||||
:is="component"
|
||||
v-bind="$attrs"
|
||||
class="sky-icon"
|
||||
:style="iconStyle"
|
||||
@@ -31,5 +58,21 @@ const iconStyle = computed<CSSProperties | undefined>(() => {
|
||||
:role="label ? 'img' : undefined"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
<SkyBadge v-if="hasBadge" class="sky-icon__badge" small :style="badgeStyle">
|
||||
{{ badge }}<slot name="badge" />
|
||||
</SkyBadge>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sky-icon {
|
||||
position: relative;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.sky-icon__badge {
|
||||
position: absolute;
|
||||
inset-block-start: -2px;
|
||||
inset-inline-end: -6px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { createSSRApp } from 'vue'
|
||||
import { renderToString } from 'vue/server-renderer'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import SkyLink from './SkyLink.vue'
|
||||
|
||||
describe('SkyLink', () => {
|
||||
it('matches the Konsta anchor default', async () => {
|
||||
const html = await renderToString(createSSRApp(SkyLink))
|
||||
|
||||
expect(html).toMatch(/^<a[^>]*class="sky-link"/)
|
||||
expect(html).toContain('tabindex="0"')
|
||||
expect(html).toContain('role="link"')
|
||||
})
|
||||
|
||||
it('keeps intrinsic visible geometry with an invisible 44px hit target', () => {
|
||||
const controls = readFileSync(
|
||||
new URL('../controls.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
expect(controls).toMatch(
|
||||
/\.sky-link\s*\{[^}]*min-width:\s*0;[^}]*min-height:\s*0;[^}]*gap:\s*4px;[^}]*padding:\s*0;[^}]*border-radius:\s*0;/s,
|
||||
)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-link::before\s*\{[^}]*width:\s*max\(100%, var\(--sky-touch-target, 44px\)\);[^}]*height:\s*max\(100%, var\(--sky-touch-target, 44px\)\)/s,
|
||||
)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-link:active:not\(:disabled\)\s*\{[^}]*opacity:\s*0\.5;/s,
|
||||
)
|
||||
expect(controls).not.toMatch(
|
||||
/\.sky-link:active:not\(:disabled\)\s*\{[^}]*background:/s,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -12,7 +12,7 @@ const props = withDefaults(
|
||||
type?: 'button' | 'reset' | 'submit'
|
||||
}>(),
|
||||
{
|
||||
component: 'button',
|
||||
component: 'a',
|
||||
disabled: false,
|
||||
href: undefined,
|
||||
iconOnly: false,
|
||||
@@ -29,7 +29,7 @@ const elementProps = computed<Record<string, unknown>>(() => {
|
||||
return {
|
||||
'aria-disabled': props.disabled || undefined,
|
||||
href: props.disabled ? undefined : props.href,
|
||||
tabindex: props.disabled ? -1 : undefined,
|
||||
tabindex: props.disabled ? -1 : 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ function handleClick(event: MouseEvent): void {
|
||||
v-bind="{ ...$attrs, ...elementProps }"
|
||||
class="sky-link"
|
||||
:class="{ 'sky-link--icon-only': iconOnly }"
|
||||
role="link"
|
||||
@click="handleClick"
|
||||
>
|
||||
<slot />
|
||||
|
||||
@@ -19,5 +19,7 @@ describe('SkyList', () => {
|
||||
expect(html).toContain('sky-list--flush')
|
||||
expect(html).toContain('sky-list--inset')
|
||||
expect(html).toContain('sky-list--strong')
|
||||
expect(html).toMatch(/^<div[^>]*class="sky-list/)
|
||||
expect(html).toMatch(/<ul class="sky-list__items">[\s\S]*<\/ul><\/div>$/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, provide } from 'vue'
|
||||
|
||||
import { skyListContextKey } from './list-context'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
withDefaults(
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
component?: 'div' | 'ol' | 'ul'
|
||||
density?: 'compact' | 'regular'
|
||||
@@ -14,7 +18,7 @@ withDefaults(
|
||||
strong?: boolean
|
||||
}>(),
|
||||
{
|
||||
component: 'ul',
|
||||
component: 'div',
|
||||
density: 'regular',
|
||||
dividers: true,
|
||||
flush: false,
|
||||
@@ -25,6 +29,11 @@ withDefaults(
|
||||
strong: false,
|
||||
},
|
||||
)
|
||||
|
||||
provide(
|
||||
skyListContextKey,
|
||||
computed(() => ({ dividers: props.dividers, nested: props.nested })),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -42,8 +51,9 @@ withDefaults(
|
||||
'sky-list--compact': density === 'compact',
|
||||
'sky-list--flush': flush,
|
||||
}"
|
||||
:role="component === 'div' ? 'list' : undefined"
|
||||
>
|
||||
<slot />
|
||||
<ul class="sky-list__items">
|
||||
<slot />
|
||||
</ul>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import SkyList from './SkyList.vue'
|
||||
import SkyListGroupInner from './SkyListGroupInner.vue'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
@@ -38,7 +39,9 @@ withDefaults(
|
||||
:outline="outline"
|
||||
:strong="strong"
|
||||
>
|
||||
<slot />
|
||||
<SkyListGroupInner>
|
||||
<slot />
|
||||
</SkyListGroupInner>
|
||||
</SkyList>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, provide } from 'vue'
|
||||
|
||||
import { skyListContextKey } from './list-context'
|
||||
|
||||
const parent = inject(skyListContextKey, undefined)
|
||||
|
||||
provide(
|
||||
skyListContextKey,
|
||||
computed(() => ({
|
||||
dividers: parent?.value.dividers ?? false,
|
||||
nested: false,
|
||||
})),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<slot />
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
import { createSSRApp } from 'vue'
|
||||
import { renderToString } from 'vue/server-renderer'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import SkyListItem from './SkyListItem.vue'
|
||||
|
||||
describe('SkyListItem', () => {
|
||||
it('matches the Konsta anchor default for link rows', async () => {
|
||||
const html = await renderToString(
|
||||
createSSRApp(SkyListItem, { link: true, title: 'Inbox' }),
|
||||
)
|
||||
|
||||
expect(html).toMatch(
|
||||
/^<li[^>]*class="sky-list-item[^"]*sky-list-item--link[^"]*"><a class="sky-list-item__row"/,
|
||||
)
|
||||
expect(html).not.toContain('<button')
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, useSlots } from 'vue'
|
||||
import { computed, inject, useSlots } from 'vue'
|
||||
|
||||
import { skyListContextKey } from './list-context'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
@@ -51,12 +53,12 @@ const props = withDefaults(
|
||||
innerClass: '',
|
||||
label: false,
|
||||
link: false,
|
||||
linkComponent: undefined,
|
||||
linkComponent: 'a',
|
||||
linkProps: () => ({}),
|
||||
media: undefined,
|
||||
mediaClass: '',
|
||||
menu: false,
|
||||
strongTitle: false,
|
||||
strongTitle: 'auto',
|
||||
subtitle: '',
|
||||
target: undefined,
|
||||
text: '',
|
||||
@@ -71,6 +73,11 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const slots = useSlots()
|
||||
const listContext = inject(skyListContextKey, undefined)
|
||||
const effectiveDividers = computed(
|
||||
() => props.dividers ?? listContext?.value.dividers ?? false,
|
||||
)
|
||||
const isNested = computed(() => listContext?.value.nested ?? false)
|
||||
const resolvedStrongTitle = computed(
|
||||
() =>
|
||||
props.strongTitle === true ||
|
||||
@@ -82,9 +89,7 @@ const hasHref = computed(
|
||||
() => typeof props.href === 'string' || props.href === true,
|
||||
)
|
||||
const isLink = computed(() => props.link || hasHref.value)
|
||||
const effectiveLinkComponent = computed(
|
||||
() => props.linkComponent ?? (hasHref.value ? 'a' : 'button'),
|
||||
)
|
||||
const effectiveLinkComponent = computed(() => props.linkComponent ?? 'a')
|
||||
|
||||
const rowComponent = computed(() => {
|
||||
if (isLink.value) return effectiveLinkComponent.value
|
||||
@@ -136,6 +141,10 @@ function handleClick(event: MouseEvent): void {
|
||||
v-if="groupTitle"
|
||||
v-bind="$attrs"
|
||||
class="sky-list-item sky-list-item--group-title"
|
||||
:class="{
|
||||
'sky-list-item--contacts': contacts,
|
||||
'sky-list-item--dividers': effectiveDividers,
|
||||
}"
|
||||
:role="component === 'div' ? 'listitem' : undefined"
|
||||
>
|
||||
<slot name="title">{{ title }}</slot>
|
||||
@@ -150,11 +159,12 @@ function handleClick(event: MouseEvent): void {
|
||||
'sky-list-item--disabled': disabled,
|
||||
'sky-list-item--active': active,
|
||||
'sky-list-item--contacts': contacts,
|
||||
'sky-list-item--dividers': dividers === true,
|
||||
'sky-list-item--no-dividers': dividers === false,
|
||||
'sky-list-item--dividers': effectiveDividers,
|
||||
'sky-list-item--no-dividers': !effectiveDividers,
|
||||
'sky-list-item--label': label,
|
||||
'sky-list-item--link': isLink,
|
||||
'sky-list-item--menu': menu,
|
||||
'sky-list-item--nested': isNested,
|
||||
}"
|
||||
:role="component === 'div' ? 'listitem' : undefined"
|
||||
>
|
||||
@@ -189,7 +199,7 @@ function handleClick(event: MouseEvent): void {
|
||||
class="sky-list-item__title-wrap"
|
||||
:class="titleWrapClass"
|
||||
>
|
||||
<strong
|
||||
<div
|
||||
v-if="title || $slots.title"
|
||||
class="sky-list-item__title"
|
||||
:class="[
|
||||
@@ -198,7 +208,7 @@ function handleClick(event: MouseEvent): void {
|
||||
]"
|
||||
>
|
||||
<slot name="title">{{ title }}</slot>
|
||||
</strong>
|
||||
</div>
|
||||
<span
|
||||
v-if="after !== undefined || $slots.after"
|
||||
class="sky-list-item__after"
|
||||
@@ -224,9 +234,9 @@ function handleClick(event: MouseEvent): void {
|
||||
<slot name="footer">{{ footer }}</slot>
|
||||
</small>
|
||||
<slot name="inner" />
|
||||
<slot />
|
||||
</span>
|
||||
<slot name="content" />
|
||||
</component>
|
||||
<slot />
|
||||
</component>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { createSSRApp, h } from 'vue'
|
||||
import { renderToString } from 'vue/server-renderer'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { SkyMediaCard } from '@/ui'
|
||||
|
||||
describe('SkyMediaCard', () => {
|
||||
it('composes media, metadata, copy, and actions on the public SkyCard shell', async () => {
|
||||
const html = await renderToString(
|
||||
createSSRApp({
|
||||
render: () =>
|
||||
h(
|
||||
SkyMediaCard,
|
||||
{
|
||||
id: 'journey-card',
|
||||
meta: 'Posted on January 21, 2021',
|
||||
outline: true,
|
||||
title: 'Journey To Mountains',
|
||||
},
|
||||
{
|
||||
actions: () => [h('a', 'Like'), h('a', 'Read more')],
|
||||
default: () => h('p', 'Card copy'),
|
||||
media: () => h('img', { alt: '', src: '/mountains.jpg' }),
|
||||
},
|
||||
),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(html).toContain('id="journey-card"')
|
||||
expect(html).toContain('sky-card--outline')
|
||||
expect(html).toContain('sky-media-card')
|
||||
expect(html).toContain('sky-media-card__media')
|
||||
expect(html).toContain('sky-media-card__visual')
|
||||
expect(html).toContain('src="/mountains.jpg"')
|
||||
expect(html).toContain('sky-media-card__title')
|
||||
expect(html).toContain('Journey To Mountains')
|
||||
expect(html).toContain('sky-media-card__meta')
|
||||
expect(html).toContain('Posted on January 21, 2021')
|
||||
expect(html).toContain('sky-media-card__copy')
|
||||
expect(html).toContain('Card copy')
|
||||
expect(html).toContain('sky-card__footer')
|
||||
expect(html).toContain('sky-media-card__actions')
|
||||
expect(html.indexOf('sky-media-card__media')).toBeLessThan(
|
||||
html.indexOf('sky-media-card__body'),
|
||||
)
|
||||
expect(html.indexOf('sky-media-card__body')).toBeLessThan(
|
||||
html.indexOf('sky-media-card__actions'),
|
||||
)
|
||||
})
|
||||
|
||||
it('supports semantic title and metadata slots without empty optional regions', async () => {
|
||||
const slottedHtml = await renderToString(
|
||||
createSSRApp({
|
||||
render: () =>
|
||||
h(
|
||||
SkyMediaCard,
|
||||
{ meta: 'Fallback meta', title: 'Fallback title' },
|
||||
{
|
||||
meta: () =>
|
||||
h('time', { datetime: '2021-01-21' }, 'January 21, 2021'),
|
||||
title: () => h('span', 'Slotted title'),
|
||||
},
|
||||
),
|
||||
}),
|
||||
)
|
||||
const copyOnlyHtml = await renderToString(
|
||||
createSSRApp({
|
||||
render: () =>
|
||||
h(SkyMediaCard, null, { default: () => h('p', 'Copy only') }),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(slottedHtml).toContain('Slotted title')
|
||||
expect(slottedHtml).not.toContain('Fallback title')
|
||||
expect(slottedHtml).toContain('datetime="2021-01-21"')
|
||||
expect(slottedHtml).not.toContain('Fallback meta')
|
||||
expect(copyOnlyHtml).toContain('sky-media-card__body')
|
||||
expect(copyOnlyHtml).toContain('sky-media-card__copy')
|
||||
expect(copyOnlyHtml).not.toContain('sky-media-card__media')
|
||||
expect(copyOnlyHtml).not.toContain('sky-media-card__meta')
|
||||
expect(copyOnlyHtml).not.toContain('sky-media-card__actions')
|
||||
expect(copyOnlyHtml).not.toContain('sky-card__footer')
|
||||
})
|
||||
|
||||
it('owns the reusable editorial geometry in the shared stylesheet', () => {
|
||||
const controls = readFileSync(
|
||||
new URL('../controls.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
expect(controls).toMatch(
|
||||
/\.sky-card__content\s*\{[\s\S]*?font-size:\s*14px;[\s\S]*?line-height:\s*20px;/,
|
||||
)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-media-card__media\s*\{[\s\S]*?height:\s*192px;[\s\S]*?align-items:\s*flex-end;/,
|
||||
)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-media-card__visual > img,[\s\S]*?object-fit:\s*cover;/,
|
||||
)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-media-card__body\s*\{[\s\S]*?padding:\s*var\(--sky-space-4, 16px\);/,
|
||||
)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-media-card__meta\s*\{[\s\S]*?margin-bottom:\s*var\(--sky-space-3, 12px\);/,
|
||||
)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-media-card__actions\s*\{[\s\S]*?justify-content:\s*space-between;[\s\S]*?gap:\s*var\(--sky-space-2, 8px\);/,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import SkyCard from './SkyCard.vue'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
meta?: number | string
|
||||
title?: number | string
|
||||
}>(),
|
||||
{
|
||||
meta: undefined,
|
||||
title: undefined,
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SkyCard v-bind="$attrs" :content-wrap="false" class="sky-media-card">
|
||||
<div
|
||||
v-if="$slots.media || title !== undefined || $slots.title"
|
||||
class="sky-media-card__media"
|
||||
>
|
||||
<div v-if="$slots.media" class="sky-media-card__visual">
|
||||
<slot name="media" />
|
||||
</div>
|
||||
<strong
|
||||
v-if="title !== undefined || $slots.title"
|
||||
class="sky-media-card__title"
|
||||
>
|
||||
<slot name="title">{{ title }}</slot>
|
||||
</strong>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="meta !== undefined || $slots.meta || $slots.default"
|
||||
class="sky-media-card__body"
|
||||
>
|
||||
<div
|
||||
v-if="meta !== undefined || $slots.meta"
|
||||
class="sky-media-card__meta"
|
||||
>
|
||||
<slot name="meta">{{ meta }}</slot>
|
||||
</div>
|
||||
<div v-if="$slots.default" class="sky-media-card__copy">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="$slots.actions" #footer>
|
||||
<div class="sky-media-card__actions">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</template>
|
||||
</SkyCard>
|
||||
</template>
|
||||
@@ -0,0 +1,77 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { createSSRApp } from 'vue'
|
||||
import { renderToString } from 'vue/server-renderer'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import SkyMenuListItem from './SkyMenuListItem.vue'
|
||||
|
||||
describe('SkyMenuListItem', () => {
|
||||
it('forwards the active and auto-strong media state to SkyListItem', async () => {
|
||||
const html = await renderToString(
|
||||
createSSRApp(SkyMenuListItem, {
|
||||
active: true,
|
||||
subtitle: 'Home subtitle',
|
||||
title: 'Home',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(html).toContain('sky-menu-list-item--active')
|
||||
expect(html).toContain('sky-list-item--active')
|
||||
expect(html).toContain('aria-current="page"')
|
||||
expect(html).toContain('sky-list-item__title--strong')
|
||||
})
|
||||
|
||||
it('matches Konsta iOS active colors in light and dark themes', () => {
|
||||
const uiDirectory = fileURLToPath(new URL('..', import.meta.url))
|
||||
const controls = readFileSync(`${uiDirectory}/controls.css`, 'utf8')
|
||||
const tokens = readFileSync(`${uiDirectory}/tokens.css`, 'utf8')
|
||||
const lightTokens = tokens.slice(
|
||||
tokens.indexOf('.sky-ui-provider,'),
|
||||
tokens.indexOf('.sky-ui-provider--dark,'),
|
||||
)
|
||||
const darkTokens = tokens.slice(tokens.indexOf('.sky-ui-provider--dark,'))
|
||||
|
||||
expect(lightTokens).toContain(
|
||||
'--sky-menu-list-active-background: var(--sky-app-accent-soft);',
|
||||
)
|
||||
expect(lightTokens).toContain(
|
||||
'--sky-menu-list-active-text: var(--sky-app-accent);',
|
||||
)
|
||||
expect(darkTokens).toContain(
|
||||
'--sky-menu-list-active-background: var(--sky-app-accent);',
|
||||
)
|
||||
expect(darkTokens).toContain('--sky-menu-list-active-text: #ffffff;')
|
||||
expect(controls).toMatch(
|
||||
/\.sky-list-item--menu\.sky-list-item--active \.sky-list-item__row\s*\{[\s\S]*?background:\s*var\(\s*--sky-menu-list-active-background[\s\S]*?color:\s*var\(\s*--sky-menu-list-active-text/,
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the title and subtitle on adjacent 20px line boxes', () => {
|
||||
const uiDirectory = fileURLToPath(new URL('..', import.meta.url))
|
||||
const controls = readFileSync(`${uiDirectory}/controls.css`, 'utf8')
|
||||
|
||||
expect(controls).toMatch(
|
||||
/\.sky-list-item--menu\s*\{[^}]*padding:\s*4px 0;/s,
|
||||
)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-list-item--menu \.sky-list-item__row\s*\{[^}]*min-height:\s*var\(--sky-touch-target, 44px\);[^}]*gap:\s*16px;[^}]*padding:\s*0 0 0 8px;/s,
|
||||
)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-list-item--menu \.sky-list-item__content\s*\{[^}]*gap:\s*0;[^}]*padding:\s*12px 16px 12px 0;/s,
|
||||
)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-list-item--menu \.sky-list-item__title-wrap\s*\{[^}]*min-height:\s*0;/s,
|
||||
)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-list-item--menu \.sky-list-item__title\s*\{[^}]*font-size:\s*14px;[^}]*font-weight:\s*500;[^}]*line-height:\s*20px;/s,
|
||||
)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-list-item--menu \.sky-list-item__title--strong\s*\{[^}]*font-weight:\s*600;/s,
|
||||
)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-list-item--menu \.sky-list-item__subtitle\s*\{[^}]*color:\s*inherit;[^}]*font-size:\s*14px;[^}]*line-height:\s*20px;/s,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -71,6 +71,7 @@ const actionProps = computed<Record<string, unknown>>(() => ({
|
||||
class="sky-menu-list-item"
|
||||
:class="{ 'sky-menu-list-item--active': active }"
|
||||
:after="after"
|
||||
:active="active"
|
||||
:aria-label="ariaLabel"
|
||||
:content-class="contentClass"
|
||||
:disabled="disabled"
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { createSSRApp, h } from 'vue'
|
||||
import { renderToString } from 'vue/server-renderer'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import SkyProgress from './SkyProgress.vue'
|
||||
|
||||
const controls = readFileSync(
|
||||
new URL('../controls.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const tokens = readFileSync(new URL('../tokens.css', import.meta.url), 'utf8')
|
||||
|
||||
async function renderProgress(progress: number): Promise<string> {
|
||||
return renderToString(
|
||||
createSSRApp({
|
||||
render: () =>
|
||||
h(SkyProgress, {
|
||||
label: 'Upload progress',
|
||||
progress,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
describe('SkyProgress', () => {
|
||||
it('clamps the semantic and visual progress values', async () => {
|
||||
const partial = await renderProgress(0.1)
|
||||
const belowRange = await renderProgress(-1)
|
||||
const aboveRange = await renderProgress(2)
|
||||
const invalid = await renderProgress(Number.NaN)
|
||||
|
||||
expect(partial).toContain('role="progressbar"')
|
||||
expect(partial).toContain('aria-label="Upload progress"')
|
||||
expect(partial).toContain('aria-valuenow="10"')
|
||||
expect(partial).toContain('transform:translateX(-90%)')
|
||||
expect(belowRange).toContain('aria-valuenow="0"')
|
||||
expect(belowRange).toContain('transform:translateX(-100%)')
|
||||
expect(aboveRange).toContain('aria-valuenow="100"')
|
||||
expect(aboveRange).toContain('transform:translateX(-0%)')
|
||||
expect(invalid).toContain('aria-valuenow="0"')
|
||||
})
|
||||
|
||||
it('matches the Konsta iOS track, fill and motion contract', () => {
|
||||
expect(tokens).toMatch(
|
||||
/\.sky-ui-provider,[\s\S]*?--sky-progress-track-color:\s*#000000;/,
|
||||
)
|
||||
expect(tokens).toMatch(
|
||||
/\.sky-ui-provider--dark,[\s\S]*?--sky-progress-track-color:\s*currentColor;/,
|
||||
)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-progress\s*\{[^}]*height:\s*6px;[^}]*position:\s*relative;[^}]*overflow:\s*hidden;[^}]*border-radius:\s*999px;[^}]*background:\s*transparent;[^}]*color:\s*var\(--sky-app-accent, #007aff\);/s,
|
||||
)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-progress::before\s*\{[^}]*inset:\s*0;[^}]*background:\s*var\(--sky-progress-track-color, #000000\);[^}]*opacity:\s*0\.1;/s,
|
||||
)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-progress__value\s*\{[^}]*width:\s*100%;[^}]*height:\s*100%;[^}]*background:\s*currentColor;[^}]*transition:\s*transform 200ms ease;/s,
|
||||
)
|
||||
expect(controls).toMatch(
|
||||
/\[dir='rtl'\] \.sky-progress,[\s\S]*?\.sky-progress\[dir='rtl'\]\s*\{[^}]*transform:\s*rotate\(180deg\);/,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -21,6 +21,7 @@ const normalizedProgress = computed(() =>
|
||||
),
|
||||
)
|
||||
const percentage = computed(() => Math.round(normalizedProgress.value * 100))
|
||||
const remainingPercentage = computed(() => 100 - normalizedProgress.value * 100)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -33,6 +34,9 @@ const percentage = computed(() => Math.round(normalizedProgress.value * 100))
|
||||
aria-valuemax="100"
|
||||
:aria-valuenow="percentage"
|
||||
>
|
||||
<span class="sky-progress__value" :style="{ width: `${percentage}%` }" />
|
||||
<span
|
||||
class="sky-progress__value"
|
||||
:style="{ transform: `translateX(-${remainingPercentage}%)` }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -59,10 +59,40 @@ describe('SkySegmented navigation', () => {
|
||||
/\.sky-segmented--strong:not\(\.sky-segmented--navigation\):not\([\s\S]*?\.sky-segmented-button\s*\{[^}]*color:\s*var\(--sky-text, #000000\)/,
|
||||
)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-segmented--strong:not\(\.sky-segmented--navigation\):not\([\s\S]*?\.sky-segmented-button--active\s*\{[^}]*color:\s*#000000[^}]*0 1px 3px rgba\(0, 0, 0, 0\.1\)[^}]*0 1px 2px -1px rgba\(0, 0, 0, 0\.1\)/,
|
||||
/\.sky-segmented--strong:not\(\.sky-segmented--navigation\):not\([\s\S]*?\.sky-segmented__highlight\s*\{[^}]*background:\s*var\(--sky-segmented-strong-highlight, #ffffff\)/,
|
||||
)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-segmented--rounded:not\(\.sky-segmented--outline\):not\([\s\S]*?\.sky-segmented-button--active\s*\{[^}]*border-radius:\s*var\(--sky-radius-pill, 999px\)/,
|
||||
/\.sky-segmented--strong:not\(\.sky-segmented--navigation\):not\([\s\S]*?\.sky-segmented__highlight\s*\{[^}]*border-radius:\s*inherit/,
|
||||
)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-segmented--strong:not\(\.sky-segmented--navigation\):not\([\s\S]*?\.sky-segmented-button--active\s*\{[^}]*background:\s*transparent;[^}]*color:\s*#000000;[^}]*box-shadow:\s*none/,
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps ordinary rounded groups square between the pill edges', () => {
|
||||
expect(controls).toMatch(
|
||||
/\.sky-segmented:not\(\.sky-segmented--navigation\):not\(\.sky-segmented--navbar\)[\s\S]*?\.sky-segmented-button::before\s*\{[^}]*inset-block:\s*-5px;[^}]*inset-inline:\s*0;/,
|
||||
)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-segmented:not\(\.sky-segmented--strong\):not\(\s*\.sky-segmented--outline\s*\):not\(\s*\.sky-segmented--navigation\s*\):not\(\.sky-segmented--navbar\)\s*\.sky-segmented-button\s*\{[^}]*border-radius:\s*0;/s,
|
||||
)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-segmented:not\(\.sky-segmented--strong\):not\(\s*\.sky-segmented--outline\s*\):not\(\s*\.sky-segmented--navigation\s*\):not\(\.sky-segmented--navbar\)\s*\.sky-segmented-button:first-child\s*\{[^}]*border-start-start-radius:\s*4px;[^}]*border-end-start-radius:\s*4px;/s,
|
||||
)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-segmented--rounded:not\(\.sky-segmented--strong\):not\(\s*\.sky-segmented--outline\s*\):not\(\.sky-segmented--navigation\):not\(\.sky-segmented--navbar\)\s*\.sky-segmented-button:first-child\s*\{[^}]*border-start-start-radius:\s*var\(--sky-radius-pill, 999px\);[^}]*border-end-start-radius:\s*var\(--sky-radius-pill, 999px\);/s,
|
||||
)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-segmented--rounded:not\(\.sky-segmented--strong\):not\(\s*\.sky-segmented--outline\s*\):not\(\.sky-segmented--navigation\):not\(\.sky-segmented--navbar\)\s*\.sky-segmented-button:last-child\s*\{[^}]*border-start-end-radius:\s*var\(--sky-radius-pill, 999px\);[^}]*border-end-end-radius:\s*var\(--sky-radius-pill, 999px\);/s,
|
||||
)
|
||||
expect(controls).not.toMatch(
|
||||
/\.sky-segmented--rounded[^{}]*\.sky-segmented-button--active\s*\{[^}]*border-radius:/s,
|
||||
)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-segmented--outline\.sky-segmented--rounded\s*\.sky-segmented-button:first-child\s*\{[^}]*border-start-start-radius:\s*var\(--sky-radius-pill, 999px\);[^}]*border-end-start-radius:\s*var\(--sky-radius-pill, 999px\);/s,
|
||||
)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-segmented--rounded \.sky-segmented-button\s*\{[^}]*border-radius:\s*var\(--sky-radius-pill, 999px\);/s,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -135,6 +165,7 @@ describe('SkySegmented navigation', () => {
|
||||
compact: true,
|
||||
itemCount: 3,
|
||||
navigation: true,
|
||||
strong: true,
|
||||
},
|
||||
{
|
||||
default: () =>
|
||||
@@ -178,6 +209,12 @@ describe('SkySegmented navigation', () => {
|
||||
expect(controls).toMatch(
|
||||
/\.sky-app-page--dark \.sky-segmented--navbar \.sky-segmented__highlight\s*\{[^}]*background:\s*rgba\(255, 255, 255, 0\.75\)/s,
|
||||
)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-segmented--navbar\.sky-segmented--strong\s*\.sky-segmented-button:not\(\.sky-segmented-button--active\)\s*\{[^}]*color:\s*inherit;/s,
|
||||
)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-segmented--navbar \.sky-segmented-button--active\s*\{[^}]*color:\s*#000000;/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('calculates the same sliding pill for full-width five-item navigation', async () => {
|
||||
@@ -191,6 +228,7 @@ describe('SkySegmented navigation', () => {
|
||||
ariaLabel: 'Five tabs',
|
||||
itemCount: 5,
|
||||
navigation: true,
|
||||
strong: true,
|
||||
},
|
||||
{
|
||||
default: () =>
|
||||
@@ -209,4 +247,51 @@ describe('SkySegmented navigation', () => {
|
||||
expect(html).toContain('width:calc(20% - 4.8px)')
|
||||
expect(html).toContain('--sky-segmented-indicator-offset:calc(400% + 16px)')
|
||||
})
|
||||
|
||||
it('keeps Konsta strong mode opt-in by default', async () => {
|
||||
const html = await renderToString(
|
||||
createSSRApp({
|
||||
render: () =>
|
||||
h(
|
||||
SkySegmented,
|
||||
{},
|
||||
{
|
||||
default: () =>
|
||||
h(SkySegmentedButton, { active: true }, () => 'One'),
|
||||
},
|
||||
),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(html).not.toContain('sky-segmented--strong')
|
||||
expect(html).not.toContain('sky-segmented__highlight')
|
||||
})
|
||||
|
||||
it('renders the rounded iOS strong highlight as a moving pill', async () => {
|
||||
const html = await renderToString(
|
||||
createSSRApp({
|
||||
render: () =>
|
||||
h(
|
||||
SkySegmented,
|
||||
{
|
||||
activeIndex: 0,
|
||||
itemCount: 4,
|
||||
rounded: true,
|
||||
strong: true,
|
||||
},
|
||||
{
|
||||
default: () =>
|
||||
['10%', '30%', '50%', '100%'].map((label, index) =>
|
||||
h(SkySegmentedButton, { active: index === 0 }, () => label),
|
||||
),
|
||||
},
|
||||
),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(html).toContain('sky-segmented--rounded')
|
||||
expect(html).toContain('sky-segmented--strong')
|
||||
expect(html).toContain('sky-segmented__highlight')
|
||||
expect(html).toContain('width:calc(25% - 4px)')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, type CSSProperties } from 'vue'
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
onUpdated,
|
||||
ref,
|
||||
type ComponentPublicInstance,
|
||||
type CSSProperties,
|
||||
} from 'vue'
|
||||
|
||||
import { useSkyNavbar } from '../navbar-context'
|
||||
import SkyGlass from './SkyGlass.vue'
|
||||
@@ -20,16 +29,16 @@ const props = withDefaults(
|
||||
strong?: boolean
|
||||
}>(),
|
||||
{
|
||||
activeIndex: 0,
|
||||
activeIndex: undefined,
|
||||
ariaLabel: '',
|
||||
compact: false,
|
||||
glassHighlight: true,
|
||||
itemCount: 0,
|
||||
itemCount: undefined,
|
||||
navigation: undefined,
|
||||
outline: false,
|
||||
raised: false,
|
||||
rounded: false,
|
||||
strong: true,
|
||||
strong: false,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -40,18 +49,63 @@ const isNavbarSegmented = computed(
|
||||
const isNavigation = computed(() => props.navigation === true)
|
||||
const usesGlass = computed(() => isNavigation.value || isNavbarSegmented.value)
|
||||
const rootComponent = computed(() => (usesGlass.value ? SkyGlass : 'div'))
|
||||
const root = ref<HTMLElement | ComponentPublicInstance | null>(null)
|
||||
const measuredItemCount = ref(0)
|
||||
const measuredActiveIndex = ref(0)
|
||||
let observer: MutationObserver | undefined
|
||||
|
||||
function rootElement(): HTMLElement | null {
|
||||
const value = root.value
|
||||
if (!value || typeof HTMLElement === 'undefined') return null
|
||||
if (value instanceof HTMLElement) return value
|
||||
return value.$el instanceof HTMLElement ? value.$el : null
|
||||
}
|
||||
|
||||
function measureChildren(): void {
|
||||
const element = rootElement()
|
||||
if (!element) return
|
||||
const buttons = Array.from(
|
||||
element.querySelectorAll<HTMLElement>(':scope > .sky-segmented-button'),
|
||||
)
|
||||
const activeIndex = buttons.findIndex((button) =>
|
||||
button.classList.contains('sky-segmented-button--active'),
|
||||
)
|
||||
|
||||
measuredItemCount.value = buttons.length
|
||||
measuredActiveIndex.value = activeIndex < 0 ? 0 : activeIndex
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
measureChildren()
|
||||
const element = rootElement()
|
||||
if (!element || typeof MutationObserver === 'undefined') return
|
||||
observer = new MutationObserver(measureChildren)
|
||||
observer.observe(element, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class'],
|
||||
childList: true,
|
||||
subtree: true,
|
||||
})
|
||||
})
|
||||
|
||||
onUpdated(() => void nextTick(measureChildren))
|
||||
onBeforeUnmount(() => observer?.disconnect())
|
||||
|
||||
const indicatorStyle = computed<CSSProperties | undefined>(() => {
|
||||
const itemCount = Number.isFinite(props.itemCount)
|
||||
? Math.max(0, Math.floor(props.itemCount))
|
||||
: 0
|
||||
if (!usesGlass.value || !props.strong || itemCount === 0) return undefined
|
||||
? Math.max(0, Math.floor(props.itemCount ?? 0))
|
||||
: measuredItemCount.value
|
||||
if (!props.strong || itemCount === 0) return undefined
|
||||
|
||||
const requestedIndex = Number.isFinite(props.activeIndex)
|
||||
? Math.floor(props.activeIndex)
|
||||
: 0
|
||||
? Math.floor(props.activeIndex ?? 0)
|
||||
: measuredActiveIndex.value
|
||||
const activeIndex = Math.max(0, Math.min(itemCount - 1, requestedIndex))
|
||||
const widthPercentage = Number((100 / itemCount).toFixed(4))
|
||||
const spacing = Number(((8 + (itemCount - 1) * 4) / itemCount).toFixed(4))
|
||||
const edgePadding = usesGlass.value ? 4 : 2
|
||||
const spacing = Number(
|
||||
((edgePadding * 2 + (itemCount - 1) * 4) / itemCount).toFixed(4),
|
||||
)
|
||||
const percentageOffset = activeIndex * 100
|
||||
const pixelOffset = activeIndex * 4
|
||||
|
||||
@@ -66,6 +120,7 @@ const indicatorStyle = computed<CSSProperties | undefined>(() => {
|
||||
<template>
|
||||
<component
|
||||
:is="rootComponent"
|
||||
ref="root"
|
||||
v-bind="$attrs"
|
||||
:highlight="usesGlass ? glassHighlight : undefined"
|
||||
class="sky-segmented"
|
||||
|
||||
@@ -10,7 +10,7 @@ const props = withDefaults(
|
||||
}>(),
|
||||
{
|
||||
label: '',
|
||||
size: 20,
|
||||
size: 32,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -29,6 +29,46 @@ const spinnerStyle = computed<CSSProperties>(() => {
|
||||
:aria-label="label || undefined"
|
||||
:role="label ? 'status' : undefined"
|
||||
>
|
||||
<i v-for="index in 8" :key="index" aria-hidden="true" />
|
||||
<svg
|
||||
class="sky-spinner__svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M10,0 C10.5522847,0 11,0.44771525 11,1 L11,6 C11,6.55228475 10.5522847,7 10,7 C9.44771525,7 9,6.55228475 9,6 L9,1 C9,0.44771525 9.44771525,0 10,0 Z"
|
||||
/>
|
||||
<path
|
||||
d="M10,13 C10.5522847,13 11,13.4477153 11,14 L11,19 C11,19.5522847 10.5522847,20 10,20 C9.44771525,20 9,19.5522847 9,19 L9,14 C9,13.4477153 9.44771525,13 10,13 Z"
|
||||
opacity=".64"
|
||||
/>
|
||||
<path
|
||||
d="M13,10 C13,9.44771525 13.4477153,9 14,9 L19,9 C19.5522847,9 20,9.44771525 20,10 C20,10.5522847 19.5522847,11 19,11 L14,11 C13.4477153,11 13,10.5522847 13,10 Z"
|
||||
opacity=".82"
|
||||
/>
|
||||
<path
|
||||
d="M-3.8285687e-16,10 C-3.8285687e-16,9.44771525 0.44771525,9 1,9 L6,9 C6.55228475,9 7,9.44771525 7,10 C7,10.5522847 6.55228475,11 6,11 L1,11 C0.44771525,11 -3.8285687e-16,10.5522847 -3.8285687e-16,10 Z"
|
||||
opacity=".46"
|
||||
/>
|
||||
<path
|
||||
d="M2.92912627,2.92912627 C3.31965056,2.53860197 3.95281554,2.53860197 4.34333983,2.92912627 L7.87887373,6.46466017 C8.26939803,6.85518446 8.26939803,7.48834944 7.87887373,7.87887373 C7.48834944,8.26939803 6.85518446,8.26939803 6.46466017,7.87887373 L2.92912627,4.34333983 C2.53860197,3.95281554 2.53860197,3.31965056 2.92912627,2.92912627 Z"
|
||||
opacity=".37"
|
||||
/>
|
||||
<path
|
||||
d="M12.1211263,12.1211263 C12.5116506,11.730602 13.1448155,11.730602 13.5353398,12.1211263 L17.0708737,15.6566602 C17.461398,16.0471845 17.461398,16.6803494 17.0708737,17.0708737 C16.6803494,17.461398 16.0471845,17.461398 15.6566602,17.0708737 L12.1211263,13.5353398 C11.730602,13.1448155 11.730602,12.5116506 12.1211263,12.1211263 Z"
|
||||
opacity=".73"
|
||||
/>
|
||||
<path
|
||||
d="M12.1211263,7.87887373 C11.730602,7.48834944 11.730602,6.85518446 12.1211263,6.46466017 L15.6566602,2.92912627 C16.0471845,2.53860197 16.6803494,2.53860197 17.0708737,2.92912627 C17.461398,3.31965056 17.461398,3.95281554 17.0708737,4.34333983 L13.5353398,7.87887373 C13.1448155,8.26939803 12.5116506,8.26939803 12.1211263,7.87887373 Z"
|
||||
opacity=".91"
|
||||
/>
|
||||
<path
|
||||
d="M2.92912627,17.0708737 C2.53860197,16.6803494 2.53860197,16.0471845 2.92912627,15.6566602 L6.46466017,12.1211263 C6.85518446,11.730602 7.48834944,11.730602 7.87887373,12.1211263 C8.26939803,12.5116506 8.26939803,13.1448155 7.87887373,13.5353398 L4.34333983,17.0708737 C3.95281554,17.461398 3.31965056,17.461398 2.92912627,17.0708737 Z"
|
||||
opacity=".55"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { createSSRApp, h } from 'vue'
|
||||
import { renderToString } from 'vue/server-renderer'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import SkyStatusCard from '@/ui/controls/SkyStatusCard.vue'
|
||||
|
||||
const controlsStyles = readFileSync(
|
||||
new URL('../controls.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('SkyStatusCard', () => {
|
||||
it('renders a textual live status with a semantic tone and indicator', async () => {
|
||||
const app = createSSRApp({
|
||||
@@ -31,4 +38,51 @@ describe('SkyStatusCard', () => {
|
||||
expect(html).toContain('Connected to 120.5 MHz')
|
||||
expect(html).toContain('Yaca')
|
||||
})
|
||||
|
||||
it('keeps an indicator in the right-hand column when no icon is present', async () => {
|
||||
const app = createSSRApp({
|
||||
render: () =>
|
||||
h(SkyStatusCard, {
|
||||
indicator: true,
|
||||
subtitle: 'Sky-only status primitive',
|
||||
title: 'Ready',
|
||||
tone: 'success',
|
||||
}),
|
||||
})
|
||||
|
||||
const html = await renderToString(app)
|
||||
const statusCardRule = controlsStyles.match(
|
||||
/\.sky-status-card\s*\{([^}]*)\}/s,
|
||||
)?.[1]
|
||||
|
||||
expect(html).toContain('sky-status-card__indicator')
|
||||
expect(statusCardRule).toContain('display: flex')
|
||||
expect(statusCardRule).not.toContain('grid-template-columns')
|
||||
expect(controlsStyles).toMatch(
|
||||
/\.sky-status-card__copy\s*\{[^}]*flex:\s*1 1 auto;/s,
|
||||
)
|
||||
expect(controlsStyles).toMatch(
|
||||
/\.sky-status-card__indicator\s*\{[^}]*flex:\s*none;/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('prefers a trailing slot over the indicator', async () => {
|
||||
const app = createSSRApp({
|
||||
render: () =>
|
||||
h(
|
||||
SkyStatusCard,
|
||||
{ indicator: true, title: 'Connected' },
|
||||
{ trailing: () => h('span', '120.5 MHz') },
|
||||
),
|
||||
})
|
||||
|
||||
const html = await renderToString(app)
|
||||
|
||||
expect(html).toContain('sky-status-card__trailing')
|
||||
expect(html).toContain('120.5 MHz')
|
||||
expect(html).not.toContain('sky-status-card__indicator')
|
||||
expect(controlsStyles).toMatch(
|
||||
/\.sky-status-card__trailing\s*\{[^}]*flex:\s*none;/s,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -55,6 +55,17 @@ describe('SkyToggle', () => {
|
||||
expect(html).toContain('aria-readonly="true"')
|
||||
})
|
||||
|
||||
it('prevents an enclosing label from activating forwarded clicks twice', () => {
|
||||
const componentSource = readFileSync(
|
||||
fileURLToPath(new URL('./SkyToggle.vue', import.meta.url)),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
expect(componentSource).toMatch(
|
||||
/if \(props\.component !== 'label' && event\.target !== input\.value\) \{\s*event\.preventDefault\(\)\s*input\.value\?\.click\(\)/,
|
||||
)
|
||||
})
|
||||
|
||||
it('binds the complete enabled-only Konsta hold effect in CSS', () => {
|
||||
const uiDirectory = fileURLToPath(new URL('..', import.meta.url))
|
||||
const controls = readFileSync(`${uiDirectory}/controls.css`, 'utf8')
|
||||
@@ -101,11 +112,12 @@ describe('SkyToggle', () => {
|
||||
expect(controls).toMatch(
|
||||
/\.sky-list-item__after > \.sky-toggle\s*\{\s*margin-block: -8px;/,
|
||||
)
|
||||
expect(controls).toMatch(/\.sky-toggle\s*\{[^}]*min-height: 28px/)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-toggle,\s*\.sky-radio\s*\{[\s\S]*?min-height: var\(--sky-touch-target, 44px\)/,
|
||||
/\.sky-toggle__input\s*\{[^}]*height: var\(--sky-touch-target, 44px\)/,
|
||||
)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-list-item__row\s*\{[\s\S]*?padding: 12px 16px;/,
|
||||
/\.sky-list-item__content\s*\{[\s\S]*?padding: 12px calc\(var\(--sky-safe-area-right\) \+ 16px\) 12px 0;/,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -70,6 +70,7 @@ function handleContainerClick(event: MouseEvent): void {
|
||||
}
|
||||
|
||||
if (props.component !== 'label' && event.target !== input.value) {
|
||||
event.preventDefault()
|
||||
input.value?.click()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createSSRApp, h } from 'vue'
|
||||
import { renderToString } from 'vue/server-renderer'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import SkyLink from './SkyLink.vue'
|
||||
import SkyToolbar from './SkyToolbar.vue'
|
||||
import SkyToolbarPane from './SkyToolbarPane.vue'
|
||||
|
||||
@@ -52,4 +53,28 @@ describe('SkyToolbar', () => {
|
||||
/\.sky-toolbar__blur\s*\{[\s\S]*?backdrop-filter: blur\(2px\);[\s\S]*?mask-image: linear-gradient/,
|
||||
)
|
||||
})
|
||||
|
||||
it('inherits the iOS foreground for Toolbar links without changing generic links', async () => {
|
||||
const html = await renderToString(
|
||||
createSSRApp({
|
||||
render: () =>
|
||||
h(SkyToolbar, {}, () =>
|
||||
h(SkyToolbarPane, {}, () => h(SkyLink, {}, () => 'Link 1')),
|
||||
),
|
||||
}),
|
||||
)
|
||||
const uiDirectory = fileURLToPath(new URL('..', import.meta.url))
|
||||
const controls = readFileSync(`${uiDirectory}/controls.css`, 'utf8')
|
||||
|
||||
expect(html).toContain('class="sky-link"')
|
||||
expect(controls).toMatch(
|
||||
/\.sky-toolbar \.sky-link\s*\{\s*color: inherit;\s*\}/,
|
||||
)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-glass\s*\{[^}]*color: var\(--sky-text, #000000\);/s,
|
||||
)
|
||||
expect(controls).toMatch(
|
||||
/\.sky-link\s*\{[^}]*color: var\(--sky-app-accent, #007aff\);/s,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ export { default as SkyBreadcrumbsItem } from './SkyBreadcrumbsItem.vue'
|
||||
export { default as SkyBreadcrumbsSeparator } from './SkyBreadcrumbsSeparator.vue'
|
||||
export { default as SkyButton } from './SkyButton.vue'
|
||||
export { default as SkyCard } from './SkyCard.vue'
|
||||
export { default as SkyMediaCard } from './SkyMediaCard.vue'
|
||||
export { default as SkyCheckbox } from './SkyCheckbox.vue'
|
||||
export { default as SkyChip } from './SkyChip.vue'
|
||||
export { default as SkyChipDeleteIcon } from './SkyChipDeleteIcon.vue'
|
||||
@@ -20,6 +21,7 @@ export { default as SkyLink } from './SkyLink.vue'
|
||||
export { default as SkyList } from './SkyList.vue'
|
||||
export { default as SkyListButton } from './SkyListButton.vue'
|
||||
export { default as SkyListGroup } from './SkyListGroup.vue'
|
||||
export { default as SkyListGroupInner } from './SkyListGroupInner.vue'
|
||||
export { default as SkyListItem } from './SkyListItem.vue'
|
||||
export { default as SkyMenuList } from './SkyMenuList.vue'
|
||||
export { default as SkyMenuListItem } from './SkyMenuListItem.vue'
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { ComputedRef, InjectionKey } from 'vue'
|
||||
|
||||
export interface SkyListContext {
|
||||
dividers: boolean
|
||||
nested: boolean
|
||||
}
|
||||
|
||||
export const skyListContextKey: InjectionKey<ComputedRef<SkyListContext>> =
|
||||
Symbol('sky-list-context')
|
||||
Vendored
+49
-25
@@ -48,12 +48,13 @@
|
||||
--sky-navbar-collapse-offset: 0px;
|
||||
--sky-navbar-compact-title-opacity: 0;
|
||||
--sky-navbar-extended-title-opacity: 1;
|
||||
--sky-navbar-safe-area-top: max(16px, var(--sky-safe-area-top));
|
||||
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-top: var(--sky-safe-area-top);
|
||||
min-height: calc(var(--sky-navbar-safe-area-top) + var(--sky-navbar-height));
|
||||
padding-top: var(--sky-navbar-safe-area-top);
|
||||
flex: none;
|
||||
color: var(--sky-text);
|
||||
background: transparent;
|
||||
@@ -66,7 +67,9 @@
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
height: calc(var(--sky-safe-area-top) + var(--sky-navbar-height) + 16px);
|
||||
height: calc(
|
||||
var(--sky-navbar-safe-area-top) + var(--sky-navbar-height) + 16px
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -102,7 +105,7 @@
|
||||
.sky-navbar--with-subnavbar .sky-navbar__blur,
|
||||
.sky-navbar--with-subnavbar .sky-navbar__background {
|
||||
height: calc(
|
||||
var(--sky-safe-area-top) + var(--sky-navbar-height) + 70px + 16px
|
||||
var(--sky-navbar-safe-area-top) + var(--sky-navbar-height) + 70px + 16px
|
||||
);
|
||||
}
|
||||
|
||||
@@ -124,25 +127,32 @@
|
||||
|
||||
.sky-navbar--no-navigation {
|
||||
padding-top: calc(
|
||||
var(--sky-safe-area-top) + var(--sky-navbar-height) + var(--sky-space-3)
|
||||
var(--sky-navbar-safe-area-top) + var(--sky-navbar-height) +
|
||||
var(--sky-space-3)
|
||||
);
|
||||
}
|
||||
|
||||
.sky-navbar__left,
|
||||
.sky-navbar__right {
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
min-height: var(--sky-navbar-height);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
border-radius: var(--sky-radius-pill);
|
||||
}
|
||||
|
||||
.sky-navbar__left {
|
||||
grid-column: 1;
|
||||
justify-self: start;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.sky-navbar__right {
|
||||
grid-column: 3;
|
||||
justify-self: end;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@@ -190,15 +200,15 @@
|
||||
min-width: var(--sky-touch-target);
|
||||
min-height: var(--sky-touch-target);
|
||||
margin: 0;
|
||||
padding: 0 var(--sky-space-1);
|
||||
padding: 0 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
justify-content: center;
|
||||
gap: var(--sky-space-1);
|
||||
border: 0;
|
||||
border-radius: var(--sky-radius-pill);
|
||||
appearance: none;
|
||||
color: var(--sky-app-accent);
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
@@ -208,6 +218,14 @@
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.sky-navbar__back-icon {
|
||||
width: 12px;
|
||||
height: 20px;
|
||||
display: block;
|
||||
flex: none;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.sky-navbar__back-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
@@ -250,14 +268,14 @@
|
||||
|
||||
.sky-navbar--medium {
|
||||
min-height: calc(
|
||||
var(--sky-safe-area-top) + var(--sky-navbar-height) +
|
||||
var(--sky-navbar-safe-area-top) + var(--sky-navbar-height) +
|
||||
var(--sky-navbar-height)
|
||||
);
|
||||
}
|
||||
|
||||
.sky-navbar--large {
|
||||
min-height: calc(
|
||||
var(--sky-safe-area-top) + var(--sky-navbar-height) +
|
||||
var(--sky-navbar-safe-area-top) + var(--sky-navbar-height) +
|
||||
var(--sky-navbar-large-title-height)
|
||||
);
|
||||
}
|
||||
@@ -293,7 +311,7 @@
|
||||
.sky-navbar__collapsed-heading {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: var(--sky-safe-area-top);
|
||||
top: var(--sky-navbar-safe-area-top);
|
||||
right: calc(var(--sky-page-gutter) + var(--sky-safe-area-right));
|
||||
left: calc(var(--sky-page-gutter) + var(--sky-safe-area-left));
|
||||
height: var(--sky-navbar-height);
|
||||
@@ -365,10 +383,7 @@
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: var(--sky-page-space)
|
||||
calc(var(--sky-page-gutter) + var(--sky-safe-area-right))
|
||||
calc(var(--sky-safe-area-bottom) + var(--sky-page-space));
|
||||
padding-left: calc(var(--sky-page-gutter) + var(--sky-safe-area-left));
|
||||
padding: 0;
|
||||
flex: 1 1 auto;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
@@ -378,6 +393,25 @@
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.sky-scroll-area--padded {
|
||||
--sky-block-gutter-left: 0px;
|
||||
--sky-block-gutter-right: 0px;
|
||||
--sky-block-inset-gutter: 0px;
|
||||
--sky-block-outer-left: 0px;
|
||||
--sky-block-outer-right: 0px;
|
||||
--sky-block-strong-gutter: 16px;
|
||||
--sky-card-outer-left: 0px;
|
||||
--sky-card-outer-right: 0px;
|
||||
--sky-list-outer-left: 0px;
|
||||
--sky-list-outer-right: 0px;
|
||||
--sky-title-gutter-left: 0px;
|
||||
--sky-title-gutter-right: 0px;
|
||||
padding: var(--sky-page-space)
|
||||
calc(var(--sky-page-gutter) + var(--sky-safe-area-right))
|
||||
calc(var(--sky-safe-area-bottom) + var(--sky-page-space));
|
||||
padding-left: calc(var(--sky-page-gutter) + var(--sky-safe-area-left));
|
||||
}
|
||||
|
||||
.sky-scroll-area::before {
|
||||
height: var(--sky-page-collapse-offset);
|
||||
display: block;
|
||||
@@ -866,16 +900,6 @@
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.sky-tabbar__links > button > span > span:last-child,
|
||||
.sky-tabbar__links > a > span > span:last-child {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 14px;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sky-navbar button:focus-visible,
|
||||
.sky-navbar a:focus-visible,
|
||||
.sky-list-card button:focus-visible,
|
||||
|
||||
+167
-89
@@ -133,15 +133,12 @@
|
||||
|
||||
.sky-popup-rise-enter-active .sky-popup__panel,
|
||||
.sky-popup-rise-leave-active .sky-popup__panel {
|
||||
transition:
|
||||
opacity 400ms ease,
|
||||
transform 400ms var(--sky-ease-out, ease-out);
|
||||
will-change: opacity, transform;
|
||||
transition: transform 400ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.sky-popup-rise-enter-from .sky-popup__panel,
|
||||
.sky-popup-rise-leave-to .sky-popup__panel {
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
@@ -149,21 +146,17 @@
|
||||
.sky-panel-left-leave-active .sky-panel__panel,
|
||||
.sky-panel-right-enter-active .sky-panel__panel,
|
||||
.sky-panel-right-leave-active .sky-panel__panel {
|
||||
transition:
|
||||
opacity 400ms ease,
|
||||
transform 400ms var(--sky-ease-out, ease-out);
|
||||
will-change: opacity, transform;
|
||||
transition: transform 400ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.sky-panel-left-enter-from .sky-panel__panel,
|
||||
.sky-panel-left-leave-to .sky-panel__panel {
|
||||
opacity: 0;
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.sky-panel-right-enter-from .sky-panel__panel,
|
||||
.sky-panel-right-leave-to .sky-panel__panel {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
@@ -242,8 +235,8 @@
|
||||
color: var(--sky-app-accent);
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 17px;
|
||||
line-height: 22px;
|
||||
font-size: 20px;
|
||||
line-height: 28px;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
@@ -342,22 +335,13 @@
|
||||
|
||||
.sky-dialog-button {
|
||||
min-width: 0;
|
||||
min-height: 44px;
|
||||
flex: 1 1 0;
|
||||
border: 0;
|
||||
border-radius: var(--sky-radius-pill, 999px);
|
||||
padding: 0 12px;
|
||||
color: var(--sky-app-accent);
|
||||
background: var(--sky-app-accent-soft);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.sky-dialog-button:active:not(:disabled) {
|
||||
.sky-dialog-button:disabled {
|
||||
background: var(--sky-pressed);
|
||||
}
|
||||
|
||||
.sky-dialog-button--strong {
|
||||
font-weight: 700;
|
||||
color: var(--sky-subtle);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sky-toast {
|
||||
@@ -450,23 +434,29 @@
|
||||
.sky-messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--sky-space-2) 0;
|
||||
margin-bottom: 48px;
|
||||
padding-right: calc(var(--sky-safe-area-right) + 8px);
|
||||
padding-left: calc(var(--sky-safe-area-left) + 8px);
|
||||
}
|
||||
|
||||
.sky-messages-title {
|
||||
margin: 8px 0;
|
||||
color: var(--sky-muted);
|
||||
margin: 10px 0 0;
|
||||
color: var(--sky-message-meta);
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sky-message {
|
||||
box-sizing: border-box;
|
||||
max-width: 70%;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
align-self: flex-start;
|
||||
margin-bottom: 8px;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.sky-message--sent {
|
||||
@@ -476,8 +466,8 @@
|
||||
.sky-message__name,
|
||||
.sky-message__header {
|
||||
display: block;
|
||||
margin: 0 16px 2px;
|
||||
color: var(--sky-muted);
|
||||
margin: 0 0 2px 16px;
|
||||
color: var(--sky-message-meta);
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
}
|
||||
@@ -500,13 +490,17 @@
|
||||
}
|
||||
|
||||
.sky-message__bubble {
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
border-radius: 16px;
|
||||
padding: 6px 16px;
|
||||
background: var(--sky-surface-muted);
|
||||
background: var(--sky-message-received-background);
|
||||
color: var(--sky-text);
|
||||
font-size: 17px;
|
||||
line-height: 22px;
|
||||
font-size: 16px;
|
||||
line-height: 20px;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@@ -540,33 +534,61 @@
|
||||
}
|
||||
|
||||
.sky-messagebar {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: var(--sky-space-2);
|
||||
border-top: 0;
|
||||
padding: 8px var(--sky-page-gutter)
|
||||
calc(var(--sky-safe-area-bottom) + var(--sky-space-2));
|
||||
background: var(--sky-glass-solid, var(--sky-surface));
|
||||
width: 100%;
|
||||
position: fixed;
|
||||
z-index: var(--sky-overlay-layer, 80);
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
--sky-messagebar-gap: 12px;
|
||||
}
|
||||
|
||||
.sky-messagebar--outline {
|
||||
border-top: 1px solid var(--sky-hairline);
|
||||
.sky-messagebar .sky-toolbar__background {
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
var(--sky-messagebar-gradient-start),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.sky-messagebar__inner {
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: var(--sky-messagebar-gap);
|
||||
}
|
||||
|
||||
.sky-messagebar__area {
|
||||
min-width: 0;
|
||||
height: 40px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.sky-messagebar textarea {
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
max-height: 100px;
|
||||
flex: 1;
|
||||
display: block;
|
||||
resize: none;
|
||||
border: 1px solid transparent;
|
||||
border: 0;
|
||||
border-radius: 24px;
|
||||
padding: 10px 16px;
|
||||
padding: 12px 16px 4px;
|
||||
outline: 0;
|
||||
color: var(--sky-text);
|
||||
background: var(--sky-surface-muted);
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 17px;
|
||||
line-height: 20px;
|
||||
font-size: 16px;
|
||||
line-height: 16px;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.sky-messagebar textarea::placeholder {
|
||||
color: var(--sky-messagebar-placeholder);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sky-messagebar__left,
|
||||
@@ -576,18 +598,58 @@
|
||||
display: grid;
|
||||
flex: none;
|
||||
place-items: center;
|
||||
color: var(--sky-app-accent);
|
||||
color: var(--sky-messagebar-icon);
|
||||
}
|
||||
|
||||
.sky-messagebar__left .sky-link,
|
||||
.sky-messagebar__right .sky-link {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.sky-messagebar .sky-toolbar-pane {
|
||||
height: 40px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.sky-messagebar__right {
|
||||
margin-right: -4px;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.sky-messagebar--embedded {
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
z-index: auto;
|
||||
right: auto;
|
||||
bottom: auto;
|
||||
left: auto;
|
||||
padding: 0;
|
||||
--sky-messagebar-gap: 4px;
|
||||
}
|
||||
|
||||
.sky-messagebar--embedded .sky-toolbar__blur,
|
||||
.sky-messagebar--embedded .sky-toolbar__background {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sky-messagebar--embedded .sky-messagebar__inner {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sky-messagebar--embedded .sky-messagebar__area {
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
overflow: visible;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
.sky-notification {
|
||||
box-sizing: border-box;
|
||||
min-height: 72px;
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
z-index: 50;
|
||||
top: calc(var(--sky-safe-area-top, 0px) + 8px);
|
||||
right: 8px;
|
||||
left: 8px;
|
||||
@@ -595,11 +657,11 @@
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--sky-hairline);
|
||||
border: 0;
|
||||
border-radius: 24px;
|
||||
background: var(--sky-glass-solid, var(--sky-surface));
|
||||
color: var(--sky-text);
|
||||
box-shadow: var(--sky-shadow-glass);
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.sky-notification__icon {
|
||||
@@ -610,22 +672,28 @@
|
||||
}
|
||||
|
||||
.sky-notification__content {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.sky-notification__header {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sky-notification__header--with-close {
|
||||
padding-inline-end: 24px;
|
||||
}
|
||||
|
||||
.sky-notification__title,
|
||||
.sky-notification__time,
|
||||
.sky-notification__subtitle,
|
||||
.sky-notification__text {
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.sky-notification__title,
|
||||
@@ -634,17 +702,13 @@
|
||||
}
|
||||
|
||||
.sky-notification__title {
|
||||
overflow: hidden;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sky-notification__time {
|
||||
margin-left: auto;
|
||||
padding-left: 8px;
|
||||
color: var(--sky-muted);
|
||||
white-space: nowrap;
|
||||
margin-inline-start: auto;
|
||||
margin-inline-end: 4px;
|
||||
color: var(--sky-notification-meta);
|
||||
}
|
||||
|
||||
.sky-notification__text {
|
||||
@@ -654,21 +718,28 @@
|
||||
.sky-notification__close {
|
||||
width: var(--sky-touch-target, 44px);
|
||||
height: var(--sky-touch-target, 44px);
|
||||
display: inline-grid;
|
||||
flex: none;
|
||||
margin: -13px -13px -13px 2px;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: -8px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--sky-muted);
|
||||
color: var(--sky-notification-close);
|
||||
font: inherit;
|
||||
font-size: 22px;
|
||||
cursor: pointer;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.sky-notification__close:active {
|
||||
background: var(--sky-pressed);
|
||||
color: var(--sky-notification-close-active);
|
||||
}
|
||||
|
||||
.sky-notification__close-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.sky-notification__close:focus-visible {
|
||||
@@ -679,14 +750,14 @@
|
||||
.sky-notification-slide-enter-active,
|
||||
.sky-notification-slide-leave-active {
|
||||
transition:
|
||||
opacity 500ms ease,
|
||||
transform 500ms var(--sky-ease-out, ease-out);
|
||||
opacity 500ms cubic-bezier(0.4, 0, 0.2, 1),
|
||||
transform 500ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.sky-notification-slide-enter-from,
|
||||
.sky-notification-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(calc(-100% - var(--sky-safe-area-top, 0px) - 8px));
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
|
||||
.sky-panel__panel,
|
||||
@@ -695,17 +766,37 @@
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
border: 0;
|
||||
background: var(--sky-glass-solid, var(--sky-surface));
|
||||
color: var(--sky-text);
|
||||
box-shadow: var(--sky-shadow-glass);
|
||||
}
|
||||
|
||||
.sky-popup__panel {
|
||||
background: var(--sky-popup-background, var(--sky-surface));
|
||||
}
|
||||
|
||||
.sky-popover__panel {
|
||||
background: var(--sky-glass-solid, var(--sky-surface));
|
||||
}
|
||||
|
||||
.sky-panel__backdrop {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.sky-popup__backdrop {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.sky-panel__panel {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 288px;
|
||||
max-width: 100%;
|
||||
overflow: auto;
|
||||
overflow: hidden;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.sky-panel__panel:not(.sky-panel__panel--floating) {
|
||||
background: var(--sky-panel-background, var(--sky-surface));
|
||||
}
|
||||
|
||||
.sky-panel__panel--left {
|
||||
@@ -732,7 +823,7 @@
|
||||
|
||||
.sky-popup__panel {
|
||||
inset: 0;
|
||||
overflow: auto;
|
||||
overflow: hidden;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@@ -853,7 +944,6 @@
|
||||
|
||||
.sky-action-button:disabled,
|
||||
.sky-action-button--disabled,
|
||||
.sky-dialog-button:disabled,
|
||||
.sky-messagebar textarea:disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
@@ -861,18 +951,6 @@
|
||||
@supports (
|
||||
(-webkit-backdrop-filter: blur(16px)) or (backdrop-filter: blur(16px))
|
||||
) {
|
||||
.sky-action-group,
|
||||
.sky-dialog__panel,
|
||||
.sky-messagebar,
|
||||
.sky-notification,
|
||||
.sky-panel__panel,
|
||||
.sky-popover__panel,
|
||||
.sky-toast__inner {
|
||||
background: var(--sky-glass);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.sky-popover__arrow {
|
||||
background: var(--sky-glass);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
|
||||
@@ -8,7 +8,7 @@ withDefaults(defineProps<{ component?: string; dividers?: boolean }>(), {
|
||||
<template>
|
||||
<component
|
||||
:is="component"
|
||||
class="sky-action-group"
|
||||
class="sky-action-group sky-glass-surface"
|
||||
:class="{ 'sky-action-group--dividers': dividers }"
|
||||
>
|
||||
<slot />
|
||||
|
||||
@@ -90,7 +90,7 @@ useOverlayFocusTrap({
|
||||
<component
|
||||
:is="component"
|
||||
ref="panel"
|
||||
class="sky-dialog__panel"
|
||||
class="sky-dialog__panel sky-glass-surface"
|
||||
:role="effectiveRole"
|
||||
:aria-modal="hasDialogRole ? ariaModal : undefined"
|
||||
:aria-label="resolvedAriaLabel"
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { createSSRApp, h } from 'vue'
|
||||
import { renderToString } from 'vue/server-renderer'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import SkyDialogButton from './SkyDialogButton.vue'
|
||||
|
||||
async function renderDialogButton(
|
||||
props: { disabled?: boolean; strong?: boolean } = {},
|
||||
): Promise<string> {
|
||||
return renderToString(
|
||||
createSSRApp({
|
||||
render: () => h(SkyDialogButton, props, () => 'Continue'),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
describe('SkyDialogButton', () => {
|
||||
it('uses the Konsta iOS tonal button for a regular action', async () => {
|
||||
const html = await renderDialogButton()
|
||||
|
||||
expect(html).toContain('<button')
|
||||
expect(html).toContain('type="button"')
|
||||
expect(html).toContain('sky-dialog-button')
|
||||
expect(html).toContain('sky-button--primary')
|
||||
expect(html).toContain('sky-button--large')
|
||||
expect(html).toContain('sky-button--rounded')
|
||||
expect(html).toContain('sky-button--tonal')
|
||||
expect(html).not.toContain('sky-dialog-button--strong')
|
||||
})
|
||||
|
||||
it('uses the Konsta iOS filled button for a strong action', async () => {
|
||||
const html = await renderDialogButton({ strong: true })
|
||||
|
||||
expect(html).toContain('sky-dialog-button--strong')
|
||||
expect(html).toContain('sky-button--primary')
|
||||
expect(html).not.toContain('sky-button--tonal')
|
||||
})
|
||||
|
||||
it('uses neutral Konsta disabled colors instead of fading the button', async () => {
|
||||
const html = await renderDialogButton({ disabled: true, strong: true })
|
||||
const overlays = readFileSync(
|
||||
new URL('../overlays.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
expect(html).toContain('disabled')
|
||||
expect(overlays).toMatch(
|
||||
/\.sky-dialog-button:disabled\s*\{[\s\S]*?background:\s*var\(--sky-pressed\);[\s\S]*?color:\s*var\(--sky-subtle\);[\s\S]*?opacity:\s*1;/,
|
||||
)
|
||||
expect(overlays).not.toMatch(
|
||||
/\.sky-action-button:disabled,[\s\S]*?\.sky-dialog-button:disabled,[\s\S]*?opacity:\s*0\.45;/,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import SkyButton from '../controls/SkyButton.vue'
|
||||
|
||||
withDefaults(defineProps<{ disabled?: boolean; strong?: boolean }>(), {
|
||||
disabled: false,
|
||||
strong: false,
|
||||
@@ -6,12 +8,14 @@ withDefaults(defineProps<{ disabled?: boolean; strong?: boolean }>(), {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
<SkyButton
|
||||
class="sky-dialog-button"
|
||||
:class="{ 'sky-dialog-button--strong': strong }"
|
||||
:disabled="disabled"
|
||||
large
|
||||
rounded
|
||||
:tonal="!strong"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
</SkyButton>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { createSSRApp, h } from 'vue'
|
||||
import { renderToString } from 'vue/server-renderer'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import SkyMessage from './SkyMessage.vue'
|
||||
|
||||
describe('SkyMessage', () => {
|
||||
it('renders received and sent message states', async () => {
|
||||
const [received, sent] = await Promise.all([
|
||||
renderToString(
|
||||
createSSRApp({
|
||||
render: () =>
|
||||
h(SkyMessage, {
|
||||
name: 'Kate',
|
||||
text: 'Hi, I am good!',
|
||||
type: 'received',
|
||||
}),
|
||||
}),
|
||||
),
|
||||
renderToString(
|
||||
createSSRApp({
|
||||
render: () => h(SkyMessage, { text: 'Hi, Kate', type: 'sent' }),
|
||||
}),
|
||||
),
|
||||
])
|
||||
|
||||
expect(received).toContain('sky-message--received')
|
||||
expect(received).toContain('sky-message__name')
|
||||
expect(received).toContain('Kate')
|
||||
expect(received).toContain('Hi, I am good!')
|
||||
expect(sent).toContain('sky-message--sent')
|
||||
})
|
||||
|
||||
it('uses the exact Konsta iOS received and metadata colors', () => {
|
||||
const overlays = readFileSync(
|
||||
new URL('../overlays.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const tokens = readFileSync(
|
||||
new URL('../tokens.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
expect(tokens).toMatch(/--sky-message-received-background:\s*#e5e5ea;/)
|
||||
expect(tokens).toMatch(/--sky-message-received-background:\s*#252525;/)
|
||||
expect(tokens).toMatch(/--sky-message-meta:\s*rgba\(0, 0, 0, 0\.45\);/)
|
||||
expect(tokens).toMatch(
|
||||
/--sky-message-meta:\s*rgba\(255, 255, 255, 0\.45\);/,
|
||||
)
|
||||
expect(overlays).toMatch(
|
||||
/\.sky-message__bubble\s*\{[\s\S]*?background:\s*var\(--sky-message-received-background\);/,
|
||||
)
|
||||
expect(overlays).toMatch(
|
||||
/\.sky-message__name,[\s\S]*?\.sky-message__header\s*\{[\s\S]*?color:\s*var\(--sky-message-meta\);/,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -13,11 +13,11 @@ withDefaults(
|
||||
type?: 'received' | 'sent'
|
||||
}>(),
|
||||
{
|
||||
component: 'article',
|
||||
component: 'div',
|
||||
name: '',
|
||||
textFooter: '',
|
||||
textHeader: '',
|
||||
type: 'received',
|
||||
type: 'sent',
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { createSSRApp, h } from 'vue'
|
||||
import { renderToString } from 'vue/server-renderer'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import SkyMessagebar from './SkyMessagebar.vue'
|
||||
|
||||
async function renderMessagebar(
|
||||
props: Record<string, unknown> = {},
|
||||
): Promise<string> {
|
||||
return renderToString(
|
||||
createSSRApp({
|
||||
render: () =>
|
||||
h(SkyMessagebar, props, {
|
||||
left: () => h('button', { class: 'camera' }, 'Camera'),
|
||||
right: () => h('button', { class: 'send' }, 'Send'),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
describe('SkyMessagebar', () => {
|
||||
it('uses a toolbar fade with separate Glass around the input', async () => {
|
||||
const html = await renderMessagebar({
|
||||
ariaLabel: 'Message',
|
||||
modelValue: 'Hello',
|
||||
})
|
||||
|
||||
expect(html).toMatch(/class="[^"]*sky-messagebar[^"]*sky-toolbar[^"]*"/)
|
||||
expect(html).toContain('sky-toolbar__blur')
|
||||
expect(html).toContain('sky-toolbar__background')
|
||||
expect(html).toContain('sky-messagebar__inner')
|
||||
expect(html).toContain('sky-messagebar__area')
|
||||
expect(html).toContain('sky-glass')
|
||||
expect(html).not.toContain('sky-glass-surface')
|
||||
expect(html).toContain('aria-label="Message"')
|
||||
expect(html).toContain('>Hello</textarea>')
|
||||
expect(html.indexOf('sky-messagebar__left')).toBeLessThan(
|
||||
html.indexOf('sky-messagebar__area'),
|
||||
)
|
||||
expect(html.indexOf('sky-messagebar__area')).toBeLessThan(
|
||||
html.indexOf('sky-messagebar__right'),
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps app-owned composers in flow through the embedded variant', async () => {
|
||||
const html = await renderMessagebar({ embedded: true })
|
||||
const overlays = readFileSync(
|
||||
new URL('../overlays.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
expect(html).toContain('sky-messagebar--embedded')
|
||||
expect(overlays).toMatch(
|
||||
/\.sky-messagebar--embedded\s*\{[\s\S]*?position:\s*relative;[\s\S]*?z-index:\s*auto;[\s\S]*?padding:\s*0;/,
|
||||
)
|
||||
expect(overlays).toMatch(
|
||||
/\.sky-messagebar--embedded \.sky-toolbar__blur,[\s\S]*?\.sky-messagebar--embedded \.sky-toolbar__background\s*\{[\s\S]*?display:\s*none;/,
|
||||
)
|
||||
})
|
||||
|
||||
it('locks the Konsta iOS messagebar geometry', () => {
|
||||
const overlays = readFileSync(
|
||||
new URL('../overlays.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
expect(overlays).toMatch(
|
||||
/\.sky-messagebar\s*\{[\s\S]*?--sky-messagebar-gap:\s*12px;/,
|
||||
)
|
||||
expect(overlays).toMatch(
|
||||
/\.sky-messagebar__area\s*\{[\s\S]*?height:\s*40px;[\s\S]*?border-radius:\s*24px;/,
|
||||
)
|
||||
expect(overlays).toMatch(
|
||||
/\.sky-messagebar textarea\s*\{[\s\S]*?height:\s*40px;[\s\S]*?padding:\s*12px 16px 4px;[\s\S]*?line-height:\s*16px;/,
|
||||
)
|
||||
expect(overlays).toMatch(
|
||||
/\.sky-messagebar \.sky-toolbar-pane\s*\{[\s\S]*?height:\s*40px;/,
|
||||
)
|
||||
expect(overlays).toMatch(
|
||||
/\.sky-messagebar__left,[\s\S]*?\.sky-messagebar__right\s*\{[\s\S]*?color:\s*var\(--sky-messagebar-icon\);/,
|
||||
)
|
||||
expect(overlays).toMatch(
|
||||
/\.sky-messagebar__left \.sky-link,[\s\S]*?\.sky-messagebar__right \.sky-link\s*\{[\s\S]*?color:\s*inherit;/,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import SkyGlass from '../controls/SkyGlass.vue'
|
||||
import SkyToolbar from '../controls/SkyToolbar.vue'
|
||||
import { useComposedFieldValue } from '@/ui/controls/useComposedFieldValue'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
@@ -9,6 +11,7 @@ const props = withDefaults(
|
||||
defineProps<{
|
||||
ariaLabel?: string
|
||||
disabled?: boolean
|
||||
embedded?: boolean
|
||||
id?: string
|
||||
modelValue?: string
|
||||
name?: string
|
||||
@@ -19,10 +22,11 @@ const props = withDefaults(
|
||||
{
|
||||
ariaLabel: '',
|
||||
disabled: false,
|
||||
embedded: false,
|
||||
id: undefined,
|
||||
modelValue: undefined,
|
||||
name: undefined,
|
||||
outline: true,
|
||||
outline: false,
|
||||
placeholder: '',
|
||||
value: '',
|
||||
},
|
||||
@@ -58,32 +62,39 @@ function handleCompositionEnd(event: CompositionEvent): void {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
<SkyToolbar
|
||||
v-bind="$attrs"
|
||||
class="sky-messagebar"
|
||||
:class="{ 'sky-messagebar--outline': outline }"
|
||||
:class="{
|
||||
'sky-messagebar--embedded': embedded,
|
||||
'sky-messagebar--outline': outline,
|
||||
}"
|
||||
inner-class="sky-messagebar__inner"
|
||||
:outline="outline"
|
||||
>
|
||||
<div v-if="$slots.left" class="sky-messagebar__left">
|
||||
<slot name="left" />
|
||||
</div>
|
||||
<textarea
|
||||
:id="id"
|
||||
:aria-label="ariaLabel || placeholder || undefined"
|
||||
:disabled="disabled"
|
||||
:name="name"
|
||||
:placeholder="placeholder"
|
||||
:value="localValue"
|
||||
rows="1"
|
||||
@blur="emit('blur', $event)"
|
||||
@change="emit('change', $event)"
|
||||
@compositionend="handleCompositionEnd"
|
||||
@compositionstart="startComposition"
|
||||
@focus="emit('focus', $event)"
|
||||
@input="handleInput"
|
||||
@keydown="emit('keydown', $event)"
|
||||
></textarea>
|
||||
<SkyGlass :highlight="false" class="sky-messagebar__area">
|
||||
<textarea
|
||||
:id="id"
|
||||
:aria-label="ariaLabel || placeholder || undefined"
|
||||
:disabled="disabled"
|
||||
:name="name"
|
||||
:placeholder="placeholder"
|
||||
:value="localValue"
|
||||
rows="1"
|
||||
@blur="emit('blur', $event)"
|
||||
@change="emit('change', $event)"
|
||||
@compositionend="handleCompositionEnd"
|
||||
@compositionstart="startComposition"
|
||||
@focus="emit('focus', $event)"
|
||||
@input="handleInput"
|
||||
@keydown="emit('keydown', $event)"
|
||||
></textarea>
|
||||
</SkyGlass>
|
||||
<div v-if="$slots.right" class="sky-messagebar__right">
|
||||
<slot name="right" />
|
||||
</div>
|
||||
</div>
|
||||
</SkyToolbar>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { createSSRApp, h } from 'vue'
|
||||
import { renderToString } from 'vue/server-renderer'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import SkyNotification from './SkyNotification.vue'
|
||||
|
||||
async function renderNotification(
|
||||
props: InstanceType<typeof SkyNotification>['$props'],
|
||||
): Promise<string> {
|
||||
return renderToString(
|
||||
createSSRApp({
|
||||
render: () =>
|
||||
h(SkyNotification, props, {
|
||||
button: () => [],
|
||||
icon: () => h('img', { alt: '', src: '/demo-icon.png' }),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
describe('SkyNotification', () => {
|
||||
it('renders the complete Konsta iOS notification structure', async () => {
|
||||
const html = await renderNotification({
|
||||
button: 'Close notification',
|
||||
opened: true,
|
||||
subtitle: 'Notification with close button',
|
||||
text: 'Click (x) button to close me',
|
||||
title: 'Konsta UI',
|
||||
titleRightText: 'now',
|
||||
})
|
||||
|
||||
expect(html).toContain('<section')
|
||||
expect(html).toContain('class="sky-notification sky-glass-surface"')
|
||||
expect(html).toContain('role="status"')
|
||||
expect(html).toContain('aria-live="polite"')
|
||||
expect(html).toContain('sky-notification__header--with-close')
|
||||
expect(html).toContain('sky-notification__close-icon')
|
||||
expect(html).toContain('viewbox="0 0 28 28"')
|
||||
expect(html).toContain('aria-label="Close notification"')
|
||||
expect(html).not.toContain('>Close notification<')
|
||||
expect(html).not.toContain('×')
|
||||
})
|
||||
|
||||
it('keeps closed notifications out of the accessibility tree', async () => {
|
||||
const html = await renderNotification({ opened: false, title: 'Hidden' })
|
||||
|
||||
expect(html).not.toContain('sky-notification')
|
||||
expect(html).not.toContain('Hidden')
|
||||
})
|
||||
|
||||
it('locks the Konsta iOS glass, typography, and close geometry', () => {
|
||||
const overlays = readFileSync(
|
||||
new URL('../overlays.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const tokens = readFileSync(
|
||||
new URL('../tokens.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const notificationRule = overlays.match(
|
||||
/\.sky-notification\s*\{([^}]*)\}/,
|
||||
)?.[1]
|
||||
|
||||
expect(notificationRule).toBeDefined()
|
||||
expect(notificationRule).toMatch(/z-index:\s*50;/)
|
||||
expect(notificationRule).toMatch(
|
||||
/top:\s*calc\(var\(--sky-safe-area-top, 0px\) \+ 8px\);/,
|
||||
)
|
||||
expect(notificationRule).toMatch(/right:\s*8px;[\s\S]*left:\s*8px;/)
|
||||
expect(notificationRule).toMatch(/gap:\s*16px;[\s\S]*padding:\s*16px;/)
|
||||
expect(notificationRule).toMatch(
|
||||
/border:\s*0;[\s\S]*border-radius:\s*24px;/,
|
||||
)
|
||||
expect(notificationRule).not.toMatch(/min-height|background:/)
|
||||
expect(overlays).toMatch(
|
||||
/\.sky-notification__title,[\s\S]*?font-size:\s*14px;[\s\S]*?line-height:\s*20px;/,
|
||||
)
|
||||
expect(overlays).toMatch(
|
||||
/\.sky-notification__time\s*\{[\s\S]*?margin-inline-start:\s*auto;[\s\S]*?margin-inline-end:\s*4px;[\s\S]*?color:\s*var\(--sky-notification-meta\);/,
|
||||
)
|
||||
expect(overlays).toMatch(
|
||||
/\.sky-notification__close\s*\{[\s\S]*?width:\s*var\(--sky-touch-target, 44px\);[\s\S]*?height:\s*var\(--sky-touch-target, 44px\);[\s\S]*?position:\s*absolute;[\s\S]*?right:\s*-8px;/,
|
||||
)
|
||||
expect(overlays).toMatch(
|
||||
/\.sky-notification__close-icon\s*\{\s*width:\s*20px;\s*height:\s*20px;/,
|
||||
)
|
||||
expect(overlays).toMatch(
|
||||
/\.sky-notification-slide-enter-from,[\s\S]*?transform:\s*translateY\(-100%\);/,
|
||||
)
|
||||
expect(tokens).toContain('--sky-notification-meta: rgba(0, 0, 0, 0.45)')
|
||||
expect(tokens).toContain(
|
||||
'--sky-notification-meta: rgba(255, 255, 255, 0.45)',
|
||||
)
|
||||
expect(tokens).toContain('--sky-notification-close: #a8a29e')
|
||||
expect(tokens).toContain('--sky-notification-close-active: #e7e5e4')
|
||||
expect(tokens).toContain('--sky-notification-close: #78716c')
|
||||
expect(tokens).toContain('--sky-notification-close-active: #44403c')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, useId, useSlots } from 'vue'
|
||||
|
||||
import SkyChipDeleteIcon from '../controls/SkyChipDeleteIcon.vue'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -48,6 +50,7 @@ const resolvedAriaLabelledby = computed(() => {
|
||||
if (props.ariaLabel) return undefined
|
||||
return props.ariaLabelledby ?? (hasTitle.value ? titleId : undefined)
|
||||
})
|
||||
const resolvedCloseLabel = computed(() => props.closeLabel ?? props.button)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -56,7 +59,7 @@ const resolvedAriaLabelledby = computed(() => {
|
||||
:is="component"
|
||||
v-if="opened"
|
||||
v-bind="$attrs"
|
||||
class="sky-notification"
|
||||
class="sky-notification sky-glass-surface"
|
||||
:role="role"
|
||||
:aria-atomic="true"
|
||||
:aria-describedby="ariaDescribedby"
|
||||
@@ -72,7 +75,12 @@ const resolvedAriaLabelledby = computed(() => {
|
||||
</div>
|
||||
|
||||
<div class="sky-notification__content">
|
||||
<div class="sky-notification__header">
|
||||
<div
|
||||
class="sky-notification__header"
|
||||
:class="{
|
||||
'sky-notification__header--with-close': hasCloseButton,
|
||||
}"
|
||||
>
|
||||
<strong v-if="hasTitle" :id="titleId" class="sky-notification__title">
|
||||
<slot name="title">{{ title }}</slot>
|
||||
</strong>
|
||||
@@ -88,13 +96,11 @@ const resolvedAriaLabelledby = computed(() => {
|
||||
v-if="hasCloseButton"
|
||||
type="button"
|
||||
class="sky-notification__close"
|
||||
:aria-label="closeLabel"
|
||||
:aria-label="resolvedCloseLabel"
|
||||
@click="emit('close', $event)"
|
||||
>
|
||||
<slot name="button">
|
||||
<span v-if="button !== undefined">{{ button }}</span>
|
||||
<span v-else aria-hidden="true">×</span>
|
||||
</slot>
|
||||
<SkyChipDeleteIcon class="sky-notification__close-icon" />
|
||||
<slot name="button" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { createSSRApp, h } from 'vue'
|
||||
import { renderToString } from 'vue/server-renderer'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import SkyPanel from './SkyPanel.vue'
|
||||
|
||||
async function renderPanel(
|
||||
props: InstanceType<typeof SkyPanel>['$props'],
|
||||
): Promise<string> {
|
||||
return renderToString(
|
||||
createSSRApp({
|
||||
render: () => h(SkyPanel, props, () => 'Panel content'),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
describe('SkyPanel', () => {
|
||||
it('renders the complete modal and floating state contract', async () => {
|
||||
const html = await renderPanel({
|
||||
ariaLabel: 'Right panel',
|
||||
floating: true,
|
||||
opened: true,
|
||||
side: 'right',
|
||||
})
|
||||
|
||||
expect(html).toContain(
|
||||
'class="sky-panel sky-panel--floating sky-panel--modal"',
|
||||
)
|
||||
expect(html).toContain('class="sky-overlay-backdrop sky-panel__backdrop"')
|
||||
expect(html).toContain('aria-hidden="true"')
|
||||
expect(html).toContain('<aside')
|
||||
expect(html).toContain('sky-panel__panel--right')
|
||||
expect(html).toContain('sky-panel__panel--floating')
|
||||
expect(html).toContain('sky-glass-surface')
|
||||
expect(html).toContain('role="dialog"')
|
||||
expect(html).toContain('aria-label="Right panel"')
|
||||
expect(html).toContain('aria-modal="true"')
|
||||
expect(html).toContain('tabindex="-1"')
|
||||
})
|
||||
|
||||
it('omits closed panels and optional backdrops', async () => {
|
||||
expect(await renderPanel({ opened: false })).not.toContain('sky-panel')
|
||||
|
||||
const html = await renderPanel({ backdrop: false, opened: true })
|
||||
expect(html).not.toContain('sky-panel__backdrop')
|
||||
expect(html).toContain('Panel content')
|
||||
})
|
||||
|
||||
it('matches the Konsta iOS surface, geometry, backdrop, and motion', () => {
|
||||
const overlays = readFileSync(
|
||||
new URL('../overlays.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const source = readFileSync(
|
||||
new URL('./SkyPanel.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const tokens = readFileSync(
|
||||
new URL('../tokens.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const panelRule = overlays.match(
|
||||
/\n\.sky-panel__panel\s*\{(?<declarations>[^}]*)\}/,
|
||||
)?.groups?.declarations
|
||||
|
||||
expect(panelRule).toBeDefined()
|
||||
expect(panelRule).toMatch(/top:\s*0;[\s\S]*bottom:\s*0;/)
|
||||
expect(panelRule).toMatch(/width:\s*288px;[\s\S]*max-width:\s*100%;/)
|
||||
expect(panelRule).toMatch(/overflow:\s*hidden;/)
|
||||
expect(overlays).toMatch(
|
||||
/\.sky-panel__panel:not\(\.sky-panel__panel--floating\)\s*\{[^}]*background:\s*var\(--sky-panel-background, var\(--sky-surface\)\);/,
|
||||
)
|
||||
expect(overlays).toMatch(
|
||||
/\.sky-panel__panel--floating\s*\{[^}]*top:\s*calc\(var\(--sky-safe-area-top, 0px\) \+ 8px\);[^}]*bottom:\s*calc\(var\(--sky-safe-area-bottom, 0px\) \+ 8px\);[^}]*border-radius:\s*32px;/s,
|
||||
)
|
||||
expect(overlays).toMatch(
|
||||
/\.sky-panel__panel--left\.sky-panel__panel--floating\s*\{[^}]*left:\s*8px;/,
|
||||
)
|
||||
expect(overlays).toMatch(
|
||||
/\.sky-panel__panel--right\.sky-panel__panel--floating\s*\{[^}]*right:\s*8px;/,
|
||||
)
|
||||
expect(overlays).toMatch(
|
||||
/\.sky-panel__backdrop\s*\{[^}]*background:\s*rgba\(0, 0, 0, 0\.5\);/,
|
||||
)
|
||||
expect(overlays).toMatch(
|
||||
/\.sky-panel-left-enter-active[\s\S]*?transition:\s*transform 400ms cubic-bezier\(0\.4, 0, 0\.2, 1\);/,
|
||||
)
|
||||
expect(overlays).toMatch(
|
||||
/\.sky-panel-left-enter-from[\s\S]*?transform:\s*translateX\(-100%\);/,
|
||||
)
|
||||
expect(overlays).toMatch(
|
||||
/\.sky-panel-right-enter-from[\s\S]*?transform:\s*translateX\(100%\);/,
|
||||
)
|
||||
expect(source).not.toContain(':duration=')
|
||||
expect(tokens).toContain('--sky-panel-background: #ffffff;')
|
||||
expect(tokens).toContain('--sky-panel-background: #000000;')
|
||||
expect(tokens).toContain('--sky-glass: rgba(255, 255, 255, 0.75);')
|
||||
expect(tokens).toContain('--sky-glass: rgba(50, 50, 50, 0.5);')
|
||||
})
|
||||
|
||||
it('resets every dark Glass override at a nested light theme boundary', () => {
|
||||
const tokens = readFileSync(
|
||||
new URL('../tokens.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const lightTheme = tokens.slice(
|
||||
tokens.indexOf('.sky-ui-provider,'),
|
||||
tokens.indexOf('.sky-ui-provider--dark,'),
|
||||
)
|
||||
|
||||
expect(lightTheme).toContain('--sky-shadow-glass:')
|
||||
expect(lightTheme).toContain('--sky-shadow-glass-thumb:')
|
||||
expect(lightTheme).toContain('--sky-shadow-glass-thumb-glow:')
|
||||
expect(lightTheme).toContain('--sky-glass-thumb-active-background:')
|
||||
expect(lightTheme).toContain('--sky-glass-highlight-color:')
|
||||
})
|
||||
})
|
||||
@@ -111,11 +111,7 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition
|
||||
:name="`sky-panel-${side}`"
|
||||
:duration="{ enter: 400, leave: 400 }"
|
||||
appear
|
||||
>
|
||||
<Transition :name="`sky-panel-${side}`" appear>
|
||||
<div
|
||||
v-if="opened"
|
||||
ref="root"
|
||||
@@ -136,7 +132,7 @@ onBeforeUnmount(() => {
|
||||
<component
|
||||
:is="component"
|
||||
ref="panel"
|
||||
class="sky-panel__panel"
|
||||
class="sky-panel__panel sky-glass-surface"
|
||||
:class="[
|
||||
sizeClass,
|
||||
`sky-panel__panel--${side}`,
|
||||
|
||||
@@ -527,7 +527,7 @@ onBeforeUnmount(detachListeners)
|
||||
<component
|
||||
:is="component"
|
||||
ref="panel"
|
||||
class="sky-popover__panel"
|
||||
class="sky-popover__panel sky-glass-surface"
|
||||
:class="
|
||||
position ? `sky-popover__panel--${position.placement}` : undefined
|
||||
"
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { createSSRApp, h } from 'vue'
|
||||
import { renderToString } from 'vue/server-renderer'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import SkyPopup from './SkyPopup.vue'
|
||||
|
||||
async function renderPopup(
|
||||
props: InstanceType<typeof SkyPopup>['$props'],
|
||||
): Promise<string> {
|
||||
return renderToString(
|
||||
createSSRApp({
|
||||
render: () => h(SkyPopup, props, () => 'Popup content'),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
describe('SkyPopup', () => {
|
||||
it('renders the complete modal contract', async () => {
|
||||
const html = await renderPopup({ ariaLabel: 'Popup', opened: true })
|
||||
|
||||
expect(html).toContain('class="sky-popup sky-popup--modal"')
|
||||
expect(html).toContain('class="sky-overlay-backdrop sky-popup__backdrop"')
|
||||
expect(html).toContain('aria-hidden="true"')
|
||||
expect(html).toContain('<section')
|
||||
expect(html).toContain('class="sky-popup__panel"')
|
||||
expect(html).toContain('role="dialog"')
|
||||
expect(html).toContain('aria-label="Popup"')
|
||||
expect(html).toContain('aria-modal="true"')
|
||||
expect(html).toContain('tabindex="-1"')
|
||||
expect(html).toContain('Popup content')
|
||||
})
|
||||
|
||||
it('omits closed popups and optional backdrops', async () => {
|
||||
expect(await renderPopup({ opened: false })).not.toContain('sky-popup')
|
||||
|
||||
const html = await renderPopup({ backdrop: false, opened: true })
|
||||
expect(html).not.toContain('sky-popup__backdrop')
|
||||
expect(html).toContain('Popup content')
|
||||
})
|
||||
|
||||
it('matches the Konsta iOS surface, geometry, backdrop, and motion', () => {
|
||||
const overlays = readFileSync(
|
||||
new URL('../overlays.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const source = readFileSync(
|
||||
new URL('./SkyPopup.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const tokens = readFileSync(
|
||||
new URL('../tokens.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const transitionRule = overlays.match(
|
||||
/\.sky-popup-rise-enter-active \.sky-popup__panel,[\s\S]*?\.sky-popup-rise-leave-active \.sky-popup__panel\s*\{(?<declarations>[^}]*)\}/,
|
||||
)?.groups?.declarations
|
||||
|
||||
expect(transitionRule).toBeDefined()
|
||||
expect(transitionRule).toContain(
|
||||
'transition: transform 400ms cubic-bezier(0.4, 0, 0.2, 1);',
|
||||
)
|
||||
expect(transitionRule).not.toContain('opacity')
|
||||
expect(overlays).toMatch(
|
||||
/\.sky-popup-rise-enter-from \.sky-popup__panel,[\s\S]*?transform:\s*translateY\(100%\);/,
|
||||
)
|
||||
expect(overlays).toMatch(
|
||||
/\.sky-popup__backdrop\s*\{[^}]*background:\s*rgba\(0, 0, 0, 0\.5\);/,
|
||||
)
|
||||
expect(overlays).toMatch(
|
||||
/\.sky-popup__panel\s*\{[^}]*background:\s*var\(--sky-popup-background, var\(--sky-surface\)\);/,
|
||||
)
|
||||
expect(overlays).toMatch(
|
||||
/\.sky-popup__panel\s*\{[^}]*inset:\s*0;[^}]*overflow:\s*hidden;[^}]*box-shadow:\s*none;/,
|
||||
)
|
||||
expect(source).not.toContain(':duration=')
|
||||
expect(tokens).toContain('--sky-popup-background: #ffffff;')
|
||||
expect(tokens).toContain('--sky-popup-background: #000000;')
|
||||
})
|
||||
})
|
||||
@@ -87,11 +87,7 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition
|
||||
name="sky-popup-rise"
|
||||
:duration="{ enter: 400, leave: 400 }"
|
||||
appear
|
||||
>
|
||||
<Transition name="sky-popup-rise" appear>
|
||||
<div
|
||||
v-if="opened"
|
||||
ref="root"
|
||||
|
||||
@@ -26,7 +26,7 @@ withDefaults(
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div class="sky-toast__inner">
|
||||
<div class="sky-toast__inner sky-glass-surface">
|
||||
<div class="sky-toast__content">
|
||||
<div class="sky-toast__text"><slot /></div>
|
||||
<div v-if="$slots.button" class="sky-toast__button">
|
||||
|
||||
@@ -232,6 +232,10 @@ label.sky-settings-row__title {
|
||||
padding: var(--sky-space-1) 16px;
|
||||
}
|
||||
|
||||
.sky-settings-group__list > .sky-field .sky-field__inner {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sky-settings-group__list > .sky-field .sky-field__label {
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
@@ -242,6 +246,10 @@ label.sky-settings-row__title {
|
||||
}
|
||||
|
||||
.sky-settings-group__list > .sky-field--inline {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.sky-settings-group__list > .sky-field--inline .sky-field__inner {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 42%) minmax(0, 58%);
|
||||
align-items: center;
|
||||
@@ -258,6 +266,7 @@ label.sky-settings-row__title {
|
||||
|
||||
.sky-settings-group__list > .sky-field--inline .sky-field__control {
|
||||
justify-content: flex-end;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sky-settings-group__list > .sky-field--inline .sky-field__input {
|
||||
@@ -265,6 +274,7 @@ label.sky-settings-row__title {
|
||||
}
|
||||
|
||||
.sky-settings-group__list > .sky-field--inline .sky-field__help,
|
||||
.sky-settings-group__list > .sky-field--inline .sky-field__error {
|
||||
.sky-settings-group__list > .sky-field--inline .sky-field__error,
|
||||
.sky-settings-group__list > .sky-field--inline .sky-field__info {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
--sky-space-6: 24px;
|
||||
--sky-radius-control: 8px;
|
||||
--sky-radius-card: 24px;
|
||||
--sky-radius-sheet: 24px;
|
||||
--sky-radius-sheet: 32px;
|
||||
--sky-radius-pill: 999px;
|
||||
--sky-font-caption: 12px;
|
||||
--sky-font-body: 17px;
|
||||
@@ -118,6 +118,13 @@
|
||||
--sky-app-accent-tint: #298fff;
|
||||
--sky-app-accent-shade: #0066d6;
|
||||
--sky-app-accent-soft: rgba(0, 122, 255, 0.15);
|
||||
--sky-progress-track-color: #000000;
|
||||
--sky-shadow-glass:
|
||||
inset -1px -1px 0 -0.5px rgba(255, 255, 255, 1),
|
||||
inset 1px 1px 0 -0.5px rgba(255, 255, 255, 1),
|
||||
inset 3px 3px 10px -3px #dddddd, inset -3px -3px 10px -3px #dddddd,
|
||||
inset 0 0 5px 1px #ffffff, inset 0 0 0 0.5px rgba(0, 0, 0, 0.25),
|
||||
inset 0 0 24px rgba(0, 0, 0, 0.1), 0 0 25px rgba(0, 0, 0, 0.2);
|
||||
--sky-shadow-glass-fab:
|
||||
inset -3px -3px 0 -3.5px #ffffff, inset 3px 3px 0 -3.5px #ffffff,
|
||||
inset 0 0 0 0.5px rgba(255, 255, 255, 0.5), inset 0 0 5px 1px #ffffff;
|
||||
@@ -125,12 +132,34 @@
|
||||
--sky-fab-accent-inset-end: -3px -3px 10px -2px;
|
||||
--sky-fab-accent-layer-opacity: 1;
|
||||
--sky-fab-dark-accent-layer-opacity: 0;
|
||||
--sky-shadow-glass-thumb:
|
||||
inset -3px -3px 0 -3.5px #ffffff, inset 3px 3px 0 -3.5px #ffffff,
|
||||
inset 0 0 0 0.5px rgba(255, 255, 255, 0.5), inset 3px 3px 10px -2px #eeeeee,
|
||||
inset -3px -3px 10px -2px #eeeeee, inset 0 0 5px 1px #ffffff;
|
||||
--sky-shadow-glass-thumb-glow: 0 0 40px 10px rgba(0, 122, 255, 0.75);
|
||||
--sky-glass-thumb-active-background: rgba(255, 255, 255, 0);
|
||||
--sky-glass-highlight-color: rgba(255, 255, 255, 0.5);
|
||||
--sky-panel-background: #ffffff;
|
||||
--sky-popup-background: #ffffff;
|
||||
--sky-bg: #efeff4;
|
||||
--sky-surface: #ffffff;
|
||||
--sky-surface-shade: #f1f1f5;
|
||||
--sky-surface-tint: #ffffff;
|
||||
--sky-surface-muted: #f7f7f8;
|
||||
--sky-surface-variant: #f4f4f4;
|
||||
--sky-message-received-background: #e5e5ea;
|
||||
--sky-message-meta: rgba(0, 0, 0, 0.45);
|
||||
--sky-messagebar-gradient-start: #efeff4;
|
||||
--sky-messagebar-icon: #007aff;
|
||||
--sky-messagebar-placeholder: rgba(0, 0, 0, 0.3);
|
||||
--sky-notification-meta: rgba(0, 0, 0, 0.45);
|
||||
--sky-notification-close: #a8a29e;
|
||||
--sky-notification-close-active: #e7e5e4;
|
||||
--sky-list-group-title-contacts-background: #f4f4f4;
|
||||
--sky-list-group-title-contacts-text: rgba(0, 0, 0, 0.9);
|
||||
--sky-menu-list-active-background: var(--sky-app-accent-soft);
|
||||
--sky-menu-list-active-text: var(--sky-app-accent);
|
||||
--sky-segmented-strong-highlight: #ffffff;
|
||||
--sky-glass: rgba(255, 255, 255, 0.75);
|
||||
--sky-glass-solid: rgba(247, 247, 248, 0.96);
|
||||
--sky-navbar-glass: #efeff4;
|
||||
@@ -141,6 +170,8 @@
|
||||
--sky-muted: rgba(0, 0, 0, 0.55);
|
||||
--sky-subtle: rgba(0, 0, 0, 0.3);
|
||||
--sky-hairline: rgba(0, 0, 0, 0.2);
|
||||
--sky-field-outline: rgba(0, 0, 0, 0.3);
|
||||
--sky-field-placeholder: rgba(0, 0, 0, 0.3);
|
||||
--sky-pressed: rgba(0, 0, 0, 0.1);
|
||||
--sky-tabbar-highlight-background: rgba(0, 0, 0, 0.1);
|
||||
--sky-tabbar-thumb-background: rgba(0, 0, 0, 0.05);
|
||||
@@ -162,12 +193,26 @@
|
||||
--sky-app-accent-tint: #409cff;
|
||||
--sky-app-accent-shade: #0070dc;
|
||||
--sky-app-accent-soft: rgba(10, 132, 255, 0.18);
|
||||
--sky-progress-track-color: currentColor;
|
||||
--sky-bg: #000000;
|
||||
--sky-surface: #1c1c1d;
|
||||
--sky-surface-shade: #060606;
|
||||
--sky-surface-tint: #444447;
|
||||
--sky-surface-muted: #121212;
|
||||
--sky-surface-variant: #232323;
|
||||
--sky-message-received-background: #252525;
|
||||
--sky-message-meta: rgba(255, 255, 255, 0.45);
|
||||
--sky-messagebar-gradient-start: rgba(0, 0, 0, 0.5);
|
||||
--sky-messagebar-icon: #ffffff;
|
||||
--sky-messagebar-placeholder: rgba(255, 255, 255, 0.4);
|
||||
--sky-notification-meta: rgba(255, 255, 255, 0.45);
|
||||
--sky-notification-close: #78716c;
|
||||
--sky-notification-close-active: #44403c;
|
||||
--sky-list-group-title-contacts-background: #323234;
|
||||
--sky-list-group-title-contacts-text: rgba(255, 255, 255, 0.9);
|
||||
--sky-menu-list-active-background: var(--sky-app-accent);
|
||||
--sky-menu-list-active-text: #ffffff;
|
||||
--sky-segmented-strong-highlight: rgba(255, 255, 255, 0.75);
|
||||
--sky-glass: rgba(50, 50, 50, 0.5);
|
||||
--sky-glass-solid: rgba(28, 28, 29, 0.96);
|
||||
--sky-navbar-glass: rgba(0, 0, 0, 0.5);
|
||||
@@ -178,6 +223,8 @@
|
||||
--sky-muted: rgba(255, 255, 255, 0.55);
|
||||
--sky-subtle: rgba(255, 255, 255, 0.3);
|
||||
--sky-hairline: rgba(255, 255, 255, 0.15);
|
||||
--sky-field-outline: rgba(255, 255, 255, 0.3);
|
||||
--sky-field-placeholder: rgba(255, 255, 255, 0.3);
|
||||
--sky-pressed: rgba(255, 255, 255, 0.1);
|
||||
--sky-tabbar-highlight-background: rgba(255, 255, 255, 0.15);
|
||||
--sky-tabbar-thumb-background: rgba(255, 255, 255, 0.1);
|
||||
@@ -213,6 +260,8 @@
|
||||
--sky-shadow-glass-thumb-glow: 0 0 40px 15px rgba(10, 132, 255, 0.5);
|
||||
--sky-glass-thumb-active-background: rgba(255, 255, 255, 0.1);
|
||||
--sky-glass-highlight-color: rgba(255, 255, 255, 0.3);
|
||||
--sky-panel-background: #000000;
|
||||
--sky-popup-background: #000000;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { getHairlinePixelStyle } from '@/utils/rendering'
|
||||
|
||||
describe('getHairlinePixelStyle', () => {
|
||||
it('compensates both browser pixels and the outer phone zoom', () => {
|
||||
expect(getHairlinePixelStyle(1.25, 1)).toEqual({
|
||||
'--k-device-pixel-ratio': 1.25,
|
||||
'--sky-hairline-scale': 0.8,
|
||||
})
|
||||
expect(getHairlinePixelStyle(0.8, 2)).toEqual({
|
||||
'--k-device-pixel-ratio': 1.6,
|
||||
'--sky-hairline-scale': 0.625,
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to an unscaled CSS pixel for invalid values', () => {
|
||||
expect(getHairlinePixelStyle(0, Number.NaN)).toEqual({
|
||||
'--k-device-pixel-ratio': 1,
|
||||
'--sky-hairline-scale': 1,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { CSSProperties } from 'vue'
|
||||
|
||||
export function getHairlinePixelStyle(
|
||||
renderScale: number,
|
||||
devicePixelRatio: number,
|
||||
): CSSProperties {
|
||||
const safeRenderScale =
|
||||
Number.isFinite(renderScale) && renderScale > 0 ? renderScale : 1
|
||||
const safeDevicePixelRatio =
|
||||
Number.isFinite(devicePixelRatio) && devicePixelRatio > 0
|
||||
? devicePixelRatio
|
||||
: 1
|
||||
const effectivePixelRatio = safeRenderScale * safeDevicePixelRatio
|
||||
|
||||
return {
|
||||
'--k-device-pixel-ratio': effectivePixelRatio,
|
||||
'--sky-hairline-scale': 1 / effectivePixelRatio,
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,9 @@ describe('AppStoreApp Sky navigation contract', () => {
|
||||
expect(source).toContain('@click="profileOpened = true"')
|
||||
expect(source).toContain('const installedApps = computed')
|
||||
expect(source).toContain('return !appStore.isInstalled(app.id)')
|
||||
expect(source).toContain('class="store-account__apps"')
|
||||
expect(source).toContain(
|
||||
'class="store-account__apps phone-effect--expensive-shadow"',
|
||||
)
|
||||
expect(source).toContain('appStore.updateApp(app.id, currentRelease)')
|
||||
expect(source).toContain(
|
||||
'appStore.uninstallApp(uninstallCandidate.value.id)',
|
||||
@@ -108,7 +110,7 @@ describe('AppStoreApp Sky navigation contract', () => {
|
||||
|
||||
it('keeps one scroll owner and accessible 44px app actions', () => {
|
||||
expect(source).toMatch(
|
||||
/<SkyScrollArea\s+ref="storeScroll"\s+class="store-scroll"[\s\S]*?with-tabbar\s*>/,
|
||||
/<SkyScrollArea\s+ref="storeScroll"\s+(?:padded\s+)?class="store-scroll"[\s\S]*?with-tabbar\s*>/,
|
||||
)
|
||||
expect(source).toMatch(/\.store-scroll\s*\{[^}]*overflow-y:\s*auto/s)
|
||||
expect(source).not.toMatch(/\.store-scroll\s*\{[^}]*padding\s*:/s)
|
||||
@@ -154,11 +156,19 @@ describe('AppStoreApp Sky navigation contract', () => {
|
||||
expect(source).toContain('getDailyHighlights(')
|
||||
expect(source).toContain('!DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id)')
|
||||
expect(source).toContain('v-else-if="tab === \'today\'"')
|
||||
expect(source).toContain('class="store-highlight store-highlight--hero"')
|
||||
expect(source).toContain('class="store-highlight store-highlight--compact"')
|
||||
expect(source).toContain(
|
||||
'class="store-highlight store-highlight--hero phone-effect--expensive-shadow"',
|
||||
)
|
||||
expect(source).toContain(
|
||||
'class="store-highlight store-highlight--compact phone-effect--expensive-shadow"',
|
||||
)
|
||||
expect(source).toContain('class="store-today__edition"')
|
||||
expect(source).toContain('class="store-ranking"')
|
||||
expect(source).toContain('class="store-final-pick"')
|
||||
expect(source).toContain(
|
||||
'class="store-ranking phone-effect--expensive-shadow"',
|
||||
)
|
||||
expect(source).toContain(
|
||||
'class="store-final-pick phone-effect--expensive-shadow"',
|
||||
)
|
||||
expect(source).toContain('editorialHighlights')
|
||||
expect(source).toContain('topToday')
|
||||
expect(source).toContain('var(--sky-touch-target)')
|
||||
@@ -198,7 +208,9 @@ describe('AppStoreApp Sky navigation contract', () => {
|
||||
it('builds clean Apps and Games pages with rotating features', () => {
|
||||
expect(source).not.toContain('class="store-browse__filters"')
|
||||
expect(source).not.toContain('browseFilter')
|
||||
expect(source).toContain('class="store-browse-feature"')
|
||||
expect(source).toContain(
|
||||
'class="store-browse-feature phone-effect--expensive-shadow"',
|
||||
)
|
||||
expect(source).toContain('class="store-list store-list--browse"')
|
||||
expect(source).toContain('featuredCandidates')
|
||||
expect(source).toContain('globalThis.setInterval')
|
||||
|
||||
@@ -408,6 +408,7 @@ watch(
|
||||
|
||||
<SkyScrollArea
|
||||
ref="storeScroll"
|
||||
padded
|
||||
class="store-scroll"
|
||||
:class="{ 'store-scroll--detail': selectedApp }"
|
||||
with-tabbar
|
||||
@@ -434,7 +435,7 @@ watch(
|
||||
|
||||
<article
|
||||
v-if="dailyHighlights[0]"
|
||||
class="store-highlight store-highlight--hero"
|
||||
class="store-highlight store-highlight--hero phone-effect--expensive-shadow"
|
||||
:style="highlightStyle(0)"
|
||||
>
|
||||
<button
|
||||
@@ -447,7 +448,10 @@ watch(
|
||||
"
|
||||
@click="openAppDetail(dailyHighlights[0])"
|
||||
></button>
|
||||
<div class="store-highlight__texture" aria-hidden="true"></div>
|
||||
<div
|
||||
class="store-highlight__texture phone-effect--decorative"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
<div class="store-highlight__copy">
|
||||
<p>
|
||||
<Sparkles :size="12" :stroke-width="2.5" aria-hidden="true" />
|
||||
@@ -470,7 +474,12 @@ watch(
|
||||
<span
|
||||
class="store-highlight__spark store-highlight__spark--two"
|
||||
></span>
|
||||
<img :src="dailyHighlights[0].iconImage" alt="" draggable="false" />
|
||||
<img
|
||||
class="phone-effect--filtered-media"
|
||||
:src="dailyHighlights[0].iconImage"
|
||||
alt=""
|
||||
draggable="false"
|
||||
/>
|
||||
</div>
|
||||
<footer class="store-highlight__footer">
|
||||
<img :src="dailyHighlights[0].iconImage" alt="" draggable="false" />
|
||||
@@ -510,7 +519,7 @@ watch(
|
||||
<article
|
||||
v-for="(app, index) in editorialHighlights"
|
||||
:key="app.id"
|
||||
class="store-highlight store-highlight--compact"
|
||||
class="store-highlight store-highlight--compact phone-effect--expensive-shadow"
|
||||
:class="{ 'store-highlight--reverse': index % 2 === 1 }"
|
||||
:style="highlightStyle(index + 1)"
|
||||
>
|
||||
@@ -524,7 +533,10 @@ watch(
|
||||
"
|
||||
@click="openAppDetail(app)"
|
||||
></button>
|
||||
<div class="store-highlight__texture" aria-hidden="true"></div>
|
||||
<div
|
||||
class="store-highlight__texture phone-effect--decorative"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
<div class="store-highlight__copy">
|
||||
<p>
|
||||
<span class="store-highlight__story-number"
|
||||
@@ -543,7 +555,12 @@ watch(
|
||||
</div>
|
||||
<div class="store-highlight__art" aria-hidden="true">
|
||||
<span class="store-highlight__orbit"></span>
|
||||
<img :src="app.iconImage" alt="" draggable="false" />
|
||||
<img
|
||||
class="phone-effect--filtered-media"
|
||||
:src="app.iconImage"
|
||||
alt=""
|
||||
draggable="false"
|
||||
/>
|
||||
</div>
|
||||
<footer class="store-highlight__footer">
|
||||
<img :src="app.iconImage" alt="" draggable="false" />
|
||||
@@ -568,7 +585,7 @@ watch(
|
||||
</footer>
|
||||
</article>
|
||||
|
||||
<section class="store-ranking">
|
||||
<section class="store-ranking phone-effect--expensive-shadow">
|
||||
<header class="store-ranking__header">
|
||||
<span class="store-ranking__icon">
|
||||
<Trophy :size="18" :stroke-width="2.2" aria-hidden="true" />
|
||||
@@ -617,7 +634,7 @@ watch(
|
||||
|
||||
<article
|
||||
v-if="finalHighlight"
|
||||
class="store-final-pick"
|
||||
class="store-final-pick phone-effect--expensive-shadow"
|
||||
:style="highlightStyle(4)"
|
||||
>
|
||||
<button
|
||||
@@ -630,13 +647,21 @@ watch(
|
||||
"
|
||||
@click="openAppDetail(finalHighlight)"
|
||||
></button>
|
||||
<div class="store-final-pick__glow" aria-hidden="true"></div>
|
||||
<div
|
||||
class="store-final-pick__glow phone-effect--decorative"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
<div class="store-final-pick__heading">
|
||||
<span>{{ phone.t('Apps.appStore.today.oneMoreThing') }}</span>
|
||||
<strong>{{ highlightTitle(finalHighlight) }}</strong>
|
||||
<small>{{ categoryDescription(finalHighlight) }}</small>
|
||||
</div>
|
||||
<img :src="finalHighlight.iconImage" alt="" draggable="false" />
|
||||
<img
|
||||
class="phone-effect--filtered-media"
|
||||
:src="finalHighlight.iconImage"
|
||||
alt=""
|
||||
draggable="false"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="store-action-button"
|
||||
@@ -662,10 +687,13 @@ watch(
|
||||
<article
|
||||
v-if="featuredApp"
|
||||
:key="featuredApp.id"
|
||||
class="store-browse-feature"
|
||||
class="store-browse-feature phone-effect--expensive-shadow"
|
||||
:style="highlightStyle(featuredSlide + 1)"
|
||||
>
|
||||
<div class="store-browse-feature__texture" aria-hidden="true"></div>
|
||||
<div
|
||||
class="store-browse-feature__texture phone-effect--decorative"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
<div class="store-browse-feature__copy">
|
||||
<span>{{ phone.t('Apps.appStore.browse.availableNow') }}</span>
|
||||
<h2>
|
||||
@@ -684,7 +712,12 @@ watch(
|
||||
:size="104"
|
||||
:stroke-width="1.1"
|
||||
/>
|
||||
<img :src="featuredApp.iconImage" alt="" draggable="false" />
|
||||
<img
|
||||
class="phone-effect--filtered-media"
|
||||
:src="featuredApp.iconImage"
|
||||
alt=""
|
||||
draggable="false"
|
||||
/>
|
||||
</div>
|
||||
<footer>
|
||||
<img :src="featuredApp.iconImage" alt="" draggable="false" />
|
||||
@@ -844,6 +877,7 @@ watch(
|
||||
v-for="card in searchDiscoverCards"
|
||||
:key="card.id"
|
||||
type="button"
|
||||
class="phone-effect--solid-surface"
|
||||
:style="{ '--store-search-tile': card.background }"
|
||||
@click="selectSearchDiscovery(card.app)"
|
||||
>
|
||||
@@ -973,7 +1007,7 @@ watch(
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="store-account__identity">
|
||||
<section class="store-account__identity phone-effect--expensive-shadow">
|
||||
<span>{{ profileInitials }}</span>
|
||||
<div>
|
||||
<strong>{{ profileName }}</strong>
|
||||
@@ -1001,7 +1035,7 @@ watch(
|
||||
<strong>{{ installedApps.length }}</strong>
|
||||
</header>
|
||||
|
||||
<div class="store-account__apps">
|
||||
<div class="store-account__apps phone-effect--expensive-shadow">
|
||||
<article v-for="app in installedApps" :key="app.id">
|
||||
<img :src="app.iconImage" alt="" draggable="false" />
|
||||
<div>
|
||||
@@ -1397,11 +1431,6 @@ watch(
|
||||
background: var(--sky-danger-soft);
|
||||
}
|
||||
|
||||
:global(.phone-app--performance) .store-account__identity,
|
||||
:global(.phone-app--performance) .store-account__apps {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.store-scroll {
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
@@ -2002,22 +2031,6 @@ watch(
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
:global(.phone-app--performance) .store-highlight,
|
||||
:global(.phone-app--performance) .store-ranking,
|
||||
:global(.phone-app--performance) .store-final-pick {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:global(.phone-app--performance) .store-final-pick__glow,
|
||||
:global(.phone-app--performance) .store-highlight__texture {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:global(.phone-app--performance) .store-highlight__art img,
|
||||
:global(.phone-app--performance) .store-final-pick > img {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.store-highlight:hover,
|
||||
.store-final-pick:hover {
|
||||
@@ -2025,7 +2038,6 @@ watch(
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
.store-browse {
|
||||
display: grid;
|
||||
gap: var(--sky-space-4);
|
||||
@@ -2319,18 +2331,6 @@ watch(
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
:global(.phone-app--performance) .store-browse-feature {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:global(.phone-app--performance) .store-browse-feature__texture {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:global(.phone-app--performance) .store-browse-feature__art > img {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.app-store-navbar :deep(.sky-navbar__subnavbar .sky-searchbar__control) {
|
||||
border-color: var(--sky-hairline);
|
||||
background: var(--sky-surface-variant);
|
||||
@@ -2505,6 +2505,7 @@ watch(
|
||||
}
|
||||
|
||||
.store-search__discover-grid > button {
|
||||
--phone-effect-solid-background: var(--store-search-tile);
|
||||
min-width: 0;
|
||||
min-height: 124px;
|
||||
position: relative;
|
||||
@@ -2568,11 +2569,6 @@ watch(
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:global(.phone-app--performance) .store-search__discover-grid > button {
|
||||
background: var(--store-search-tile);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.store-featured-enter-active,
|
||||
.store-featured-leave-active,
|
||||
|
||||
@@ -18,7 +18,9 @@ describe('AppStoreDetail contract', () => {
|
||||
expect(source).toContain("emit('back')")
|
||||
expect(source).toContain("emit('share')")
|
||||
expect(source).toContain('<AppStoreAction :action="action" />')
|
||||
expect(source).toContain("'store-detail__action--icon': action !== 'open'")
|
||||
expect(source).toContain(
|
||||
":class=\"{ 'store-detail__action--icon': action !== 'open' }\"",
|
||||
)
|
||||
expect(source).not.toContain(':has(')
|
||||
expect(source).toMatch(
|
||||
/\.store-detail\s*\{[^}]*padding:\s*var\(--sky-space-3\) 0 var\(--sky-space-6\)/s,
|
||||
|
||||
@@ -120,7 +120,12 @@ function updateActivePreview(): void {
|
||||
</header>
|
||||
|
||||
<section class="store-detail__hero">
|
||||
<img :src="app.iconImage" alt="" draggable="false" />
|
||||
<img
|
||||
class="phone-effect--filtered-media phone-effect--expensive-shadow"
|
||||
:src="app.iconImage"
|
||||
alt=""
|
||||
draggable="false"
|
||||
/>
|
||||
<div>
|
||||
<h1>{{ appName }}</h1>
|
||||
<p>{{ developer }}</p>
|
||||
@@ -204,7 +209,9 @@ function updateActivePreview(): void {
|
||||
class="store-detail__previews"
|
||||
@scroll.passive="updateActivePreview"
|
||||
>
|
||||
<article class="store-detail-preview store-detail-preview--overview">
|
||||
<article
|
||||
class="store-detail-preview store-detail-preview--overview phone-effect--expensive-shadow"
|
||||
>
|
||||
<div class="store-detail-preview__status">
|
||||
<span>{{ phone.t('Apps.appStore.details.previewLive') }}</span>
|
||||
<Activity :size="14" aria-hidden="true" />
|
||||
@@ -223,7 +230,9 @@ function updateActivePreview(): void {
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="store-detail-preview store-detail-preview--community">
|
||||
<article
|
||||
class="store-detail-preview store-detail-preview--community phone-effect--expensive-shadow"
|
||||
>
|
||||
<div class="store-detail-preview__status">
|
||||
<span>{{ phone.t('Apps.appStore.details.previewCommunity') }}</span>
|
||||
<Users :size="14" aria-hidden="true" />
|
||||
@@ -242,7 +251,9 @@ function updateActivePreview(): void {
|
||||
<img :src="app.iconImage" alt="" draggable="false" />
|
||||
</article>
|
||||
|
||||
<article class="store-detail-preview store-detail-preview--insights">
|
||||
<article
|
||||
class="store-detail-preview store-detail-preview--insights phone-effect--expensive-shadow"
|
||||
>
|
||||
<div class="store-detail-preview__status">
|
||||
<span>{{ phone.t('Apps.appStore.details.previewInsights') }}</span>
|
||||
<BarChart3 :size="14" aria-hidden="true" />
|
||||
@@ -734,11 +745,6 @@ function updateActivePreview(): void {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
:global(.phone-app--performance) .store-detail-preview,
|
||||
:global(.phone-app--performance) .store-detail__hero > img {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.store-detail__toolbar button:hover {
|
||||
border-color: rgba(255, 255, 255, 0.16);
|
||||
|
||||
@@ -1059,6 +1059,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<SkyScrollArea
|
||||
v-if="screen === 'root' && activeTab === 'directory'"
|
||||
padded
|
||||
class="companies-content companies-directory"
|
||||
with-tabbar
|
||||
>
|
||||
@@ -1193,6 +1194,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<SkyScrollArea
|
||||
v-else-if="screen === 'root' && activeTab === 'requests'"
|
||||
padded
|
||||
class="companies-content"
|
||||
with-tabbar
|
||||
>
|
||||
@@ -1303,6 +1305,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<SkyScrollArea
|
||||
v-else-if="screen === 'root' && activeTab === 'work'"
|
||||
padded
|
||||
class="companies-content companies-work"
|
||||
with-tabbar
|
||||
>
|
||||
@@ -1441,6 +1444,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<SkyScrollArea
|
||||
v-else-if="screen === 'company'"
|
||||
padded
|
||||
class="companies-content company-profile"
|
||||
>
|
||||
<SkyEmptyState
|
||||
@@ -1626,6 +1630,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<SkyScrollArea
|
||||
v-else-if="screen === 'request'"
|
||||
padded
|
||||
class="companies-content request-thread"
|
||||
>
|
||||
<SkyEmptyState
|
||||
@@ -1766,6 +1771,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<SkyScrollArea
|
||||
v-else-if="screen === 'manager' && workCompany"
|
||||
padded
|
||||
class="companies-content manager-screen"
|
||||
>
|
||||
<SkyCard class="manager-intro">
|
||||
@@ -2562,8 +2568,8 @@ onBeforeUnmount(() => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.company-list :deep(.sky-list-item__row),
|
||||
.company-request-list :deep(.sky-list-item__row) {
|
||||
.company-list :deep(.sky-list-item__content),
|
||||
.company-request-list :deep(.sky-list-item__content) {
|
||||
padding-top: 11px;
|
||||
padding-bottom: 11px;
|
||||
}
|
||||
@@ -2964,11 +2970,11 @@ onBeforeUnmount(() => {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background: var(--company-surface);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.companies-app.sky-app-page--dark .company-messagebar {
|
||||
background: var(--company-surface);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.manager-screen {
|
||||
|
||||
@@ -3812,4 +3812,9 @@ onBeforeUnmount(() => {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.crewlink-orbits i {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1640,11 +1640,19 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.dc-danger-row :deep(.sky-list-item__title),
|
||||
.dc-danger-row :deep(svg),
|
||||
.dc-danger-text {
|
||||
.dc-danger-row :deep(svg) {
|
||||
color: var(--sky-danger);
|
||||
}
|
||||
|
||||
.sky-dialog-button.dc-danger-text:not(:disabled) {
|
||||
background: var(--sky-danger);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.sky-dialog-button.dc-danger-text:active:not(:disabled) {
|
||||
filter: brightness(0.86);
|
||||
}
|
||||
|
||||
.dc-privacy-note {
|
||||
display: flex;
|
||||
margin: 4px 16px 22px;
|
||||
@@ -1839,7 +1847,7 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
</SkyNavbar>
|
||||
|
||||
<SkyScrollArea with-tabbar class="dc-inbox-content">
|
||||
<SkyScrollArea padded with-tabbar class="dc-inbox-content">
|
||||
<SkyGlass
|
||||
component="button"
|
||||
class="dc-security"
|
||||
@@ -1923,7 +1931,7 @@ onBeforeUnmount(() => {
|
||||
:back-label="phone.t('Common.back')"
|
||||
@back="back"
|
||||
/>
|
||||
<SkyScrollArea class="dc-scroll-content">
|
||||
<SkyScrollArea padded class="dc-scroll-content">
|
||||
<SkyCard class="dc-hero" outline>
|
||||
<span class="dc-icon-tile"><QrCode :size="27" /></span>
|
||||
<h2>{{ t('connectPrivately') }}</h2>
|
||||
@@ -2238,6 +2246,7 @@ onBeforeUnmount(() => {
|
||||
<SkyGlass class="dc-composer-pill">
|
||||
<SkyMessagebar
|
||||
class="dc-composer"
|
||||
embedded
|
||||
:outline="false"
|
||||
:value="draft"
|
||||
:placeholder="t('message')"
|
||||
@@ -2269,7 +2278,7 @@ onBeforeUnmount(() => {
|
||||
:back-label="phone.t('Common.back')"
|
||||
@back="back"
|
||||
/>
|
||||
<SkyScrollArea class="dc-scroll-content">
|
||||
<SkyScrollArea padded class="dc-scroll-content">
|
||||
<div class="dc-profile-hero">
|
||||
<span
|
||||
class="dc-avatar dc-avatar--profile"
|
||||
@@ -2381,7 +2390,7 @@ onBeforeUnmount(() => {
|
||||
<SkyLink @click="saveProfile">{{ phone.t('Common.done') }}</SkyLink>
|
||||
</template>
|
||||
</SkyNavbar>
|
||||
<SkyScrollArea class="dc-scroll-content">
|
||||
<SkyScrollArea padded class="dc-scroll-content">
|
||||
<div class="dc-profile-hero">
|
||||
<span
|
||||
class="dc-avatar dc-avatar--profile"
|
||||
|
||||
@@ -1680,7 +1680,7 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
</template>
|
||||
</SkyNavbar>
|
||||
<SkyScrollArea with-tabbar class="light-screen discover-screen">
|
||||
<SkyScrollArea padded with-tabbar class="light-screen discover-screen">
|
||||
<div class="trend-pills">
|
||||
<SkyChip component="button" type="button"># LosSantos</SkyChip>
|
||||
<SkyChip component="button" type="button"># Roleplay</SkyChip>
|
||||
@@ -1720,7 +1720,7 @@ onBeforeUnmount(() => {
|
||||
</SkyLink>
|
||||
</template>
|
||||
</SkyNavbar>
|
||||
<SkyScrollArea with-tabbar class="light-screen activity-list">
|
||||
<SkyScrollArea padded with-tabbar class="light-screen activity-list">
|
||||
<article
|
||||
v-for="activity in store.activities"
|
||||
:key="activity.id"
|
||||
@@ -1755,6 +1755,7 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
<SkyScrollArea
|
||||
v-if="currentProfile"
|
||||
padded
|
||||
with-tabbar
|
||||
class="light-screen profile-screen"
|
||||
>
|
||||
@@ -1898,7 +1899,7 @@ onBeforeUnmount(() => {
|
||||
back-appearance="surface"
|
||||
@back="composeOpen = false"
|
||||
/>
|
||||
<SkyScrollArea class="compose-body">
|
||||
<SkyScrollArea padded class="compose-body">
|
||||
<header class="compose-intro">
|
||||
<span><Video /></span>
|
||||
<div>
|
||||
@@ -2095,7 +2096,7 @@ onBeforeUnmount(() => {
|
||||
</SkyButton>
|
||||
</template>
|
||||
</SkyNavbar>
|
||||
<SkyScrollArea class="profile-edit__body">
|
||||
<SkyScrollArea padded class="profile-edit__body">
|
||||
<section class="profile-photo-editor">
|
||||
<button
|
||||
class="profile-photo-trigger"
|
||||
@@ -2385,6 +2386,7 @@ onBeforeUnmount(() => {
|
||||
<SkyMessagebar
|
||||
v-model="commentBody"
|
||||
class="comments-messagebar"
|
||||
embedded
|
||||
:outline="false"
|
||||
:aria-label="t('addComment')"
|
||||
:placeholder="
|
||||
@@ -2393,6 +2395,7 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<template #right>
|
||||
<SkyLink
|
||||
component="button"
|
||||
icon-only
|
||||
type="submit"
|
||||
:aria-label="phone.t('Common.send')"
|
||||
|
||||
@@ -257,7 +257,7 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
</SkyEmptyState>
|
||||
|
||||
<SkyScrollArea v-else class="garage-scroll">
|
||||
<SkyScrollArea v-else padded class="garage-scroll">
|
||||
<SkyGlass class="garage-summary">
|
||||
<div class="garage-summary__heading">
|
||||
<span>
|
||||
@@ -1135,6 +1135,12 @@ onBeforeUnmount(() => {
|
||||
transform: translateX(155%);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.garage-valet-live__track i {
|
||||
animation: none;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
.garage-page.sky-app-page--dark .garage-vehicle__visual {
|
||||
border-color: rgb(10 132 255 / 18%);
|
||||
background: #17263a;
|
||||
|
||||
@@ -858,7 +858,7 @@ onBeforeUnmount(() => {
|
||||
accent-soft="rgba(52, 199, 89, 0.16)"
|
||||
>
|
||||
<SkyNavbar variant="large" :title="phone.t('Apps.messages.name')" />
|
||||
<SkyScrollArea class="messages-sky-state-scroll">
|
||||
<SkyScrollArea padded class="messages-sky-state-scroll">
|
||||
<SkyEmptyState
|
||||
:title="phone.t('Apps.messages.noSim')"
|
||||
:body="phone.t('Apps.messages.noSimBody')"
|
||||
@@ -922,7 +922,7 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
</SkyNavbar>
|
||||
|
||||
<SkyScrollArea with-tabbar class="messages-sky-inbox-scroll">
|
||||
<SkyScrollArea padded with-tabbar class="messages-sky-inbox-scroll">
|
||||
<SkySegmented
|
||||
v-if="!editingList"
|
||||
navigation
|
||||
@@ -1082,7 +1082,7 @@ onBeforeUnmount(() => {
|
||||
</SkyLink>
|
||||
</template>
|
||||
</SkyNavbar>
|
||||
<SkyScrollArea class="messages-sky-compose-scroll">
|
||||
<SkyScrollArea padded class="messages-sky-compose-scroll">
|
||||
<SkyList class="messages-recipient-field" density="compact" flush>
|
||||
<SkyField
|
||||
v-model="composerNumber"
|
||||
@@ -1226,7 +1226,7 @@ onBeforeUnmount(() => {
|
||||
</SkyLink>
|
||||
</template>
|
||||
</SkyNavbar>
|
||||
<SkyScrollArea class="messages-contact-overlay__scroll">
|
||||
<SkyScrollArea padded class="messages-contact-overlay__scroll">
|
||||
<div class="messages-contact-details__hero">
|
||||
<span
|
||||
class="messages-avatar messages-avatar--contact"
|
||||
@@ -1319,7 +1319,7 @@ onBeforeUnmount(() => {
|
||||
</SkyScrollArea>
|
||||
</SkyAppPage>
|
||||
|
||||
<SkyScrollArea class="messages-sky-thread-scroll">
|
||||
<SkyScrollArea padded class="messages-sky-thread-scroll">
|
||||
<SkyMessages class="messages-bubbles">
|
||||
<template
|
||||
v-for="(message, index) in messages.messages"
|
||||
@@ -1579,6 +1579,7 @@ onBeforeUnmount(() => {
|
||||
<SkyMessagebar
|
||||
v-model="draft"
|
||||
class="messages-sky-messagebar"
|
||||
embedded
|
||||
:outline="false"
|
||||
:placeholder="phone.t('Apps.messages.message')"
|
||||
:disabled="sending"
|
||||
@@ -1674,6 +1675,9 @@ onBeforeUnmount(() => {
|
||||
|
||||
.messages-sky-conversation :deep(.sky-list-item__row) {
|
||||
min-height: 72px;
|
||||
}
|
||||
|
||||
.messages-sky-conversation :deep(.sky-list-item__content) {
|
||||
padding-top: 9px;
|
||||
padding-bottom: 9px;
|
||||
}
|
||||
@@ -1769,10 +1773,18 @@ onBeforeUnmount(() => {
|
||||
padding: 0 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.messages-recipient-field :deep(.sky-field__inner) {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.messages-contact-list :deep(.sky-list-item__row) {
|
||||
background: transparent;
|
||||
}
|
||||
@@ -1792,6 +1804,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
.messages-recipient-field :deep(.sky-field__control) {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.messages-recipient-field :deep(.sky-field__input) {
|
||||
@@ -1867,7 +1880,7 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.messages-bubbles :deep(.sky-message--received .sky-message__bubble) {
|
||||
background: var(--sky-surface-muted);
|
||||
background: var(--sky-message-received-background);
|
||||
color: var(--sky-text);
|
||||
}
|
||||
|
||||
|
||||
@@ -1008,7 +1008,6 @@ onBeforeUnmount(() => {
|
||||
:style="popoverStyle"
|
||||
:class="{
|
||||
'phone-app--light': !phone.isDarkMode,
|
||||
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
|
||||
}"
|
||||
role="group"
|
||||
:aria-label="phone.t('Apps.music.addMusic')"
|
||||
@@ -1053,7 +1052,6 @@ onBeforeUnmount(() => {
|
||||
:style="popoverStyle"
|
||||
:class="{
|
||||
'phone-app--light': !phone.isDarkMode,
|
||||
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
|
||||
}"
|
||||
role="group"
|
||||
:aria-label="phone.t('Apps.music.songActions')"
|
||||
|
||||
@@ -1804,6 +1804,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
<SkyMessagebar
|
||||
v-model="commentBody"
|
||||
embedded
|
||||
:placeholder="t(replyingTo ? 'replyPlaceholder' : 'addComment')"
|
||||
@keydown.enter.prevent="submitComment"
|
||||
><template #right
|
||||
@@ -3304,17 +3305,42 @@ button {
|
||||
.ps-compose-fields :deep(.sky-field),
|
||||
.ps-edit-fields :deep(.sky-field) {
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--sky-hairline);
|
||||
border-radius: var(--sky-radius-card);
|
||||
background: var(--sky-surface);
|
||||
}
|
||||
.ps-fields :deep(.sky-field__inner),
|
||||
.ps-compose-fields :deep(.sky-field__inner),
|
||||
.ps-edit-fields :deep(.sky-field__inner) {
|
||||
padding: 0;
|
||||
}
|
||||
.ps-fields :deep(.sky-field__label),
|
||||
.ps-compose-fields :deep(.sky-field__label),
|
||||
.ps-edit-fields :deep(.sky-field__label) {
|
||||
margin-top: 0;
|
||||
}
|
||||
.ps-fields :deep(.sky-field__label-text),
|
||||
.ps-compose-fields :deep(.sky-field__label-text),
|
||||
.ps-edit-fields :deep(.sky-field__label-text) {
|
||||
top: auto;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.ps-fields :deep(.sky-field__control),
|
||||
.ps-compose-fields :deep(.sky-field__control),
|
||||
.ps-edit-fields :deep(.sky-field__control) {
|
||||
margin: 0;
|
||||
border-radius: calc(var(--sky-radius-control) + 10px);
|
||||
background: var(--sky-surface-muted);
|
||||
}
|
||||
.ps-fields :deep(.sky-field__border),
|
||||
.ps-compose-fields :deep(.sky-field__border),
|
||||
.ps-edit-fields :deep(.sky-field__border) {
|
||||
display: none;
|
||||
}
|
||||
.ps-compose-fields :deep(.sky-field__textarea),
|
||||
.ps-edit-fields :deep(.sky-field__textarea) {
|
||||
padding-inline: 4px;
|
||||
|
||||
@@ -47,7 +47,7 @@ describe('RadioApp Sky UI contract', () => {
|
||||
expect(navigationSource.match(/:size="20"/g)).toHaveLength(2)
|
||||
expect(navigationSource).not.toContain('compact')
|
||||
expect(source).toContain(
|
||||
'<SkyScrollArea class="radio-content" with-tabbar>',
|
||||
'<SkyScrollArea padded class="radio-content" with-tabbar>',
|
||||
)
|
||||
expect(source.indexOf('</SkyScrollArea>')).toBeLessThan(
|
||||
source.indexOf('<SkyPillNavigation'),
|
||||
|
||||
@@ -235,7 +235,7 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<SkyNavbar :title="phone.t('Apps.radio.name')" />
|
||||
|
||||
<SkyScrollArea class="radio-content" with-tabbar>
|
||||
<SkyScrollArea padded class="radio-content" with-tabbar>
|
||||
<div v-if="radio.isLoading && !radio.data.provider" class="radio-loading">
|
||||
<SkySpinner :label="phone.t('Common.loading')" />
|
||||
<span aria-hidden="true">{{ phone.t('Common.loading') }}</span>
|
||||
|
||||
@@ -719,6 +719,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<SkyScrollArea
|
||||
ref="settingsPage"
|
||||
padded
|
||||
class="settings-content"
|
||||
:class="{ 'settings-content--subpage': activeView !== 'root' }"
|
||||
>
|
||||
@@ -1356,7 +1357,9 @@ onBeforeUnmount(() => {
|
||||
</section>
|
||||
|
||||
<SkySettingsGroup :title="phone.t('Apps.settings.erasedFromPhone')">
|
||||
<SkySettingsRow :title="phone.t('Apps.settings.eraseDeviceSettings')" />
|
||||
<SkySettingsRow
|
||||
:title="phone.t('Apps.settings.eraseDeviceSettings')"
|
||||
/>
|
||||
<SkySettingsRow :title="phone.t('Apps.settings.eraseLocalContent')" />
|
||||
<SkySettingsRow :title="phone.t('Apps.settings.eraseLocalApps')" />
|
||||
</SkySettingsGroup>
|
||||
@@ -1577,8 +1580,12 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<div class="settings-reset-content">
|
||||
<div class="settings-reset-mark" aria-hidden="true">
|
||||
<span class="settings-reset-mark__layer settings-reset-mark__layer--back"></span>
|
||||
<span class="settings-reset-mark__layer settings-reset-mark__layer--middle"></span>
|
||||
<span
|
||||
class="settings-reset-mark__layer settings-reset-mark__layer--back"
|
||||
></span>
|
||||
<span
|
||||
class="settings-reset-mark__layer settings-reset-mark__layer--middle"
|
||||
></span>
|
||||
<div class="settings-reset-mark__face">
|
||||
<Smartphone :size="35" :stroke-width="1.45" />
|
||||
<span class="settings-reset-mark__erase">
|
||||
@@ -1596,7 +1603,13 @@ onBeforeUnmount(() => {
|
||||
<strong>{{ Math.floor(factoryResetProgress) }}%</strong>
|
||||
<span>{{ factoryResetSecondsRemaining }}s</span>
|
||||
</div>
|
||||
<div class="settings-reset-progress" role="progressbar" :aria-valuenow="Math.floor(factoryResetProgress)" aria-valuemin="0" aria-valuemax="100">
|
||||
<div
|
||||
class="settings-reset-progress"
|
||||
role="progressbar"
|
||||
:aria-valuenow="Math.floor(factoryResetProgress)"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
>
|
||||
<span :style="{ width: `${factoryResetProgress}%` }" />
|
||||
</div>
|
||||
<p class="settings-reset-detail">{{ factoryResetPhase.detail }}</p>
|
||||
@@ -1610,7 +1623,9 @@ onBeforeUnmount(() => {
|
||||
<small>{{ phone.t('Apps.settings.factoryResetCloudSafe') }}</small>
|
||||
</span>
|
||||
</div>
|
||||
<p class="settings-reset-warning">{{ phone.t('Apps.settings.factoryResetWarning') }}</p>
|
||||
<p class="settings-reset-warning">
|
||||
{{ phone.t('Apps.settings.factoryResetWarning') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1767,8 +1782,14 @@ onBeforeUnmount(() => {
|
||||
border: 1px solid rgb(255 255 255 / 10%);
|
||||
border-radius: 24px;
|
||||
color: #ff665f;
|
||||
background: linear-gradient(145deg, rgb(255 74 66 / 18%), rgb(255 255 255 / 5%));
|
||||
box-shadow: inset 0 1px rgb(255 255 255 / 12%), 0 18px 35px rgb(0 0 0 / 14%);
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
rgb(255 74 66 / 18%),
|
||||
rgb(255 255 255 / 5%)
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 1px rgb(255 255 255 / 12%),
|
||||
0 18px 35px rgb(0 0 0 / 14%);
|
||||
}
|
||||
|
||||
.settings-reset-hero h2 {
|
||||
@@ -2122,9 +2143,24 @@ onBeforeUnmount(() => {
|
||||
position: absolute;
|
||||
inset: -30%;
|
||||
background:
|
||||
linear-gradient(118deg, transparent 36%, rgb(100 151 255 / 7%) 36.2%, transparent 36.7%),
|
||||
linear-gradient(118deg, transparent 49%, rgb(164 122 255 / 6%) 49.2%, transparent 49.8%),
|
||||
linear-gradient(118deg, transparent 63%, rgb(72 187 255 / 5%) 63.2%, transparent 63.7%);
|
||||
linear-gradient(
|
||||
118deg,
|
||||
transparent 36%,
|
||||
rgb(100 151 255 / 7%) 36.2%,
|
||||
transparent 36.7%
|
||||
),
|
||||
linear-gradient(
|
||||
118deg,
|
||||
transparent 49%,
|
||||
rgb(164 122 255 / 6%) 49.2%,
|
||||
transparent 49.8%
|
||||
),
|
||||
linear-gradient(
|
||||
118deg,
|
||||
transparent 63%,
|
||||
rgb(72 187 255 / 5%) 63.2%,
|
||||
transparent 63.7%
|
||||
);
|
||||
content: '';
|
||||
transform: translateX(-7%);
|
||||
}
|
||||
@@ -2135,7 +2171,12 @@ onBeforeUnmount(() => {
|
||||
right: -20%;
|
||||
width: 78%;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgb(155 187 255 / 18%), transparent);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgb(155 187 255 / 18%),
|
||||
transparent
|
||||
);
|
||||
box-shadow:
|
||||
0 132px rgb(155 187 255 / 7%),
|
||||
0 264px rgb(155 187 255 / 5%);
|
||||
@@ -2171,7 +2212,11 @@ onBeforeUnmount(() => {
|
||||
.settings-reset-mark__layer--back {
|
||||
top: 0;
|
||||
left: 13px;
|
||||
background: linear-gradient(145deg, rgb(72 135 255 / 22%), rgb(82 53 190 / 12%));
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
rgb(72 135 255 / 22%),
|
||||
rgb(82 53 190 / 12%)
|
||||
);
|
||||
transform: rotate(-12deg);
|
||||
}
|
||||
|
||||
@@ -2179,7 +2224,11 @@ onBeforeUnmount(() => {
|
||||
top: 13px;
|
||||
right: 11px;
|
||||
border-color: rgb(168 126 255 / 20%);
|
||||
background: linear-gradient(145deg, rgb(134 87 255 / 19%), rgb(41 89 194 / 9%));
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
rgb(134 87 255 / 19%),
|
||||
rgb(41 89 194 / 9%)
|
||||
);
|
||||
transform: rotate(9deg);
|
||||
}
|
||||
|
||||
@@ -2189,7 +2238,11 @@ onBeforeUnmount(() => {
|
||||
display: grid;
|
||||
overflow: hidden;
|
||||
border-color: rgb(255 255 255 / 20%);
|
||||
background: linear-gradient(145deg, rgb(255 255 255 / 16%), rgb(255 255 255 / 6%));
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
rgb(255 255 255 / 16%),
|
||||
rgb(255 255 255 / 6%)
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 1px rgb(255 255 255 / 15%),
|
||||
0 22px 48px rgb(0 0 0 / 28%);
|
||||
@@ -2217,7 +2270,9 @@ onBeforeUnmount(() => {
|
||||
box-shadow: 0 0 9px rgb(81 158 255 / 50%);
|
||||
}
|
||||
|
||||
.settings-reset-heading { margin-top: 25px; }
|
||||
.settings-reset-heading {
|
||||
margin-top: 25px;
|
||||
}
|
||||
|
||||
.settings-reset-heading h2 {
|
||||
margin: 0;
|
||||
@@ -2285,7 +2340,11 @@ onBeforeUnmount(() => {
|
||||
padding: 13px 14px;
|
||||
border: 1px solid rgb(255 255 255 / 11%);
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, rgb(255 255 255 / 8%), rgb(255 255 255 / 4%));
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgb(255 255 255 / 8%),
|
||||
rgb(255 255 255 / 4%)
|
||||
);
|
||||
box-shadow: inset 0 1px rgb(255 255 255 / 5%);
|
||||
text-align: left;
|
||||
}
|
||||
@@ -2326,8 +2385,13 @@ onBeforeUnmount(() => {
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
.settings-dialog-button--danger {
|
||||
color: var(--sky-danger);
|
||||
.settings-dialog-button--danger:not(:disabled) {
|
||||
background: var(--sky-danger);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.settings-dialog-button--danger:active:not(:disabled) {
|
||||
filter: brightness(0.86);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(
|
||||
new URL('./WeatherApp.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('WeatherApp layout contract', () => {
|
||||
it('preserves its exact custom forecast gutter instead of generic page padding', () => {
|
||||
expect(source).toMatch(
|
||||
/<SkyScrollArea[\s\S]*?class="weather-scroll"[\s\S]*?>/,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.weather-scroll\s*\{\s*padding:\s*4px 14px 24px;\s*\}/,
|
||||
)
|
||||
expect(source).not.toMatch(
|
||||
/<SkyScrollArea[\s\S]*?class="weather-scroll"[\s\S]*?\spadded(?:\s|=)[\s\S]*?>/,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -344,3 +344,9 @@ watch(
|
||||
</SkyToast>
|
||||
</SkyAppPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.weather-scroll {
|
||||
padding: 4px 14px 24px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -17,6 +17,11 @@ const homeSource = readFileSync(
|
||||
join(demoDirectory, 'SkyUiDemoHome.vue'),
|
||||
'utf8',
|
||||
)
|
||||
const demoPageSource = readFileSync(
|
||||
join(demoDirectory, 'SkyUiDemoPage.vue'),
|
||||
'utf8',
|
||||
)
|
||||
const demoCssSource = readFileSync(join(demoDirectory, 'demo.css'), 'utf8')
|
||||
const extensionsSource = readFileSync(
|
||||
join(pagesDirectory, 'SkyExtensionsDemo.vue'),
|
||||
'utf8',
|
||||
@@ -113,6 +118,10 @@ function demoFileName(id: string): string {
|
||||
.join('')}Demo.vue`
|
||||
}
|
||||
|
||||
function demoPage(name: string): string {
|
||||
return readFileSync(join(pagesDirectory, `${name}Demo.vue`), 'utf8')
|
||||
}
|
||||
|
||||
describe('development Sky UI Kitchen Sink contract', () => {
|
||||
it('mirrors the exact Konsta 5.3 Vue component submenu catalog', () => {
|
||||
expect(SKY_UI_DEMO_CATALOG.map(({ id, title }) => [id, title])).toEqual(
|
||||
@@ -136,6 +145,73 @@ describe('development Sky UI Kitchen Sink contract', () => {
|
||||
expect(combinedDemoSource).toContain('./assets/demo-icon.png')
|
||||
})
|
||||
|
||||
it('keeps the shared demo shell aligned with the Konsta reference', () => {
|
||||
expect(demoPageSource).not.toContain('back-appearance="surface"')
|
||||
expect(demoCssSource).toMatch(/\.sky-ui-demo-copy\s*\{\s*margin:\s*0;\s*\}/)
|
||||
expect(homeSource).not.toMatch(/<SkyPopover[\s\S]*?\sangle(?:\s|>)/)
|
||||
expect(homeSource).toContain("{ color: '#4cd964', name: 'Green'")
|
||||
expect(homeSource).toContain("{ color: '#9c27b0', name: 'Purple'")
|
||||
})
|
||||
|
||||
it('preserves the locally expressible Konsta examples and states', () => {
|
||||
const cards = demoPage('Cards')
|
||||
const checkbox = demoPage('Checkbox')
|
||||
const contacts = demoPage('ContactsList')
|
||||
const dialog = demoPage('Dialog')
|
||||
const fab = demoPage('Fab')
|
||||
const list = demoPage('List')
|
||||
const listButton = demoPage('ListButton')
|
||||
const messages = demoPage('Messages')
|
||||
const menuList = demoPage('MenuList')
|
||||
const notification = demoPage('Notification')
|
||||
const panels = demoPage('SidePanels')
|
||||
const popover = demoPage('Popover')
|
||||
const popup = demoPage('Popup')
|
||||
const preloader = demoPage('Preloader')
|
||||
const sheet = demoPage('SheetModal')
|
||||
const tabbar = demoPage('Tabbar')
|
||||
const toggle = demoPage('Toggle')
|
||||
const toolbar = demoPage('Toolbar')
|
||||
|
||||
expect(cards).not.toContain('linear-gradient')
|
||||
expect(cards).not.toContain('font-size: 22px')
|
||||
expect(cards).toContain('<SkyMediaCard')
|
||||
expect(cards).not.toMatch(/cards-demo__(?:image|date|copy|actions)/)
|
||||
expect(checkbox).toContain(
|
||||
'<SkyList class="checkbox-demo__children" nested>',
|
||||
)
|
||||
expect(contacts).toContain('<SkyListItem contacts group-title')
|
||||
expect(contacts).toMatch(/contacts\s+:title="name"/)
|
||||
expect(dialog).toContain(
|
||||
'margin: 0 calc(var(--sky-space-4) * -1) calc(var(--sky-space-4) * -1);',
|
||||
)
|
||||
expect(fab.match(/class="fab-demo__button/g)).toHaveLength(6)
|
||||
expect(fab).toContain('fab-demo__button--right-top sky-ui-demo-color-red')
|
||||
expect(listButton).toContain('<SkyList dividers outline strong>')
|
||||
expect(list).toContain('user, etc.')
|
||||
expect(list).not.toContain(':chevron="false"')
|
||||
expect(list).toContain("import demoIcon from '../assets/demo-icon.png'")
|
||||
expect(messages).toContain("return text.split('\\n')")
|
||||
expect(messages).toContain('<template #text>')
|
||||
expect(menuList).toContain("import demoIcon from '../assets/demo-icon.png'")
|
||||
expect(notification).toContain(
|
||||
'@click="opened.notificationWithButton = false"',
|
||||
)
|
||||
expect(notification).not.toContain('<SkyDialogButton strong')
|
||||
expect(panels).toContain('sky-ui-demo-panel-page--floating')
|
||||
expect(panels).toContain('--sky-safe-area-top: 0px;')
|
||||
expect(popover).not.toMatch(/<SkyPopover[\s\S]*?\sangle(?:\s|>)/)
|
||||
expect(popup).toContain('"Temporary Views".')
|
||||
expect(popup).toContain('Also not, that by default popup')
|
||||
expect(preloader).toContain('color: #4cd964;')
|
||||
expect(preloader).toContain('color: #9c27b0;')
|
||||
expect(sheet).toContain('Such modals allow to create custom overlays')
|
||||
expect(tabbar).not.toContain('color: var(--sky-muted)')
|
||||
expect(toggle).toMatch(/<SkyListItem[\s\S]*?\slabel[\s\S]*?<SkyToggle/)
|
||||
expect(toolbar).toContain('Cras vehicula bibendum lorem quis imperdiet.')
|
||||
expect(toolbar).toContain('sed risus aliquet, vel accumsan dolor feugiat.')
|
||||
})
|
||||
|
||||
it('demonstrates every public Sky component without Konsta runtime code', () => {
|
||||
const missingComponents = publicComponents.filter(
|
||||
(component) =>
|
||||
|
||||
@@ -21,7 +21,11 @@ const initialAccent: SkyUiDemoAccent = {
|
||||
name: 'Blue',
|
||||
soft: 'rgba(0, 122, 255, 0.16)',
|
||||
}
|
||||
const dark = ref(phone.isDarkMode)
|
||||
const dark = computed({
|
||||
get: () => phone.isDarkMode,
|
||||
set: (value: boolean) =>
|
||||
phone.setPreference('appearanceMode', value ? 'dark' : 'light'),
|
||||
})
|
||||
const accentChoice = ref(initialAccent)
|
||||
|
||||
const demoPages: Record<SkyUiDemoId, Component> = {
|
||||
|
||||
@@ -25,9 +25,9 @@ const colorPickerTarget = ref<HTMLElement | null>(null)
|
||||
const accents: SkyUiDemoAccent[] = [
|
||||
{ color: '#007aff', name: 'Blue', soft: 'rgba(0, 122, 255, 0.16)' },
|
||||
{ color: '#ff3b30', name: 'Red', soft: 'rgba(255, 59, 48, 0.16)' },
|
||||
{ color: '#34c759', name: 'Green', soft: 'rgba(52, 199, 89, 0.16)' },
|
||||
{ color: '#4cd964', name: 'Green', soft: 'rgba(76, 217, 100, 0.16)' },
|
||||
{ color: '#ffcc00', name: 'Yellow', soft: 'rgba(255, 204, 0, 0.18)' },
|
||||
{ color: '#af52de', name: 'Purple', soft: 'rgba(175, 82, 222, 0.16)' },
|
||||
{ color: '#9c27b0', name: 'Purple', soft: 'rgba(156, 39, 176, 0.16)' },
|
||||
]
|
||||
|
||||
function chooseAccent(accent: SkyUiDemoAccent): void {
|
||||
@@ -163,7 +163,6 @@ onBeforeUnmount(() => {
|
||||
</SkyScrollArea>
|
||||
|
||||
<SkyPopover
|
||||
angle
|
||||
aria-label="Choose color theme"
|
||||
:opened="colorPickerOpened"
|
||||
:target="colorPickerTarget"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useSkyUiDemoContext } from './context'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
padded?: boolean
|
||||
scrollClass?: string
|
||||
subtitle?: string
|
||||
title: string
|
||||
@@ -13,6 +14,7 @@ withDefaults(
|
||||
withTabbar?: boolean
|
||||
}>(),
|
||||
{
|
||||
padded: false,
|
||||
scrollClass: '',
|
||||
subtitle: '',
|
||||
transparent: false,
|
||||
@@ -33,7 +35,6 @@ const demo = useSkyUiDemoContext()
|
||||
:label="title"
|
||||
>
|
||||
<SkyNavbar
|
||||
back-appearance="surface"
|
||||
back-label="Back"
|
||||
show-back
|
||||
:subtitle="subtitle"
|
||||
@@ -53,6 +54,7 @@ const demo = useSkyUiDemoContext()
|
||||
<SkyScrollArea
|
||||
class="sky-ui-demo-page__scroll"
|
||||
:class="scrollClass"
|
||||
:padded="padded"
|
||||
:with-tabbar="withTabbar"
|
||||
>
|
||||
<slot />
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
type BadgeIosIconName =
|
||||
| 'calendar'
|
||||
| 'cloud-upload-fill'
|
||||
| 'envelope-fill'
|
||||
| 'person-circle-fill'
|
||||
|
||||
defineProps<{
|
||||
name: BadgeIosIconName
|
||||
}>()
|
||||
|
||||
// Framework7 Icons 5.0.5 SVG geometry (MIT), matching Konsta's iOS demo.
|
||||
const iconPaths: Record<BadgeIosIconName, string> = {
|
||||
calendar:
|
||||
'M 11.9923 49.5742 L 44.0079 49.5742 C 48.9066 49.5742 51.3671 47.1367 51.3671 42.3086 L 51.3671 13.6914 C 51.3671 8.8633 48.9066 6.4258 44.0079 6.4258 L 11.9923 6.4258 C 7.1173 6.4258 4.6329 8.8398 4.6329 13.6914 L 4.6329 42.3086 C 4.6329 47.1602 7.1173 49.5742 11.9923 49.5742 Z M 11.6642 45.8008 C 9.5782 45.8008 8.4064 44.6992 8.4064 42.5195 L 8.4064 20.4180 C 8.4064 18.2617 9.5782 17.1367 11.6642 17.1367 L 44.3126 17.1367 C 46.3985 17.1367 47.5938 18.2617 47.5938 20.4180 L 47.5938 42.5195 C 47.5938 44.6992 46.3985 45.8008 44.3126 45.8008 Z M 23.4064 25.5508 L 24.7892 25.5508 C 25.6095 25.5508 25.8907 25.3164 25.8907 24.4961 L 25.8907 23.1133 C 25.8907 22.2930 25.6095 22.0352 24.7892 22.0352 L 23.4064 22.0352 C 22.5860 22.0352 22.3282 22.2930 22.3282 23.1133 L 22.3282 24.4961 C 22.3282 25.3164 22.5860 25.5508 23.4064 25.5508 Z M 31.2111 25.5508 L 32.5704 25.5508 C 33.4142 25.5508 33.6720 25.3164 33.6720 24.4961 L 33.6720 23.1133 C 33.6720 22.2930 33.4142 22.0352 32.5704 22.0352 L 31.2111 22.0352 C 30.3907 22.0352 30.1095 22.2930 30.1095 23.1133 L 30.1095 24.4961 C 30.1095 25.3164 30.3907 25.5508 31.2111 25.5508 Z M 38.9923 25.5508 L 40.3751 25.5508 C 41.1954 25.5508 41.4767 25.3164 41.4767 24.4961 L 41.4767 23.1133 C 41.4767 22.2930 41.1954 22.0352 40.3751 22.0352 L 38.9923 22.0352 C 38.1720 22.0352 37.8907 22.2930 37.8907 23.1133 L 37.8907 24.4961 C 37.8907 25.3164 38.1720 25.5508 38.9923 25.5508 Z M 15.6251 33.2149 L 17.0079 33.2149 C 17.8282 33.2149 18.1095 32.9805 18.1095 32.1602 L 18.1095 30.7774 C 18.1095 29.9571 17.8282 29.7227 17.0079 29.7227 L 15.6251 29.7227 C 14.8048 29.7227 14.5235 29.9571 14.5235 30.7774 L 14.5235 32.1602 C 14.5235 32.9805 14.8048 33.2149 15.6251 33.2149 Z M 23.4064 33.2149 L 24.7892 33.2149 C 25.6095 33.2149 25.8907 32.9805 25.8907 32.1602 L 25.8907 30.7774 C 25.8907 29.9571 25.6095 29.7227 24.7892 29.7227 L 23.4064 29.7227 C 22.5860 29.7227 22.3282 29.9571 22.3282 30.7774 L 22.3282 32.1602 C 22.3282 32.9805 22.5860 33.2149 23.4064 33.2149 Z M 31.2111 33.2149 L 32.5704 33.2149 C 33.4142 33.2149 33.6720 32.9805 33.6720 32.1602 L 33.6720 30.7774 C 33.6720 29.9571 33.4142 29.7227 32.5704 29.7227 L 31.2111 29.7227 C 30.3907 29.7227 30.1095 29.9571 30.1095 30.7774 L 30.1095 32.1602 C 30.1095 32.9805 30.3907 33.2149 31.2111 33.2149 Z M 38.9923 33.2149 L 40.3751 33.2149 C 41.1954 33.2149 41.4767 32.9805 41.4767 32.1602 L 41.4767 30.7774 C 41.4767 29.9571 41.1954 29.7227 40.3751 29.7227 L 38.9923 29.7227 C 38.1720 29.7227 37.8907 29.9571 37.8907 30.7774 L 37.8907 32.1602 C 37.8907 32.9805 38.1720 33.2149 38.9923 33.2149 Z M 15.6251 40.9024 L 17.0079 40.9024 C 17.8282 40.9024 18.1095 40.6445 18.1095 39.8242 L 18.1095 38.4414 C 18.1095 37.6211 17.8282 37.3867 17.0079 37.3867 L 15.6251 37.3867 C 14.8048 37.3867 14.5235 37.6211 14.5235 38.4414 L 14.5235 39.8242 C 14.5235 40.6445 14.8048 40.9024 15.6251 40.9024 Z M 23.4064 40.9024 L 24.7892 40.9024 C 25.6095 40.9024 25.8907 40.6445 25.8907 39.8242 L 25.8907 38.4414 C 25.8907 37.6211 25.6095 37.3867 24.7892 37.3867 L 23.4064 37.3867 C 22.5860 37.3867 22.3282 37.6211 22.3282 38.4414 L 22.3282 39.8242 C 22.3282 40.6445 22.5860 40.9024 23.4064 40.9024 Z M 31.2111 40.9024 L 32.5704 40.9024 C 33.4142 40.9024 33.6720 40.6445 33.6720 39.8242 L 33.6720 38.4414 C 33.6720 37.6211 33.4142 37.3867 32.5704 37.3867 L 31.2111 37.3867 C 30.3907 37.3867 30.1095 37.6211 30.1095 38.4414 L 30.1095 39.8242 C 30.1095 40.6445 30.3907 40.9024 31.2111 40.9024 Z',
|
||||
'cloud-upload-fill':
|
||||
'M29.956181,36.8524845 L29.956181,43.5739817 C29.956181,44.6785512 29.0607505,45.5739817 27.956181,45.5739817 C26.8516115,45.5739817 25.956181,44.6785512 25.956181,43.5739817 L25.956181,36.8524845 L10.8722742,36.8524845 C5.16179086,36.8524845 0.532529419,32.223223 0.532529419,26.5127397 C0.532529419,21.6045779 3.95235292,17.4951588 8.5391803,16.4373019 C8.53657409,16.3495183 8.53526178,16.2614098 8.53526178,16.1729948 C8.53526178,11.3440365 12.449906,7.42939227 17.2788644,7.42939227 C18.1407721,7.42939227 18.9735519,7.5541039 19.7601433,7.7864667 C22.5068558,3.56329032 27.2678994,0.770663069 32.6811961,0.770663069 C41.1876691,0.770663069 48.0835279,7.66652189 48.0835279,16.1729948 C48.0835279,16.6568639 48.0612156,17.1355216 48.0175636,17.6079953 C52.3210371,18.6221788 55.524131,22.4871994 55.524131,27.1003427 C55.524131,32.4863019 51.1579484,36.8524845 45.7719893,36.8524845 L29.956181,36.8524845 L29.956181,36.8524845 Z M27.944431,17.0584061 C27.475631,17.0584061 27.053831,17.2224061 26.585031,17.6912061 L18.663131,25.3318061 C18.311631,25.6834061 18.124131,26.0584061 18.124131,26.5740061 C18.124131,27.5349061 18.850631,28.2146061 19.835031,28.2146061 C20.280331,28.2146061 20.772531,28.0271061 21.100631,27.6521061 L24.639731,23.8787061 L26.233531,22.2146061 L26.092831,25.7068061 L26.092831,35.6869731 C26.092831,36.6712731 26.936631,37.4916731 27.944431,37.4916731 C28.952231,37.4916731 29.819431,36.6712731 29.819431,35.6869731 L29.819431,25.7068061 L29.655331,22.2146061 L31.249131,23.8787061 L34.811631,27.6521061 C35.139731,28.0271061 35.631931,28.2146061 36.100631,28.2146061 C37.085031,28.2146061 37.788231,27.5349061 37.788231,26.5740061 C37.788231,26.0584061 37.577231,25.6834061 37.225631,25.3318061 L29.303831,17.6912061 C28.835031,17.2224061 28.436631,17.0584061 27.944431,17.0584061 Z',
|
||||
'envelope-fill':
|
||||
'M 28.0468 30.7070 C 29.0312 30.7070 29.9218 30.2617 30.9296 29.2305 L 51.3200 9.0508 C 50.4532 8.2070 48.8360 7.8086 46.5157 7.8086 L 8.7812 7.8086 C 6.8124 7.8086 5.4296 8.1836 4.6562 8.9570 L 25.1640 29.2305 C 26.1718 30.2383 27.0858 30.7070 28.0468 30.7070 Z M 2.7109 44.4180 L 19.2812 28.0352 L 2.6640 11.6523 C 2.3124 12.3086 2.1249 13.4336 2.1249 15.0508 L 2.1249 40.9258 C 2.1249 42.5898 2.3358 43.7617 2.7109 44.4180 Z M 53.3360 44.3945 C 53.6874 43.7148 53.8751 42.5664 53.8751 40.9258 L 53.8751 15.0508 C 53.8751 13.4805 53.7109 12.3555 53.3591 11.7227 L 36.8124 28.0352 Z M 9.4843 48.1914 L 47.2184 48.1914 C 49.1874 48.1914 50.5468 47.8164 51.3200 47.0664 L 34.4452 30.3320 L 32.8749 31.9023 C 31.2812 33.4492 29.7577 34.1523 28.0468 34.1523 C 26.3358 34.1523 24.8124 33.4492 23.2187 31.9023 L 21.6484 30.3320 L 4.7968 47.0430 C 5.6874 47.8164 7.2577 48.1914 9.4843 48.1914 Z',
|
||||
'person-circle-fill':
|
||||
'M 27.9999 51.9062 C 41.0546 51.9062 51.9063 41.0547 51.9063 28.0000 C 51.9063 14.9219 41.0312 4.0938 27.9765 4.0938 C 14.8983 4.0938 4.0937 14.9219 4.0937 28.0000 C 4.0937 41.0547 14.9218 51.9062 27.9999 51.9062 Z M 27.9999 26.6875 C 24.7421 26.6640 22.1640 23.9453 22.1640 20.3125 C 22.1640 16.9140 24.7421 14.0781 27.9999 14.0781 C 31.2343 14.0781 33.8124 16.9140 33.8124 20.3125 C 33.8124 23.9453 31.2343 26.7344 27.9999 26.6875 Z M 17.6171 39.7422 C 16.7030 39.7422 16.2577 39.1328 16.2577 38.3125 C 16.2577 35.8047 20.0312 29.3594 27.9999 29.3594 C 35.9687 29.3594 39.7187 35.8047 39.7187 38.3125 C 39.7187 39.1328 39.2733 39.7422 38.3827 39.7422 Z',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
fill="currentcolor"
|
||||
height="1em"
|
||||
viewBox="0 0 56 56"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
:d="iconPaths[name]"
|
||||
:fill-rule="name === 'cloud-upload-fill' ? 'evenodd' : undefined"
|
||||
:transform="name === 'cloud-upload-fill' ? 'translate(0 3)' : undefined"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -29,9 +29,6 @@
|
||||
|
||||
.sky-ui-demo-copy {
|
||||
margin: 0;
|
||||
color: var(--sky-muted);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.sky-ui-demo-media {
|
||||
|
||||
@@ -3,14 +3,18 @@ import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(new URL('./BadgeDemo.vue', import.meta.url), 'utf8')
|
||||
const iconSource = readFileSync(
|
||||
new URL('../assets/BadgeIosIcon.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('BadgeDemo', () => {
|
||||
it('uses the shared interactive Glass system for the profile action', () => {
|
||||
expect(source).toContain('SkyGlass,')
|
||||
it('uses the plain Konsta-style navbar link for the profile action', () => {
|
||||
expect(source).toContain('SkyLink,')
|
||||
expect(source).toMatch(
|
||||
/<SkyGlass[\s\S]*?aria-label="Profile, 5 notifications"[\s\S]*?class="badge-demo__profile-action"[\s\S]*?component="button"[\s\S]*?>/,
|
||||
/<SkyLink[\s\S]*?aria-label="Profile, 5 notifications"[\s\S]*?class="badge-demo__profile-action"[\s\S]*?icon-only[\s\S]*?>/,
|
||||
)
|
||||
expect(source).not.toContain('<SkyLink aria-label="Profile')
|
||||
expect(source).not.toContain('SkyGlass,')
|
||||
})
|
||||
|
||||
it('keeps the profile action circular, accessible, and token sized', () => {
|
||||
@@ -21,7 +25,43 @@ describe('BadgeDemo', () => {
|
||||
/\.badge-demo__profile-action:focus-visible\s*\{[^}]*outline:\s*2px solid var\(--sky-app-accent, #007aff\)/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.badge-demo__icon-badge\s*\{[^}]*inset-block-start:\s*-2px[^}]*inset-inline-end:\s*-6px/s,
|
||||
/\.badge-demo__list-icon\s*\{[^}]*width:\s*28px[^}]*height:\s*28px/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the exact filled Framework7 iOS glyph geometry', () => {
|
||||
expect(source).not.toContain('lucide-vue-next')
|
||||
expect(source).toContain('<BadgeIosIcon name="person-circle-fill" />')
|
||||
expect(source).toContain('<BadgeIosIcon name="envelope-fill" />')
|
||||
expect(source).toContain('<BadgeIosIcon name="calendar" />')
|
||||
expect(source).toContain('<BadgeIosIcon name="cloud-upload-fill" />')
|
||||
|
||||
expect(iconSource).toContain('fill="currentcolor"')
|
||||
expect(iconSource).toContain('viewBox="0 0 56 56"')
|
||||
expect(iconSource).toContain('M 27.9999 51.9062')
|
||||
expect(iconSource).toContain('M 28.0468 30.7070')
|
||||
expect(iconSource).toContain('M 11.9923 49.5742')
|
||||
expect(iconSource).toContain('M29.956181,36.8524845')
|
||||
})
|
||||
|
||||
it('matches the reference badge colors and default primary CEO badge', () => {
|
||||
expect(source).toContain(':badge-colors="{ bg: \'#22c55e\' }"')
|
||||
expect(source.match(/:badge-colors="\{ bg: '#ef4444' \}"/g)).toHaveLength(3)
|
||||
expect(source).toMatch(
|
||||
/title="Foo Bar"[\s\S]*?<SkyBadge class="badge-demo__badge--gray">0<\/SkyBadge>/,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/title="Ivan Petrov"[\s\S]*?<SkyBadge>CEO<\/SkyBadge>/,
|
||||
)
|
||||
expect(source).not.toContain('tone="info"')
|
||||
expect(source).toMatch(
|
||||
/\.badge-demo__badge--gray\s*\{[^}]*background:\s*#6b7280[^}]*color:\s*#ffffff/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.badge-demo__badge--green\s*\{[^}]*background:\s*#22c55e/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.badge-demo__badge--yellow\s*\{[^}]*background:\s*#eab308/s,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CalendarDays,
|
||||
CircleUserRound,
|
||||
CloudUpload,
|
||||
Mail,
|
||||
} from 'lucide-vue-next'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import {
|
||||
SkyBadge,
|
||||
SkyGlass,
|
||||
SkyIcon,
|
||||
SkyLink,
|
||||
SkyList,
|
||||
SkyListItem,
|
||||
SkyTabBar,
|
||||
@@ -18,6 +12,7 @@ import {
|
||||
} from '@/ui'
|
||||
|
||||
import SkyUiDemoPage from '../SkyUiDemoPage.vue'
|
||||
import BadgeIosIcon from '../assets/BadgeIosIcon.vue'
|
||||
import demoIcon from '../assets/demo-icon.png'
|
||||
|
||||
const activeTab = ref('inbox')
|
||||
@@ -26,19 +21,15 @@ const activeTab = ref('inbox')
|
||||
<template>
|
||||
<SkyUiDemoPage title="Badge" with-tabbar>
|
||||
<template #navbarRight>
|
||||
<SkyGlass
|
||||
<SkyLink
|
||||
aria-label="Profile, 5 notifications"
|
||||
class="badge-demo__profile-action"
|
||||
component="button"
|
||||
type="button"
|
||||
icon-only
|
||||
>
|
||||
<span class="badge-demo__icon-wrap">
|
||||
<SkyIcon :size="28"><CircleUserRound /></SkyIcon>
|
||||
<SkyBadge class="badge-demo__icon-badge" small tone="danger">
|
||||
5
|
||||
</SkyBadge>
|
||||
</span>
|
||||
</SkyGlass>
|
||||
<SkyIcon badge="5" :badge-colors="{ bg: '#ef4444' }" :size="28">
|
||||
<BadgeIosIcon name="person-circle-fill" />
|
||||
</SkyIcon>
|
||||
</SkyLink>
|
||||
</template>
|
||||
|
||||
<SkyList inset strong>
|
||||
@@ -46,25 +37,31 @@ const activeTab = ref('inbox')
|
||||
<template #media>
|
||||
<img class="badge-demo__list-icon" :src="demoIcon" alt="" />
|
||||
</template>
|
||||
<template #after><SkyBadge>0</SkyBadge></template>
|
||||
<template #after>
|
||||
<SkyBadge class="badge-demo__badge--gray">0</SkyBadge>
|
||||
</template>
|
||||
</SkyListItem>
|
||||
<SkyListItem title="Ivan Petrov">
|
||||
<template #media>
|
||||
<img class="badge-demo__list-icon" :src="demoIcon" alt="" />
|
||||
</template>
|
||||
<template #after><SkyBadge tone="info">CEO</SkyBadge></template>
|
||||
<template #after><SkyBadge>CEO</SkyBadge></template>
|
||||
</SkyListItem>
|
||||
<SkyListItem title="John Doe">
|
||||
<template #media>
|
||||
<img class="badge-demo__list-icon" :src="demoIcon" alt="" />
|
||||
</template>
|
||||
<template #after><SkyBadge tone="success">5</SkyBadge></template>
|
||||
<template #after>
|
||||
<SkyBadge class="badge-demo__badge--green">5</SkyBadge>
|
||||
</template>
|
||||
</SkyListItem>
|
||||
<SkyListItem title="Jane Doe">
|
||||
<template #media>
|
||||
<img class="badge-demo__list-icon" :src="demoIcon" alt="" />
|
||||
</template>
|
||||
<template #after><SkyBadge tone="warning">NEW</SkyBadge></template>
|
||||
<template #after>
|
||||
<SkyBadge class="badge-demo__badge--yellow">NEW</SkyBadge>
|
||||
</template>
|
||||
</SkyListItem>
|
||||
</SkyList>
|
||||
|
||||
@@ -76,12 +73,9 @@ const activeTab = ref('inbox')
|
||||
@click="activeTab = 'inbox'"
|
||||
>
|
||||
<template #icon>
|
||||
<span class="badge-demo__icon-wrap">
|
||||
<Mail />
|
||||
<SkyBadge class="badge-demo__icon-badge" small tone="success">
|
||||
5
|
||||
</SkyBadge>
|
||||
</span>
|
||||
<SkyIcon badge="5" :badge-colors="{ bg: '#22c55e' }" :size="28">
|
||||
<BadgeIosIcon name="envelope-fill" />
|
||||
</SkyIcon>
|
||||
</template>
|
||||
</SkyTabButton>
|
||||
<SkyTabButton
|
||||
@@ -90,12 +84,9 @@ const activeTab = ref('inbox')
|
||||
@click="activeTab = 'calendar'"
|
||||
>
|
||||
<template #icon>
|
||||
<span class="badge-demo__icon-wrap">
|
||||
<CalendarDays />
|
||||
<SkyBadge class="badge-demo__icon-badge" small tone="danger">
|
||||
7
|
||||
</SkyBadge>
|
||||
</span>
|
||||
<SkyIcon badge="7" :badge-colors="{ bg: '#ef4444' }" :size="28">
|
||||
<BadgeIosIcon name="calendar" />
|
||||
</SkyIcon>
|
||||
</template>
|
||||
</SkyTabButton>
|
||||
<SkyTabButton
|
||||
@@ -104,12 +95,9 @@ const activeTab = ref('inbox')
|
||||
@click="activeTab = 'upload'"
|
||||
>
|
||||
<template #icon>
|
||||
<span class="badge-demo__icon-wrap">
|
||||
<CloudUpload />
|
||||
<SkyBadge class="badge-demo__icon-badge" small tone="danger">
|
||||
1
|
||||
</SkyBadge>
|
||||
</span>
|
||||
<SkyIcon badge="1" :badge-colors="{ bg: '#ef4444' }" :size="28">
|
||||
<BadgeIosIcon name="cloud-upload-fill" />
|
||||
</SkyIcon>
|
||||
</template>
|
||||
</SkyTabButton>
|
||||
</SkyTabBar>
|
||||
@@ -136,31 +124,22 @@ const activeTab = ref('inbox')
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.badge-demo__icon-wrap {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.badge-demo__icon-wrap > svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.badge-demo__icon-badge {
|
||||
position: absolute;
|
||||
inset-block-start: -2px;
|
||||
inset-inline-end: -6px;
|
||||
.badge-demo__badge--gray {
|
||||
background: #6b7280;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.badge-demo__badge--green {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.badge-demo__badge--yellow {
|
||||
background: #eab308;
|
||||
}
|
||||
|
||||
.badge-demo__list-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: block;
|
||||
border-radius: 50%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -21,7 +21,10 @@ describe('BreadcrumbsDemo', () => {
|
||||
expect(popover.match(/\blink\b/g)).toHaveLength(3)
|
||||
expect(popover).not.toMatch(/\smenu(?:\s|=|>)/)
|
||||
expect(source).toMatch(/\.breadcrumbs-demo__menu\s*\{[^}]*width:\s*100%;/s)
|
||||
expect(source).not.toMatch(/width:\s*120px;/)
|
||||
expect(source).toContain('class="breadcrumbs-demo__popover"')
|
||||
expect(source).toMatch(
|
||||
/\.breadcrumbs-demo__popover :deep\(\.sky-popover__panel\)\s*\{[^}]*width:\s*120px;/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the collapsed popover usable through every close path', () => {
|
||||
|
||||
@@ -79,6 +79,7 @@ const popoverTarget = ref<HTMLElement | null>(null)
|
||||
<SkyPopover
|
||||
id="breadcrumbs-menu"
|
||||
aria-label="Hidden breadcrumbs"
|
||||
class="breadcrumbs-demo__popover"
|
||||
:opened="popoverOpened"
|
||||
:offset="0"
|
||||
role="region"
|
||||
@@ -101,6 +102,10 @@ const popoverTarget = ref<HTMLElement | null>(null)
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.breadcrumbs-demo__popover :deep(.sky-popover__panel) {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.breadcrumbs-demo__target {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user