mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 09:13:24 +00:00
ENH - merge latest dev into weather app
Integrates the current dev branch while preserving the weather-app Dynamic Island alongside the new hardware volume HUD and homescreen editing state. Makes the new Companies contract tests line-ending agnostic so the merged suite passes on Windows checkouts.
This commit is contained in:
@@ -89,10 +89,15 @@ function updateHighlightPosition(event: PointerEvent): void {
|
||||
return
|
||||
}
|
||||
|
||||
const bounds = root.value?.getBoundingClientRect()
|
||||
if (!bounds) return
|
||||
highlightX.value = `${Math.max(0, Math.min(bounds.width, event.clientX - bounds.left))}px`
|
||||
highlightY.value = `${Math.max(0, Math.min(bounds.height, event.clientY - bounds.top))}px`
|
||||
const element = root.value
|
||||
const bounds = element?.getBoundingClientRect()
|
||||
if (!element || !bounds) return
|
||||
const scaleX = bounds.width > 0 ? element.offsetWidth / bounds.width : 1
|
||||
const scaleY = bounds.height > 0 ? element.offsetHeight / bounds.height : 1
|
||||
const localX = (event.clientX - bounds.left) * scaleX
|
||||
const localY = (event.clientY - bounds.top) * scaleY
|
||||
highlightX.value = `${Math.max(0, Math.min(element.offsetWidth, localX))}px`
|
||||
highlightY.value = `${Math.max(0, Math.min(element.offsetHeight, localY))}px`
|
||||
highlightVisible.value = true
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(new URL('./SkyGlass.vue', import.meta.url), 'utf8')
|
||||
|
||||
describe('SkyGlass pointer geometry contract', () => {
|
||||
it('maps viewport pointer coordinates into the scaled element layout', () => {
|
||||
expect(source).toContain('element.offsetWidth / bounds.width')
|
||||
expect(source).toContain('element.offsetHeight / bounds.height')
|
||||
expect(source).toContain('(event.clientX - bounds.left) * scaleX')
|
||||
expect(source).toContain('(event.clientY - bounds.top) * scaleY')
|
||||
expect(source).not.toContain(
|
||||
'Math.min(bounds.width, event.clientX - bounds.left)',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -244,6 +244,10 @@
|
||||
background: rgba(120, 120, 128, 0.38);
|
||||
}
|
||||
|
||||
.sky-sheet [data-sky-sheet-drag-handle] {
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.sky-action-sheet__panel {
|
||||
max-width: 448px;
|
||||
margin: 0 auto;
|
||||
@@ -378,6 +382,10 @@
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.sky-dialog-button.sky-button {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.sky-dialog-button:disabled {
|
||||
background: var(--sky-pressed);
|
||||
color: var(--sky-subtle);
|
||||
|
||||
@@ -19,6 +19,10 @@ async function renderDialogButton(
|
||||
describe('SkyDialogButton', () => {
|
||||
it('uses the Konsta iOS tonal button for a regular action', async () => {
|
||||
const html = await renderDialogButton()
|
||||
const overlays = readFileSync(
|
||||
new URL('../overlays.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
expect(html).toContain('<button')
|
||||
expect(html).toContain('type="button"')
|
||||
@@ -28,6 +32,9 @@ describe('SkyDialogButton', () => {
|
||||
expect(html).toContain('sky-button--rounded')
|
||||
expect(html).toContain('sky-button--tonal')
|
||||
expect(html).not.toContain('sky-dialog-button--strong')
|
||||
expect(overlays).toMatch(
|
||||
/\.sky-dialog-button\.sky-button\s*\{\s*color:\s*#fff;/,
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the Konsta iOS filled button for a strong action', async () => {
|
||||
|
||||
@@ -23,6 +23,9 @@ describe('SkyDropdown', () => {
|
||||
it('supports checked, submenu, destructive, disabled, and divided items', () => {
|
||||
expect(component).toContain("'menuitemradio'")
|
||||
expect(component).toContain(':aria-checked=')
|
||||
expect(component).toContain("section.group ? 'group' : 'presentation'")
|
||||
expect(component).toContain('section.group ? section.label : undefined')
|
||||
expect(component).toContain('groupLabel?: string')
|
||||
expect(component).toContain(':aria-haspopup=')
|
||||
expect(component).toContain('item.destructive')
|
||||
expect(component).toContain('item.disabled')
|
||||
@@ -35,9 +38,7 @@ describe('SkyDropdown', () => {
|
||||
expect(overlays).toMatch(
|
||||
/\.sky-dropdown__item\s*\{[^}]*min-height:\s*var\(--sky-touch-target, 44px\)/s,
|
||||
)
|
||||
expect(overlays).toMatch(
|
||||
/\.sky-dropdown__menu\s*\{[^}]*padding:\s*6px;/s,
|
||||
)
|
||||
expect(overlays).toMatch(/\.sky-dropdown__menu\s*\{[^}]*padding:\s*6px;/s)
|
||||
expect(overlays).toMatch(
|
||||
/\.sky-dropdown__item\s*\{[^}]*border-radius:\s*12px;/s,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Check, ChevronRight } from 'lucide-vue-next'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import SkyPopover from './SkyPopover.vue'
|
||||
|
||||
@@ -9,13 +10,15 @@ interface SkyDropdownItem {
|
||||
checked?: boolean
|
||||
destructive?: boolean
|
||||
disabled?: boolean
|
||||
group?: string
|
||||
groupLabel?: string
|
||||
id: string
|
||||
label: string
|
||||
separatorBefore?: boolean
|
||||
submenu?: boolean
|
||||
}
|
||||
|
||||
withDefaults(
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
items: readonly SkyDropdownItem[]
|
||||
label: string
|
||||
@@ -34,6 +37,33 @@ const emit = defineEmits<{
|
||||
positionerror: [reason: string]
|
||||
select: [id: string, event: MouseEvent]
|
||||
}>()
|
||||
|
||||
const menuSections = computed(() => {
|
||||
const sections: Array<{
|
||||
group: string | null
|
||||
items: SkyDropdownItem[]
|
||||
key: string
|
||||
label?: string
|
||||
}> = []
|
||||
|
||||
props.items.forEach((item) => {
|
||||
const group = item.group ?? null
|
||||
const previous = sections[sections.length - 1]
|
||||
if (!previous || previous.group !== group) {
|
||||
sections.push({
|
||||
group,
|
||||
items: [item],
|
||||
key: `${group ?? 'items'}:${sections.length}`,
|
||||
label: item.groupLabel,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
previous.items.push(item)
|
||||
})
|
||||
|
||||
return sections
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -50,33 +80,40 @@ const emit = defineEmits<{
|
||||
@positionerror="emit('positionerror', $event)"
|
||||
>
|
||||
<div class="sky-dropdown__menu">
|
||||
<button
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="sky-dropdown__item"
|
||||
:class="{
|
||||
'sky-dropdown__item--destructive': item.destructive,
|
||||
'sky-dropdown__item--separator': item.separatorBefore,
|
||||
}"
|
||||
:aria-checked="item.checked === undefined ? undefined : item.checked"
|
||||
:aria-haspopup="item.submenu ? 'menu' : undefined"
|
||||
:disabled="item.disabled"
|
||||
:role="item.checked === undefined ? 'menuitem' : 'menuitemradio'"
|
||||
type="button"
|
||||
@click="emit('select', item.id, $event)"
|
||||
<div
|
||||
v-for="section in menuSections"
|
||||
:key="section.key"
|
||||
:aria-label="section.group ? section.label : undefined"
|
||||
:role="section.group ? 'group' : 'presentation'"
|
||||
>
|
||||
<span class="sky-dropdown__indicator" aria-hidden="true">
|
||||
<Check v-if="item.checked" :size="21" :stroke-width="2.5" />
|
||||
</span>
|
||||
<span class="sky-dropdown__label">{{ item.label }}</span>
|
||||
<ChevronRight
|
||||
v-if="item.submenu"
|
||||
class="sky-dropdown__chevron"
|
||||
:size="21"
|
||||
:stroke-width="2.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
v-for="item in section.items"
|
||||
:key="item.id"
|
||||
class="sky-dropdown__item"
|
||||
:class="{
|
||||
'sky-dropdown__item--destructive': item.destructive,
|
||||
'sky-dropdown__item--separator': item.separatorBefore,
|
||||
}"
|
||||
:aria-checked="item.checked === undefined ? undefined : item.checked"
|
||||
:aria-haspopup="item.submenu ? 'menu' : undefined"
|
||||
:disabled="item.disabled"
|
||||
:role="item.checked === undefined ? 'menuitem' : 'menuitemradio'"
|
||||
type="button"
|
||||
@click="emit('select', item.id, $event)"
|
||||
>
|
||||
<span class="sky-dropdown__indicator" aria-hidden="true">
|
||||
<Check v-if="item.checked" :size="21" :stroke-width="2.5" />
|
||||
</span>
|
||||
<span class="sky-dropdown__label">{{ item.label }}</span>
|
||||
<ChevronRight
|
||||
v-if="item.submenu"
|
||||
class="sky-dropdown__chevron"
|
||||
:size="21"
|
||||
:stroke-width="2.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</SkyPopover>
|
||||
</template>
|
||||
|
||||
@@ -24,10 +24,16 @@ describe('SkySheet', () => {
|
||||
swipeToClose: true,
|
||||
})
|
||||
const ordinary = await renderSheet({ opened: true })
|
||||
const hiddenGrabber = await renderSheet({
|
||||
opened: true,
|
||||
showGrabber: false,
|
||||
swipeToClose: true,
|
||||
})
|
||||
|
||||
expect(swipeable).toContain('class="sky-sheet__grabber"')
|
||||
expect(swipeable).toContain('Sheet content')
|
||||
expect(ordinary).not.toContain('sky-sheet__grabber')
|
||||
expect(hiddenGrabber).not.toContain('sky-sheet__grabber')
|
||||
})
|
||||
|
||||
it('can expose the drag handle as an accessible close button', async () => {
|
||||
@@ -56,11 +62,16 @@ describe('SkySheet', () => {
|
||||
expect(source).toContain('swipeclose: [event: PointerEvent]')
|
||||
expect(source).toContain('grabberclick: [event: MouseEvent]')
|
||||
expect(source).toContain('setPointerCapture(event.pointerId)')
|
||||
expect(source).toContain("target.closest('[data-sky-sheet-drag-handle]')")
|
||||
expect(source).toContain('@pointerdown="startDrag"')
|
||||
expect(source).toContain('dragOffset.value >= closeThreshold')
|
||||
expect(source).toContain("emit('swipeclose', event)")
|
||||
expect(overlays).toMatch(
|
||||
/\.sky-sheet__grabber\s*\{[^}]*touch-action:\s*none;/s,
|
||||
)
|
||||
expect(overlays).toMatch(
|
||||
/\.sky-sheet \[data-sky-sheet-drag-handle\]\s*\{[^}]*touch-action:\s*none;/s,
|
||||
)
|
||||
expect(overlays).toMatch(
|
||||
/\.sky-sheet__panel--settling\s*\{[^}]*transition:\s*transform 220ms/s,
|
||||
)
|
||||
|
||||
@@ -16,6 +16,7 @@ const props = withDefaults(
|
||||
grabberLabel?: string
|
||||
opened: boolean
|
||||
role?: 'alertdialog' | 'dialog' | 'none' | 'presentation'
|
||||
showGrabber?: boolean
|
||||
swipeToClose?: boolean
|
||||
tabindex?: number | string
|
||||
}>(),
|
||||
@@ -25,6 +26,7 @@ const props = withDefaults(
|
||||
component: 'div',
|
||||
grabberClickable: false,
|
||||
grabberLabel: '',
|
||||
showGrabber: true,
|
||||
tabindex: -1,
|
||||
},
|
||||
)
|
||||
@@ -76,9 +78,26 @@ function settleDrag(): void {
|
||||
}
|
||||
|
||||
function startDrag(event: PointerEvent): void {
|
||||
const target = event.target
|
||||
const grabber =
|
||||
target instanceof Element
|
||||
? target.closest('.sky-sheet__grabber')
|
||||
: undefined
|
||||
const extendedHandle =
|
||||
target instanceof Element
|
||||
? target.closest('[data-sky-sheet-drag-handle]')
|
||||
: undefined
|
||||
const interactiveTarget =
|
||||
target instanceof Element
|
||||
? target.closest(
|
||||
'button, a, input, select, textarea, [role="button"], [contenteditable="true"]',
|
||||
)
|
||||
: undefined
|
||||
|
||||
if (
|
||||
!props.swipeToClose ||
|
||||
!event.isPrimary ||
|
||||
(!grabber && (!extendedHandle || interactiveTarget)) ||
|
||||
(event.pointerType === 'mouse' && event.button !== 0)
|
||||
) {
|
||||
return
|
||||
@@ -204,20 +223,20 @@ useOverlayFocusTrap({
|
||||
"
|
||||
:aria-describedby="effectiveRole ? ariaDescribedby : undefined"
|
||||
:tabindex="tabindex"
|
||||
@lostpointercapture="finishDrag($event, true)"
|
||||
@pointercancel="finishDrag($event, true)"
|
||||
@pointerdown="startDrag"
|
||||
@pointermove="moveDrag"
|
||||
@pointerup="finishDrag($event)"
|
||||
>
|
||||
<component
|
||||
:is="grabberClickable ? 'button' : 'div'"
|
||||
v-if="swipeToClose"
|
||||
v-if="swipeToClose && showGrabber"
|
||||
class="sky-sheet__grabber"
|
||||
:type="grabberClickable ? 'button' : undefined"
|
||||
:aria-hidden="grabberClickable ? undefined : true"
|
||||
:aria-label="grabberClickable ? grabberLabel : undefined"
|
||||
@click="grabberClickable && emit('grabberclick', $event)"
|
||||
@lostpointercapture="finishDrag($event, true)"
|
||||
@pointercancel="finishDrag($event, true)"
|
||||
@pointerdown="startDrag"
|
||||
@pointermove="moveDrag"
|
||||
@pointerup="finishDrag($event)"
|
||||
></component>
|
||||
<slot />
|
||||
</component>
|
||||
|
||||
@@ -190,19 +190,81 @@ label.sky-settings-row__title {
|
||||
}
|
||||
|
||||
.sky-settings-range-row__frame {
|
||||
padding-top: var(--sky-space-1);
|
||||
padding-bottom: var(--sky-space-1);
|
||||
min-height: 88px;
|
||||
padding-top: 12px;
|
||||
padding-bottom: 14px;
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sky-settings-group--compact .sky-settings-row__frame,
|
||||
.sky-settings-group--compact .sky-settings-range-row__frame {
|
||||
min-height: 48px;
|
||||
min-height: 76px;
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.sky-settings-range-row .sky-range {
|
||||
.sky-settings-range-row__header,
|
||||
.sky-settings-range-row__control {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sky-settings-range-row__header {
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.sky-settings-range-row__title,
|
||||
.sky-settings-range-row__value {
|
||||
font-size: 17px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.sky-settings-range-row__title {
|
||||
min-width: 0;
|
||||
color: var(--sky-text);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.sky-settings-range-row__value {
|
||||
flex: none;
|
||||
color: var(--sky-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sky-settings-range-row__control {
|
||||
min-height: 28px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sky-settings-range-row__control .sky-range {
|
||||
width: 100%;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.sky-settings-range-row__endpoint {
|
||||
width: 24px;
|
||||
height: 28px;
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--sky-muted);
|
||||
}
|
||||
|
||||
.sky-settings-range-row__endpoint > svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sky-settings-range-row__endpoint--leading > svg {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
}
|
||||
|
||||
.sky-settings-icon {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createSSRApp } from 'vue'
|
||||
import { createSSRApp, h } from 'vue'
|
||||
import { renderToString } from 'vue/server-renderer'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
@@ -26,7 +26,9 @@ describe('SkySettingsRangeRow', () => {
|
||||
expect(html).toContain('min="25"')
|
||||
expect(html).toContain('max="100"')
|
||||
expect(html).toContain('step="5"')
|
||||
expect(html).toContain('75%')
|
||||
expect(html).toContain('class="sky-settings-range-row__title">Brightness')
|
||||
expect(html).toContain('class="sky-settings-range-row__value">75%')
|
||||
expect(html).not.toContain('sky-range__caption')
|
||||
})
|
||||
|
||||
it('uses the effective numeric value as its default visible label', async () => {
|
||||
@@ -37,7 +39,9 @@ describe('SkySettingsRangeRow', () => {
|
||||
|
||||
const html = await renderToString(app)
|
||||
|
||||
expect(html).toMatch(/class="sky-range__label">.*1\.25.*<\/span>/)
|
||||
expect(html).toMatch(
|
||||
/class="sky-settings-range-row__value">.*1\.25.*<\/span>/,
|
||||
)
|
||||
expect(html).not.toContain('aria-valuetext=')
|
||||
})
|
||||
|
||||
@@ -51,7 +55,31 @@ describe('SkySettingsRangeRow', () => {
|
||||
const html = await renderToString(app)
|
||||
|
||||
expect(html).toContain('aria-valuetext="75%"')
|
||||
expect(html).toMatch(/class="sky-range__label">.*75%.*<\/span>/)
|
||||
expect(html).toMatch(
|
||||
/class="sky-settings-range-row__value">.*75%.*<\/span>/,
|
||||
)
|
||||
})
|
||||
|
||||
it('supports decorative range endpoint icons without changing the label', async () => {
|
||||
const app = createSSRApp({
|
||||
render: () =>
|
||||
h(
|
||||
SkySettingsRangeRow,
|
||||
{ modelValue: 50, title: 'Volume', valueLabel: '50%' },
|
||||
{
|
||||
leading: () => h('svg', { 'data-endpoint': 'low' }),
|
||||
trailing: () => h('svg', { 'data-endpoint': 'high' }),
|
||||
},
|
||||
),
|
||||
})
|
||||
|
||||
const html = await renderToString(app)
|
||||
|
||||
expect(html).toContain('sky-settings-range-row__endpoint--leading')
|
||||
expect(html).toContain('sky-settings-range-row__endpoint--trailing')
|
||||
expect(html).toContain('data-endpoint="low"')
|
||||
expect(html).toContain('data-endpoint="high"')
|
||||
expect(html).toContain('aria-label="Volume"')
|
||||
})
|
||||
|
||||
it('exposes input, change, and numeric model update events', () => {
|
||||
|
||||
@@ -53,21 +53,38 @@ const accessibleValue = computed(
|
||||
:class="{ 'sky-settings-range-row--disabled': disabled }"
|
||||
>
|
||||
<div class="sky-settings-range-row__frame">
|
||||
<SkyRange
|
||||
:aria-label="title"
|
||||
:aria-value-text="accessibleValue"
|
||||
:caption="title"
|
||||
:disabled="disabled"
|
||||
:max="max"
|
||||
:min="min"
|
||||
:model-value="effectiveValue"
|
||||
:step="step"
|
||||
@change="emit('change', $event)"
|
||||
@input="emit('input', $event)"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
{{ visibleValue }}
|
||||
</SkyRange>
|
||||
<div class="sky-settings-range-row__header">
|
||||
<span class="sky-settings-range-row__title">{{ title }}</span>
|
||||
<span class="sky-settings-range-row__value">{{ visibleValue }}</span>
|
||||
</div>
|
||||
<div class="sky-settings-range-row__control">
|
||||
<span
|
||||
v-if="$slots.leading"
|
||||
class="sky-settings-range-row__endpoint sky-settings-range-row__endpoint--leading"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<slot name="leading" />
|
||||
</span>
|
||||
<SkyRange
|
||||
:aria-label="title"
|
||||
:aria-value-text="accessibleValue"
|
||||
:disabled="disabled"
|
||||
:max="max"
|
||||
:min="min"
|
||||
:model-value="effectiveValue"
|
||||
:step="step"
|
||||
@change="emit('change', $event)"
|
||||
@input="emit('input', $event)"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
/>
|
||||
<span
|
||||
v-if="$slots.trailing"
|
||||
class="sky-settings-range-row__endpoint sky-settings-range-row__endpoint--trailing"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<slot name="trailing" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user