1.2.6: dedicated FontPicker component with in-line type previews

Replaces the bare <select> with a custom combobox that fits the
properties panel's visual language:

- Trigger looks like the other inputs (var(--bg)/var(--border)/
  var(--radius-sm)) and shows the selected font's name rendered in
  that font itself, with a rotating caret.
- Options live in a teleported popover anchored beneath the trigger.
  Each option renders its own label in its own typeface so the user
  sees what they're picking before applying it. Active row uses
  var(--accent-soft) to match the rest of the editor.
- Click-outside closes via window pointerdown; Escape closes and
  returns focus to the trigger.
- Storage shape simplified: textFontModel uses '' for system-sans
  instead of the literal 'system-sans' id, so the model field round-
  trips through JSON without a placeholder string ever leaking.

Updated PropertiesPanel test to walk the new picker (click trigger,
read options from .font-popover, click an option) instead of
dispatching a select 'change' event. Picker label text doubles as
the test selector — System Sans / Inter / Permanent Marker confirm
the option list is wired up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-26 10:27:19 +02:00
1 parent 4c2b7c7c35
commit 8c4f744afc
5 files changed
+258 -23

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "ruwio",
"private": true,
"version": "1.2.5",
"version": "1.2.6",
"type": "module",
"scripts": {
"dev": "vite",
+224
View File
@@ -0,0 +1,224 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { PhCaretDown, PhCheck } from '@phosphor-icons/vue'
import { AVAILABLE_FONTS, fontDef } from '@/lib/fonts'
const props = defineProps<{
// Empty string = system-sans (no override). Mirrors the storage
// shape: TextAsset.fontFamily is undefined for the default.
modelValue: string
}>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
const open = ref(false)
const trigger = ref<HTMLButtonElement | null>(null)
const popover = ref<HTMLDivElement | null>(null)
const selected = computed(() => fontDef(props.modelValue || undefined))
function toggle() {
open.value = !open.value
}
function pick(id: string) {
// Storage convention: 'system-sans' clears the override.
emit('update:modelValue', id === 'system-sans' ? '' : id)
open.value = false
trigger.value?.focus()
}
function onWindowPointerDown(e: PointerEvent) {
if (!open.value) return
const t = e.target as Node | null
if (!t) return
if (trigger.value?.contains(t)) return
if (popover.value?.contains(t)) return
open.value = false
}
function onKeydown(e: KeyboardEvent) {
if (!open.value) return
if (e.key === 'Escape') {
e.preventDefault()
open.value = false
trigger.value?.focus()
}
}
onMounted(() => {
window.addEventListener('pointerdown', onWindowPointerDown, true)
window.addEventListener('keydown', onKeydown)
})
onBeforeUnmount(() => {
window.removeEventListener('pointerdown', onWindowPointerDown, true)
window.removeEventListener('keydown', onKeydown)
})
// Anchor the popover beneath the trigger using the trigger's
// post-layout rect. Re-measure when it opens.
const popStyle = ref<Record<string, string>>({})
watch(open, async (isOpen) => {
if (!isOpen) return
// Wait a tick so the popover element exists before measuring.
await Promise.resolve()
const t = trigger.value
if (!t) return
const r = t.getBoundingClientRect()
popStyle.value = {
left: `${r.left}px`,
top: `${r.bottom + 4}px`,
minWidth: `${r.width}px`,
}
})
</script>
<template>
<div class="font-picker">
<button
ref="trigger"
type="button"
class="trigger"
:class="{ open }"
:aria-expanded="open"
@click="toggle"
>
<span class="label" :style="{ fontFamily: selected.family }">{{ selected.label }}</span>
<PhCaretDown :size="12" class="caret" />
</button>
<Teleport to="body">
<div
v-if="open"
ref="popover"
class="font-popover"
:style="popStyle"
role="listbox"
>
<button
v-for="f in AVAILABLE_FONTS"
:key="f.id"
type="button"
class="font-option"
:class="{ active: (modelValue || 'system-sans') === f.id }"
role="option"
:aria-selected="(modelValue || 'system-sans') === f.id"
@click="pick(f.id)"
>
<PhCheck
:size="12"
class="check"
:style="{ visibility: (modelValue || 'system-sans') === f.id ? 'visible' : 'hidden' }"
/>
<span class="option-label" :style="{ fontFamily: f.family }">{{ f.label }}</span>
</button>
</div>
</Teleport>
</div>
</template>
<style scoped>
.font-picker {
position: relative;
width: 100%;
}
.trigger {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
padding: 6px 8px;
font-size: 12px;
text-transform: none;
letter-spacing: 0;
cursor: pointer;
text-align: left;
}
.trigger:hover {
border-color: var(--border-strong, var(--border));
}
.trigger.open,
.trigger:focus-visible {
border-color: var(--accent);
outline: none;
}
.trigger .label {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.trigger .caret {
flex-shrink: 0;
color: var(--text-dim);
transition: transform 0.15s ease;
}
.trigger.open .caret {
transform: rotate(180deg);
}
</style>
<style>
/* Popover is teleported to <body>, so the styles need to be global. */
.font-popover {
position: fixed;
z-index: 1000;
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
padding: 4px;
display: flex;
flex-direction: column;
gap: 1px;
max-height: 320px;
overflow-y: auto;
}
.font-popover .font-option {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-sm);
color: var(--text);
padding: 6px 8px;
font-size: 13px;
text-transform: none;
letter-spacing: 0;
cursor: pointer;
text-align: left;
}
.font-popover .font-option:hover {
background: var(--panel-hover, var(--panel-alt));
}
.font-popover .font-option.active {
background: var(--accent-soft);
color: var(--accent);
}
.font-popover .check {
flex-shrink: 0;
}
.font-popover .option-label {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+7 -6
View File
@@ -18,8 +18,8 @@ import { sampleLayer } from '@/lib/interpolate'
import type { Action, AnimatableProps, Layer } from '@/types/scene'
import RangeSlider from './RangeSlider.vue'
import ActionEditor from './ActionEditor.vue'
import FontPicker from './FontPicker.vue'
import { t } from '@/i18n'
import { AVAILABLE_FONTS } from '@/lib/fonts'
const SECTION_STORAGE_KEY = 'animatr:propSections'
@@ -138,11 +138,14 @@ const textAsset = computed(() => {
return a && a.kind === 'text' ? a : null
})
// Empty string represents the system-sans default. Stored as
// `undefined` on the asset so the model field round-trips cleanly
// through JSON without leaking a bogus 'system-sans' id.
const textFontModel = computed({
get: () => textAsset.value?.fontFamily ?? 'system-sans',
get: () => textAsset.value?.fontFamily ?? '',
set: (id: string) => {
if (!textAsset.value) return
textAsset.value.fontFamily = id === 'system-sans' ? undefined : id
textAsset.value.fontFamily = id || undefined
},
})
@@ -497,9 +500,7 @@ function deleteSelection() {
<label>{{ t('properties.text.fontSize') }}<input type="number" min="1" v-model.number="textAsset.fontSize" /></label>
<label>
{{ t('properties.text.font') }}
<select v-model="textFontModel">
<option v-for="f in AVAILABLE_FONTS" :key="f.id" :value="f.id">{{ f.label }}</option>
</select>
<FontPicker v-model="textFontModel" />
</label>
</div>
</section>
+25 -15
View File
@@ -15032,7 +15032,7 @@ export function registerAllTests(): void {
expect(fontCssFamily('does-not-exist')).toBe(SYSTEM_SANS.family)
})
test('PropertiesPanel renders a font dropdown for text assets and writes back', async () => {
test('PropertiesPanel renders the font picker for text assets and writes back', async () => {
const s = makeFreshStore()
const a = s.addAsset({
kind: 'text', name: 'T', text: 'hello', fontSize: 32, fill: '#000',
@@ -15043,30 +15043,40 @@ export function registerAllTests(): void {
const handle = mountComponent(PropertiesPanel, {})
try {
await nextTick()
const select = handle.host.querySelector('select') as HTMLSelectElement | null
expect(select).toBeTruthy()
const options = Array.from(select!.options).map(o => o.value)
expect(options).toContain('system-sans')
expect(options).toContain('inter')
expect(options).toContain('marker')
// Default selection: system-sans (asset.fontFamily is undefined).
expect(select!.value).toBe('system-sans')
// Pick Inter — the asset should mutate.
select!.value = 'inter'
select!.dispatchEvent(new Event('change'))
const trigger = handle.host.querySelector('.font-picker .trigger') as HTMLButtonElement | null
expect(!!trigger).toBe(true)
// Closed state: the label shows the default — System Sans.
expect((trigger!.textContent ?? '').includes('System Sans')).toBe(true)
trigger!.click()
await nextTick()
// Popover lives in document.body via Teleport.
const opts = Array.from(document.querySelectorAll('.font-popover .font-option')) as HTMLButtonElement[]
expect(opts.length).toBeGreaterThan(5)
const labels = opts.map(o => o.textContent?.trim() ?? '')
expect(labels.some(l => l.includes('System Sans'))).toBe(true)
expect(labels.some(l => l.includes('Inter'))).toBe(true)
expect(labels.some(l => l.includes('Permanent Marker'))).toBe(true)
// Click Inter.
const interOpt = opts.find(o => (o.textContent ?? '').trim() === 'Inter')!
interOpt.click()
await nextTick()
const textAsset = s.scene.assets.find(x => x.id === a.id)!
if (textAsset.kind !== 'text') throw new Error('unexpected kind')
expect(textAsset.fontFamily).toBe('inter')
// Picking system-sans clears the field (undefined, not the literal id).
select!.value = 'system-sans'
select!.dispatchEvent(new Event('change'))
// Re-open + click System Sans → clears (undefined).
trigger!.click()
await nextTick()
const opts2 = Array.from(document.querySelectorAll('.font-popover .font-option')) as HTMLButtonElement[]
const sysOpt = opts2.find(o => (o.textContent ?? '').trim() === 'System Sans')!
sysOpt.click()
await nextTick()
const textAsset2 = s.scene.assets.find(x => x.id === a.id)!
if (textAsset2.kind !== 'text') throw new Error('unexpected kind')
expect(textAsset2.fontFamily === undefined).toBe(true)
} finally {
handle.unmount()
// Make sure the teleported popover doesn't leak into other tests.
document.querySelectorAll('.font-popover').forEach(el => el.remove())
}
})
+1 -1
View File
@@ -1,2 +1,2 @@
export const APP_VERSION = '1.2.5'
export const APP_VERSION = '1.2.6'
export const APP_NAME = 'RUWIO'