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:
Dante
2026-03-13 18:49:18 +09:00
committed by GitHub
parent c318cc4c14
commit 07d5bd50f6
38 changed files with 1528 additions and 25 deletions

View 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'
}
]
}
}
}

View 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>

View 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
}
]
}
}
}

View 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>

View 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
}

View File

@@ -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>
`
})
}

View File

@@ -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>

View File

@@ -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>