mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-14 17:37:46 +00:00
feat: extract SeedControlButton component (#9744)
<img width="1048" height="482" alt="스크린샷 2026-03-12 오전 9 11 56" src="https://github.com/user-attachments/assets/68009980-097c-4736-b7c4-eb8f9a6f05be" /> ## Summary - Extract inline value control button from `WidgetWithControl` into reusable `SeedControlButton` component - Support `badge` (pill) and `button` (square) variants per Figma design system spec - Use native `<button>` element for proper a11y (works with Reka UI's `PopoverTrigger as-child`) ## Test plan - [x] Verify seed control button renders correctly on KSampler node's seed widget - [x] Verify popover opens on click and mode selection works - [x] Verify all 4 modes display correct icon/text (shuffle, pencil-off, +1, -1) 🤖 Generated with [Claude Code](https://claude.com/claude-code) ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9744-feat-extract-SeedControlButton-component-3206d73d365081a3823cc19e48d205c1) by [Unito](https://www.unito.io) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
57
src/components/ui/chart/ChartBar.stories.ts
Normal file
57
src/components/ui/chart/ChartBar.stories.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import ChartBar from './ChartBar.vue'
|
||||
|
||||
const meta: Meta<typeof ChartBar> = {
|
||||
title: 'Components/Chart/ChartBar',
|
||||
component: ChartBar,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'centered' },
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template:
|
||||
'<div class="w-[413px] bg-neutral-900 p-4 rounded-lg"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
ariaLabel: 'Bar chart example',
|
||||
data: {
|
||||
labels: ['A', 'B', 'C', 'D'],
|
||||
datasets: [
|
||||
{
|
||||
label: 'BarName1',
|
||||
data: [10, 50, 35, 75],
|
||||
backgroundColor: '#ff8000'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const MultipleDatasets: Story = {
|
||||
args: {
|
||||
ariaLabel: 'Bar chart with multiple datasets',
|
||||
data: {
|
||||
labels: ['A', 'B', 'C', 'D'],
|
||||
datasets: [
|
||||
{
|
||||
label: 'Series 1',
|
||||
data: [30, 60, 45, 80],
|
||||
backgroundColor: '#ff8000'
|
||||
},
|
||||
{
|
||||
label: 'Series 2',
|
||||
data: [50, 40, 70, 20],
|
||||
backgroundColor: '#4ade80'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
34
src/components/ui/chart/ChartBar.vue
Normal file
34
src/components/ui/chart/ChartBar.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn('rounded-lg bg-component-node-widget-background p-6', props.class)
|
||||
"
|
||||
>
|
||||
<canvas ref="canvasRef" :aria-label="ariaLabel" role="img" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ChartData, ChartOptions } from 'chart.js'
|
||||
import { computed, ref, toRef } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { useChart } from './useChart'
|
||||
|
||||
const props = defineProps<{
|
||||
data: ChartData<'bar'>
|
||||
options?: ChartOptions<'bar'>
|
||||
ariaLabel?: string
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
|
||||
useChart(
|
||||
canvasRef,
|
||||
ref('bar'),
|
||||
toRef(() => props.data),
|
||||
computed(() => props.options as ChartOptions | undefined)
|
||||
)
|
||||
</script>
|
||||
76
src/components/ui/chart/ChartLine.stories.ts
Normal file
76
src/components/ui/chart/ChartLine.stories.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import ChartLine from './ChartLine.vue'
|
||||
|
||||
const meta: Meta<typeof ChartLine> = {
|
||||
title: 'Components/Chart/ChartLine',
|
||||
component: ChartLine,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'centered' },
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template:
|
||||
'<div class="w-[413px] bg-neutral-900 p-4 rounded-lg"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
ariaLabel: 'Line chart example',
|
||||
data: {
|
||||
labels: ['A', 'B', 'C', 'D'],
|
||||
datasets: [
|
||||
{
|
||||
label: 'LineName1',
|
||||
data: [10, 45, 25, 80],
|
||||
borderColor: '#4ade80',
|
||||
borderDash: [5, 5],
|
||||
fill: true,
|
||||
backgroundColor: '#4ade8033',
|
||||
tension: 0.4
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const MultipleLines: Story = {
|
||||
args: {
|
||||
ariaLabel: 'Line chart with multiple lines',
|
||||
data: {
|
||||
labels: ['A', 'B', 'C', 'D'],
|
||||
datasets: [
|
||||
{
|
||||
label: 'LineName1',
|
||||
data: [10, 45, 25, 80],
|
||||
borderColor: '#4ade80',
|
||||
borderDash: [5, 5],
|
||||
fill: true,
|
||||
backgroundColor: '#4ade8033',
|
||||
tension: 0.4
|
||||
},
|
||||
{
|
||||
label: 'LineName2',
|
||||
data: [80, 60, 40, 10],
|
||||
borderColor: '#ff8000',
|
||||
fill: true,
|
||||
backgroundColor: '#ff800033',
|
||||
tension: 0.4
|
||||
},
|
||||
{
|
||||
label: 'LineName3',
|
||||
data: [60, 70, 35, 40],
|
||||
borderColor: '#ef4444',
|
||||
fill: true,
|
||||
backgroundColor: '#ef444433',
|
||||
tension: 0.4
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
34
src/components/ui/chart/ChartLine.vue
Normal file
34
src/components/ui/chart/ChartLine.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn('rounded-lg bg-component-node-widget-background p-6', props.class)
|
||||
"
|
||||
>
|
||||
<canvas ref="canvasRef" :aria-label="ariaLabel" role="img" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ChartData, ChartOptions } from 'chart.js'
|
||||
import { computed, ref, toRef } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { useChart } from './useChart'
|
||||
|
||||
const props = defineProps<{
|
||||
data: ChartData<'line'>
|
||||
options?: ChartOptions<'line'>
|
||||
ariaLabel?: string
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
|
||||
useChart(
|
||||
canvasRef,
|
||||
ref('line'),
|
||||
toRef(() => props.data),
|
||||
computed(() => props.options as ChartOptions | undefined)
|
||||
)
|
||||
</script>
|
||||
196
src/components/ui/chart/useChart.ts
Normal file
196
src/components/ui/chart/useChart.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import type { ChartData, ChartOptions, ChartType } from 'chart.js'
|
||||
import {
|
||||
BarController,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
Chart,
|
||||
Filler,
|
||||
Legend,
|
||||
LinearScale,
|
||||
LineController,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Tooltip
|
||||
} from 'chart.js'
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
Chart.register(
|
||||
BarController,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
Filler,
|
||||
Legend,
|
||||
LinearScale,
|
||||
LineController,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Tooltip
|
||||
)
|
||||
|
||||
function getCssVar(name: string): string {
|
||||
return getComputedStyle(document.documentElement)
|
||||
.getPropertyValue(name)
|
||||
.trim()
|
||||
}
|
||||
|
||||
function getDefaultOptions(type: ChartType): ChartOptions {
|
||||
const foreground = getCssVar('--color-base-foreground') || '#ffffff'
|
||||
const muted = getCssVar('--color-muted-foreground') || '#8a8a8a'
|
||||
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
align: 'start',
|
||||
labels: {
|
||||
color: foreground,
|
||||
usePointStyle: true,
|
||||
pointStyle: 'circle',
|
||||
boxWidth: 8,
|
||||
boxHeight: 8,
|
||||
padding: 16,
|
||||
font: { family: 'Inter', size: 11 },
|
||||
generateLabels(chart) {
|
||||
const datasets = chart.data.datasets
|
||||
return datasets.map((dataset, i) => {
|
||||
const color =
|
||||
(dataset as { borderColor?: string }).borderColor ??
|
||||
(dataset as { backgroundColor?: string }).backgroundColor ??
|
||||
'#888'
|
||||
return {
|
||||
text: dataset.label ?? '',
|
||||
fillStyle: color as string,
|
||||
strokeStyle: color as string,
|
||||
lineWidth: 0,
|
||||
pointStyle: 'circle' as const,
|
||||
hidden: !chart.isDatasetVisible(i),
|
||||
datasetIndex: i
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true
|
||||
}
|
||||
},
|
||||
elements: {
|
||||
point: {
|
||||
radius: 0,
|
||||
hoverRadius: 4
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
color: muted,
|
||||
font: { family: 'Inter', size: 11 },
|
||||
padding: 8
|
||||
},
|
||||
grid: {
|
||||
display: true,
|
||||
color: muted + '33',
|
||||
drawTicks: false
|
||||
},
|
||||
border: { display: true, color: muted }
|
||||
},
|
||||
y: {
|
||||
ticks: {
|
||||
color: muted,
|
||||
font: { family: 'Inter', size: 11 },
|
||||
padding: 4
|
||||
},
|
||||
grid: {
|
||||
display: false,
|
||||
drawTicks: false
|
||||
},
|
||||
border: { display: true, color: muted }
|
||||
}
|
||||
},
|
||||
...(type === 'bar' && {
|
||||
datasets: {
|
||||
bar: {
|
||||
borderRadius: { topLeft: 4, topRight: 4 },
|
||||
borderSkipped: false,
|
||||
barPercentage: 0.6,
|
||||
categoryPercentage: 0.8
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function useChart(
|
||||
canvasRef: Ref<HTMLCanvasElement | null>,
|
||||
type: Ref<ChartType>,
|
||||
data: Ref<ChartData>,
|
||||
options?: Ref<ChartOptions | undefined>
|
||||
) {
|
||||
const chartInstance = ref<Chart | null>(null)
|
||||
|
||||
function createChart() {
|
||||
if (!canvasRef.value) return
|
||||
|
||||
chartInstance.value?.destroy()
|
||||
|
||||
const defaults = getDefaultOptions(type.value)
|
||||
const merged = options?.value
|
||||
? deepMerge(defaults, options.value)
|
||||
: defaults
|
||||
|
||||
chartInstance.value = new Chart(canvasRef.value, {
|
||||
type: type.value,
|
||||
data: data.value,
|
||||
options: merged
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(createChart)
|
||||
|
||||
watch([type, data, options ?? ref(undefined)], () => {
|
||||
if (chartInstance.value) {
|
||||
chartInstance.value.data = data.value
|
||||
chartInstance.value.options = options?.value
|
||||
? deepMerge(getDefaultOptions(type.value), options.value)
|
||||
: getDefaultOptions(type.value)
|
||||
chartInstance.value.update()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
chartInstance.value?.destroy()
|
||||
chartInstance.value = null
|
||||
})
|
||||
|
||||
return { chartInstance }
|
||||
}
|
||||
|
||||
function deepMerge<T extends Record<string, unknown>>(
|
||||
target: T,
|
||||
source: Record<string, unknown>
|
||||
): T {
|
||||
const result = { ...target } as Record<string, unknown>
|
||||
for (const key of Object.keys(source)) {
|
||||
const srcVal = source[key]
|
||||
const tgtVal = result[key]
|
||||
if (
|
||||
srcVal &&
|
||||
typeof srcVal === 'object' &&
|
||||
!Array.isArray(srcVal) &&
|
||||
tgtVal &&
|
||||
typeof tgtVal === 'object' &&
|
||||
!Array.isArray(tgtVal)
|
||||
) {
|
||||
result[key] = deepMerge(
|
||||
tgtVal as Record<string, unknown>,
|
||||
srcVal as Record<string, unknown>
|
||||
)
|
||||
} else {
|
||||
result[key] = srcVal
|
||||
}
|
||||
}
|
||||
return result as T
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import { CONTROL_OPTIONS } from '@/types/simplifiedWidget'
|
||||
|
||||
import ValueControlButton from './ValueControlButton.vue'
|
||||
|
||||
const meta: Meta<typeof ValueControlButton> = {
|
||||
title: 'Components/InputHelpers/ValueControlButton',
|
||||
component: ValueControlButton,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'centered' },
|
||||
argTypes: {
|
||||
mode: { control: 'select', options: [...CONTROL_OPTIONS] },
|
||||
variant: { control: 'select', options: ['badge', 'button'] }
|
||||
},
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template:
|
||||
'<div class="flex items-center justify-center rounded-lg bg-node-component-surface p-8"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Randomize: Story = {
|
||||
args: { mode: 'randomize', variant: 'badge' }
|
||||
}
|
||||
|
||||
export const Fixed: Story = {
|
||||
args: { mode: 'fixed', variant: 'badge' }
|
||||
}
|
||||
|
||||
export const Increment: Story = {
|
||||
args: { mode: 'increment', variant: 'badge' }
|
||||
}
|
||||
|
||||
export const Decrement: Story = {
|
||||
args: { mode: 'decrement', variant: 'badge' }
|
||||
}
|
||||
|
||||
export const AllModes: Story = {
|
||||
render: () => ({
|
||||
components: { ValueControlButton },
|
||||
template: `
|
||||
<div class="flex flex-col gap-6">
|
||||
<div>
|
||||
<p class="mb-2 text-sm text-muted-foreground">Badge</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<ValueControlButton mode="randomize" variant="badge" />
|
||||
<ValueControlButton mode="fixed" variant="badge" />
|
||||
<ValueControlButton mode="increment" variant="badge" />
|
||||
<ValueControlButton mode="decrement" variant="badge" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm text-muted-foreground">Button</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<ValueControlButton mode="randomize" variant="button" />
|
||||
<ValueControlButton mode="fixed" variant="button" />
|
||||
<ValueControlButton mode="increment" variant="button" />
|
||||
<ValueControlButton mode="decrement" variant="button" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { ControlOptions } from '@/types/simplifiedWidget'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { mode, variant = 'badge' } = defineProps<{
|
||||
mode: ControlOptions
|
||||
variant?: 'badge' | 'button'
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const iconMap: Record<ControlOptions, string | null> = {
|
||||
fixed: 'icon-[lucide--pencil-off]',
|
||||
randomize: 'icon-[lucide--shuffle]',
|
||||
increment: null,
|
||||
decrement: null
|
||||
}
|
||||
|
||||
const textMap: Record<ControlOptions, string | null> = {
|
||||
increment: '+1',
|
||||
decrement: '-1',
|
||||
fixed: null,
|
||||
randomize: null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="t('widgets.valueControl.' + mode)"
|
||||
:class="
|
||||
cn(
|
||||
'flex shrink-0 cursor-pointer items-center justify-center border-none focus-visible:ring-2 focus-visible:ring-primary-background focus-visible:ring-offset-1 focus-visible:outline-none',
|
||||
variant === 'badge' ? 'h-4.5 w-8 rounded-full' : 'size-6 rounded-sm',
|
||||
mode !== 'fixed'
|
||||
? 'bg-primary-background/30 hover:bg-primary-background-hover/30'
|
||||
: 'bg-transparent'
|
||||
)
|
||||
"
|
||||
>
|
||||
<i
|
||||
v-if="iconMap[mode]"
|
||||
aria-hidden="true"
|
||||
:class="
|
||||
cn(
|
||||
iconMap[mode] ?? '',
|
||||
'text-xs',
|
||||
mode === 'fixed' ? 'text-muted-foreground' : 'text-primary-background'
|
||||
)
|
||||
"
|
||||
/>
|
||||
<span
|
||||
v-else-if="textMap[mode]"
|
||||
class="text-xs font-normal text-primary-background"
|
||||
>
|
||||
{{ textMap[mode] }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
@@ -1,14 +1,15 @@
|
||||
<script setup lang="ts" generic="T extends WidgetValue">
|
||||
import { computed, defineAsyncComponent, ref, watch } from 'vue'
|
||||
import { defineAsyncComponent, ref, watch } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type {
|
||||
SimplifiedControlWidget,
|
||||
WidgetValue
|
||||
} from '@/types/simplifiedWidget'
|
||||
|
||||
import ValueControlButton from './ValueControlButton.vue'
|
||||
|
||||
const ValueControlPopover = defineAsyncComponent(
|
||||
() => import('./ValueControlPopover.vue')
|
||||
)
|
||||
@@ -22,19 +23,6 @@ const modelValue = defineModel<T>()
|
||||
|
||||
const controlModel = ref(props.widget.controlWidget.value)
|
||||
|
||||
const controlButtonIcon = computed(() => {
|
||||
switch (controlModel.value) {
|
||||
case 'increment':
|
||||
return 'pi pi-plus'
|
||||
case 'decrement':
|
||||
return 'pi pi-minus'
|
||||
case 'fixed':
|
||||
return 'icon-[lucide--pencil-off]'
|
||||
default:
|
||||
return 'icon-[lucide--shuffle]'
|
||||
}
|
||||
})
|
||||
|
||||
watch(controlModel, props.widget.controlWidget.update)
|
||||
</script>
|
||||
<template>
|
||||
@@ -42,15 +30,7 @@ watch(controlModel, props.widget.controlWidget.update)
|
||||
<component :is="component" v-bind="$attrs" v-model="modelValue" :widget>
|
||||
<Popover>
|
||||
<template #button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
class="h-4 w-7 self-center rounded-xl bg-primary-background/30 p-0 hover:bg-primary-background-hover/30"
|
||||
>
|
||||
<i
|
||||
:class="`${controlButtonIcon} w-full text-xs text-primary-background`"
|
||||
/>
|
||||
</Button>
|
||||
<ValueControlButton :mode="controlModel" class="self-center" />
|
||||
</template>
|
||||
<ValueControlPopover v-model="controlModel" />
|
||||
</Popover>
|
||||
|
||||
Reference in New Issue
Block a user