mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 17:23:25 +00:00
ENH - align Sky UI with Konsta kitchen sink
This commit is contained in:
@@ -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,',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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)')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -12,6 +12,7 @@ const props = withDefaults(
|
||||
| 'button'
|
||||
| 'div'
|
||||
| 'label'
|
||||
| 'nav'
|
||||
| 'section'
|
||||
| 'span'
|
||||
disabled?: boolean
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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/,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user