fix(ui): refine export scale control

This commit is contained in:
Danila Poyarkov 2026-07-01 08:08:44 +03:00
parent bcf18b8022
commit 3accbbb835
5 changed files with 116 additions and 50 deletions

View file

@ -182,7 +182,13 @@ function vueTemplateAst(source, filename) {
}
function isVueSourceFile(file) {
return file.endsWith('.vue') && (file.includes('/src/') || file.includes('/packages/vue/src/'))
return (
file.endsWith('.vue') &&
(file.startsWith('src/') ||
file.includes('/src/') ||
file.startsWith('packages/vue/src/') ||
file.includes('/packages/vue/src/'))
)
}
function sourceLineCount(source) {
@ -272,8 +278,7 @@ const noVueStyleBlocks = {
},
create(context) {
const file = normalizedFilename(context)
if (!file.endsWith('.vue')) return {}
if (!file.includes('/src/') && !file.includes('/packages/vue/src/')) return {}
if (!isVueSourceFile(file)) return {}
return {
Program(node) {
@ -297,8 +302,7 @@ const noNativeTitleAttributesInVue = {
},
create(context) {
const file = normalizedFilename(context)
if (!file.endsWith('.vue')) return {}
if (!file.includes('/src/') && !file.includes('/packages/vue/src/')) return {}
if (!isVueSourceFile(file)) return {}
return {
Program(node) {
@ -329,8 +333,7 @@ const noHardcodedTipLabelsInVue = {
},
create(context) {
const file = normalizedFilename(context)
if (!file.endsWith('.vue')) return {}
if (!file.includes('/src/') && !file.includes('/packages/vue/src/')) return {}
if (!isVueSourceFile(file)) return {}
return {
Program(node) {

View file

@ -12,6 +12,7 @@ import { vTestId, type TestIdProps } from '@open-pencil/vue'
import { useInputUI } from '@/components/ui/input'
import { menuItem, useMenuUI } from '@/components/ui/menu'
import Tip from '@/components/ui/Tip.vue'
interface ExportScaleInputProps extends TestIdProps {
presets: readonly number[]
@ -35,7 +36,10 @@ const text = ref('')
// Keep the editable text in sync with the committed scale (e.g. 1.5 -> "1.5x").
watch(modelValue, (value) => (text.value = `${value}x`), { immediate: true })
const inputClass = useInputUI({ size: 'sm', ui: { base: 'min-w-0 flex-1' } }).base
const inputClass = useInputUI({
size: 'sm',
ui: { base: 'min-w-0 flex-1 rounded-none border-0 bg-transparent focus:border-transparent' }
}).base
const menuCls = useMenuUI({ content: 'min-w-[7rem]' })
const itemCls = menuItem({ justify: 'between' })
@ -58,46 +62,50 @@ function isActive(scale: number) {
</script>
<template>
<div class="flex min-w-0 flex-1 items-center gap-1">
<input
ref="inputRef"
v-model="text"
v-test-id="testId"
type="text"
:title="label"
:class="inputClass"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
@focus="inputRef?.select()"
@blur="commit"
@keydown.enter.prevent="inputRef?.blur()"
@keydown.escape.prevent="((text = `${modelValue}x`), inputRef?.blur())"
/>
<DropdownMenuRoot v-model:open="open">
<DropdownMenuTrigger as-child>
<button
type="button"
:title="label"
class="flex shrink-0 cursor-pointer items-center rounded border border-border bg-input px-1 py-1 text-surface hover:bg-hover"
>
<icon-lucide-chevron-down class="size-3 text-muted" />
</button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent side="bottom" align="end" :side-offset="4" :class="menuCls.content">
<DropdownMenuItem
v-for="scale in presets"
:key="scale"
:class="itemCls"
@select="pick(scale)"
<Tip :label="label" :disabled="!label">
<div
class="flex min-w-0 flex-1 overflow-hidden rounded border border-border bg-input focus-within:border-accent"
>
<input
ref="inputRef"
v-model="text"
v-test-id="testId"
type="text"
:aria-label="label"
:class="inputClass"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
@focus="inputRef?.select()"
@blur="commit"
@keydown.enter.prevent="inputRef?.blur()"
@keydown.escape.prevent="((text = `${modelValue}x`), inputRef?.blur())"
/>
<DropdownMenuRoot v-model:open="open">
<DropdownMenuTrigger as-child>
<button
type="button"
:aria-label="label"
class="flex shrink-0 cursor-pointer items-center border-l border-border px-1.5 text-surface hover:bg-hover"
>
<span>{{ scale }}x</span>
<icon-lucide-check v-if="isActive(scale)" class="size-3 text-accent" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</div>
<icon-lucide-chevron-down class="size-3 text-muted" />
</button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent side="bottom" align="end" :side-offset="4" :class="menuCls.content">
<DropdownMenuItem
v-for="scale in presets"
:key="scale"
:class="itemCls"
@select="pick(scale)"
>
<span>{{ scale }}x</span>
<icon-lucide-check v-if="isActive(scale)" class="size-3 text-accent" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</div>
</Tip>
</template>

View file

@ -49,7 +49,7 @@ const indicator = ui?.indicator ?? 'absolute left-1.5 inline-flex items-center j
<template>
<SelectRoot v-model="modelValue">
<SelectTrigger v-test-id="testId" :class="select.trigger" :aria-label="label" :title="label">
<SelectTrigger v-test-id="testId" :class="select.trigger" :aria-label="label">
<SelectValue :placeholder="placeholder" />
<icon-lucide-chevron-down class="ml-1 size-3 shrink-0 text-muted" />
</SelectTrigger>

View file

@ -38,6 +38,7 @@ export default defineConfig([
'open-pencil/no-non-ui-imports-in-shared-ui': 'error',
'open-pencil/no-app-imports-in-shared-ui': 'error',
'open-pencil/no-property-panel-internals-outside-panel': 'error',
'open-pencil/no-native-title-attributes-in-vue': 'error',
'open-pencil/no-ui-imports-in-core': 'error',
'open-pencil/scripts-are-entrypoint-shims': 'error',
'open-pencil/strict-tools-layout': 'error'

View file

@ -1,5 +1,7 @@
import path from 'node:path'
import { parse as parseVueSfc } from 'vue/compiler-sfc'
import {
collectFolders,
createFileRule,
@ -360,6 +362,57 @@ const noHardcodedMacOSShortcutGlyphs = createTextRule(
}
)
const VUE_ATTRIBUTE_NODE = 6
const VUE_DIRECTIVE_NODE = 7
type VueTemplateNode = {
type?: number
name?: string
arg?: { content?: string }
props?: VueTemplateNode[]
children?: VueTemplateNode[]
loc?: { start?: { line?: number; column?: number } }
}
function walkVueTemplateAst(node: VueTemplateNode, visitor: (node: VueTemplateNode) => void) {
visitor(node)
for (const prop of node.props ?? []) walkVueTemplateAst(prop, visitor)
for (const child of node.children ?? []) walkVueTemplateAst(child, visitor)
}
function vuePropName(prop: VueTemplateNode) {
if (prop.type === VUE_ATTRIBUTE_NODE) return prop.name ?? null
if (prop.type === VUE_DIRECTIVE_NODE && prop.name === 'bind') return prop.arg?.content ?? null
return null
}
const noNativeTitleAttributesInVue = createTextRule(
'open-pencil/no-native-title-attributes-in-vue',
(sourceRel, content) => {
if (
!sourceRel.endsWith('.vue') ||
(!sourceRel.startsWith('src/') && !sourceRel.startsWith('packages/vue/src/'))
) {
return []
}
const template = parseVueSfc(content, { filename: sourceRel }).descriptor.template?.ast
if (!template) return []
const diagnostics: Array<{ message: string; line?: number; column?: number }> = []
walkVueTemplateAst(template as VueTemplateNode, (node) => {
if (vuePropName(node) !== 'title') return
const loc = node.loc?.start
diagnostics.push({
message: 'Use Tip/Reka tooltip patterns instead of native title attributes.',
line: loc?.line,
column: loc?.column
})
})
return diagnostics
}
)
const SHORTCUT_LABEL_PATTERN = /(?:Shift|Ctrl|Alt|Option|Cmd|Command|⌘|⇧|⌥|⌃)\s*[+)\w]/u
const noShortcutTextInLabels = createTextRule(
@ -429,6 +482,7 @@ export const openPencilArchitecturePlugin = {
noNonUiImportsInSharedUi,
noAppImportsInSharedUi,
noPropertyPanelInternalsOutsidePanel,
noNativeTitleAttributesInVue,
noShortcutTextInLabels,
noHardcodedMacOSShortcutGlyphs,
noUiImportsInCore