ENH - align Sky UI with Konsta kitchen sink

This commit is contained in:
DerEchteAlec
2026-08-14 03:46:38 +02:00
parent aecadeec04
commit b59d46b252
87 changed files with 7497 additions and 752 deletions
@@ -0,0 +1,76 @@
import { readFileSync } from 'node:fs'
import { createSSRApp, h } from 'vue'
import { renderToString } from 'vue/server-renderer'
import { describe, expect, it } from 'vitest'
import SkyBreadcrumbs from './SkyBreadcrumbs.vue'
import SkyBreadcrumbsCollapsed from './SkyBreadcrumbsCollapsed.vue'
import SkyBreadcrumbsItem from './SkyBreadcrumbsItem.vue'
import SkyBreadcrumbsSeparator from './SkyBreadcrumbsSeparator.vue'
const controls = readFileSync(
new URL('../controls.css', import.meta.url),
'utf8',
)
describe('SkyBreadcrumbs', () => {
it('renders semantic current-page and collapsed navigation controls', async () => {
const html = await renderToString(
createSSRApp({
render: () =>
h(
SkyBreadcrumbs,
{ ariaLabel: 'Catalog breadcrumb' },
{
default: () => [
h(SkyBreadcrumbsItem, { component: 'button' }, () => 'Home'),
h(SkyBreadcrumbsSeparator),
h(SkyBreadcrumbsCollapsed, {
ariaControls: 'hidden-levels',
ariaLabel: 'Show hidden levels',
expanded: false,
}),
h(SkyBreadcrumbsSeparator),
h(SkyBreadcrumbsItem, { active: true }, () => 'iPhone 12'),
],
},
),
}),
)
expect(html).toContain('<nav')
expect(html).toContain('aria-label="Catalog breadcrumb"')
expect(html).toContain('aria-current="page"')
expect(html).toContain('aria-controls="hidden-levels"')
expect(html).toContain('aria-expanded="false"')
expect(html.match(/sky-breadcrumbs-collapsed__dot/g)).toHaveLength(3)
})
it('matches Konsta iOS geometry while retaining 44px hit boxes', () => {
expect(controls).toMatch(
/\.sky-breadcrumbs\s*\{[^}]*height:\s*var\(--sky-touch-target, 44px\)[^}]*gap:\s*12px[^}]*overflow-x:\s*auto[^}]*overflow-y:\s*hidden[^}]*margin-block:\s*-6px[^}]*padding:\s*10px 0[^}]*font-size:\s*17px[^}]*line-height:\s*24px/s,
)
expect(controls).toMatch(
/\.sky-breadcrumbs-separator\s*\{[^}]*width:\s*12px[^}]*min-width:\s*12px[^}]*opacity:\s*0\.35/s,
)
expect(controls).toMatch(
/\.sky-breadcrumbs-collapsed\s*\{[^}]*width:\s*var\(--sky-touch-target, 44px\)[^}]*height:\s*var\(--sky-touch-target, 44px\)[^}]*margin-inline:\s*-7px[^}]*padding:\s*0/s,
)
expect(controls).toMatch(
/\.sky-breadcrumbs-collapsed::before\s*\{[^}]*width:\s*30px[^}]*height:\s*17px[^}]*background:\s*rgba\(0, 0, 0, 0\.15\)/s,
)
expect(controls).toMatch(
/\.sky-app-page--dark \.sky-breadcrumbs-collapsed::before\s*\{[^}]*background:\s*rgba\(255, 255, 255, 0\.15\)/s,
)
})
it('keeps rounded outline segments rounded at both ends', () => {
expect(controls).toMatch(
/\.sky-segmented--outline\.sky-segmented--rounded[\s\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\)/,
)
expect(controls).toMatch(
/\.sky-segmented--outline\.sky-segmented--rounded[\s\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\)/,
)
})
})
@@ -0,0 +1,69 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { createSSRApp, h } from 'vue'
import { renderToString } from 'vue/server-renderer'
import { describe, expect, it } from 'vitest'
import SkyButton from './SkyButton.vue'
describe('SkyButton', () => {
it('renders a native button with the requested variants', async () => {
const html = await renderToString(
createSSRApp({
render: () =>
h(
SkyButton,
{ outline: true, rounded: true, tonal: true, type: 'submit' },
() => 'Continue',
),
}),
)
expect(html).toContain('<button')
expect(html).toContain('type="submit"')
expect(html).toContain('sky-button--outline')
expect(html).toContain('sky-button--rounded')
expect(html).toContain('sky-button--tonal')
expect(html).toContain('Continue')
})
it('keeps focus and pressed feedback on the contextual accent', () => {
const uiDirectory = fileURLToPath(new URL('..', import.meta.url))
const controls = readFileSync(`${uiDirectory}/controls.css`, 'utf8')
const buttonStyles = controls.slice(
controls.indexOf('.sky-button:focus-visible'),
controls.indexOf('.sky-badge'),
)
expect(buttonStyles).toMatch(
/\.sky-button:focus-visible[\s\S]*?outline: 2px solid var\(--sky-app-accent, #007aff\)/,
)
expect(buttonStyles).toMatch(
/\.sky-button--primary:active:not\(:disabled\)\s*\{\s*background: var\(--sky-app-accent, #007aff\);\s*filter: brightness\(0\.86\)/,
)
expect(buttonStyles).toMatch(
/\.sky-button--tonal:active:not\(:disabled\)\s*\{\s*background: var\(--sky-app-accent-soft, rgba\(0, 122, 255, 0\.15\)\);\s*filter: brightness\(0\.92\)/,
)
expect(buttonStyles).not.toContain('--sky-app-accent-shade')
})
it('keeps outline hover border, surface, and text in one color state', () => {
const uiDirectory = fileURLToPath(new URL('..', import.meta.url))
const controls = readFileSync(`${uiDirectory}/controls.css`, 'utf8')
const buttonStyles = controls.slice(
controls.indexOf('.sky-button:focus-visible'),
controls.indexOf('.sky-badge'),
)
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;/,
)
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;/,
)
expect(buttonStyles).toContain(
'color var(--sky-transition-fast, 100ms) ease,',
)
})
})
+62
View File
@@ -0,0 +1,62 @@
import { readFileSync } from 'node:fs'
import { createSSRApp, h } from 'vue'
import { renderToString } from 'vue/server-renderer'
import { describe, expect, it } from 'vitest'
import SkyChip from './SkyChip.vue'
const controls = readFileSync(
new URL('../controls.css', import.meta.url),
'utf8',
)
describe('SkyChip', () => {
it('matches Konsta iOS chip geometry and colors', () => {
expect(controls).toMatch(
/\.sky-chip\s*\{[^}]*height:\s*28px[^}]*min-height:\s*28px[^}]*padding:\s*0 12px[^}]*border:\s*0[^}]*background:\s*rgba\(0, 0, 0, 0\.1\)[^}]*font-size:\s*14px[^}]*font-weight:\s*400/s,
)
expect(controls).toMatch(
/\.sky-app-page--dark \.sky-chip:not\(\.sky-chip--outline\):not\(\.sky-chip--selected\)\s*\{[^}]*background:\s*rgba\(255, 255, 255, 0\.1\)/s,
)
expect(controls).toMatch(
/\.sky-chip--outline\s*\{[^}]*border:\s*1px solid\s*var\(--sky-chip-outline-border, rgba\(0, 0, 0, 0\.2\)\)[^}]*background:\s*transparent[^}]*color:\s*var\(--sky-chip-outline-text, currentColor\)/s,
)
})
it('matches Konsta media and delete geometry while retaining 44px hit areas', () => {
expect(controls).toMatch(
/\.sky-chip__media\s*\{[^}]*margin-block:\s*-4px[^}]*margin-inline-end:\s*4px[^}]*margin-inline-start:\s*-12px/s,
)
expect(controls).toMatch(
/\.sky-chip__delete\s*\{[^}]*width:\s*24px[^}]*height:\s*28px[^}]*margin-inline-end:\s*-8px[^}]*margin-inline-start:\s*4px/s,
)
expect(controls).toMatch(
/\.sky-chip__delete::before\s*\{[^}]*inset:\s*-8px -10px/s,
)
expect(controls).toMatch(
/button\.sky-chip::before,[\s\S]*?a\.sky-chip::before\s*\{[^}]*inset:\s*-8px -4px/s,
)
})
it('renders one accessible delete action with the Konsta delete icon', async () => {
const html = await renderToString(
createSSRApp({
render: () =>
h(
SkyChip,
{
deleteButton: true,
deleteLabel: 'Delete Adam Smith',
},
() => 'Adam Smith',
),
}),
)
expect(html).toContain('sky-chip--with-delete')
expect(html).toContain('aria-label="Delete Adam Smith"')
expect(html).toContain('sky-chip__delete-icon')
expect(html.match(/role="button"/g)).toHaveLength(1)
})
})
+3 -1
View File
@@ -1,6 +1,8 @@
<script setup lang="ts">
import { computed } from 'vue'
import SkyChipDeleteIcon from './SkyChipDeleteIcon.vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
@@ -104,7 +106,7 @@ function handleDelete(event: KeyboardEvent | MouseEvent): void {
@keydown.space.prevent.stop="handleDelete"
>
<slot name="delete">
<span class="sky-chip__delete-icon" aria-hidden="true" />
<SkyChipDeleteIcon />
</slot>
</span>
</component>
@@ -0,0 +1,14 @@
<template>
<svg
class="sky-chip__delete-icon"
width="16"
height="16"
viewBox="0 0 28 28"
fill="currentColor"
aria-hidden="true"
>
<path
d="M14 0C21.732 0 28 6.268 28 14S21.732 28 14 28 0 21.732 0 14 6.268 0 14 0Zm4.939 6.939L14 11.879 9.061 6.939 6.939 9.061 11.879 14l-4.94 4.939 2.122 2.122L14 16.121l4.939 4.94 2.122-2.122L16.121 14l4.94-4.939-2.122-2.122Z"
/>
</svg>
</template>
+58
View File
@@ -0,0 +1,58 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { createSSRApp, h } from 'vue'
import { renderToString } from 'vue/server-renderer'
import { describe, expect, it } from 'vitest'
import SkyFab from './SkyFab.vue'
describe('SkyFab', () => {
it('composes the iOS glass layers with native button semantics', async () => {
const html = await renderToString(
createSSRApp({
render: () =>
h(
SkyFab,
{ ariaLabel: 'Create', text: 'Create' },
{ icon: () => '+' },
),
}),
)
expect(html).toContain('<button')
expect(html).toContain('type="button"')
expect(html).toContain('aria-label="Create"')
expect(html).toContain('sky-glass')
expect(html).toContain('sky-fab__accent-layer')
expect(html).toContain('sky-fab__dark-accent-layer')
expect(html).toContain('sky-fab__surface-layer')
expect(html).toContain('Create')
})
it('keeps every FAB shadow layer on the contextual accent', () => {
const uiDirectory = fileURLToPath(new URL('..', import.meta.url))
const controls = readFileSync(`${uiDirectory}/controls.css`, 'utf8')
const tokens = readFileSync(`${uiDirectory}/tokens.css`, 'utf8')
const fabStyles = controls.slice(
controls.indexOf('.sky-glass.sky-fab {'),
controls.indexOf('.sky-glass {'),
)
const fabTokens = tokens.slice(
tokens.indexOf('--sky-shadow-glass-fab:'),
tokens.indexOf('--sky-shadow-glass-thumb:'),
)
expect(fabStyles).toContain('background: var(--sky-app-accent, #007aff)')
expect(fabStyles).toMatch(
/\.sky-fab__surface-layer\s*\{[\s\S]*?var\(--sky-fab-accent-inset-start\) var\(--sky-app-accent, #007aff\)/,
)
expect(fabStyles).toMatch(
/\.sky-fab__dark-accent-layer\s*\{[\s\S]*?inset 0 -5px 5px var\(--sky-app-accent, #007aff\)/,
)
expect(fabStyles).not.toContain('--sky-app-accent-shade')
expect(fabStyles).not.toContain('scale(0.96)')
expect(fabTokens).toContain('--sky-fab-accent-inset-start')
expect(fabTokens).not.toContain('rgba(10, 132, 255, 0.25)')
})
})
+12 -3
View File
@@ -1,6 +1,8 @@
<script setup lang="ts">
import { computed, useSlots } from 'vue'
import SkyGlass from './SkyGlass.vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
@@ -59,17 +61,24 @@ function handleClick(event: MouseEvent): void {
</script>
<template>
<component
:is="component"
<SkyGlass
v-bind="{ ...$attrs, ...elementProps }"
:component="component"
:disabled="disabled"
:href="href"
:type="type"
class="sky-fab"
:class="{
'sky-fab--disabled': disabled,
'sky-fab--icon-only': !hasText,
'sky-fab--with-text': hasText,
}"
role="button"
@click="handleClick"
>
<span class="sky-fab__accent-layer" aria-hidden="true"></span>
<span class="sky-fab__dark-accent-layer" aria-hidden="true"></span>
<span class="sky-fab__surface-layer" aria-hidden="true"></span>
<span v-if="hasText && textPosition === 'before'" class="sky-fab__text">
{{ text }}<slot name="text" />
</span>
@@ -80,5 +89,5 @@ function handleClick(event: MouseEvent): void {
{{ text }}<slot name="text" />
</span>
<slot />
</component>
</SkyGlass>
</template>
+94 -1
View File
@@ -1,10 +1,12 @@
import { readFileSync } from 'node:fs'
import { createSSRApp, h } from 'vue'
import { renderToString } from 'vue/server-renderer'
import { describe, expect, it } from 'vitest'
import SkyField from '@/ui/controls/SkyField.vue'
describe('SkyField numeric constraints', () => {
describe('SkyField', () => {
it('forwards min, max, and step to the native input', async () => {
const app = createSSRApp(SkyField, {
ariaLabel: 'Primary frequency',
@@ -73,6 +75,97 @@ describe('SkyField numeric constraints', () => {
expect(input).toContain('spellcheck="false"')
})
it('renders a native date input without changing its accessible label', async () => {
const app = createSSRApp(SkyField, {
id: 'birthday',
label: 'Birthday',
modelValue: '2014-04-30',
type: 'date',
})
const html = await renderToString(app)
expect(html).toContain('<label class="sky-field__label" for="birthday"')
expect(html).toMatch(
/<input[^>]+id="birthday"[^>]+type="date"[^>]+value="2014-04-30"/,
)
})
it('renders typed select options and native options from the default slot', async () => {
const options = [
{ label: 'Male', value: 'male' },
{ disabled: true, label: 'Unavailable', value: 0 },
] as const
const app = createSSRApp({
render: () =>
h(
SkyField,
{
ariaLabel: 'Gender',
modelValue: 'male',
options,
placeholder: 'Please choose...',
type: 'select',
},
{
default: () => h('option', { value: 'custom' }, 'Custom'),
},
),
})
const html = await renderToString(app)
const select = html.match(/<select[^>]+>/)?.[0] ?? ''
expect(select).toContain('class="sky-field__input sky-field__select"')
expect(select).toContain('aria-label="Gender"')
expect(select).toContain('value="male"')
expect(html).toContain(
'<option disabled value="">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('raises floating labels only after the field has a value', async () => {
const emptyApp = createSSRApp(SkyField, {
floatingLabel: true,
label: 'Name',
placeholder: 'Your name',
})
const valuedApp = createSSRApp(SkyField, {
floatingLabel: true,
label: 'Name',
modelValue: 'Sky',
placeholder: 'Your name',
})
const emptyHtml = await renderToString(emptyApp)
const valuedHtml = await renderToString(valuedApp)
expect(emptyHtml).toContain('sky-field--floating-label')
expect(emptyHtml).not.toContain('sky-field--floating-raised')
expect(valuedHtml).toContain('sky-field--floating-label')
expect(valuedHtml).toContain('sky-field--floating-raised')
})
it('keeps floating-label controls touch-sized and reduced-motion aware', () => {
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,
)
expect(controls).toMatch(
/\.sky-field--floating-label:not\(\.sky-field--inline\) \.sky-field__control\s*\{[^}]*min-height:\s*58px;[^}]*padding-top:\s*14px/s,
)
expect(controls).toMatch(
/@media \(prefers-reduced-motion: reduce\)\s*\{[^}]*\.sky-field__label/s,
)
})
it('renders an explicitly labelled clear control only for a non-empty value', async () => {
const app = createSSRApp(SkyField, {
clearButton: true,
+66 -5
View File
@@ -1,11 +1,16 @@
<script setup lang="ts">
import { computed, useId, useSlots, type CSSProperties } from 'vue'
import { computed, ref, useId, useSlots, type CSSProperties } from 'vue'
import { useComposedFieldValue } from '@/ui/controls/useComposedFieldValue'
defineOptions({ inheritAttrs: false })
type FieldValue = number | string
type FieldOption = {
disabled?: boolean
label: string
value: FieldValue
}
const props = withDefaults(
defineProps<{
@@ -23,6 +28,7 @@ const props = withDefaults(
clearLabel?: string
disabled?: boolean
error?: string
floatingLabel?: boolean
help?: string
id?: string
inputMode?:
@@ -43,6 +49,7 @@ const props = withDefaults(
modelValue?: FieldValue
name?: string
outline?: boolean
options?: readonly FieldOption[]
pattern?: string
placeholder?: string
readonly?: boolean
@@ -51,11 +58,13 @@ const props = withDefaults(
spellcheck?: boolean
step?: number | string
type?:
| 'date'
| 'datetime-local'
| 'email'
| 'number'
| 'password'
| 'search'
| 'select'
| 'tel'
| 'text'
| 'textarea'
@@ -72,6 +81,7 @@ const props = withDefaults(
clearLabel: '',
disabled: false,
error: '',
floatingLabel: false,
help: '',
id: undefined,
inputMode: undefined,
@@ -84,6 +94,7 @@ const props = withDefaults(
modelValue: undefined,
name: undefined,
outline: false,
options: () => [],
pattern: undefined,
placeholder: '',
readonly: false,
@@ -107,6 +118,7 @@ const emit = defineEmits<{
const generatedId = useId()
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`)
@@ -126,6 +138,12 @@ const hasClearButton = computed(
Boolean(props.clearLabel) &&
localValue.value.length > 0,
)
const isFloatingRaised = computed(
() =>
Boolean(props.label) &&
props.floatingLabel &&
(isFocused.value || localValue.value.length > 0),
)
const describedBy = computed(() => {
const ids: string[] = []
@@ -138,6 +156,7 @@ function fieldValue(event: Event): string {
const target = event.target
if (
!(target instanceof HTMLInputElement) &&
!(target instanceof HTMLSelectElement) &&
!(target instanceof HTMLTextAreaElement)
) {
return ''
@@ -155,6 +174,16 @@ function handleCompositionEnd(event: CompositionEvent): void {
endComposition(fieldValue(event))
}
function handleFocus(event: FocusEvent): void {
isFocused.value = true
emit('focus', event)
}
function handleBlur(event: FocusEvent): void {
isFocused.value = false
emit('blur', event)
}
function clear(): void {
if (props.disabled || props.readonly) return
clearValue()
@@ -169,6 +198,8 @@ function clear(): void {
: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),
@@ -200,13 +231,43 @@ function clear(): void {
:spellcheck="spellcheck"
:style="inputStyle"
:value="localValue"
@blur="emit('blur', $event)"
@blur="handleBlur"
@change="emit('change', $event)"
@compositionend="handleCompositionEnd"
@compositionstart="startComposition"
@focus="emit('focus', $event)"
@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"
>
<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"
>
{{ option.label }}
</option>
<slot />
</select>
<input
v-else
:id="inputId"
@@ -232,11 +293,11 @@ function clear(): void {
:style="inputStyle"
:type="type"
:value="localValue"
@blur="emit('blur', $event)"
@blur="handleBlur"
@change="emit('change', $event)"
@compositionend="handleCompositionEnd"
@compositionstart="startComposition"
@focus="emit('focus', $event)"
@focus="handleFocus"
@input="handleInput"
/>
<span v-if="$slots.trailing" class="sky-field__trailing">
+1
View File
@@ -12,6 +12,7 @@ const props = withDefaults(
| 'button'
| 'div'
| 'label'
| 'nav'
| 'section'
| 'span'
disabled?: boolean
+51
View File
@@ -0,0 +1,51 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { createSSRApp, h } from 'vue'
import { renderToString } from 'vue/server-renderer'
import { describe, expect, it } from 'vitest'
import SkyRadio from './SkyRadio.vue'
describe('SkyRadio', () => {
it('keeps native radio semantics and accessible label wiring', async () => {
const html = await renderToString(
createSSRApp({
render: () =>
h(
SkyRadio,
{
modelValue: 'apps',
name: 'view',
value: 'apps',
},
{ default: () => 'Apps' },
),
}),
)
expect(html).toContain('type="radio"')
expect(html).toContain('checked')
expect(html).toContain('name="view"')
expect(html).toContain('value="apps"')
expect(html).toContain('aria-labelledby=')
expect(html).toContain('sky-radio--checked')
expect(html).toContain('Apps')
})
it('uses the 22px mark footprint without shrinking the 44px input target', () => {
const uiDirectory = fileURLToPath(new URL('..', import.meta.url))
const controls = readFileSync(`${uiDirectory}/controls.css`, 'utf8')
const radioStyles = controls.slice(
controls.indexOf('.sky-radio {'),
controls.indexOf('.sky-range {'),
)
expect(radioStyles).toMatch(
/\.sky-radio\s*\{\s*min-width: 22px;\s*min-height: 22px/,
)
expect(radioStyles).toMatch(
/\.sky-radio__input\s*\{[\s\S]*?width: var\(--sky-touch-target, 44px\)[\s\S]*?height: var\(--sky-touch-target, 44px\)[\s\S]*?inset-inline-start: -11px/,
)
})
})
@@ -45,6 +45,27 @@ async function renderNavigation(activeIndex = 1): Promise<string> {
}
describe('SkySegmented navigation', () => {
it('keeps ordinary iOS segments at Konsta height and Strong colors', () => {
expect(controls).toMatch(
/\.sky-segmented\s*\{[^}]*height:\s*34px[^}]*min-height:\s*34px/s,
)
expect(controls).toMatch(
/\.sky-segmented-button\s*\{[^}]*height:\s*34px[^}]*min-height:\s*34px[^}]*padding:\s*0 8px[^}]*font-size:\s*15px[^}]*font-weight:\s*500/s,
)
expect(controls).toMatch(
/\.sky-segmented--strong:not\(\.sky-segmented--navigation\):not\([\s\S]*?height:\s*38px[^}]*padding:\s*2px[^}]*background:\s*rgba\(0, 0, 0, 0\.05\)/,
)
expect(controls).toMatch(
/\.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\)/,
)
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\)/,
)
})
it('renders the Konsta iOS glass stack and one moving highlight', async () => {
const html = await renderNavigation()
@@ -144,6 +165,21 @@ describe('SkySegmented navigation', () => {
)
})
it('keeps Navbar segmented visuals at Konsta size with 44px targets', () => {
expect(controls).toMatch(
/\.sky-glass\.sky-segmented--navbar\s*\{[^}]*height:\s*var\(--sky-touch-target, 44px\)[^}]*gap:\s*4px[^}]*padding:\s*0 4px/s,
)
expect(controls).toMatch(
/\.sky-segmented--navbar \.sky-segmented-button\s*\{[^}]*min-height:\s*var\(--sky-touch-target, 44px\)[^}]*padding:\s*0 8px[^}]*font-size:\s*15px[^}]*font-weight:\s*500/s,
)
expect(controls).toMatch(
/\.sky-segmented--navbar \.sky-segmented__highlight\s*\{[^}]*top:\s*5px[^}]*bottom:\s*5px[^}]*background:\s*#ffffff/s,
)
expect(controls).toMatch(
/\.sky-app-page--dark \.sky-segmented--navbar \.sky-segmented__highlight\s*\{[^}]*background:\s*rgba\(255, 255, 255, 0\.75\)/s,
)
})
it('calculates the same sliding pill for full-width five-item navigation', async () => {
const html = await renderToString(
createSSRApp({
+9 -4
View File
@@ -34,13 +34,17 @@ const props = withDefaults(
)
const insideNavbar = useSkyNavbar()
const isNavigation = computed(() => props.navigation ?? insideNavbar)
const rootComponent = computed(() => (isNavigation.value ? SkyGlass : 'div'))
const isNavbarSegmented = computed(
() => insideNavbar && typeof props.navigation === 'undefined',
)
const isNavigation = computed(() => props.navigation === true)
const usesGlass = computed(() => isNavigation.value || isNavbarSegmented.value)
const rootComponent = computed(() => (usesGlass.value ? SkyGlass : 'div'))
const indicatorStyle = computed<CSSProperties | undefined>(() => {
const itemCount = Number.isFinite(props.itemCount)
? Math.max(0, Math.floor(props.itemCount))
: 0
if (!isNavigation.value || !props.strong || itemCount === 0) return undefined
if (!usesGlass.value || !props.strong || itemCount === 0) return undefined
const requestedIndex = Number.isFinite(props.activeIndex)
? Math.floor(props.activeIndex)
@@ -63,10 +67,11 @@ const indicatorStyle = computed<CSSProperties | undefined>(() => {
<component
:is="rootComponent"
v-bind="$attrs"
:highlight="isNavigation ? glassHighlight : undefined"
:highlight="usesGlass ? glassHighlight : undefined"
class="sky-segmented"
:class="{
'sky-segmented--navigation': isNavigation,
'sky-segmented--navbar': isNavbarSegmented,
'sky-segmented--compact': compact,
'sky-segmented--outline': outline,
'sky-segmented--raised': raised,
@@ -0,0 +1,76 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { createSSRApp, h } from 'vue'
import { renderToString } from 'vue/server-renderer'
import { describe, expect, it } from 'vitest'
import SkyStepper from './SkyStepper.vue'
describe('SkyStepper', () => {
it('matches Konsta iOS small, medium and large geometry', () => {
const uiDirectory = fileURLToPath(new URL('..', import.meta.url))
const controls = readFileSync(`${uiDirectory}/controls.css`, 'utf8')
expect(controls).toMatch(
/\.sky-stepper\s*\{[^}]*height:\s*34px[^}]*min-height:\s*34px/s,
)
expect(controls).toMatch(
/\.sky-stepper__button\s*\{[^}]*width:\s*40px[^}]*min-width:\s*40px[^}]*height:\s*34px[^}]*min-height:\s*34px/s,
)
expect(controls).toMatch(
/\.sky-stepper__value\s*\{[^}]*width:\s*44px[^}]*min-width:\s*44px[^}]*height:\s*34px[^}]*min-height:\s*34px/s,
)
expect(controls).toMatch(
/\.sky-stepper--small\s*\{[^}]*height:\s*28px[^}]*min-height:\s*28px/s,
)
expect(controls).toMatch(
/\.sky-stepper--large\s*\{[^}]*height:\s*48px[^}]*min-height:\s*48px/s,
)
})
it('renders a native, labelled text input between the step buttons', async () => {
const html = await renderToString(
createSSRApp({
render: () =>
h(SkyStepper, {
decrementLabel: 'Decrease value',
incrementLabel: 'Increase value',
input: true,
inputLabel: 'Stepper value',
modelValue: 1,
}),
}),
)
expect(html).toContain('sky-stepper--input')
expect(html).toContain('aria-label="Decrease value"')
expect(html).toContain('aria-label="Stepper value"')
expect(html).toContain('aria-label="Increase value"')
expect(html).toContain('value="1"')
})
it('keeps the value and input on the contextual accent', () => {
const uiDirectory = fileURLToPath(new URL('..', import.meta.url))
const controls = readFileSync(`${uiDirectory}/controls.css`, 'utf8')
expect(controls).toMatch(
/\.sky-stepper__button\s*\{[\s\S]*?background: var\(--sky-app-accent, #007aff\);[\s\S]*?color: #ffffff;/,
)
expect(controls).toMatch(
/\.sky-stepper__value\s*\{[\s\S]*?border-top: 2px solid var\(--sky-app-accent, #007aff\);[\s\S]*?border-bottom: 2px solid var\(--sky-app-accent, #007aff\);[\s\S]*?color: var\(--sky-app-accent, #007aff\);[\s\S]*?caret-color: var\(--sky-app-accent, #007aff\);/,
)
expect(controls).toMatch(
/\.sky-stepper--outline \.sky-stepper__button\s*\{[\s\S]*?border: 2px solid var\(--sky-app-accent, #007aff\);[\s\S]*?background: transparent;[\s\S]*?color: var\(--sky-app-accent, #007aff\);/,
)
expect(controls).toMatch(
/\.sky-stepper__decrement\s*\{[\s\S]*?border-start-start-radius: inherit;[\s\S]*?border-end-start-radius: inherit;/,
)
expect(controls).toMatch(
/\.sky-stepper__increment\s*\{[\s\S]*?border-start-end-radius: inherit;[\s\S]*?border-end-end-radius: inherit;/,
)
expect(controls).not.toMatch(
/\.sky-stepper__value\s*\{[\s\S]*?color: inherit;/,
)
})
})
@@ -93,4 +93,19 @@ describe('SkyToggle', () => {
expect(reducedMotion).toContain('.sky-toggle__thumb-wrap')
expect(reducedMotion).toContain('transform: translateX(-22px)')
})
it('keeps trailing list toggles compact without shrinking their touch target', () => {
const uiDirectory = fileURLToPath(new URL('..', import.meta.url))
const controls = readFileSync(`${uiDirectory}/controls.css`, 'utf8')
expect(controls).toMatch(
/\.sky-list-item__after > \.sky-toggle\s*\{\s*margin-block: -8px;/,
)
expect(controls).toMatch(
/\.sky-toggle,\s*\.sky-radio\s*\{[\s\S]*?min-height: var\(--sky-touch-target, 44px\)/,
)
expect(controls).toMatch(
/\.sky-list-item__row\s*\{[\s\S]*?padding: 12px 16px;/,
)
})
})
@@ -0,0 +1,55 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { createSSRApp, h } from 'vue'
import { renderToString } from 'vue/server-renderer'
import { describe, expect, it } from 'vitest'
import SkyToolbar from './SkyToolbar.vue'
import SkyToolbarPane from './SkyToolbarPane.vue'
describe('SkyToolbar', () => {
it('renders separate fade layers and glass panes', async () => {
const html = await renderToString(
createSSRApp({
render: () =>
h(SkyToolbar, { ariaLabel: 'Actions', component: 'nav' }, () => [
h(SkyToolbarPane, {}, () => 'Link 1'),
h(SkyToolbarPane, {}, () => 'Link 2'),
]),
}),
)
expect(html).toContain('role="toolbar"')
expect(html).toContain('aria-label="Actions"')
expect(html).toContain('sky-toolbar__blur')
expect(html).toContain('sky-toolbar__background')
expect(html.match(/sky-toolbar-pane/g)).toHaveLength(2)
expect(html.match(/sky-glass/g)?.length).toBeGreaterThanOrEqual(2)
})
it('matches the Konsta iOS pane and background geometry', () => {
const uiDirectory = fileURLToPath(new URL('..', import.meta.url))
const controls = readFileSync(`${uiDirectory}/controls.css`, 'utf8')
const toolbarStyles = controls.slice(
controls.indexOf('.sky-toolbar {'),
controls.indexOf('.sky-visually-hidden'),
)
expect(toolbarStyles).toMatch(
/\.sky-toolbar__inner\s*\{[\s\S]*?justify-content: space-between;[\s\S]*?gap: 16px;/,
)
expect(toolbarStyles).toMatch(
/\.sky-toolbar\s*\{[\s\S]*?padding-right: calc\(var\(--sky-safe-area-right, 0px\) \+ 16px\);[\s\S]*?padding-left: calc\(var\(--sky-safe-area-left, 0px\) \+ 16px\);/,
)
expect(toolbarStyles).toMatch(
/\.sky-toolbar-pane\s*\{[\s\S]*?height: 48px;[\s\S]*?border-radius: var\(--sky-radius-pill, 999px\);/,
)
expect(toolbarStyles).toMatch(
/\.sky-toolbar--top \.sky-toolbar__blur,[\s\S]*?\.sky-toolbar--top \.sky-toolbar__background\s*\{\s*display: none;/,
)
expect(controls).toMatch(
/\.sky-toolbar__blur\s*\{[\s\S]*?backdrop-filter: blur\(2px\);[\s\S]*?mask-image: linear-gradient/,
)
})
})
+1
View File
@@ -43,6 +43,7 @@ withDefaults(
:aria-orientation="tabbar ? undefined : 'horizontal'"
:role="tabbar ? 'tablist' : 'toolbar'"
>
<span class="sky-toolbar__blur" aria-hidden="true" />
<span class="sky-toolbar__background" :class="bgClass" aria-hidden="true" />
<div class="sky-toolbar__inner" :class="innerClass">
<slot />
+4 -2
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import SkyGlass from './SkyGlass.vue'
defineOptions({ inheritAttrs: false })
withDefaults(
@@ -12,7 +14,7 @@ withDefaults(
</script>
<template>
<component :is="component" v-bind="$attrs" class="sky-toolbar-pane">
<SkyGlass :component="component" v-bind="$attrs" class="sky-toolbar-pane">
<slot />
</component>
</SkyGlass>
</template>
+1
View File
@@ -11,6 +11,7 @@ export { default as SkyButton } from './SkyButton.vue'
export { default as SkyCard } from './SkyCard.vue'
export { default as SkyCheckbox } from './SkyCheckbox.vue'
export { default as SkyChip } from './SkyChip.vue'
export { default as SkyChipDeleteIcon } from './SkyChipDeleteIcon.vue'
export { default as SkyFab } from './SkyFab.vue'
export { default as SkyField } from './SkyField.vue'
export { default as SkyGlass } from './SkyGlass.vue'