feat(morfo/time-picker): declare HourSlider/MinuteSlider/SecondSlider as parts + ruler opt-out prop

User pointed out that TimePicker exposes <TimePicker.HourSlider> /
.MinuteSlider / .SecondSlider in its public API but those weren't
declared in the time-picker morfo — the contract was implicit (data-
slider attrs from the slider morfo). Now made explicit.

Morfo additions (all optional, archetype: 'group'):
- HourSlider   → data-time-picker-hour-slider
- MinuteSlider → data-time-picker-minute-slider
- SecondSlider → data-time-picker-second-slider

Eidos slider wrappers stamp the corresponding data-attr on the outer
wrapper div so morfo:check finds the parts in the DOM. The inner
slider DOM (data-slider, data-slider-thumb, data-slider-range) keeps
its own contract via the slider morfo — composition, not duplication.

Also:
- Hour ticks now end-inclusive: 24h shows 0/3/6/9/12/15/18/21/23
  (was 0..21, missing the upper cap). 12h shows 1/3/6/9/12.
- Minute / Second scale now ends with 59 so the upper bound is shown.
- New optional `ruler` prop on <TimePicker> (default `true`) and
  matching per-slider `ruler` opt-out. Propagated via visual context.
- Demo: new switch to toggle the ruler scale.

67/67 component-audit PASS, 39/39 morfo:check PASS, svelte-check
0 errors / 0 warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
dev 5 months ago
parent e7b2e461bc
commit 2f147897d9

@ -7,6 +7,13 @@ export interface TimePickerVisualContext {
getSize(): TimePickerSize;
getVariant(): TimePickerVariant;
getColor(): TimePickerColor;
/**
* Whether to render the labelled ruler scale next to each slider.
* `true` by default; pass `ruler={false}` on `<TimePicker>` to hide
* the rulers entirely, or pass `ruler={false}` on an individual
* slider eidos wrapper to opt that one out.
*/
getRuler(): boolean;
}
export function setTimePickerVisualContext(ctx: TimePickerVisualContext): TimePickerVisualContext {

@ -2,20 +2,32 @@
import * as TimePicker from '$soma/components/time-picker';
import { TimePickerProvider } from '$soma/components/time-picker';
import * as Slider from '$soma/components/slider';
import { getTimePickerVisualContext } from './context.svelte';
import type { TimePickerSliderProps } from './types';
let { children, ...rest }: TimePickerSliderProps = $props();
type Props = TimePickerSliderProps & {
/** Show the labelled ruler above the track. Falls back to the
* picker-level `ruler` flag (default `true`). */
ruler?: boolean;
};
let { children, ruler, ...rest }: Props = $props();
const provider = TimePickerProvider.require();
const visual = getTimePickerVisualContext();
const cycle = $derived(provider.resolvedHourCycle);
// Cap-inclusive range. 24h → 0..23 (step 3 ticks): 0,3,6,9,12,15,18,21,23
// 12h → 1..12 (step 1, every 3): 1,3,6,9,12
// Endpoint-inclusive scale. 24h: every 3h plus the upper cap (23) so the
// scale shows the full available range, not just up to 21. 12h: 1, 3, 6,
// 9, 12 — already endpoint-inclusive.
const tickValues = $derived.by(() => {
if (cycle === 12) return [1, 3, 6, 9, 12];
return [0, 3, 6, 9, 12, 15, 18, 21];
return [0, 3, 6, 9, 12, 15, 18, 21, 23];
});
const min = $derived(provider.hourMin);
const max = $derived(provider.hourMax);
const showRuler = $derived(ruler ?? visual?.getRuler() ?? true);
function percent(v: number): string {
const t = (v - min) / (max - min || 1);
@ -23,15 +35,19 @@
}
</script>
<div class="time-picker-slider time-picker-hour-slider">
<div class="time-picker-scale" aria-hidden="true">
{#each tickValues as v}
<span class="time-picker-scale-tick" style:left={percent(v)}>
<span class="time-picker-scale-mark"></span>
<span class="time-picker-scale-label">{cycle === 12 ? v : String(v).padStart(2, '0')}</span>
</span>
{/each}
</div>
<div data-time-picker-hour-slider class="time-picker-slider time-picker-hour-slider">
{#if showRuler}
<div class="time-picker-scale" aria-hidden="true">
{#each tickValues as v}
<span class="time-picker-scale-tick" style:left={percent(v)}>
<span class="time-picker-scale-mark"></span>
<span class="time-picker-scale-label"
>{cycle === 12 ? v : String(v).padStart(2, '0')}</span
>
</span>
{/each}
</div>
{/if}
<TimePicker.HourSlider {...rest}>
{#if children}
{@render children?.()}

@ -1,22 +1,33 @@
<script lang="ts">
import * as TimePicker from '$soma/components/time-picker';
import * as Slider from '$soma/components/slider';
import { getTimePickerVisualContext } from './context.svelte';
import type { TimePickerSliderProps } from './types';
let { children, ...rest }: TimePickerSliderProps = $props();
type Props = TimePickerSliderProps & {
/** Show the labelled ruler below the track. Falls back to the
* picker-level `ruler` flag (default `true`). */
ruler?: boolean;
};
// Tick every 15 minutes by default: 0, 15, 30, 45 (range 0..59).
const tickValues = [0, 15, 30, 45];
let { children, ruler, ...rest }: Props = $props();
// Endpoint-inclusive: 0, 15, 30, 45, 59. Final mark sits at the right
// end so the user sees the upper bound of the available range.
const tickValues = [0, 15, 30, 45, 59];
const min = 0;
const max = 59;
const visual = getTimePickerVisualContext();
const showRuler = $derived(ruler ?? visual?.getRuler() ?? true);
function percent(v: number): string {
const t = (v - min) / (max - min);
return `${Math.max(0, Math.min(1, t)) * 100}%`;
}
</script>
<div class="time-picker-slider time-picker-minute-slider">
<div data-time-picker-minute-slider class="time-picker-slider time-picker-minute-slider">
<TimePicker.MinuteSlider {...rest}>
{#if children}
{@render children?.()}
@ -25,12 +36,14 @@
<Slider.Thumb />
{/if}
</TimePicker.MinuteSlider>
<div class="time-picker-scale time-picker-scale-below" aria-hidden="true">
{#each tickValues as v}
<span class="time-picker-scale-tick" style:left={percent(v)}>
<span class="time-picker-scale-mark"></span>
<span class="time-picker-scale-label">{String(v).padStart(2, '0')}</span>
</span>
{/each}
</div>
{#if showRuler}
<div class="time-picker-scale time-picker-scale-below" aria-hidden="true">
{#each tickValues as v}
<span class="time-picker-scale-tick" style:left={percent(v)}>
<span class="time-picker-scale-mark"></span>
<span class="time-picker-scale-label">{String(v).padStart(2, '0')}</span>
</span>
{/each}
</div>
{/if}
</div>

@ -1,21 +1,31 @@
<script lang="ts">
import * as TimePicker from '$soma/components/time-picker';
import * as Slider from '$soma/components/slider';
import { getTimePickerVisualContext } from './context.svelte';
import type { TimePickerSliderProps } from './types';
let { children, ...rest }: TimePickerSliderProps = $props();
type Props = TimePickerSliderProps & {
/** Show the labelled ruler below the track. Falls back to the
* picker-level `ruler` flag (default `true`). */
ruler?: boolean;
};
const tickValues = [0, 15, 30, 45];
let { children, ruler, ...rest }: Props = $props();
const tickValues = [0, 15, 30, 45, 59];
const min = 0;
const max = 59;
const visual = getTimePickerVisualContext();
const showRuler = $derived(ruler ?? visual?.getRuler() ?? true);
function percent(v: number): string {
const t = (v - min) / (max - min);
return `${Math.max(0, Math.min(1, t)) * 100}%`;
}
</script>
<div class="time-picker-slider time-picker-second-slider">
<div data-time-picker-second-slider class="time-picker-slider time-picker-second-slider">
<TimePicker.SecondSlider {...rest}>
{#if children}
{@render children?.()}
@ -24,12 +34,14 @@
<Slider.Thumb />
{/if}
</TimePicker.SecondSlider>
<div class="time-picker-scale time-picker-scale-below" aria-hidden="true">
{#each tickValues as v}
<span class="time-picker-scale-tick" style:left={percent(v)}>
<span class="time-picker-scale-mark"></span>
<span class="time-picker-scale-label">{String(v).padStart(2, '0')}</span>
</span>
{/each}
</div>
{#if showRuler}
<div class="time-picker-scale time-picker-scale-below" aria-hidden="true">
{#each tickValues as v}
<span class="time-picker-scale-tick" style:left={percent(v)}>
<span class="time-picker-scale-mark"></span>
<span class="time-picker-scale-label">{String(v).padStart(2, '0')}</span>
</span>
{/each}
</div>
{/if}
</div>

@ -8,6 +8,7 @@
size = 'md',
variant = 'surface',
color = 'primary',
ruler = true,
value = $bindable(),
placeholder = $bindable(),
open = $bindable(false),
@ -21,7 +22,8 @@
setTimePickerVisualContext({
getSize: () => resolvedSize,
getVariant: () => variant,
getColor: () => color
getColor: () => color,
getRuler: () => ruler
});
</script>

@ -33,6 +33,11 @@ export type TimePickerProps = ProviderProps & {
variant?: TimePickerVariant
/** Intent palette used for focus and selected affordances. @default 'primary' */
color?: TimePickerColor
/**
* Show the labelled ruler scale next to each slider in the clock
* popover. @default true
*/
ruler?: boolean
}
export type TimePickerTriggerProps = SomaTimePickerTriggerProps & {

@ -230,6 +230,58 @@ export const timePickerMorfo = {
{ key: 'Shift+Tab', action: 'focus-prev' }
]
},
{
// Hour / Minute / Second sliders — composite parts. The outer
// wrapper element carries `data-time-picker-{X}-slider`; the
// inner slider DOM is declared by the slider morfo (data-slider,
// data-slider-thumb, data-slider-range).
name: 'HourSlider',
kebab: 'hour-slider',
archetype: 'group',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [{ attr: 'data-disabled', severity: 'optional' }],
aria: [
{
attr: 'aria-label',
value: v.translationRef('#?components.time-picker.hour|Hour'),
severity: 'optional'
}
]
},
{
name: 'MinuteSlider',
kebab: 'minute-slider',
archetype: 'group',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [{ attr: 'data-disabled', severity: 'optional' }],
aria: [
{
attr: 'aria-label',
value: v.translationRef('#?components.time-picker.minute|Minute'),
severity: 'optional'
}
]
},
{
name: 'SecondSlider',
kebab: 'second-slider',
archetype: 'group',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [{ attr: 'data-disabled', severity: 'optional' }],
aria: [
{
attr: 'aria-label',
value: v.translationRef('#?components.time-picker.second|Second'),
severity: 'optional'
}
]
},
{
name: 'DayPeriodToggle',
kebab: 'day-period-toggle',

@ -38,6 +38,7 @@
let disabled = $state(false);
let readonly = $state(false);
let lockSeconds = $state(false);
let ruler = $state(true);
let granularity = $state<TimeGranularity>('minute');
let hourCycle = $state<HourCycle>(24);
let bounds = $state(false);
@ -239,6 +240,7 @@
{granularity}
{hourCycle}
{locale}
{ruler}
{size}
{variant}
{color}
@ -421,6 +423,13 @@
<span data-uix-switch-label>seconds readonly</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>clock</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={ruler} />
<span data-uix-switch-label>show ruler scale</span>
</span>
</label>
</div>
<div data-uix-subsection-head>Footer composition</div>

Loading…
Cancel
Save

Powered by TurnKey Linux.