ENH - modernize Clock controls with Sky UI

This commit is contained in:
DerEchteAlec
2026-08-17 05:15:29 +02:00
parent 29e767d73b
commit 9b29a69624
9 changed files with 676 additions and 289 deletions
+261 -50
View File
@@ -4632,17 +4632,49 @@ button {
color: #a0a0a7;
}
.clock-content {
--sky-block-gutter-left: 0px;
--sky-block-gutter-right: 0px;
min-height: 0;
flex: 1;
overflow-y: auto;
padding: 0 0
calc(var(--sky-tabbar-height) + var(--sky-safe-area-bottom) + 16px);
}
.clock-navbar.sky-navbar--no-navigation {
min-height: calc(
var(--sky-navbar-safe-area-top) + var(--sky-navbar-large-title-height)
);
padding-top: var(--sky-navbar-safe-area-top);
.clock-navbar.sky-navbar {
--sky-navbar-safe-area-top: var(--sky-space-2);
--sky-page-gutter: 12px;
}
.clock-navbar--alarm .sky-navbar__inner {
margin-bottom: 0;
}
.clock-navbar--alarm .sky-navbar__left.sky-glass-surface,
.clock-navbar--alarm .sky-navbar__right.sky-glass-surface {
overflow: visible;
border-radius: 0;
background: transparent;
box-shadow: none;
-webkit-backdrop-filter: none;
backdrop-filter: none;
}
.clock-navbar-action.sky-link {
min-height: var(--sky-touch-target);
padding: 0 14px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--sky-radius-pill);
color: #fff;
font-size: 15px;
font-weight: 600;
line-height: 1;
}
.clock-navbar-action--icon.sky-link {
width: var(--sky-touch-target);
padding: 0;
}
.clock-navbar-action--icon svg {
width: 21px;
height: 21px;
}
.phone-call-content {
min-height: 0;
@@ -4663,16 +4695,17 @@ button {
}
.clock-world-now {
margin: 0;
padding: 76px 16px 0;
padding: 84px 16px 0;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.clock-world-location {
color: #8e8e93;
color: var(--sky-muted);
font-size: 19px;
font-weight: 500;
line-height: 24px;
}
.clock-world-time {
margin-top: 8px;
@@ -4680,63 +4713,169 @@ button {
font-size: 74px;
font-weight: 200;
letter-spacing: -4px;
line-height: 1;
}
.clock-world-zone {
margin-top: 2px;
color: #8e8e93;
margin-top: 4px;
color: var(--sky-muted);
font-size: 14px;
line-height: 20px;
}
.clock-alarm-list {
margin-top: 8px;
margin: 4px 12px 0;
}
.clock-alarm-item {
min-height: 86px;
min-height: 104px;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
border-bottom: 1px solid var(--sky-hairline);
}
.clock-alarm-remove {
.clock-alarm-copy {
min-width: 0;
min-height: 104px;
grid-column: 1 / 3;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
padding: 10px 0;
border: 0;
background: transparent;
color: var(--sky-text);
text-align: left;
}
.clock-alarm-remove + .clock-alarm-copy {
grid-column: 2;
}
.clock-alarm-copy:focus-visible {
border-radius: 10px;
outline: 2px solid var(--sky-app-accent);
outline-offset: -2px;
}
.clock-alarm-time {
font-variant-numeric: tabular-nums;
font-size: 50px;
font-weight: 200;
letter-spacing: -2px;
line-height: 54px;
}
.clock-alarm-subtitle {
max-width: 100%;
overflow: hidden;
font-size: 14px;
line-height: 19px;
text-overflow: ellipsis;
white-space: nowrap;
}
.clock-alarm-item--disabled .clock-alarm-copy {
color: var(--sky-muted);
}
.clock-alarm-chevron {
width: 18px;
height: 18px;
grid-column: 3;
justify-self: end;
color: var(--sky-subtle);
}
.clock-alarm-item > .sky-toggle {
grid-column: 3;
justify-self: end;
}
.clock-alarm-remove.sky-button--icon-only {
width: 28px;
min-width: 28px;
height: 28px;
min-height: 28px;
flex: none;
padding: 0;
border-radius: 50%;
}
.clock-alarm-remove svg {
width: 17px;
height: 17px;
stroke-width: 3;
}
.clock-alarm-editor {
.clock-alarm-sheet {
--sky-overlay-layer: 90;
--sky-sheet-background: #1c1c1d;
}
.clock-alarm-sheet .sky-sheet__panel {
height: calc(100% - var(--sky-safe-area-top) - var(--sky-space-2));
max-height: calc(100% - var(--sky-safe-area-top) - var(--sky-space-2));
overflow: hidden;
border-radius: var(--sky-radius-sheet) var(--sky-radius-sheet) 0 0;
background: var(--sky-sheet-background);
}
.clock-alarm-sheet .sky-sheet__grabber {
display: none;
}
.clock-alarm-sheet-surface {
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
border-radius: var(--sky-radius-sheet) var(--sky-radius-sheet) 0 0;
background: var(--sky-sheet-background);
color: var(--sky-text);
}
.clock-alarm-sheet-navbar.sky-navbar,
.clock-alarm-sheet-surface > .sky-navbar {
--sky-navbar-safe-area-top: var(--sky-space-3);
--sky-page-gutter: 12px;
--sky-navbar-glass: transparent;
flex: none;
background: transparent;
}
.clock-alarm-sheet-navbar .sky-navbar__blur,
.clock-alarm-sheet-navbar .sky-navbar__background {
background: transparent;
}
.clock-alarm-editor.sky-scroll-area--padded {
--sky-page-gutter: 12px;
min-height: 0;
flex: 1;
margin: 0;
padding: 8px 0 32px;
padding-top: 8px;
overflow-y: auto;
}
.clock-alarm-editor .time-wheel-picker {
width: 100%;
margin-right: 0;
margin-left: 0;
}
.clock-alarm-editor .sky-list--strong,
.clock-alarm-editor .sky-list--inset {
background: #2c2c2e;
}
.time-wheel-picker {
position: relative;
width: calc(100% - 32px);
height: 200px;
margin: 0 16px 16px;
height: 240px;
margin: 8px 16px 12px;
display: grid;
grid-template-columns: 64px 64px;
grid-template-columns: repeat(2, minmax(0, 88px));
justify-content: center;
gap: 8px;
gap: 4px;
overflow: hidden;
}
.time-wheel-picker--duration {
grid-template-columns: repeat(3, 64px);
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0;
}
.time-wheel-picker__field {
position: relative;
width: 64px;
height: 200px;
width: 100%;
height: 240px;
}
.time-wheel-picker--duration .time-wheel-picker__field {
width: 64px;
width: 100%;
}
.time-wheel-picker__selection {
position: absolute;
z-index: 0;
top: 80px;
top: 100px;
right: 0;
left: 0;
height: 40px;
@@ -4747,8 +4886,8 @@ button {
.time-wheel-picker__column {
position: relative;
z-index: 1;
height: 200px;
padding: 80px 0;
height: 240px;
padding: 100px 0;
overflow-x: hidden;
overflow-y: auto;
scrollbar-width: none;
@@ -4766,16 +4905,17 @@ button {
);
}
.time-wheel-picker--unit-labels .time-wheel-picker__column {
width: 36px;
width: 54%;
}
.time-wheel-picker__unit {
position: absolute;
z-index: 2;
top: 80px;
left: 34px;
top: 100px;
left: 53%;
height: 40px;
color: #8e8e93;
font-size: 9px;
color: var(--sky-text);
font-size: 15px;
font-weight: 600;
line-height: 40px;
pointer-events: none;
}
@@ -4795,7 +4935,7 @@ button {
scroll-snap-align: center;
background: transparent;
color: #8e8e93;
font-size: 27px;
font-size: 28px;
font-weight: 300;
line-height: 40px;
opacity: 0.62;
@@ -4810,25 +4950,40 @@ button {
}
.time-wheel-picker__value--selected {
color: #fff;
font-size: 32px;
opacity: 1;
}
.clock-alarm-delete {
width: calc(100% - 32px);
margin: 18px 16px 0;
width: 100%;
margin: 18px 0 0;
}
.clock-alarm-save.sky-link {
color: #ff9f0a;
background: #ff9f0a;
color: #fff;
}
.clock-tool {
margin: 0;
padding: 0 16px;
text-align: center;
}
.clock-stopwatch {
--sky-block-gutter-left: calc(
var(--sky-page-gutter) + var(--sky-safe-area-left)
);
--sky-block-gutter-right: calc(
var(--sky-page-gutter) + var(--sky-safe-area-right)
);
min-height: 100%;
display: flex;
flex-direction: column;
}
.clock-digits {
margin: 70px 0 54px;
margin: 118px 0 96px;
font-variant-numeric: tabular-nums;
font-size: 52px;
font-size: 68px;
font-weight: 200;
letter-spacing: -4px;
line-height: 1;
}
.clock-sound-menu {
min-height: 0;
@@ -4850,18 +5005,52 @@ button {
height: 17px;
}
.clock-actions {
display: flex;
justify-content: space-between;
margin: 12px 0;
display: grid;
grid-template-columns: 82px minmax(0, 1fr) 82px;
align-items: center;
gap: 12px;
margin: 16px 0;
}
.clock-action-button {
width: 66px;
height: 66px;
min-width: 66px;
.clock-action-button.sky-button {
width: 82px;
height: 82px;
min-width: 82px;
padding: 0;
border-radius: 50%;
font-size: 16px;
font-weight: 500;
}
.clock-action-button.clock-action-button--secondary.sky-button--secondary {
background: #1c1c1e;
color: #8e8e93;
}
.clock-action-button.clock-action-button--start.sky-button--secondary {
grid-column: 3;
background: #032d13;
color: #30d158;
}
.clock-action-button.clock-action-button--stop.sky-button--secondary {
grid-column: 3;
background: #330c0a;
color: #ff453a;
}
.clock-action-button.sky-button:disabled {
opacity: 0.42;
}
.clock-action-button--icon svg {
width: 34px;
height: 34px;
stroke-width: 2;
}
.clock-action-button--icon.clock-action-button--start svg {
fill: currentColor;
}
.clock-tool-divider {
height: 1px;
background: var(--sky-hairline);
}
.clock-lap-list {
margin-top: 24px;
margin-top: 0;
}
.timer-ring {
position: relative;
@@ -4899,17 +5088,24 @@ button {
font-variant-numeric: tabular-nums;
}
.clock-timer-settings {
width: 100%;
overflow: hidden;
margin-right: 0;
margin-top: 24px;
margin-left: 0;
}
.clock-timer-note {
margin-top: 16px;
}
.clock-timer-sound {
margin-top: 12px;
}
.clock-timer {
display: flex;
flex-direction: column;
align-items: center;
padding-bottom: 28px;
justify-content: center;
height: 100%;
}
.clock-timer-actions {
width: 100%;
margin-top: 8px;
margin-bottom: 24px;
}
.store-app {
background: #000;
@@ -5034,6 +5230,21 @@ button {
background: #f2f2f7;
color: #000;
}
.phone-app--light
.clock-action-button.clock-action-button--secondary.sky-button--secondary {
background: #e5e5ea;
color: #8e8e93;
}
.phone-app--light
.clock-action-button.clock-action-button--start.sky-button--secondary {
background: #d9f5df;
color: #248a3d;
}
.phone-app--light
.clock-action-button.clock-action-button--stop.sky-button--secondary {
background: #ffe5e5;
color: #d70015;
}
.phone-app--light .time-wheel-picker__selection {
background: #e5e5ea;
}
+117 -99
View File
@@ -1,6 +1,5 @@
<script setup lang="ts">
import {
SkyBlock as kBlock,
SkyBlockTitle as kBlockTitle,
SkyButton as kButton,
SkyField as kListInput,
@@ -8,6 +7,8 @@ import {
SkyList as kList,
SkyListItem as kListItem,
SkyNavbar as kNavbar,
SkyScrollArea as kScrollArea,
SkySheet as kSheet,
} from '@/ui'
import { ChevronRight, Check, X } from 'lucide-vue-next'
import { reactive, ref } from 'vue'
@@ -61,106 +62,123 @@ function toggleWeekday(weekday: number): void {
</script>
<template>
<AlarmSoundMenu
v-if="soundMenuOpen"
:back-label="phone.t('Apps.clock.tabs.alarm')"
:selected-sound="draft.sound"
@close="soundMenuOpen = false"
@select="draft.sound = $event"
/>
<k-navbar v-show="!soundMenuOpen" :title="phone.t('Apps.clock.tabs.alarm')">
<template #left>
<k-link
component="button"
icon-only
:link-props="{ type: 'button' }"
:aria-label="phone.t('Common.cancel')"
@click="emit('cancel')"
>
<X aria-hidden="true" />
</k-link>
</template>
<template #right>
<k-link
component="button"
icon-only
class="clock-alarm-save"
:link-props="{ type: 'button' }"
:aria-label="phone.t('Common.save')"
@click="save"
>
<Check aria-hidden="true" />
</k-link>
</template>
</k-navbar>
<k-block
v-show="!soundMenuOpen"
component="section"
class="clock-alarm-editor"
<k-sheet
opened
class="clock-alarm-sheet"
:aria-label="phone.t('Apps.clock.tabs.alarm')"
:show-grabber="false"
swipe-to-close
@backdropclick="emit('cancel')"
@escape="emit('cancel')"
@swipeclose="emit('cancel')"
>
<TimeWheelPicker
v-model="draft.time"
:hours-label="phone.t('Apps.clock.alarm.hours')"
:label="phone.t('Apps.clock.alarm.time')"
:minutes-label="phone.t('Apps.clock.minutes')"
/>
<k-block-title>{{ phone.t('Apps.clock.alarm.repeat') }}</k-block-title>
<k-list strong inset>
<k-list-item
v-for="weekday in WEEKDAY_IDS"
:key="weekday"
link
:chevron="false"
:title="phone.t(`Apps.clock.alarm.days.${weekdayKeys[weekday]}`)"
@click="toggleWeekday(weekday)"
>
<template #after>
<Check
v-if="draft.weekdays.includes(weekday)"
class="w-5 h-5 text-primary"
/>
</template>
</k-list-item>
</k-list>
<k-list strong inset>
<k-list-input
:label="phone.t('Apps.clock.alarm.note')"
:placeholder="phone.t('Apps.clock.alarm.notePlaceholder')"
:value="draft.note"
maxlength="80"
@input="setNote"
<section class="clock-alarm-sheet-surface">
<AlarmSoundMenu
v-if="soundMenuOpen"
:back-label="phone.t('Apps.clock.tabs.alarm')"
:selected-sound="draft.sound"
@close="soundMenuOpen = false"
@select="draft.sound = $event"
/>
</k-list>
<k-list strong inset>
<k-list-item
link
:chevron="false"
:title="phone.t('Apps.clock.alarm.sound')"
@click="soundMenuOpen = true"
>
<template #after>
<span class="clock-sound-selection">
{{ phone.t(`Apps.clock.alarm.sounds.${draft.sound}`) }}
<ChevronRight aria-hidden="true" />
</span>
</template>
</k-list-item>
</k-list>
<template v-else>
<k-navbar
class="clock-alarm-sheet-navbar"
data-sky-sheet-drag-handle
:title="phone.t('Apps.clock.tabs.alarm')"
>
<template #left>
<k-link
component="button"
icon-only
:link-props="{ type: 'button' }"
:aria-label="phone.t('Common.cancel')"
@click="emit('cancel')"
>
<X aria-hidden="true" />
</k-link>
</template>
<template #right>
<k-link
component="button"
icon-only
class="clock-alarm-save"
:link-props="{ type: 'button' }"
:aria-label="phone.t('Common.save')"
@click="save"
>
<Check aria-hidden="true" />
</k-link>
</template>
</k-navbar>
<k-button
v-if="alarm"
large
rounded
variant="danger"
class="clock-alarm-delete"
@click="emit('delete')"
>
{{ phone.t('Apps.clock.alarm.delete') }}
</k-button>
</k-block>
<k-scroll-area padded class="clock-alarm-editor">
<TimeWheelPicker
v-model="draft.time"
:hours-label="phone.t('Apps.clock.alarm.hours')"
:label="phone.t('Apps.clock.alarm.time')"
:minutes-label="phone.t('Apps.clock.minutes')"
/>
<k-block-title>{{
phone.t('Apps.clock.alarm.repeat')
}}</k-block-title>
<k-list strong inset>
<k-list-item
v-for="weekday in WEEKDAY_IDS"
:key="weekday"
link
:chevron="false"
:title="phone.t(`Apps.clock.alarm.days.${weekdayKeys[weekday]}`)"
@click="toggleWeekday(weekday)"
>
<template #after>
<Check
v-if="draft.weekdays.includes(weekday)"
class="w-5 h-5 text-primary"
/>
</template>
</k-list-item>
</k-list>
<k-list strong inset>
<k-list-input
:label="phone.t('Apps.clock.alarm.note')"
:placeholder="phone.t('Apps.clock.alarm.notePlaceholder')"
:value="draft.note"
maxlength="80"
@input="setNote"
/>
</k-list>
<k-list strong inset>
<k-list-item
link
:chevron="false"
:title="phone.t('Apps.clock.alarm.sound')"
@click="soundMenuOpen = true"
>
<template #after>
<span class="clock-sound-selection">
{{ phone.t(`Apps.clock.alarm.sounds.${draft.sound}`) }}
<ChevronRight aria-hidden="true" />
</span>
</template>
</k-list-item>
</k-list>
<k-button
v-if="alarm"
large
rounded
variant="danger"
class="clock-alarm-delete"
@click="emit('delete')"
>
{{ phone.t('Apps.clock.alarm.delete') }}
</k-button>
</k-scroll-area>
</template>
</section>
</k-sheet>
</template>
+1
View File
@@ -3666,6 +3666,7 @@ const defaultLocales: LocaleTree = {
minutesShort: 'min',
seconds: 'Seconds',
secondsShort: 'sec',
description: 'Description',
note: 'Note',
notePlaceholder: 'Timer',
sound: 'Sound',
+4
View File
@@ -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;
+11
View File
@@ -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,
)
+25 -6
View File
@@ -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>
+112 -11
View File
@@ -6,28 +6,42 @@ const clockView = readFileSync(
new URL('./ClockApp.vue', import.meta.url),
'utf8',
)
const alarmEditorView = readFileSync(
new URL('../../components/AlarmEditor.vue', import.meta.url),
'utf8',
)
const mainCss = readFileSync(
new URL('../../assets/main.css', import.meta.url),
'utf8',
)
describe('Clock app controls', () => {
it('removes the empty navigation row from world clock and timer titles', () => {
expect(clockView).toContain('class="clock-navbar"')
it('only renders a title navbar for the alarm tab', () => {
expect(clockView.match(/<sky-navbar/g)).toHaveLength(1)
expect(clockView).toContain('v-if="tab === \'alarm\'"')
expect(clockView).toContain('class="clock-navbar clock-navbar--alarm"')
expect(clockView).toContain('variant="large"')
expect(clockView).not.toContain('clock-navbar__empty-inner')
expect(clockView).not.toContain('v-else-if="tab !== \'stopwatch\'"')
expect(mainCss).toMatch(
/\.clock-navbar\.sky-navbar--no-navigation\s*\{[^}]*--sky-navbar-large-title-height[^}]*padding-top:\s*var\(--sky-navbar-safe-area-top\)/s,
/\.clock-navbar\.sky-navbar\s*\{[^}]*--sky-navbar-safe-area-top:\s*var\(--sky-space-2\);/s,
)
expect(mainCss).not.toContain('.clock-navbar.sky-navbar--no-navigation')
})
it('keeps the single world clock centered with readable line boxes', () => {
expect(clockView).toContain('class="clock-world-now"')
expect(clockView).not.toContain('clock-world-list')
expect(mainCss).toMatch(
/\.clock-world-time\s*\{[^}]*font-size:\s*74px;[^}]*line-height:\s*1;/s,
)
})
it('uses the danger color while stopwatch and timer controls are running', () => {
expect(clockView).toMatch(
/clock\.stopwatchStartedAt === null\s*\? 'primary'\s*:\s*'danger'/,
)
expect(clockView).toMatch(
/:variant="clock\.timerStartedAt === null \? 'primary' : 'danger'"/,
)
it('uses the orange clock accent and state-specific circular actions', () => {
expect(clockView).toContain('accent="#ff9f0a"')
expect(clockView).toContain('clock-action-button--start')
expect(clockView).toContain('clock-action-button--stop')
expect(clockView).toContain('<Play')
expect(clockView).toContain('<Pause')
})
it('uses the shared full-width Sky tab bar', () => {
@@ -35,4 +49,91 @@ describe('Clock app controls', () => {
expect(clockView).toContain('<sky-tab-button')
expect(clockView).not.toContain('<sky-segmented')
})
it('does not nest a second SkyBlock around the timer layout', () => {
expect(clockView).toContain(
'<section v-else class="clock-tool clock-timer">',
)
expect(clockView).not.toContain(
'<sky-block v-else nested class="clock-tool clock-timer">',
)
expect(mainCss).toMatch(
/\.clock-timer\s*\{[^}]*display:\s*flex;[^}]*flex-direction:\s*column;[^}]*align-items:\s*center;/s,
)
expect(mainCss).toMatch(/\.clock-timer-actions\s*\{[^}]*width:\s*100%;/s)
expect(mainCss).toMatch(/\.clock-timer-settings\s*\{[^}]*width:\s*100%;/s)
})
it('keeps the stopwatch single-page and separates alarm actions', () => {
expect(clockView).not.toContain('clock-stopwatch-pages')
expect(clockView).not.toContain('clock-stopwatch-page')
expect(clockView).toMatch(
/<template #left>[\s\S]*?clock-navbar-action--icon[\s\S]*?<\/template>\s*<template #right>[\s\S]*?clock-navbar-action--edit/,
)
expect(clockView).not.toContain('clock-navbar-actions')
expect(clockView).toMatch(
/<sky-button\s+v-if="alarmsEditing"[\s\S]*?icon-only[\s\S]*?class="clock-alarm-remove"/,
)
expect(mainCss).toMatch(
/\.clock-navbar--alarm \.sky-navbar__inner\s*\{[^}]*margin-bottom:\s*0;/s,
)
expect(mainCss).toMatch(
/\.clock-navbar--alarm \.sky-navbar__left\.sky-glass-surface,\s*\.clock-navbar--alarm \.sky-navbar__right\.sky-glass-surface\s*\{/s,
)
expect(mainCss).not.toMatch(/\.clock-navbar--alarm\s*\{[^}]*margin-top:/s)
expect(mainCss).toMatch(
/\.clock-content\s*\{[^}]*--sky-block-gutter-left:\s*0px;[^}]*--sky-block-gutter-right:\s*0px;/s,
)
expect(mainCss).toMatch(
/\.clock-stopwatch\s*\{[^}]*--sky-block-gutter-left:\s*calc\(\s*var\(--sky-page-gutter\) \+ var\(--sky-safe-area-left\)\s*\);[^}]*--sky-block-gutter-right:\s*calc\(\s*var\(--sky-page-gutter\) \+ var\(--sky-safe-area-right\)\s*\);/s,
)
expect(mainCss).toMatch(
/\.clock-alarm-item > \.sky-toggle\s*\{[^}]*grid-column:\s*3;[^}]*justify-self:\s*end;/s,
)
expect(mainCss).toMatch(
/\.clock-alarm-remove\.sky-button--icon-only\s*\{[^}]*width:\s*28px;[^}]*height:\s*28px;[^}]*min-height:\s*28px;[^}]*padding:\s*0;[^}]*border-radius:\s*50%;/s,
)
})
it('opens the alarm editor as a compact Sky sheet over the alarm page', () => {
expect(clockView).toMatch(/<AlarmEditor\s+v-if="alarmEditor"/)
expect(clockView).not.toContain('v-else-if="alarmEditor"')
expect(alarmEditorView).toContain('<k-sheet')
expect(alarmEditorView).toContain(':show-grabber="false"')
expect(alarmEditorView).toContain('swipe-to-close')
expect(alarmEditorView).toContain('data-sky-sheet-drag-handle')
expect(alarmEditorView).toContain(
'<k-scroll-area padded class="clock-alarm-editor">',
)
expect(mainCss).toMatch(
/\.clock-alarm-sheet \.sky-sheet__panel\s*\{[^}]*height:\s*calc\(\s*100% - var\(--sky-safe-area-top\) - var\(--sky-space-2\)\s*\);[^}]*overflow:\s*hidden;[^}]*border-radius:\s*var\(--sky-radius-sheet\) var\(--sky-radius-sheet\) 0 0;[^}]*background:\s*var\(--sky-sheet-background\);/s,
)
expect(mainCss).toMatch(
/\.clock-alarm-sheet\s*\{[^}]*--sky-sheet-background:\s*#1c1c1d;/s,
)
expect(mainCss).toMatch(
/\.clock-alarm-sheet \.sky-sheet__grabber\s*\{[^}]*display:\s*none;/s,
)
expect(mainCss).toMatch(
/\.clock-alarm-sheet-surface\s*\{[^}]*height:\s*100%;[^}]*border-radius:\s*var\(--sky-radius-sheet\) var\(--sky-radius-sheet\) 0 0;[^}]*background:\s*var\(--sky-sheet-background\);/s,
)
expect(mainCss).toMatch(
/\.clock-alarm-sheet-navbar\.sky-navbar,[\s\S]*?\.clock-alarm-sheet-surface > \.sky-navbar\s*\{[^}]*--sky-navbar-safe-area-top:\s*var\(--sky-space-3\);/s,
)
expect(mainCss).toMatch(
/\.clock-alarm-sheet-navbar \.sky-navbar__blur,[\s\S]*?\.clock-alarm-sheet-navbar \.sky-navbar__background\s*\{[^}]*background:\s*transparent;/s,
)
expect(mainCss).toMatch(
/\.clock-alarm-save\.sky-link\s*\{[^}]*background:\s*#ff9f0a;[^}]*color:\s*#fff;/s,
)
expect(mainCss).toMatch(
/\.clock-alarm-editor\.sky-scroll-area--padded\s*\{[^}]*--sky-page-gutter:\s*12px;/s,
)
expect(mainCss).toMatch(
/\.clock-alarm-editor \.time-wheel-picker\s*\{[^}]*width:\s*100%;[^}]*margin-right:\s*0;[^}]*margin-left:\s*0;/s,
)
expect(mainCss).toMatch(
/\.clock-alarm-editor \.sky-list--strong,[\s\S]*?\.clock-alarm-editor \.sky-list--inset\s*\{[^}]*background:\s*#2c2c2e;/s,
)
})
})
+144 -122
View File
@@ -15,11 +15,14 @@ import {
import {
AlarmClock,
ChevronRight,
Clock3,
Globe2,
Minus,
Pause,
Play,
Plus,
Timer,
TimerReset,
X,
} from 'lucide-vue-next'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
@@ -95,6 +98,18 @@ const currentTimeZone = computed(
.formatToParts(now.value)
.find((part) => part.type === 'timeZoneName')?.value ?? browserTimeZone,
)
const stopwatchDecimalSeparator = computed(
() =>
new Intl.NumberFormat(phone.lang)
.formatToParts(1.1)
.find((part) => part.type === 'decimal')?.value ?? '.',
)
const stopwatchDisplay = computed(() =>
formatStopwatch(stopwatchValue.value).replace(
'.',
stopwatchDecimalSeparator.value,
),
)
const selectedAlarm = computed(() => {
const editor = alarmEditor.value
return editor?.mode === 'edit'
@@ -102,7 +117,7 @@ const selectedAlarm = computed(() => {
: undefined
})
const tabs = [
{ id: 'world', icon: Clock3 },
{ id: 'world', icon: Globe2 },
{ id: 'alarm', icon: AlarmClock },
{ id: 'stopwatch', icon: Timer },
{ id: 'timer', icon: TimerReset },
@@ -207,7 +222,12 @@ onBeforeUnmount(() => {
</script>
<template>
<sky-app-page component="main" class="native-app clock-app">
<sky-app-page
component="main"
accent="#ff9f0a"
accent-soft="rgba(255, 159, 10, 0.16)"
class="native-app clock-app"
>
<AlarmSoundMenu
v-if="timerSoundMenuOpen"
:back-label="phone.t('Apps.clock.tabs.timer')"
@@ -216,42 +236,35 @@ onBeforeUnmount(() => {
@select="clock.setTimerSound($event)"
/>
<AlarmEditor
v-else-if="alarmEditor"
:alarm="selectedAlarm"
@cancel="alarmEditor = null"
@delete="deleteSelectedAlarm"
@save="saveAlarm"
/>
<template v-else>
<sky-navbar
v-if="tab !== 'stopwatch'"
v-if="tab === 'alarm'"
:title="phone.t(`Apps.clock.tabs.${tab}`)"
variant="large"
transparent
class="clock-navbar"
class="clock-navbar clock-navbar--alarm"
>
<template #left>
<sky-link
v-if="tab === 'alarm'"
component="button"
:link-props="{ type: 'button' }"
@click="toggleAlarmEditing"
>
{{ phone.t(alarmsEditing ? 'Common.done' : 'Common.edit') }}
</sky-link>
</template>
<template #right>
<sky-link
v-if="tab === 'alarm'"
component="button"
icon-only
class="clock-navbar-action clock-navbar-action--icon sky-glass-surface"
:link-props="{ type: 'button' }"
:aria-label="phone.t('Apps.clock.add')"
@click="openAlarmEditor()"
>
<Plus />
<Plus aria-hidden="true" />
</sky-link>
</template>
<template #right>
<sky-link
v-if="clock.alarms.length"
component="button"
class="clock-navbar-action clock-navbar-action--edit sky-glass-surface"
:link-props="{ type: 'button' }"
@click="toggleAlarmEditing"
>
{{ phone.t(alarmsEditing ? 'Common.done' : 'Common.edit') }}
</sky-link>
</template>
</sky-navbar>
@@ -265,55 +278,70 @@ onBeforeUnmount(() => {
<span class="clock-world-zone">{{ currentTimeZone }}</span>
</sky-block>
<sky-list
<section
v-else-if="tab === 'alarm'"
class="clock-konsta-list clock-alarm-list"
class="clock-alarm-list"
:aria-label="phone.t('Apps.clock.tabs.alarm')"
>
<sky-list-item
<article
v-for="alarm in clock.alarms"
:key="alarm.id"
link
:chevron="alarmsEditing"
:title="alarm.time"
:subtitle="alarmSubtitle(alarm)"
:strong-title="false"
title-font-size-ios="text-[38px] font-light"
class="clock-alarm-item"
@click="openAlarmEditor(alarm.id)"
:class="{ 'clock-alarm-item--disabled': !alarm.enabled }"
>
<template v-if="alarmsEditing" #media>
<sky-button
rounded
small
inline
variant="danger"
class="clock-alarm-remove"
:aria-label="phone.t('Apps.clock.alarm.delete')"
@click.stop="clock.deleteAlarm(alarm.id)"
>
<Minus aria-hidden="true" />
</sky-button>
</template>
<template #after>
<span v-if="!alarmsEditing" @click.stop>
<sky-toggle
:checked="alarm.enabled"
style="--sky-app-accent: #30d158"
:aria-label="`${alarm.time}, ${alarmSubtitle(alarm)}`"
@change="clock.toggleAlarm(alarm.id)"
/>
</span>
</template>
</sky-list-item>
</sky-list>
<sky-button
v-if="alarmsEditing"
rounded
small
icon-only
variant="danger"
class="clock-alarm-remove"
:aria-label="phone.t('Apps.clock.alarm.delete')"
@click="clock.deleteAlarm(alarm.id)"
>
<Minus aria-hidden="true" />
</sky-button>
<sky-block v-else-if="tab === 'stopwatch'" nested class="clock-tool">
<div class="clock-digits">{{ formatStopwatch(stopwatchValue) }}</div>
<div class="clock-actions">
<button
type="button"
class="clock-alarm-copy"
@click="openAlarmEditor(alarm.id)"
>
<time class="clock-alarm-time">{{ alarm.time }}</time>
<span class="clock-alarm-subtitle">{{
alarmSubtitle(alarm)
}}</span>
</button>
<ChevronRight
v-if="alarmsEditing"
class="clock-alarm-chevron"
aria-hidden="true"
/>
<sky-toggle
v-else
:checked="alarm.enabled"
style="--sky-app-accent: #30d158"
:aria-label="`${alarm.time}, ${alarmSubtitle(alarm)}`"
@change="clock.toggleAlarm(alarm.id)"
/>
</article>
</section>
<sky-block
v-else-if="tab === 'stopwatch'"
nested
class="clock-tool clock-stopwatch"
>
<time class="clock-digits">{{ stopwatchDisplay }}</time>
<div class="clock-actions clock-stopwatch-actions">
<sky-button
rounded
variant="secondary"
class="clock-action-button"
class="clock-action-button clock-action-button--secondary"
:disabled="
clock.stopwatchStartedAt === null && stopwatchValue <= 0
"
@click="
clock.stopwatchStartedAt === null
? clock.resetStopwatch()
@@ -330,15 +358,13 @@ onBeforeUnmount(() => {
</sky-button>
<sky-button
rounded
:variant="
clock.stopwatchStartedAt === null ? 'primary' : 'danger'
"
:style="
clock.stopwatchStartedAt === null
? { '--sky-app-accent': '#30d158' }
: undefined
"
variant="secondary"
class="clock-action-button"
:class="
clock.stopwatchStartedAt === null
? 'clock-action-button--start'
: 'clock-action-button--stop'
"
@click="
clock.stopwatchStartedAt === null
? clock.startStopwatch(Date.now())
@@ -354,6 +380,7 @@ onBeforeUnmount(() => {
}}
</sky-button>
</div>
<div class="clock-tool-divider" aria-hidden="true" />
<sky-list dividers class="clock-konsta-list clock-lap-list">
<sky-list-item
v-for="(lap, index) in clock.laps"
@@ -364,7 +391,7 @@ onBeforeUnmount(() => {
</sky-list>
</sky-block>
<sky-block v-else nested class="clock-tool clock-timer">
<section v-else class="clock-tool clock-timer">
<div
v-if="clock.timerStartedAt !== null"
class="timer-ring"
@@ -400,25 +427,47 @@ onBeforeUnmount(() => {
:seconds-unit-label="phone.t('Apps.clock.timer.secondsShort')"
/>
<sky-list
strong
inset
class="clock-konsta-list clock-timer-settings clock-timer-note"
>
<div class="clock-actions clock-timer-actions">
<sky-button
rounded
variant="secondary"
class="clock-action-button clock-action-button--secondary clock-action-button--icon"
:aria-label="phone.t('Common.reset')"
@click="clock.resetTimer()"
>
<X aria-hidden="true" />
</sky-button>
<sky-button
rounded
variant="secondary"
class="clock-action-button clock-action-button--start clock-action-button--icon"
:disabled="clock.timerStartedAt === null && timerValue <= 0"
:aria-label="
phone.t(
clock.timerStartedAt === null
? 'Common.start'
: 'Common.pause',
)
"
@click="
clock.timerStartedAt === null
? clock.startTimer(now)
: clock.pauseTimer(now)
"
>
<Play v-if="clock.timerStartedAt === null" aria-hidden="true" />
<Pause v-else aria-hidden="true" />
</sky-button>
</div>
<sky-list strong inset class="clock-konsta-list clock-timer-settings">
<sky-field
:label="phone.t('Apps.clock.timer.note')"
:label="phone.t('Apps.clock.timer.description')"
:placeholder="phone.t('Apps.clock.timer.notePlaceholder')"
:value="clock.timerNote"
maxlength="80"
@input="setTimerNote"
/>
</sky-list>
<sky-list
strong
inset
class="clock-konsta-list clock-timer-settings clock-timer-sound"
>
<sky-list-item
link
:chevron="false"
@@ -433,42 +482,7 @@ onBeforeUnmount(() => {
</template>
</sky-list-item>
</sky-list>
<div class="clock-actions">
<sky-button
rounded
variant="secondary"
class="clock-action-button"
@click="clock.resetTimer()"
>
{{ phone.t('Common.reset') }}
</sky-button>
<sky-button
rounded
:variant="clock.timerStartedAt === null ? 'primary' : 'danger'"
:style="
clock.timerStartedAt === null
? { '--sky-app-accent': '#30d158' }
: undefined
"
class="clock-action-button"
:disabled="clock.timerStartedAt === null && timerValue <= 0"
@click="
clock.timerStartedAt === null
? clock.startTimer(now)
: clock.pauseTimer(now)
"
>
{{
phone.t(
clock.timerStartedAt === null
? 'Common.start'
: 'Common.pause',
)
}}
</sky-button>
</div>
</sky-block>
</section>
</sky-block>
<sky-tab-bar
@@ -492,6 +506,14 @@ onBeforeUnmount(() => {
</template>
</sky-tab-button>
</sky-tab-bar>
<AlarmEditor
v-if="alarmEditor"
:alarm="selectedAlarm"
@cancel="alarmEditor = null"
@delete="deleteSelectedAlarm"
@save="saveAlarm"
/>
</template>
</sky-app-page>
</template>
+1 -1
View File
@@ -1446,7 +1446,7 @@ Locales["en"] = {
timer = {
time = "Timer duration", hours = "Hours", hoursShort = "hr", minutes = "Minutes", minutesShort = "min",
seconds = "Seconds", secondsShort = "sec",
note = "Note", notePlaceholder = "Timer", sound = "Sound", ringing = "Timer",
description = "Description", note = "Note", notePlaceholder = "Timer", sound = "Sound", ringing = "Timer",
},
},
mail = {