Compare commits

...

68 Commits

Author SHA1 Message Date
snomiao
ce71c2c529 feat: Add more Storybook stories for UI components 2025-09-22 21:10:30 +00:00
Alexander Brown
e5d4d07d32 Refactor: More state management simplification (#5721)
## Summary

Remove more procedural synchronization in favor of using reactive
references.

> Note: Also includes some fixes for issues caused during HMR.

## Review Focus

In testing it seems to work the same, but let me know if I missed
something.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5721-Refactor-More-state-management-simplification-2766d73d3650819b8d7ddc047c460f2b)
by [Unito](https://www.unito.io)
2025-09-22 13:15:33 -07:00
Alexander Piskun
f086377307 add pricing for new api nodes (#5724)
## Summary

Added prices for the new upcoming API nodes. Backport required.
2025-09-22 11:33:00 -07:00
filtered
687b9e659c Fix reroute ID 0 treated as invalid (#5723)
## Summary

Fixes old logic bug from refactor
https://github.com/Comfy-Org/litegraph.js/pull/602/files

## Changes

- Fixes truthy refactor to explicitly check undefined

## Review Focus

No expectation that this will impact prod, however it may impact
extensions IF someone has explicitly been setting link parentId to 0.
This would be very strange, as it would cause unexpected behaviour in
other parts of the code (which all explicitly check `undefined`).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5723-Fix-reroute-ID-0-treated-as-invalid-2766d73d365081568124ce1f85cdf84e)
by [Unito](https://www.unito.io)
2025-09-22 11:13:38 -07:00
Christian Byrne
da0d51311b fix Vue node being dragged when interacting with widgets (e.g., resizing textarea) (#5719)
## Summary

Applying changes in
https://github.com/Comfy-Org/ComfyUI_frontend/pull/5516 to entire widget
wrapper.
 
## Changes

- **What**: Added `.stop` modifier to pointer events in NodeWidgets
component to prevent [event
propagation](https://developer.mozilla.org/en-US/docs/Web/API/Event/stopPropagation)

## Review Focus

Verify widget interactions remain functional while ensuring parent node
drag/selection behavior is properly isolated.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5719-fix-Vue-node-being-dragged-when-interacting-with-widgets-e-g-resizing-textarea-2766d73d3650815091adcd1d65197c7b)
by [Unito](https://www.unito.io)
2025-09-21 21:56:03 -07:00
Christian Byrne
e314d9cbd9 [refactor] Simplify current user resolved hook implementation (#5718)
## Summary

Refactored `onUserResolved` function in auth composable to use VueUse
`whenever` utility instead of manual watch implementation and use
`immediate` option instead of invoking manually before creating watcher.

## Changes

- **What**: Replaced manual watch + immediate check pattern with [VueUse
whenever](https://vueuse.org/shared/whenever/) utility in
`useCurrentUser.ts:37`

## Review Focus

Behavioral equivalence verification - `whenever` with `immediate: true`
should maintain identical callback timing and cleanup semantics.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5718-refactor-Simplify-current-user-resolved-hook-implementation-2766d73d365081008b6de156dd78f940)
by [Unito](https://www.unito.io)
2025-09-21 21:53:25 -07:00
Christian Byrne
95baf8d2f1 [style] update Vue node tooltip style (#5717)
## Summary

Change Vue node tooltips to align with
[design](https://www.figma.com/design/31uH3r4x3xbIctuRWYW6NM/V3---Nodes?node-id=6267-16837&m=dev)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5717-style-update-Vue-node-tooltip-style-2766d73d365081bdb095faef17f6aeb6)
by [Unito](https://www.unito.io)
2025-09-21 20:01:33 -07:00
Christian Byrne
f951e07cea fix bypass hotkey in vue nodes and fix node data instrumentation setup issue when switching to Vue nodes after initial load (#5715)
## Summary

Fixed Vue node keybinding target element ID to enable
bypass/pin/collapse hotkeys in both LiteGraph and Vue rendering modes.

Also fixed a bug when starting in litegraph mode => switching to Vue
nodes without reloading => `graph.onTrigger` is set to `undefined` which
interferes with proper setup of node data instrumentation, among other
things.

## Changes

- **What**: Updated keybinding `targetElementId` from `graph-canvas` to
`graph-canvas-container` for node manipulation commands (parent of both
the canvas and transform pane -- vue nodes container).
- **What**: Added conditional `onTrigger` handler restoration in slot
layout sync to prevent Vue node manager conflicts

## Review Focus

Event handler precedence between Vue nodes and LiteGraph systems during
mode switching, ensuring hotkeys work consistently across rendering
modes.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5715-fix-bypass-hotkey-in-vue-nodes-and-fix-node-data-instrumentation-setup-issue-when-switchi-2756d73d3650815c8ec8d5e4d06232e3)
by [Unito](https://www.unito.io)
2025-09-21 17:32:12 -07:00
Christian Byrne
023e466dba fix using shift modifier to (de-)select Vue nodes (#5714)
## Summary

Fixes https://github.com/Comfy-Org/ComfyUI_frontend/issues/5688 by
adding shift modifier support for multi-selecting Vue nodes, enabling
standard shift+click selection behavior alongside existing
ctrl/cmd+click.

## Changes

- **What**: Updated Vue node event handlers to include `event.shiftKey`
in multi-select logic
- **Testing**: Added browser tests for both ctrl and shift modifier
selection behaviors

## Review Focus

Multi-select behavior consistency across different input modifiers and
platform compatibility (Windows/Mac/Linux shift key handling).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5714-fix-using-shift-modifier-to-de-select-Vue-nodes-2756d73d365081bcb5e0fe80eacdb2f0)
by [Unito](https://www.unito.io)
2025-09-21 14:39:40 -07:00
Christian Byrne
abd6823744 [refactor] Remove redundant module comment (#5711)
Removes a comment added in initial Vue Nodes commit. The comment is
interpolated between import statements which is stylistically awkward
and it is almost totally redundant with the doc comment on the
composable:


c1d4709e96/src/renderer/extensions/vueNodes/layout/useNodeLayout.ts (L10-L14)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5711-refactor-Remove-redundant-module-comment-2756d73d365081ef9bffe0257b3670f1)
by [Unito](https://www.unito.io)
2025-09-21 14:30:58 -07:00
Alexander Brown
c4c0e52e64 Refactor: Let LGraphNode handle more events itself (#5709)
## Summary

Don't route events up through GraphCanvas if the component itself can
handle the changes

## Changes

- **What**: Reduce the indirect access or action dispatch to
composables/stores.

## Review Focus

The behavior should be either equivalent or a little snappier than
before. Also, the local state in LGraphNode has (almost) all been
removed in favor of reacting to the nodeData prop.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5709-Refactor-Let-LGraphNode-handle-more-events-itself-2756d73d365081e6a88ce6241bceecc0)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2025-09-20 22:14:30 -07:00
Christian Byrne
295332dc46 update CODEOWNERS (#5667)
Add explicit CODEOWNERS for new features to allow more domain-driven
review/approval/ownership processes.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5667-update-CODEOWNERS-2736d73d3650817ea52be9c4a8fe5ff2)
by [Unito](https://www.unito.io)
2025-09-20 20:11:35 -07:00
Christian Byrne
5c498348b8 fix: update to standardized mobile web app meta tag syntax (#5672)
## Summary

Fixed WebKit deprecation warning by updating to standardized mobile web
app meta tag syntax.

## Changes

- **What**: Replaced deprecated `apple-mobile-web-app-capable` with
cross-platform
[`mobile-web-app-capable`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta/name#mobile-web-app-capable)
meta tag to align with WebKit's move toward vendor-neutral standards

## Review Focus

Verify "Add to Home Screen" functionality still works on iOS/iPadOS and
that the WebKit console warning is resolved in production builds.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5672-fix-update-to-standardized-mobile-web-app-meta-tag-syntax-2736d73d3650811cb2a1f0b14ce0a0e7)
by [Unito](https://www.unito.io)
2025-09-20 20:10:51 -07:00
Alexander Brown
8133bd4b7b Refactor: Composable disentangling (#5695)
## Summary

Prerequisite refactor/cleanup to use a global store instead of having
nodes throw up events to a parent component that stores a reference to a
singleton service that itself bootstraps and synchronizes with a
separate service to maintain a partially reactive but not fully reactive
set of states that describe some but not all aspects of the nodes on
either the litegraph, the vue side, or both.

## Changes

- **What**: Refactoring, the behavior should not change.
- **Dependencies**: A type utility to help with Vue component props

## Review Focus

Is there something about the current structure that this could affect
that would not be caught by our tests or using the application?

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5695-Refactor-Composable-disentangling-2746d73d365081e6938ce656932f3e36)
by [Unito](https://www.unito.io)
2025-09-20 13:06:42 -07:00
Arjan Singh
fd12591756 [feat] integrate asset browser with widget system (#5629)
## Summary

Add asset browser dialog integration for combo widgets with full
animation support and proper state management.

(Thank you Claude from saving me me from merge conflict hell on this
one.)

## Changes

- Widget integration: combo widgets now use AssetBrowserModal for
eligible asset types
- Dialog animations: added animateHide() for smooth close transitions
- Async operations: proper sequencing of widget updates and dialog
animations
- Service layer: added getAssetsForNodeType() and getAssetDetails()
methods
- Type safety: comprehensive TypeScript types and error handling
- Test coverage: unit tests for all new functionality
- Bonus: fixed the hardcoded labels in AssetFilterBar

Widget behavior:
- Shows asset browser button for eligible widgets when asset API enabled
- Handles asset selection with proper callback sequencing
- Maintains widget value updates and litegraph notification

## Review Focus

I will call out some stuff inline.

## Screenshots


https://github.com/user-attachments/assets/9d3a72cf-d2b0-445f-8022-4c49daa04637

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5629-feat-integrate-asset-browser-with-widget-system-2726d73d365081a9a98be9a2307aee0b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2025-09-20 11:44:18 -07:00
Christian Byrne
b3c939ff15 fix: add Safari requestIdleCallback polyfill (#5664)
## Summary

Implemented cross-browser requestIdleCallback polyfill to fix Safari
crashes during graph initialization.

## Changes

- **What**: Added
[requestIdleCallback](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback)
polyfill following [VS Code's
pattern](https://github.com/microsoft/vscode/blob/main/src/vs/base/common/async.ts)
with setTimeout fallback for Safari
- **Breaking**: None - maintains existing GraphView behavior

## Review Focus

Safari compatibility testing and timeout handling in the 15ms fallback
window. Verify that initialization tasks (keybindings, server config,
model loading) still execute properly on Safari iOS.

## References

- [VS Code async.ts
implementation](https://github.com/microsoft/vscode/blob/main/src/vs/base/common/async.ts)
- Source pattern for our polyfill
- [MDN
requestIdleCallback](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback)
- Browser API documentation
- [Safari requestIdleCallback
support](https://caniuse.com/requestidlecallback) - Browser
compatibility table

Fixes CLOUD-FRONTEND-STAGING-N

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5664-fix-add-Safari-requestIdleCallback-polyfill-2736d73d365081cdbcf1fb816fe098d6)
by [Unito](https://www.unito.io)

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-19 23:34:15 -07:00
Christian Byrne
0801778f60 feat: Add Vue node subgraph title button and fix subgraph navigation with vue nodes (#5572)
## Summary
- Adds subgraph title button to Vue node headers (matching LiteGraph
behavior)
- Fixes Vue node lifecycle issues during subgraph navigation and tab
switching
- Extracts reusable `useSubgraphNavigation` composable with
callback-based API
- Adds comprehensive tests for subgraph functionality
- Ensures proper graph context restoration during tab switches



https://github.com/user-attachments/assets/fd4ff16a-4071-4da6-903f-b2be8dd6e672



┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5572-feat-Add-Vue-node-subgraph-title-button-with-lifecycle-management-26f6d73d365081bfbd9cfd7d2775e1ef)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: DrJKL <DrJKL@users.noreply.github.com>
2025-09-19 14:19:06 -07:00
Johnpaul Chiwetelu
8ffe63f54e Layoutstore Minimap calculation (#5547)
This pull request refactors the minimap rendering system to use a
unified, extensible data source abstraction for all minimap operations.
By introducing a data source interface and factory, the minimap can now
seamlessly support multiple sources of node layout (such as the
`LayoutStore` or the underlying `LiteGraph`), improving maintainability
and future extensibility. Rendering logic and change detection
throughout the minimap have been updated to use this new abstraction,
resulting in cleaner code and easier support for new data models.

**Core architecture improvements:**

* Introduced a new `IMinimapDataSource` interface and related data types
(`MinimapNodeData`, `MinimapLinkData`, `MinimapGroupData`) to
standardize node, link, and group data for minimap rendering.
* Added an abstract base class `AbstractMinimapDataSource` that provides
shared logic for bounds and group/link extraction, and implemented two
concrete data sources: `LiteGraphDataSource` (for classic graph data)
and `LayoutStoreDataSource` (for layout store data).
[[1]](diffhunk://#diff-ea46218fc9ffced84168a5ff975e4a30e43f7bf134ee8f02ed2eae66efbb729dR1-R95)
[[2]](diffhunk://#diff-9a6b7c6be25b4dbeb358fea18f3a21e78797058ccc86c818ed1e5f69c7355273R1-R30)
[[3]](diffhunk://#diff-f200ba9495a03157198abff808ed6c3761746071404a52adbad98f6a9d01249bR1-R42)
* Created a `MinimapDataSourceFactory` that selects the appropriate data
source based on the presence of layout store data, enabling seamless
switching between data models.

**Minimap rendering and logic refactoring:**

* Updated all minimap rendering functions (`renderGroups`,
`renderNodes`, `renderConnections`) and the main `renderMinimapToCanvas`
entry point to use the unified data source interface, significantly
simplifying the rendering code and decoupling it from the underlying
graph structure.
[[1]](diffhunk://#diff-3670f99330b2e24aca3cffeeac6600adf8abadd6dd585f596d60fde1dd093121L1-R11)
[[2]](diffhunk://#diff-3670f99330b2e24aca3cffeeac6600adf8abadd6dd585f596d60fde1dd093121R33-R75)
[[3]](diffhunk://#diff-3670f99330b2e24aca3cffeeac6600adf8abadd6dd585f596d60fde1dd093121L66-R124)
[[4]](diffhunk://#diff-3670f99330b2e24aca3cffeeac6600adf8abadd6dd585f596d60fde1dd093121L134-R161)
[[5]](diffhunk://#diff-3670f99330b2e24aca3cffeeac6600adf8abadd6dd585f596d60fde1dd093121L153-R187)
[[6]](diffhunk://#diff-3670f99330b2e24aca3cffeeac6600adf8abadd6dd585f596d60fde1dd093121L187-L188)
[[7]](diffhunk://#diff-3670f99330b2e24aca3cffeeac6600adf8abadd6dd585f596d60fde1dd093121R227-R231)
[[8]](diffhunk://#diff-3670f99330b2e24aca3cffeeac6600adf8abadd6dd585f596d60fde1dd093121L230-R248)
* Refactored minimap viewport and graph change detection logic to use
the data source abstraction for bounds, node, and link change detection,
and to respond to layout store version changes.
[[1]](diffhunk://#diff-d92e448dee5e30782a66b9e66d8c8b05626dffd0b2ff1032f2612b9a9b9c51f6L2-R10)
[[2]](diffhunk://#diff-d92e448dee5e30782a66b9e66d8c8b05626dffd0b2ff1032f2612b9a9b9c51f6R33-R35)
[[3]](diffhunk://#diff-d92e448dee5e30782a66b9e66d8c8b05626dffd0b2ff1032f2612b9a9b9c51f6L99-R141)
[[4]](diffhunk://#diff-d92e448dee5e30782a66b9e66d8c8b05626dffd0b2ff1032f2612b9a9b9c51f6R157-R160)
[[5]](diffhunk://#diff-338d14c67dabffaf6f68fbf09b16e8d67bead2b9df340e46601b2fbd57331521L8-R11)
[[6]](diffhunk://#diff-338d14c67dabffaf6f68fbf09b16e8d67bead2b9df340e46601b2fbd57331521L56-R64)

These changes make the minimap codebase more modular and robust, and lay
the groundwork for supporting additional node layout strategies in the
future.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5547-Layoutstore-Minimap-calculation-26e6d73d3650813e9457c051dff41ca1)
by [Unito](https://www.unito.io)
2025-09-19 13:52:57 -07:00
Benjamin Lu
893409dfc8 Add playwright tests for links and slots in vue nodes mode (#5668)
Tests added
- Should show a link dragging out from a slot when dragging on a slot
- Should create a link when dropping on a compatible slot
- Should not create a link when dropping on an incompatible slot(s)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5668-Add-playwright-tests-for-links-and-slots-in-vue-nodes-mode-2736d73d36508188a47dceee5d1a11e5)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-09-19 13:51:47 -07:00
Christian Byrne
df2fda6077 [refactor] Replace manual semantic version utilities/functions with semver package (#5653)
## Summary
- Replace custom `compareVersions()` with `semver.compare()`
- Replace custom `isSemVer()` with `semver.valid()`  
- Remove deprecated version comparison functions from `formatUtil.ts`
- Update all version comparison logic across components and stores
- Fix tests to use semver mocking instead of formatUtil mocking

## Benefits
- **Industry standard**: Uses well-maintained, battle-tested `semver`
package
- **Better reliability**: Handles edge cases more robustly than custom
implementation
- **Consistent behavior**: All version comparisons now use the same
underlying logic
- **Type safety**: Better TypeScript support with proper semver types


Fixes #4787

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5653-refactor-Replace-manual-semantic-version-utilities-functions-with-semver-package-2736d73d365081fb8498ee11cbcc10e2)
by [Unito](https://www.unito.io)

---------

Co-authored-by: DrJKL <DrJKL@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-19 12:27:49 -07:00
Christian Byrne
4f5bbe0605 [refactor] Remove legacy manager UI support and tag from header (#5665)
## Summary

Removed the informational "Use Legacy UI" tag from the ManagerHeader
component while preserving all underlying legacy manager functionality.

## Changes

- **What**: Removed Tag component displaying legacy UI information from
ManagerHeader
- **Breaking**: None - all legacy manager functionality remains intact
- **Dependencies**: None

## Review Focus

Visual cleanup only - the `--enable-manager-legacy-ui` CLI flag and all
related functionality continues to work normally. Only the informational
UI tag has been removed from the header.
2025-09-19 02:07:51 -07:00
Christian Byrne
a975e50f1b [feat] Add tooltip support for Vue nodes (#5577)
## Summary

Added tooltip support for Vue node components using PrimeVue's v-tooltip
directive with proper data integration and container scoping.


https://github.com/user-attachments/assets/d1af31e6-ef6a-4df8-8de4-5098aa4490a1

## Changes

- **What**: Implemented tooltip functionality for Vue node headers,
input/output slots, and widgets using [PrimeVue
v-tooltip](https://primevue.org/tooltip/) directive
- **Dependencies**: Leverages existing PrimeVue tooltip system, no new
dependencies

## Review Focus

Container scoping implementation via provide/inject pattern for tooltip
positioning, proper TypeScript interfaces eliminating `as any` casts,
and integration with existing settings store for tooltip delays and
enable/disable functionality.

```mermaid
graph TD
    A[LGraphNode Container] --> B[provide tooltipContainer]
    B --> C[NodeHeader inject]
    B --> D[InputSlot inject]
    B --> E[OutputSlot inject]
    B --> F[NodeWidgets inject]

    G[useNodeTooltips composable] --> H[NodeDefStore lookup]
    G --> I[Settings integration]
    G --> J[i18n fallback]

    C --> G
    D --> G
    E --> G
    F --> G

    style A fill:#f9f9f9,stroke:#333,color:#000
    style G fill:#e8f4fd,stroke:#0066cc,color:#000
```

---------

Co-authored-by: DrJKL <DrJKL@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2025-09-19 01:07:50 -07:00
Christian Byrne
a17c74fa0c fix: add optional chaining to nodeDef access in NodeTooltip (#5663)
## Summary

Extension of https://github.com/Comfy-Org/ComfyUI_frontend/pull/5659:
Added optional chaining to NodeTooltip component to prevent TypeError
when `nodeDef` is undefined for unknown node types.

## Changes

- **What**: Added [optional chaining
operator](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Optional_chaining)
(`?.`) to safely access `nodeDef` properties in NodeTooltip component

## Review Focus

Error handling for node types not found in nodeDefStore and tooltip
display behavior for unrecognized nodes.

Fixes CLOUD-FRONTEND-STAGING-3N

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-19 01:04:12 -07:00
Christian Byrne
5e625a5002 [test] add Vue FormSelectButton widget component tests (#5576)
## Summary

Added comprehensive component tests for FormSelectButton widget with 497
test cases covering all interaction patterns and edge cases.

## Changes

- **What**: Created test suite for
[FormSelectButton.vue](https://vuejs.org/guide/scaling-up/testing.html)
component with full coverage of string/number/object options, PrimeVue
compatibility, disabled states, and visual styling
- **Dependencies**: No new dependencies (uses existing vitest,
@vue/test-utils)

## Review Focus

Test completeness covering edge cases like unicode characters, duplicate
values, and objects with missing properties. Verify test helper
functions correctly simulate user interactions.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5576-Add-Vue-FormSelectButton-widget-component-tests-26f6d73d36508171ae08ee74d0605db2)
by [Unito](https://www.unito.io)

---------

Co-authored-by: DrJKL <DrJKL@users.noreply.github.com>
2025-09-19 00:12:25 -07:00
Christian Byrne
002fac0232 [refactor] Migrate manager code to DDD structure (#5662)
## Summary

Reorganized custom nodes manager functionality from scattered technical
layers into a cohesive domain-focused module following [domain-driven
design](https://en.wikipedia.org/wiki/Domain-driven_design) principles.

## Changes

- **What**: Migrated all manager code from technical layers
(`src/components/`, `src/stores/`, etc.) to unified domain structure at
`src/workbench/extensions/manager/`
- **Breaking**: Import paths changed for all manager-related modules
(40+ files updated)

## Review Focus

Verify all import path updates are correct and no circular dependencies
introduced. Check that [Vue 3 composition
API](https://vuejs.org/guide/reusability/composables.html) patterns
remain consistent across relocated composables.


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5662-refactor-Migrate-manager-code-to-DDD-structure-2736d73d3650812c87faf6ed0fffb196)
by [Unito](https://www.unito.io)
2025-09-19 00:03:05 -07:00
Christian Byrne
7e115543fa fix: prevent TypeError when nodeDef is undefined in NodeTooltip (#5659)
## Summary

Fix TypeError in NodeTooltip component when `nodeDef` is undefined. This
occurs when hovering over nodes whose type is not found in the
nodeDefStore.

## Changes

- Add optional chaining (`?.`) to `nodeDef.description` access on line
71
- Follows the same defensive pattern used in previous fixes for similar
issues

## Context

This addresses Sentry issue
[CLOUD-FRONTEND-STAGING-1B](https://comfy-org.sentry.io/issues/6829258525/)
which shows 19 occurrences affecting 14 users.

The fix follows the same pattern as previous commits:
-
[290bf52fc](290bf52fc5)
- Fixed similar issue on line 112
-
[e8997a765](e8997a7653)
- Fixed multiple similar issues


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5659-fix-prevent-TypeError-when-nodeDef-is-undefined-in-NodeTooltip-2736d73d3650816e8be3f44889198b58)
by [Unito](https://www.unito.io)
2025-09-18 23:53:58 -07:00
Christian Byrne
80d75bb164 fix TypeError: nodes is not iterable when loading graph (#5660)
## Summary
- Fixes Sentry issue CLOUD-FRONTEND-STAGING-29 (TypeError: nodes is not
iterable)
- Adds defensive guard to check if nodes is valid array before iteration
- Gracefully handles malformed workflow data by skipping node processing

## Root Cause
The `collectMissingNodesAndModels` function in `src/scripts/app.ts:1135`
was attempting to iterate over `nodes` without checking if it was a
valid iterable, causing crashes when workflow data was malformed or
missing the nodes property.

## Fix
Added null/undefined/array validation before the for-loop:
```typescript
if (\!nodes || \!Array.isArray(nodes)) {
  console.warn('Workflow nodes data is missing or invalid, skipping node processing', { nodes, path })
  return
}
```

Fixes CLOUD-FRONTEND-STAGING-29

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5660-fix-TypeError-nodes-is-not-iterable-when-loading-graph-2736d73d365081cfb828d27e59a4811c)
by [Unito](https://www.unito.io)
2025-09-18 23:27:42 -07:00
snomiao
d59885839a fix: correct Claude PR review to use BASE_SHA for accurate diff comparison (#5654)
## Summary
- Fixes the Claude automated PR review comparing against wrong commits
- Updates the comprehensive-pr-review.md command to use `$BASE_SHA`
instead of `origin/$BASE_BRANCH`
- Resolves issue where Claude was reviewing unrelated changes from other
PRs

## Problem
As identified in #5651 (comment
https://github.com/Comfy-Org/ComfyUI_frontend/pull/5651#issuecomment-3310416767),
the Claude automated review was incorrectly analyzing changes that
weren't part of the PR being reviewed. The review was mentioning Turkish
language removal, linkRenderer changes, and other modifications that
weren't in the actual PR diff.

## Root Cause Analysis

### The Issue Explained (from Discord discussion)
When Christian Byrne noticed Claude was referencing things from previous
reviews on other PRs, we investigated and found:

1. **The backport branch was created from origin/main BEFORE Turkish
language support was merged**
   - Branch state: `main.A`
   - Backport changes committed: `main.A.Backport`

2. **Turkish language support was then merged into origin/main**
   - Main branch updated to: `main.A.Turkish`

3. **Claude review workflow checked out `main.A.Backport` and ran git
diff against `origin/main`**
   - This compared: `main.A.Backport <> main.A.Turkish`
   - The diff showed: `+++Backport` changes and `---Turkish` removal
   - Because the common parent of both branches was `main.A`

### Why This Happens
When using `origin/$BASE_BRANCH`, git resolves to the latest commit on
that branch. The diff includes:
1. The PR's actual changes (+++Backport)
2. The reverse of all commits merged to main since the PR was created
(---Turkish)

This causes Claude to review changes that appear as "removals" of code
from other merged PRs, leading to confusing comments about unrelated
code.

## Solution
Changed the git diff commands to use `$BASE_SHA` directly, which GitHub
Actions provides as the exact commit SHA that represents the merge base.
This ensures Claude only reviews the actual changes introduced by the
PR.

### Before (incorrect):
```bash
git diff --name-only "origin/$BASE_BRANCH"  # Compares against latest main
git diff "origin/$BASE_BRANCH"
git diff --name-status "origin/$BASE_BRANCH"
```

### After (correct):
```bash
git diff --name-only "$BASE_SHA"  # Compares against merge base
git diff "$BASE_SHA"
git diff --name-status "$BASE_SHA"
```

## Technical Details

### GitHub Actions Environment Variables
- `BASE_SHA`: The commit SHA of the merge base (where PR branched from
main)
- `BASE_BRANCH`: Not provided by GitHub Actions (this was the bug)
- Using `origin/$BASE_BRANCH` was falling back to comparing against the
latest main commit

### Alternative Approaches Considered
1. **Approach 1**: Rebase/update branch before running Claude review
   - Downside: Changes the PR's commits, not always desirable
2. **Approach 2**: Use BASE_SHA to diff against the merge base 
   - This is what GitHub's PR diff view does
   - Shows only the changes introduced by the PR

## Testing
The BASE_SHA environment variable is already correctly set in the
claude-pr-review.yml workflow (line 88), so this change will work
immediately once merged.

## Impact
- Claude reviews will now be accurate and only analyze the actual PR
changes
- No false positives about "removed" code from other PRs
- More reliable automated PR review process
- Developers won't be confused by comments about code they didn't change

## Verification
You can verify this fix by:
1. Creating a PR from an older branch
2. Merging another PR to main
3. Triggering Claude review with the label
4. Claude should only review the PR's changes, not show removals from
the newly merged commits

## Credits
Thanks to @Christian-Byrne for reporting the issue and @snomiao for the
root cause analysis.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-18 22:09:20 -07:00
snomiao
cbb0f765b8 feat: enable verbatimModuleSyntax in TypeScript config (#5533)
## Summary
- Enable `verbatimModuleSyntax` compiler option in TypeScript
configuration
- Update all type imports to use explicit `import type` syntax
- This change will Improve tree-shaking and bundler compatibility

## Motivation
The `verbatimModuleSyntax` option ensures that type-only imports are
explicitly marked with the `type` keyword. This:
- Makes import/export intentions clearer
- Improves tree-shaking by helping bundlers identify what can be safely
removed
- Ensures better compatibility with modern bundlers
- Follows TypeScript best practices for module syntax

## Changes
- Added `"verbatimModuleSyntax": true` to `tsconfig.json`
- Updated another 48+ files to use explicit `import type` syntax for
type-only imports
- No functional changes, only import/export syntax improvements

## Test Plan
- [x] TypeScript compilation passes
- [x] Build completes successfully  
- [x] Tests pass
- [ ] No runtime behavior changes

🤖 Generated with [Claude Code](https://claude.ai/code)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5533-feat-enable-verbatimModuleSyntax-in-TypeScript-config-26d6d73d36508190b424ef9b379b5130)
by [Unito](https://www.unito.io)
2025-09-18 21:05:56 -07:00
Christian Byrne
726a2fbbc9 feat: add manual dispatch to backport workflow (#5651)
Enables manual backport triggering for scenarios where labels are added
after PR merge.

Adds workflow_dispatch trigger to the backport workflow with support
for:
- Specifying PR number to backport post-merge
- Force rerun option to override duplicate detection  
- Proper handling of multi-version backport scenarios

Solves the issue where adding version labels (e.g., 1.27) after a PR is
already merged and backported (e.g., to 1.26) would not trigger
additional backports.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5651-feat-add-manual-dispatch-to-backport-workflow-2736d73d365081b6ba00c7a43c9ba06b)
by [Unito](https://www.unito.io)
2025-09-18 21:01:07 -07:00
snomiao
553b5aa02b feat: Add Turkish language support (#5438)
## Summary
- Added complete Turkish language translation for ComfyUI Frontend
- Integrated Turkish locale into the i18n system
- Added Turkish as a selectable language option in settings

## Implementation Details
- Added Turkish translation files provided by @naxci1:
  - `src/locales/tr/main.json` - Main UI translations
  - `src/locales/tr/commands.json` - Command translations
  - `src/locales/tr/nodeDefs.json` - Node definitions translations
  - `src/locales/tr/settings.json` - Settings translations
- Updated `src/i18n.ts` to import and register Turkish locale
- Added Turkish option to language selector in
`src/constants/coreSettings.ts`

## Test Plan
- [ ] Verify Turkish translations load correctly
- [ ] Test language switching to/from Turkish
- [ ] Check all UI elements display properly in Turkish
- [ ] Verify node descriptions and tooltips in Turkish
- [ ] Test command palette in Turkish

Fixes #5437

🤖 Generated with [Claude Code](https://claude.ai/code)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5438-feat-Add-Turkish-language-support-2686d73d36508184bbf2dc1e0cd15350)
by [Unito](https://www.unito.io)
2025-09-18 19:43:53 -07:00
Benjamin Lu
2ff0d951ed Slot functionality for vue nodes (#5628)
Allows for simple slot functionality in vue nodes mode.

Has:
- Drag new link from slot
- Connect new link from dropping on slot

Now:
- Tests

After:
- Drop on reroute
- Correct link color on connect
- Drop on node
- Hover effects

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5628-Slot-functionality-for-vue-nodes-2716d73d365081c59a3cef7c8a5e539e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: bymyself <cbyrne@comfy.org>
Co-authored-by: AustinMroz <austin@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-18 19:35:15 -07:00
Christian Byrne
1f88925144 fix: don't immediately close missing nodes dialog if manager is disabled (#5647)
If manager is disabled, it assumed all missing nodes are installed and
immediately closes the missing nodes warning when loading a workflow
with missing nodes.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5647-fix-don-t-immediately-close-missing-nodes-dialog-if-manager-is-disabled-2736d73d36508199a50bca2026528ab6)
by [Unito](https://www.unito.io)
2025-09-18 17:54:37 -07:00
AustinMroz
250433a91a Fix SaveAs (#5643)
Implementing subgraph blueprints (#5139) included changes to saving to
ensure that SaveAs generates a new workflow of the correct type. However
this code failed to utilize the pre-prepared state when performing the
actual save. This produced a couple of problems with both failing to
detach the workflow and failing to apply the correct state

This error is only encountered when using Save As from a non temporary
workflow (one loaded from the workflows sidebar tab).

As this state calculation code is only used in this code path, it has
been moved into the saveAs function of the workflowStore.

Resolves #5592

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5643-Fix-SaveAs-2726d73d3650818faa7af449d1f13c26)
by [Unito](https://www.unito.io)
2025-09-18 16:56:49 -07:00
AustinMroz
eb664f47af Fix cyclic prototype errors with subgraphNodes (#5637)
#5024 added support for connecting primitive nodes to subgraph inputs.
To accomplish this, it pulls WidgetLocator information from the node
owning the widget.

This `node` property does not exist on all IBaseWidget. `toConcrete` was
used to instead have a BaseWidget which is guaranteed to have a node
property. The issue that was missed, is that a widget which lacks this
information (such as most implemented by custom nodes) sets the node
value to the argument which was passed. Here that is the reference to
the subgraph node. Sometimes, this `#setWidget` call is made multiple
times, and when this occurs, the `input.widget` has itself set as the
protoyep, throwing an error.

This is resolved by instead taking an additional input which is
unambiguous.

For reference, this is a near minimal workflow using comfy_mtb that
replicates the issue

[cyclic.json](https://github.com/user-attachments/files/22412187/cyclic.json)

Special thanks to @melMass for assistance discovering this issue.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5637-Fix-cyclic-prototype-errors-with-subgraphNodes-2726d73d365081fea356f5197e4c2b42)
by [Unito](https://www.unito.io)
2025-09-18 16:06:12 -07:00
Christian Byrne
bc85d4e87b Make Vue nodes read-only when in panning mode (#5574)
## Summary

Integrated Vue node components with canvas panning mode to prevent UI
interference during navigation.

## Changes

- **What**: Added
[canCapturePointerEvents](https://docs.comfy.org/guide/vue-nodes)
computed property to `useCanvasInteractions` composable that checks
canvas read-only state
- **What**: Modified Vue node components (LGraphNode, NodeWidgets) to
conditionally handle pointer events based on canvas navigation mode
- **What**: Updated node event handlers to respect panning mode and
forward events to canvas when appropriate

## Review Focus

Event forwarding logic in panning mode and pointer event capture state
management across Vue node hierarchy.

```mermaid
graph TD
    A[User Interaction] --> B{Canvas in Panning Mode?}
    B -->|Yes| C[Forward to Canvas]
    B -->|No| D[Handle in Vue Component]
    C --> E[Canvas Navigation]
    D --> F[Node Selection/Widget Interaction]

    G[canCapturePointerEvents] --> H{read_only === false}
    H -->|Yes| I[Allow Vue Events]
    H -->|No| J[Block Vue Events]

    style A fill:#f9f9f9,stroke:#333,color:#000
    style E fill:#f9f9f9,stroke:#333,color:#000
    style F fill:#f9f9f9,stroke:#333,color:#000
    style I fill:#e1f5fe,stroke:#01579b,color:#000
    style J fill:#ffebee,stroke:#c62828,color:#000
```

## Screenshots




https://github.com/user-attachments/assets/00dc5e4a-2b56-43be-b92e-eaf511e52542

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5574-Make-Vue-nodes-read-only-when-in-panning-mode-26f6d73d3650818c951cd82c8fe58972)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2025-09-18 15:43:35 -07:00
Comfy Org PR Bot
7585444ce6 1.28.0 (#5640)
Minor version increment to 1.28.0

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5640-1-28-0-2726d73d3650818e846fcf78cbf33b73)
by [Unito](https://www.unito.io)

Co-authored-by: benceruleanlu <162923238+benceruleanlu@users.noreply.github.com>
2025-09-18 14:13:33 -07:00
Robin Huang
a886798a10 Explicitly add email scope for social auth login. (#5638)
## Summary

Some users were authenticating successfully but their email addresses
weren't being extracted from the Firebase token. This happened because
we weren't explicitly requesting the email scope during OAuth
authentication.
 
While Firebase's default configuration includes basic profile info, it
doesn't guarantee email access for all account types - particularly
Google Workspace accounts with restrictive policies or users with
privacy-conscious settings.

[Github
Scopes](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps)

## Changes

Adding email scope for Google + Github social OAuth.

## Review Focus
N/A

## Screenshots (if applicable)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5638-Explicitly-add-email-scope-for-social-auth-login-2726d73d3650817ab356fc9c04f8641b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2025-09-18 14:09:16 -07:00
Christian Byrne
37975e4eac [test] Add component test for image compare widget (#5549)
## Summary

Added comprehensive component test suite for WidgetImageCompare widget
with 410 test assertions covering display, edge cases, and integration
scenarios.

## Changes

- **What**: Created [Vue Test Utils](https://vue-test-utils.vuejs.org/)
test suite for [WidgetImageCompare
component](src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.vue)
using [Vitest](https://vitest.dev/) testing framework

## Review Focus

Test coverage completeness for string vs object value handling,
accessibility attribute propagation, and edge case robustness including
malformed URLs and empty states.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5549-test-Add-component-test-for-image-compare-widget-26e6d73d365081189fe0d010f87d1eec)
by [Unito](https://www.unito.io)

---------

Co-authored-by: DrJKL <DrJKL@users.noreply.github.com>
2025-09-18 13:44:21 -07:00
Jin Yi
a41b8a6d4f refactor: Change manager flag from --disable-manager to --enable-manager (#5635)
## Summary
- Updated frontend to align with backend changes in ComfyUI core PR
#7555
- Changed manager startup argument from `--disable-manager` (opt-out) to
`--enable-manager` (opt-in)
- Manager is now disabled by default unless explicitly enabled

## Changes
- Modified `useManagerState.ts` to check for `--enable-manager` flag
presence
- Inverted logic: manager is disabled when the flag is NOT present
- Updated all related tests to reflect the new opt-in behavior
- Fixed edge case where `systemStats` is null

## Related
- Backend PR: https://github.com/comfyanonymous/ComfyUI/pull/7555

## Test Plan
- [x] All unit tests pass
- [x] Verified manager state logic with different flag combinations
- [x] TypeScript type checking passes
- [x] Linting passes

🤖 Generated with [Claude Code](https://claude.ai/code)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5635-refactor-Change-manager-flag-from-disable-manager-to-enable-manager-2726d73d36508153a88bd9f152132b2a)
by [Unito](https://www.unito.io)
2025-09-18 11:45:07 -07:00
Alexander Brown
b264685052 lint: add tsconfig for browser_tests, fix existing violations (#5633)
## Summary

See https://typescript-eslint.io/blog/project-service/ for context.
Creates a browser_tests specific tsconfig so that they can be linted.

Does not add a package.json script to do the linting yet, but `pnpm exec
eslint browser_tests` should work for now.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5633-lint-add-tsconfig-for-browser_tests-fix-existing-violations-2726d73d3650819d8ef2c4b0abc31e14)
by [Unito](https://www.unito.io)
2025-09-18 11:35:44 -07:00
Johnpaul Chiwetelu
78d0ea6fa5 LazyImage on Safari (#5626)
This pull request improves the lazy loading behavior and caching
strategy for images in the `LazyImage.vue` component. The most
significant changes are focused on optimizing image rendering and
resource management, as well as improving code clarity.

**Lazy loading behavior improvements:**

* Changed the `<img>` element to render only when `cachedSrc` is
available, ensuring that images are not displayed before they are ready.
* Updated watchers in `LazyImage.vue` to use clearer variable names
(`shouldLoadVal` instead of `shouldLoad`) for better readability and
maintainability.
[[1]](diffhunk://#diff-3a1bfa7eb8cb26b04bea73f7b4b4e3c01e9d20a7eba6c3738fb47f96da1a7c95L80-R81)
[[2]](diffhunk://#diff-3a1bfa7eb8cb26b04bea73f7b4b4e3c01e9d20a7eba6c3738fb47f96da1a7c95L96-R96)

**Caching strategy enhancement:**

* Modified the `fetch` call in `mediaCacheService.ts` to use `{ cache:
'force-cache' }`, which leverages the browser's cache more aggressively
when loading media, potentially improving performance and reducing
network requests.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5626-LazyImage-on-Safari-2716d73d365081eeb1d3c2a96be4d408)
by [Unito](https://www.unito.io)
2025-09-18 11:20:19 -07:00
Christian Byrne
ea4e57b602 Move VueFire persistence configuration to initialization (#5614)
Currently, we set persistence method in the auth store setup. This
creates pattern of using the default on init (indexed DB) up until the
firebase store is initialized and `setPersistence` is called. For
devices that don't support indexed DB or have the connection aggresively
terminated or cleared, like
[Safari](https://comfy-org.sentry.io/issues/6879071102/?project=4509681221369857&query=is%3Aunresolved&referrer=issue-stream),
this can create problems with maintaing auth persistence.

Fix by setting persistence method in the initialization in main.ts

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5614-Move-VueFire-persistence-configuration-to-initialization-2716d73d3650817480e0c8feb1f37b9a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-18 11:18:05 -07:00
Johnpaul Chiwetelu
4789d86fe8 Line Selection toolbox up with Vue Nodes (#5601)
This pull request improves the selection toolbox behavior during node
dragging by ensuring that it correctly responds to both LiteGraph and
Vue node drag events. The main changes introduce a reactive drag state
for Vue nodes in the layout store and update the selection toolbox
composable and Vue node component to use this state.

**Selection toolbox behavior improvements:**

* Added a helper function and separate watchers in
`useSelectionToolboxPosition.ts` to hide the selection toolbox when
either LiteGraph or Vue nodes are being dragged. This ensures consistent
UI feedback regardless of node type.
[[1]](diffhunk://#diff-57a51ac5e656e64ae7fd276d71b115058631621755de33b1eb8e8a4731d48713L171-R172)
[[2]](diffhunk://#diff-57a51ac5e656e64ae7fd276d71b115058631621755de33b1eb8e8a4731d48713R212-R224)

**Vue node drag state management:**

* Added a reactive `isDraggingVueNodes` property to the
`LayoutStoreImpl` class, along with getter and setter methods to manage
Vue node drag state. This allows other components to reactively track
when Vue nodes are being dragged.
[[1]](diffhunk://#diff-80d32fe0fb72730c16cf7259adef8b20732ff214df240b1d39ae516737beaf3bR133-R135)
[[2]](diffhunk://#diff-80d32fe0fb72730c16cf7259adef8b20732ff214df240b1d39ae516737beaf3bR354-R367)
* Updated `LGraphNode.vue` to set and clear the Vue node dragging state
in the layout store during pointer down and up events, ensuring the
selection toolbox is hidden while dragging Vue nodes.
[[1]](diffhunk://#diff-a7744614cf842e54416047326db79ad81f7c7ab7bfb66ae2b46f5c73ac7d47f2R357-R360)
[[2]](diffhunk://#diff-a7744614cf842e54416047326db79ad81f7c7ab7bfb66ae2b46f5c73ac7d47f2R376-R378)

**Dependency updates:**

* Imported the `layoutStore` in `LGraphNode.vue` to access the new drag
state management methods.
* Added missing `ref` import in `layoutStore.ts` to support the new
reactive property.



https://github.com/user-attachments/assets/d6e9c15e-63b5-4de2-9688-ebbc6a3be545

---------

Co-authored-by: GitHub Action <action@github.com>
2025-09-18 11:17:14 -07:00
filtered
09e7d1040e Add desktop dialogs framework (#5605)
### Summary

Adds desktop dialog framework with data-driven dialog definitions.

### Changes

- Data-driven dialog structure in `desktopDialogs.ts`
- Dynamic dialog view component with i18n support
- Button action types: openUrl, close, cancel
- Button severity levels for styling (primary, secondary, danger, warn)
- Fallback invalid dialog for error handling
- i18n collection script updated for dialog strings
2025-09-17 20:32:53 -07:00
Arjan Singh
dfa1cbba4f Asset Browser Modal Component (#5607)
* [ci] ignore playwright mcp directory

* [feat] add AssetBrowserModal

And all related sub components

* [feat] reactive filter functions

* [ci] clean up storybook config

* [feat] add sematic AssetCard

* [fix] i love lucide

* [fix] AssetCard layout issues

* [fix] add AssetBadge type

* [fix] simplify useAssetBrowser

* [fix] modal layout

* [fix] simplify useAssetBrowserDialog

* [fix] add tailwind back to storybook

* [fix] better reponsive layout

* [fix] missed i18n string

* [fix] missing i18n translations

* [fix] remove erroneous prevent on keyboard.space

* [feat] add asset metadata validation utilities

* [fix] remove erroneous test code

* [fix] remove forced min and max width on AssetCard

* [fix] import statement nits
2025-09-17 16:17:09 -07:00
Alexander Brown
08220d50d9 Lint: Turn on rules that should allow for verbatimModuleSyntax (#5616)
* lint: turn on type import rules setting up for verbatimModuleSyntax

* lint: --fix for type imports
2025-09-16 22:03:41 -07:00
Comfy Org PR Bot
045232a99b [release] Increment version to 1.27.5 (#5619)
Co-authored-by: benceruleanlu <162923238+benceruleanlu@users.noreply.github.com>
2025-09-16 20:41:55 -07:00
Benjamin Lu
fb07941700 Bump vite-plugin-dts (#5618) 2025-09-16 20:33:03 -07:00
Terry Jia
6866e1277a new design for left click and wheel (#5566)
* new design for left click and wheel

* update snap

* fix import

* fix test

* default value

* fix test

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-09-16 20:11:17 -07:00
Benjamin Lu
ff5d0923ca Refactor vue slot tracking (#5463)
* add dom element resize observer registry for vue node components

* Update src/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking.ts

Co-authored-by: AustinMroz <austin@comfy.org>

* refactor(vue-nodes): typed TransformState InjectionKey, safer ResizeObserver sizing, centralized slot tracking, and small readability updates

* chore: make TransformState interface non-exported to satisfy knip pre-push

* Revert "chore: make TransformState interface non-exported to satisfy knip pre-push"

This reverts commit 110ecf31da.

* Revert "refactor(vue-nodes): typed TransformState InjectionKey, safer ResizeObserver sizing, centralized slot tracking, and small readability updates"

This reverts commit 428752619c.

* [refactor] Improve resize tracking composable documentation and test utilities

- Rename parameters in useVueElementTracking for clarity (appIdentifier, trackingType)
- Add comprehensive docstring with examples to prevent DOM attribute confusion
- Extract mountLGraphNode test utility to eliminate repetitive mock setup
- Add technical implementation notes documenting optimization decisions

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* remove typo comment

* convert to functional bounds collection

* remove inline import

* add interfaces for bounds mutations

* remove change log

* fix bounds collection when vue nodes turned off

* fix title offset on y

* move from resize observer to selection toolbox bounds

* refactor(vue-nodes): typed TransformState InjectionKey, safer ResizeObserver sizing, centralized slot tracking, and small readability updates

* Fix conversion

* Readd padding

* revert churn reducings from layoutStore.ts

* Rely on RO for resize, and batch

* Improve churn

* Cache canvas offset

* rename from measure

* remove unused

* address review comments

* Update legacy injection

* nit

* Split into store

* nit

* perf improvement

---------

Co-authored-by: bymyself <cbyrne@comfy.org>
Co-authored-by: AustinMroz <austin@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-16 19:28:04 -07:00
Alexander Brown
6b59f839e0 Devex: Faster linting (#5611)
* devex: Keep the presubmit from wiping the lint cache

* devex: typescript for eslint config

* devex: upgrade lint plugins and dedupe lockfile

* lint: Fix autofixable rules with updated vue linter

* lint: Remove default for required prop

* lint: temporarily disable warnings for missing defaults

* deps: Update vue-tsc

* lint: use the config convenience utility, switch to using projectService

* lint: Fix redundant eslint config blocks and misplaced parser

* lint: Split up parsing options for typescript vs vue files
2025-09-16 19:23:03 -07:00
AustinMroz
6a01b08ebf Subgraph widget promotion - Part 1 (#5537)
* Prerequisite tweaks for subgraph widget promotion

* Clean up DOMWidget tracking on graph change

* Mark migrated CombOWidget functions private

* Cleanup placeholder node cast
2025-09-16 19:17:35 -07:00
Benjamin Lu
ede43c5e5c Add --no-git-checks and ignore playwright (#5602) 2025-09-16 19:00:00 -07:00
Christian Byrne
0483630f82 Show sampling previews on Vue nodes (#5579)
* refactor: simplify preview state provider

- Remove unnecessary event listeners and manual syncing
- Use computed() to directly reference app.nodePreviewImages
- Eliminate data duplication and any types
- Rely on Vue's reactivity for automatic updates
- Follow established patterns from execution state provider

* feat: optimize Vue node preview image display with reactive store

- Move preview display logic from inline ternaries to computed properties
- Add useNodePreviewState composable for preview state management
- Implement reactive store approach using Pinia storeToRefs
- Use VueUse useTimeoutFn for modern timeout management instead of window.setTimeout
- Add v-memo optimization for preview image template rendering
- Maintain proper sync between app.nodePreviewImages and reactive store state

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: update props usage for Vue 3.5 destructured props syntax

* [refactor] improve code style and architecture based on review feedback

- Replace inject pattern with direct store access in useNodePreviewState
- Use optional chaining for more concise conditional checks
- Use modern Array.at(-1) for accessing last element
- Remove provide/inject for nodePreviewImages in favor of direct store refs
- Update preview image styling: remove rounded borders, use flexible height
- Simplify scheduleRevoke function with optional chaining

Co-authored-by: DrJKL <DrJKL@users.noreply.github.com>

* [cleanup] remove unused NodePreviewImagesKey injection key

Addresses knip unused export warning after switching from provide/inject
to direct store access pattern.

* [test] add mock for useNodePreviewState in LGraphNode test

Fixes test failure after adding preview functionality to LGraphNode component.

* [fix] update workflowStore import path after rebase

Updates import to new location: @/platform/workflow/management/stores/workflowStore

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: DrJKL <DrJKL@users.noreply.github.com>
2025-09-16 17:34:04 -07:00
Alexander Brown
15cffe9d9e style: Flip the shade direction for charoal to go from light to dark (#5609) 2025-09-16 16:31:13 -07:00
Christian Byrne
f5b949762d [test] Add Vue node markdown widget component test (#5575)
* add markdown widget test

* [fix] correct test comments from 'is exposed' to 'is not exposed' - addresses review feedback

The TypeScript suppression comments incorrectly stated that properties
were exposed when they should indicate they are not exposed, since
@ts-expect-error is used to access private properties.

Co-authored-by: christian-byrne <christian-byrne@users.noreply.github.com>

---------

Co-authored-by: christian-byrne <christian-byrne@users.noreply.github.com>
2025-09-16 16:06:33 -07:00
Christian Byrne
6786d8e4fb Add hook to expose uid (#5612)
* add hook to expose uid

* use whenever for cleaner code
2025-09-16 14:42:40 -07:00
Christian Byrne
71ca28a46f [test] Add component test for TreeSelect Vue widget (#5551)
* add tree select widget component test

* [refactor] export TreeNode type from component - addresses review feedback

Co-authored-by: DrJKL <DrJKL@users.noreply.github.com>

* [refactor] move createTreeData to module scope - addresses review feedback

Co-authored-by: DrJKL <DrJKL@users.noreply.github.com>

---------

Co-authored-by: DrJKL <DrJKL@users.noreply.github.com>
2025-09-16 14:40:02 -07:00
filtered
4ff18fd7f0 Add Inter font used by Figma designs (#5535)
* Add ABC ROM fonts from Comfy.org

* Import ABC ROM fonts CSS in main.ts

* Move font import to style.css

* Add ABC ROM fonts as CSS variables in @theme

* Add Inter font .woff2 files

* Replace ABC ROM with Inter font declarations

* Update CSS variables to use Inter font

* Remove unused ABC ROM font files

* Autoformat style.css

* Remove redundant font declarations
2025-09-16 10:31:36 -07:00
filtered
45a46be513 Remove redundant output from prettier format script (#5603)
* Add --list-changed to prettier format scripts

* Remove --list-changed from check commands

* Fix cursor tab complete emits woe
2025-09-16 09:15:44 -07:00
Comfy Org PR Bot
631746939a [chore] Update electron-types to 0.4.73-0 (#5606)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-09-16 00:56:29 -07:00
Alexander Brown
4f8e820c51 feat: Let mode changes trigger a re-render for Vue nodes (#5599)
* feat: Let mode changes trigger a re-render for Vue nodes

* Oops!
2025-09-15 18:25:58 -07:00
filtered
b8d8193a38 Copy startup terminal (#5585)
* Add copyTerminal translation key

* Add copy terminal button with select all functionality

* Remove copy button from error view button group

* Add hover-based copy button overlay to terminal

* Fix clipboard copy implementation in BaseTerminal

* Add 'Copy all' tooltip to terminal copy button

* Fix copy button to be away from right hand side

* Update copy button to respect existing selection

- Copy only selected text if any exists
- Copy all text and clear selection if nothing selected
- Update tooltip to reflect new behavior

* Add dynamic tooltip showing actual copy action

- Show 'Copy selection' when text is selected
- Show 'Copy all' when no text is selected

* Remove redundant i18n

* Fix aria-label to use dynamic tooltip text

* Remove debug console.error statements from useTerminal

Clean up debug logging added during development:
- Remove selection change debug logging
- Remove focus state debug logging
- Remove keyboard event debug logging
- Remove copy/paste debug logging

* Remove redundant keyboard handling from useTerminal

The rebase commit already fixed basic copy/paste.
Removed only the complex keyboard event handling that
duplicates the rebase fix. Kept the valuable UI features:
- Hover copy button overlay
- Right-click context menu

* Use Tailwind transition classes instead of custom CSS

Replace custom .animate-fade-in with standard Tailwind
transition-opacity duration-200 classes

* Use VueUse useElementHover for robust hover handling

Replace manual mouseenter/mouseleave events with VueUse
useElementHover composable which properly handles all
edge cases including mouseout and interrupted events

* Move tooltip to left of button

Relieves squished tooltip

* Simplify code

* Fix listener lifecycle management

Consolidate setup into single onMounted block instead
of creating unnecessary duplicate lifecycle hooks

* Replace any type with proper IDisposable type

* Refactor copy logic for clarity

* Use v-show for proper opacity transitions

* Prefer optional chaining

* Use useEventListener for context menu

* Remove redundant opacity classes

* Add BaseTerminal component tests

* Use pointer-events for button interactivity

* Update tests for pointer-events button behavior

* Fix clipboard mock in tests

* Fix test expectations for opacity classes

* Simplify hover tests for button state

* Remove low-value 'renders terminal container' test

* Remove non-functional 'button responds to hover' test

* Remove implementation detail test for dispose listener

* Remove redundant 'tracks selection changes' test

* Remove obvious comments from test file

* Use cn() utility for conditional classes

* Update tests-ui/tests/components/bottomPanel/tabs/terminal/BaseTerminal.spec.ts

Co-authored-by: Alexander Brown <drjkl@comfy.org>

* [auto-fix] Apply ESLint and Prettier fixes

* Remove 'any' types from wrapper and terminalMock variables

Add assertion to verify onSelectionChange was called

* Move mountBaseTerminal factory to module scope

* Rename test file

- Current consensus is .test.ts for component files

* Update src/components/bottomPanel/tabs/terminal/BaseTerminal.vue

* nit

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2025-09-15 17:06:41 -07:00
AustinMroz
2b57291756 Fix context menu creating nodes in wrong position (#5595)
* Fix context menu creating nodes in wrong position

When nodes are created from the context menu, they previously had there
position set immediately after the node itself was created. Under some
circumstances, this new position would be overwritten by the layout
store.

This is solved by setting the position before node initialization.

* nit: Move size fix to named variable

Also remove ternary. The elements are always numberic, so checking if a
number is truthy before multiplying by 0 is a little silly.

* nit: Further variable extraction
2025-09-15 14:49:10 -07:00
Arjan Singh
62897c669b [fix] add focus state and aria to select components (#5596)
* [fix] add focus state and aria to select components

* [fix] typo in gitignore

* [fix] code review feedback
2025-09-15 21:48:52 +00:00
Benjamin Lu
9918914b9d Add publish frontend types workflow (#5495)
* Add workflow

* Add concurrency

* Add warning

* Use publish-frontend-types for release.yaml

* address claude review

* nit
2025-09-15 14:47:29 -07:00
Arjan Singh
e601bcb300 [refactor] create src/platform/assets (#5598)
* [refactor] create src/platform/assets

Per @christian-byrne's feedback. Just bringing this into the repo sooner to clean up from my feature branch

* [fix] code review feedback
2025-09-15 14:36:33 -07:00
39 changed files with 5670 additions and 103 deletions

View File

@@ -0,0 +1,137 @@
name: Publish Frontend Types
on:
workflow_dispatch:
inputs:
version:
description: 'Version to publish (e.g., 1.26.7)'
required: true
type: string
dist_tag:
description: 'npm dist-tag to use'
required: true
default: latest
type: string
ref:
description: 'Git ref to checkout (commit SHA, tag, or branch)'
required: false
type: string
workflow_call:
inputs:
version:
required: true
type: string
dist_tag:
required: false
type: string
default: latest
ref:
required: false
type: string
concurrency:
group: publish-frontend-types-${{ github.workflow }}-${{ inputs.version }}-${{ inputs.dist_tag }}
cancel-in-progress: false
jobs:
publish_types_manual:
name: Publish @comfyorg/comfyui-frontend-types
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Validate inputs
shell: bash
run: |
set -euo pipefail
VERSION="${{ inputs.version }}"
SEMVER_REGEX='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+([0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*))?$'
if [[ ! "$VERSION" =~ $SEMVER_REGEX ]]; then
echo "::error title=Invalid version::Version '$VERSION' must follow semantic versioning (x.y.z[-suffix][+build])" >&2
exit 1
fi
- name: Determine ref to checkout
id: resolve_ref
shell: bash
run: |
set -euo pipefail
REF="${{ inputs.ref }}"
VERSION="${{ inputs.version }}"
if [ -n "$REF" ]; then
if ! git check-ref-format --allow-onelevel "$REF"; then
echo "::error title=Invalid ref::Ref '$REF' fails git check-ref-format validation." >&2
exit 1
fi
echo "ref=$REF" >> "$GITHUB_OUTPUT"
else
echo "ref=refs/tags/v$VERSION" >> "$GITHUB_OUTPUT"
fi
- name: Checkout repository
uses: actions/checkout@v5
with:
ref: ${{ steps.resolve_ref.outputs.ref }}
fetch-depth: 1
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: 'lts/*'
cache: 'pnpm'
registry-url: https://registry.npmjs.org
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build types
run: pnpm build:types
- name: Verify version matches input
id: verify
shell: bash
run: |
PKG_VERSION=$(node -p "require('./package.json').version")
TYPES_PKG_VERSION=$(node -p "require('./dist/package.json').version")
if [ "$PKG_VERSION" != "${{ inputs.version }}" ]; then
echo "Error: package.json version $PKG_VERSION does not match input ${{ inputs.version }}" >&2
exit 1
fi
if [ "$TYPES_PKG_VERSION" != "${{ inputs.version }}" ]; then
echo "Error: dist/package.json version $TYPES_PKG_VERSION does not match input ${{ inputs.version }}" >&2
exit 1
fi
echo "version=${{ inputs.version }}" >> $GITHUB_OUTPUT
- name: Check if version already on npm
id: check_npm
shell: bash
run: |
set -euo pipefail
NAME=$(node -p "require('./dist/package.json').name")
VER="${{ steps.verify.outputs.version }}"
STATUS=0
OUTPUT=$(npm view "${NAME}@${VER}" --json 2>&1) || STATUS=$?
if [ "$STATUS" -eq 0 ]; then
echo "exists=true" >> "$GITHUB_OUTPUT"
echo "::warning title=Already published::${NAME}@${VER} already exists on npm. Skipping publish."
else
if echo "$OUTPUT" | grep -q "E404"; then
echo "exists=false" >> "$GITHUB_OUTPUT"
else
echo "::error title=Registry lookup failed::$OUTPUT" >&2
exit "$STATUS"
fi
fi
- name: Publish package
if: steps.check_npm.outputs.exists == 'false'
run: pnpm publish --access public --tag "${{ inputs.dist_tag }}"
working-directory: dist
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@@ -18,7 +18,7 @@ jobs:
is_prerelease: ${{ steps.check_prerelease.outputs.is_prerelease }}
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
@@ -73,7 +73,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Download dist artifact
uses: actions/download-artifact@v4
with:
@@ -98,7 +98,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Download dist artifact
uses: actions/download-artifact@v4
with:
@@ -126,34 +126,8 @@ jobs:
publish_types:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 'lts/*'
cache: 'pnpm'
registry-url: https://registry.npmjs.org
- name: Cache tool outputs
uses: actions/cache@v4
with:
path: |
.cache
tsconfig.tsbuildinfo
dist
key: types-tools-cache-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
types-tools-cache-${{ runner.os }}-
- run: pnpm install --frozen-lockfile
- run: pnpm build:types
- name: Publish package
run: pnpm publish --access public
working-directory: dist
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
uses: ./.github/workflows/publish-frontend-types.yaml
with:
version: ${{ needs.build.outputs.version }}
ref: ${{ github.event.pull_request.merge_commit_sha }}
secrets: inherit

2
.gitignore vendored
View File

@@ -51,7 +51,7 @@ tests-ui/workflows/examples
/blob-report/
/playwright/.cache/
browser_tests/**/*-win32.png
browser-tests/local/
browser_tests/local/
.env

View File

@@ -65,7 +65,6 @@ export const withTheme = (Story: any, context: any) => {
return Story()
}
const preview: Preview = {
parameters: {
controls: {

View File

@@ -101,7 +101,7 @@
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.72",
"@comfyorg/comfyui-electron-types": "0.4.73-0",
"@iconify/json": "^2.2.380",
"@primeuix/forms": "0.0.2",
"@primeuix/styled": "0.3.2",

10
pnpm-lock.yaml generated
View File

@@ -15,8 +15,8 @@ importers:
specifier: ^1.3.1
version: 1.3.1
'@comfyorg/comfyui-electron-types':
specifier: ^0.4.72
version: 0.4.72
specifier: 0.4.73-0
version: 0.4.73-0
'@iconify/json':
specifier: ^2.2.380
version: 2.2.380
@@ -986,8 +986,8 @@ packages:
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
'@comfyorg/comfyui-electron-types@0.4.72':
resolution: {integrity: sha512-Ecf0XYOKDqqIcnjSWL8GHLo6MOsuwqs0I1QgWc3Hv+BZm+qUE4vzOXCyhfFoTIGHLZFTwe37gnygPPKFzMu00Q==}
'@comfyorg/comfyui-electron-types@0.4.73-0':
resolution: {integrity: sha512-WlItGJQx9ZWShNG9wypx3kq+19pSig/U+s5sD2SAeEcMph4u8A/TS+lnRgdKhT58VT1uD7cMcj2SJpfdBPNWvw==}
'@csstools/color-helpers@5.1.0':
resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
@@ -7502,7 +7502,7 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {}
'@comfyorg/comfyui-electron-types@0.4.72': {}
'@comfyorg/comfyui-electron-types@0.4.73-0': {}
'@csstools/color-helpers@5.1.0': {}

View File

@@ -0,0 +1,546 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import BatchCountEdit from './BatchCountEdit.vue'
const meta: Meta = {
title: 'Components/Actionbar/BatchCountEdit',
component: BatchCountEdit,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'BatchCountEdit allows users to set the batch count for queue operations with smart increment/decrement logic. Features exponential scaling (doubling/halving) and integrates with the queue settings store for ComfyUI workflow execution. This component can accept props for controlled mode or use Pinia store state by default.'
}
}
},
argTypes: {
minQueueCount: {
control: 'number',
description: 'Minimum allowed batch count',
table: {
defaultValue: { summary: '1' }
}
},
maxQueueCount: {
control: 'number',
description: 'Maximum allowed batch count',
table: {
defaultValue: { summary: '100' }
}
}
},
tags: ['autodocs']
}
export default meta
type Story = StoryObj
export const Default: Story = {
args: {
minQueueCount: 1,
maxQueueCount: 100
},
render: (_args) => ({
components: { BatchCountEdit },
data() {
return {
count: 1,
logAction: (action: string, value: number) => {
console.log(`${action}: ${value}`)
}
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px;">
<h3 style="margin: 0 0 8px 0; color: #374151;">Batch Count Editor</h3>
<p style="margin: 0; font-size: 14px; color: #6b7280;">
Set the number of times to run the workflow. Smart increment/decrement with exponential scaling.
</p>
</div>
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 16px;">
<span style="font-weight: 600;">Batch Count:</span>
<BatchCountEdit
v-model:batch-count="count"
:min-queue-count="+_args.minQueueCount"
:max-queue-count="+_args.maxQueueCount"
@update:batch-count="(v) => logAction('Set', Number(v))"
/>
</div>
<div style="font-size: 12px; color: #6b7280; background: rgba(0,0,0,0.05); padding: 12px; border-radius: 4px;">
<strong>Note:</strong> Current value: {{count}}. Check console for action logs.
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Default batch count editor with smart exponential scaling. Uses Pinia store for state management. Click +/- buttons to see the doubling/halving behavior.'
}
}
}
}
export const WithTooltip: Story = {
args: {
minQueueCount: 1,
maxQueueCount: 50
},
render: (_args) => ({
components: { BatchCountEdit },
data() {
return {
count: 4,
logAction: (action: string, value: number) => {
console.log(`${action}: ${value}`)
}
}
},
template: `
<div style="padding: 40px;">
<div style="margin-bottom: 16px; text-align: center;">
<div style="font-size: 14px; color: #6b7280; margin-bottom: 8px;">
Hover over the input to see tooltip
</div>
<BatchCountEdit
v-model:batch-count="count"
:min-queue-count="+_args.minQueueCount"
:max-queue-count="+_args.maxQueueCount"
@update:batch-count="(v) => logAction('Set', Number(v))"
/>
</div>
<div style="font-size: 12px; color: #6b7280; text-align: center; margin-top: 20px;">
⬆️ Tooltip appears on hover with 600ms delay
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'BatchCountEdit with tooltip functionality - hover to see the "Batch Count" tooltip.'
}
}
}
}
export const HighBatchCount: Story = {
args: {
minQueueCount: 1,
maxQueueCount: 200
},
render: (_args) => ({
components: { BatchCountEdit },
data() {
return {
count: 16,
logAction: (action: string, value: number) => {
console.log(`${action}: ${value}`)
}
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px;">
<div style="font-size: 14px; color: #6b7280; margin-bottom: 8px;">
High batch count scenario (16 generations):
</div>
<div style="display: flex; align-items: center; gap: 12px;">
<span style="font-weight: 600;">Batch Count:</span>
<BatchCountEdit
v-model:batch-count="count"
:min-queue-count="+_args.minQueueCount"
:max-queue-count="+_args.maxQueueCount"
@update:batch-count="(v) => logAction('Set', Number(v))"
/>
</div>
</div>
<div style="background: rgba(255, 193, 7, 0.1); border: 1px solid rgba(255, 193, 7, 0.3); border-radius: 4px; padding: 12px;">
<div style="display: flex; align-items: center; gap: 8px; color: #b45309;">
<i class="pi pi-exclamation-triangle"></i>
<span style="font-size: 14px; font-weight: 600;">High Batch Count Warning</span>
</div>
<div style="font-size: 12px; color: #92400e; margin-top: 4px;">
Running 16 generations will consume significant GPU time and memory. Consider reducing batch size for faster iteration.
</div>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'High batch count scenario showing potential performance warnings for large generation batches.'
}
}
}
}
export const ActionBarContext: Story = {
args: {
minQueueCount: 1,
maxQueueCount: 100
},
render: (_args) => ({
components: { BatchCountEdit },
data() {
return {
count: 2,
logAction: (action: string, value: number) => {
console.log(`${action}: ${value}`)
}
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
BatchCountEdit in realistic action bar context:
</div>
<div style="display: flex; align-items: center; gap: 16px; padding: 12px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px;">
<!-- Mock Queue Button -->
<button style="display: flex; align-items: center; gap: 8px; padding: 8px 16px; background: #3b82f6; color: white; border: none; border-radius: 6px; font-weight: 600; cursor: pointer;">
<i class="pi pi-play"></i>
Queue Prompt
</button>
<!-- BatchCountEdit -->
<div style="display: flex; align-items: center; gap: 8px;">
<label style="font-size: 12px; color: #6b7280; font-weight: 600;">BATCH:</label>
<BatchCountEdit
v-model:batch-count="count"
:min-queue-count="+_args.minQueueCount"
:max-queue-count="+_args.maxQueueCount"
@update:batch-count="(v) => logAction('Set', Number(v))"
/>
</div>
<!-- Mock Clear Button -->
<button style="display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: #6b7280; color: white; border: none; border-radius: 6px; cursor: pointer;">
<i class="pi pi-trash"></i>
Clear
</button>
<!-- Mock Settings -->
<button style="padding: 8px; background: none; border: 1px solid #d1d5db; border-radius: 6px; cursor: pointer;">
<i class="pi pi-cog" style="color: #6b7280;"></i>
</button>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'BatchCountEdit integrated within a realistic ComfyUI action bar layout with queue controls.'
}
}
}
}
export const ExponentialScaling: Story = {
args: {
minQueueCount: 1,
maxQueueCount: 100
},
render: (_args) => ({
components: { BatchCountEdit },
data() {
return {
scalingLog: [],
currentValue: 1,
count: 1,
logAction: (action: string, value: number) => {
console.log(`${action}: ${value}`)
}
}
},
methods: {
simulateIncrement() {
const current = this.currentValue
const newValue = Math.min(current * 2, 100)
this.scalingLog.unshift(`Increment: ${current}${newValue} (×2)`)
this.currentValue = newValue
if (this.scalingLog.length > 10) this.scalingLog.pop()
},
simulateDecrement() {
const current = this.currentValue
const newValue = Math.floor(current / 2) || 1
this.scalingLog.unshift(`Decrement: ${current}${newValue} (÷2)`)
this.currentValue = newValue
if (this.scalingLog.length > 10) this.scalingLog.pop()
},
reset() {
this.currentValue = 1
this.scalingLog = []
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px;">
<h3 style="margin: 0 0 8px 0; color: #374151;">Exponential Scaling Demo</h3>
<p style="margin: 0; font-size: 14px; color: #6b7280;">
Demonstrates the smart doubling/halving behavior of batch count controls.
</p>
</div>
<div style="display: flex; align-items: center; gap: 16px; margin-bottom: 16px;">
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-weight: 600;">Current Value:</span>
<span style="font-size: 18px; font-weight: bold; color: #3b82f6;">{{ currentValue }}</span>
</div>
<BatchCountEdit
v-model:batch-count="count"
:min-queue-count="+_args.minQueueCount"
:max-queue-count="+_args.maxQueueCount"
@update:batch-count="(v) => logAction('Set', Number(v))"
/>
</div>
<div style="display: flex; gap: 8px; margin-bottom: 16px;">
<button @click="simulateIncrement" style="padding: 6px 12px; background: #10b981; color: white; border: none; border-radius: 4px; cursor: pointer;">
<i class="pi pi-plus"></i> Double
</button>
<button @click="simulateDecrement" style="padding: 6px 12px; background: #ef4444; color: white; border: none; border-radius: 4px; cursor: pointer;">
<i class="pi pi-minus"></i> Halve
</button>
<button @click="reset" style="padding: 6px 12px; background: #6b7280; color: white; border: none; border-radius: 4px; cursor: pointer;">
<i class="pi pi-refresh"></i> Reset
</button>
</div>
<div v-if="scalingLog.length" style="background: rgba(0,0,0,0.05); padding: 12px; border-radius: 4px;">
<div style="font-weight: 600; margin-bottom: 8px; font-size: 14px;">Scaling Log:</div>
<div v-for="(entry, index) in scalingLog" :key="index" style="font-size: 12px; color: #4b5563; margin-bottom: 2px; font-family: monospace;">
{{ entry }}
</div>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Demonstrates the exponential scaling behavior - increment doubles the value, decrement halves it.'
}
}
}
}
export const QueueWorkflowContext: Story = {
args: {
minQueueCount: 1,
maxQueueCount: 50
},
render: (_args) => ({
components: { BatchCountEdit },
data() {
return {
queueStatus: 'Ready',
totalGenerations: 1,
estimatedTime: '~2 min',
count: 1,
logAction: (action: string, value: number) => {
console.log(`${action}: ${value}`)
}
}
},
computed: {
statusColor() {
return this.queueStatus === 'Ready'
? '#10b981'
: this.queueStatus === 'Running'
? '#f59e0b'
: '#6b7280'
}
},
methods: {
updateEstimate() {
// Simulate batch count change affecting estimates
this.totalGenerations = 1 // This would be updated by actual batch count
this.estimatedTime = `~${this.totalGenerations * 2} min`
},
queueWorkflow() {
this.queueStatus = 'Running'
setTimeout(() => {
this.queueStatus = 'Complete'
}, 3000)
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px;">
<h3 style="margin: 0 0 8px 0; color: #374151;">Queue Workflow Context</h3>
<p style="margin: 0; font-size: 14px; color: #6b7280;">
BatchCountEdit within a complete workflow queuing interface.
</p>
</div>
<!-- Mock Workflow Preview -->
<div style="background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px; margin-bottom: 16px;">
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 12px;">
<i class="pi pi-sitemap" style="color: #6366f1;"></i>
<span style="font-weight: 600;">SDXL Portrait Generation</span>
<span :style="{color: statusColor, fontSize: '12px', fontWeight: '600'}" style="background: rgba(0,0,0,0.05); padding: 2px 8px; border-radius: 12px;">
{{ queueStatus }}
</span>
</div>
<!-- Queue Controls -->
<div style="display: flex; align-items: center; gap: 12px; justify-content: space-between;">
<div style="display: flex; align-items: center; gap: 12px;">
<button @click="queueWorkflow" style="display: flex; align-items: center; gap: 8px; padding: 8px 16px; background: #3b82f6; color: white; border: none; border-radius: 6px; font-weight: 600; cursor: pointer;">
<i class="pi pi-play"></i>
Queue Prompt
</button>
<div style="display: flex; align-items: center; gap: 8px;">
<label style="font-size: 12px; color: #6b7280; font-weight: 600;">BATCH:</label>
<BatchCountEdit
v-model:batch-count="count"
:min-queue-count="+_args.minQueueCount"
:max-queue-count="+_args.maxQueueCount"
@update:batch-count="(v) => logAction('Set', Number(v))"
/>
</div>
</div>
<div style="text-align: right;">
<div style="font-size: 12px; color: #6b7280;">Total: {{ totalGenerations }} generations</div>
<div style="font-size: 12px; color: #6b7280;">Est. time: {{ estimatedTime }}</div>
</div>
</div>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'BatchCountEdit in a complete workflow queuing context with status and time estimates.'
}
}
}
}
export const LimitConstraints: Story = {
args: {
minQueueCount: 1,
maxQueueCount: 200
},
render: (_args) => ({
components: { BatchCountEdit },
data() {
return {
count: 1,
logAction: (action: string, value: number) => {
console.log(`${action}: ${value}`)
},
scenarios: [
{
name: 'Conservative (max 10)',
maxLimit: 10,
description: 'For memory-constrained systems'
},
{
name: 'Standard (max 50)',
maxLimit: 50,
description: 'Typical production usage'
},
{
name: 'High-end (max 200)',
maxLimit: 200,
description: 'For powerful GPU setups'
}
]
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px;">
<h3 style="margin: 0 0 8px 0; color: #374151;">Limit Constraints</h3>
<p style="margin: 0; font-size: 14px; color: #6b7280;">
Different batch count limits for various system configurations.
</p>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 16px;">
<div v-for="scenario in scenarios" :key="scenario.name" style="border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px;">
<div style="font-weight: 600; margin-bottom: 4px;">{{ scenario.name }}</div>
<div style="font-size: 12px; color: #6b7280; margin-bottom: 12px;">{{ scenario.description }}</div>
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 12px; font-weight: 600;">BATCH:</span>
<BatchCountEdit
v-model:batch-count="count"
:min-queue-count="+_args.minQueueCount"
:max-queue-count="+_args.maxQueueCount"
@update:batch-count="(v) => logAction('Set', Number(v))"
/>
</div>
<div style="font-size: 11px; color: #9ca3af; margin-top: 8px;">
Max limit: {{ scenario.maxLimit }}
</div>
</div>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Different batch count limit scenarios for various system configurations and use cases.'
}
}
}
}
export const MinimalInline: Story = {
args: {
minQueueCount: 1,
maxQueueCount: 20
},
render: (_args) => ({
components: { BatchCountEdit },
data() {
return {
count: 3,
logAction: (action: string, value: number) => {
console.log(`${action}: ${value}`)
}
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
Minimal inline usage:
</div>
<div style="display: flex; align-items: center; gap: 8px; font-size: 14px;">
<span>Run</span>
<BatchCountEdit
v-model:batch-count="count"
:min-queue-count="+_args.minQueueCount"
:max-queue-count="+_args.maxQueueCount"
@update:batch-count="(v) => logAction('Set', Number(v))"
/>
<span>times</span>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Minimal inline usage of BatchCountEdit within a sentence context.'
}
}
}
}

View File

@@ -40,15 +40,51 @@ import { computed } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useQueueSettingsStore } from '@/stores/queueStore'
interface Props {
batchCount?: number
minQueueCount?: number
maxQueueCount?: number
}
interface Emits {
(e: 'update:batch-count', value: number): void
}
const props = withDefaults(defineProps<Props>(), {
batchCount: undefined,
minQueueCount: 1,
maxQueueCount: undefined
})
const emit = defineEmits<Emits>()
const queueSettingsStore = useQueueSettingsStore()
const { batchCount } = storeToRefs(queueSettingsStore)
const minQueueCount = 1
const { batchCount: storeBatchCount } = storeToRefs(queueSettingsStore)
const settingStore = useSettingStore()
const maxQueueCount = computed(() =>
const defaultMaxQueueCount = computed(() =>
settingStore.get('Comfy.QueueButton.BatchCountLimit')
)
// Use props if provided, otherwise fallback to store values
const batchCount = computed({
get() {
return props.batchCount ?? storeBatchCount.value
},
set(value: number) {
if (props.batchCount !== undefined) {
emit('update:batch-count', value)
} else {
storeBatchCount.value = value
}
}
})
const minQueueCount = computed(() => props.minQueueCount)
const maxQueueCount = computed(
() => props.maxQueueCount ?? defaultMaxQueueCount.value
)
const handleClick = (increment: boolean) => {
let newCount: number
if (increment) {

View File

@@ -3,15 +3,37 @@
<div class="p-terminal rounded-none h-full w-full p-2">
<div ref="terminalEl" class="h-full terminal-host" />
</div>
<Button
v-tooltip.left="{
value: tooltipText,
showDelay: 300
}"
icon="pi pi-copy"
severity="secondary"
size="small"
:class="
cn('absolute top-2 right-8 transition-opacity', {
'opacity-0 pointer-events-none select-none': !isHovered
})
"
:aria-label="tooltipText"
@click="handleCopy"
/>
</div>
</template>
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import { Ref, onUnmounted, ref } from 'vue'
import { useElementHover, useEventListener } from '@vueuse/core'
import type { IDisposable } from '@xterm/xterm'
import Button from 'primevue/button'
import { Ref, computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useTerminal } from '@/composables/bottomPanelTabs/useTerminal'
import { electronAPI, isElectron } from '@/utils/envUtil'
import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()
const emit = defineEmits<{
created: [ReturnType<typeof useTerminal>, Ref<HTMLElement | undefined>]
@@ -19,7 +41,39 @@ const emit = defineEmits<{
}>()
const terminalEl = ref<HTMLElement | undefined>()
const rootEl = ref<HTMLElement | undefined>()
emit('created', useTerminal(terminalEl), rootEl)
const hasSelection = ref(false)
const isHovered = useElementHover(rootEl)
const terminalData = useTerminal(terminalEl)
emit('created', terminalData, rootEl)
const { terminal } = terminalData
let selectionDisposable: IDisposable | undefined
const tooltipText = computed(() => {
return hasSelection.value
? t('serverStart.copySelectionTooltip')
: t('serverStart.copyAllTooltip')
})
const handleCopy = async () => {
const existingSelection = terminal.getSelection()
const shouldSelectAll = !existingSelection
if (shouldSelectAll) terminal.selectAll()
const selectedText = shouldSelectAll
? terminal.getSelection()
: existingSelection
if (selectedText) {
await navigator.clipboard.writeText(selectedText)
if (shouldSelectAll) {
terminal.clearSelection()
}
}
}
const showContextMenu = (event: MouseEvent) => {
event.preventDefault()
@@ -30,7 +84,16 @@ if (isElectron()) {
useEventListener(terminalEl, 'contextmenu', showContextMenu)
}
onUnmounted(() => emit('unmounted'))
onMounted(() => {
selectionDisposable = terminal.onSelectionChange(() => {
hasSelection.value = terminal.hasSelection()
})
})
onUnmounted(() => {
selectionDisposable?.dispose()
emit('unmounted')
})
</script>
<style scoped>

View File

@@ -0,0 +1,152 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ContentDivider from './ContentDivider.vue'
const meta: Meta<typeof ContentDivider> = {
title: 'Components/Common/ContentDivider',
component: ContentDivider,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'ContentDivider provides a visual separation between content sections. It supports both horizontal and vertical orientations with customizable width/thickness.'
}
}
},
argTypes: {
orientation: {
control: 'select',
options: ['horizontal', 'vertical'],
description: 'Direction of the divider line',
defaultValue: 'horizontal'
},
width: {
control: { type: 'range', min: 0.1, max: 10, step: 0.1 },
description: 'Width/thickness of the divider in pixels',
defaultValue: 0.3
}
},
tags: ['autodocs']
}
export default meta
type Story = StoryObj<typeof ContentDivider>
export const Horizontal: Story = {
args: {
orientation: 'horizontal',
width: 0.3
},
parameters: {
docs: {
description: {
story:
'Default horizontal divider for separating content sections vertically.'
}
}
},
decorators: [
() => ({
template: `
<div style="width: 300px; padding: 20px;">
<div style="padding: 10px; background: rgba(100, 100, 100, 0.1); margin-bottom: 10px;">
Content Section 1
</div>
<story />
<div style="padding: 10px; background: rgba(100, 100, 100, 0.1); margin-top: 10px;">
Content Section 2
</div>
</div>
`
})
]
}
export const Vertical: Story = {
args: {
orientation: 'vertical',
width: 0.3
},
parameters: {
docs: {
description: {
story: 'Vertical divider for separating content sections horizontally.'
}
}
},
decorators: [
() => ({
template: `
<div style="height: 200px; display: flex; align-items: stretch; padding: 20px;">
<div style="padding: 10px; background: rgba(100, 100, 100, 0.1); margin-right: 10px; flex: 1;">
Left Content
</div>
<story />
<div style="padding: 10px; background: rgba(100, 100, 100, 0.1); margin-left: 10px; flex: 1;">
Right Content
</div>
</div>
`
})
]
}
export const ThickHorizontal: Story = {
args: {
orientation: 'horizontal',
width: 2
},
parameters: {
docs: {
description: {
story:
'Thicker horizontal divider for more prominent visual separation.'
}
}
},
decorators: [
() => ({
template: `
<div style="width: 300px; padding: 20px;">
<div style="padding: 10px; background: rgba(100, 100, 100, 0.1); margin-bottom: 10px;">
Content Section 1
</div>
<story />
<div style="padding: 10px; background: rgba(100, 100, 100, 0.1); margin-top: 10px;">
Content Section 2
</div>
</div>
`
})
]
}
export const ThickVertical: Story = {
args: {
orientation: 'vertical',
width: 3
},
parameters: {
docs: {
description: {
story: 'Thicker vertical divider for more prominent visual separation.'
}
}
},
decorators: [
() => ({
template: `
<div style="height: 200px; display: flex; align-items: stretch; padding: 20px;">
<div style="padding: 10px; background: rgba(100, 100, 100, 0.1); margin-right: 15px; flex: 1;">
Left Content
</div>
<story />
<div style="padding: 10px; background: rgba(100, 100, 100, 0.1); margin-left: 15px; flex: 1;">
Right Content
</div>
</div>
`
})
]
}

View File

@@ -0,0 +1,259 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import EditableText from './EditableText.vue'
const meta: Meta<typeof EditableText> = {
title: 'Components/Common/EditableText',
component: EditableText,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'EditableText allows inline text editing with sophisticated focus management and keyboard handling. It supports automatic text selection, smart filename handling (excluding extensions), and seamless transitions between view and edit modes.'
}
}
},
argTypes: {
modelValue: {
control: 'text',
description: 'The text value to display and edit'
},
isEditing: {
control: 'boolean',
description: 'Whether the component is currently in edit mode'
}
},
tags: ['autodocs']
}
export default meta
type Story = StoryObj<typeof EditableText>
const createEditableStoryRender =
(
initialText = 'Click to edit this text',
initialEditing = false,
stayEditing = false
) =>
(args: any) => ({
components: { EditableText },
setup() {
const text = ref(args.modelValue || initialText)
const editing = ref(args.isEditing ?? initialEditing)
const actions = ref<string[]>([])
const logAction = (action: string, data?: any) => {
const timestamp = new Date().toLocaleTimeString()
const message = data
? `${action}: "${data}" (${timestamp})`
: `${action} (${timestamp})`
actions.value.unshift(message)
if (actions.value.length > 5) actions.value.pop()
console.log(action, data)
}
const handleEdit = (newValue: string) => {
logAction('Edit completed', newValue)
text.value = newValue
editing.value = stayEditing // Stay in edit mode if specified
}
const startEdit = () => {
editing.value = true
logAction('Edit started')
}
return { args, text, editing, actions, handleEdit, startEdit }
},
template: `
<div style="padding: 20px;">
<div @click="startEdit" style="cursor: pointer; border: 2px dashed #ccc; border-radius: 4px; padding: 20px;">
<div style="margin-bottom: 8px; font-size: 12px; color: #666;">Click text to edit:</div>
<EditableText
:modelValue="text"
:isEditing="editing"
@edit="handleEdit"
/>
</div>
<div v-if="actions.length > 0" style="margin-top: 16px; padding: 12px; background: #f5f5f5; border-radius: 4px; font-family: monospace; font-size: 12px;">
<div style="font-weight: bold; margin-bottom: 8px;">Actions Log:</div>
<div v-for="action in actions" :key="action" style="margin: 2px 0;">{{ action }}</div>
</div>
</div>
`
})
export const Default: Story = {
render: createEditableStoryRender(),
args: {
modelValue: 'Click to edit this text',
isEditing: false
}
}
export const AlwaysEditing: Story = {
render: createEditableStoryRender('Always in edit mode', true, true),
args: {
modelValue: 'Always in edit mode',
isEditing: true
}
}
export const FilenameEditing: Story = {
render: () => ({
components: { EditableText },
setup() {
const filenames = ref([
'my_workflow.json',
'image_processing.png',
'model_config.yaml',
'final_render.mp4'
])
const actions = ref<string[]>([])
const logAction = (action: string, filename: string, newName: string) => {
const timestamp = new Date().toLocaleTimeString()
actions.value.unshift(
`${action}: "${filename}" → "${newName}" (${timestamp})`
)
if (actions.value.length > 5) actions.value.pop()
console.log(action, { filename, newName })
}
const handleFilenameEdit = (index: number, newValue: string) => {
const oldName = filenames.value[index]
filenames.value[index] = newValue
logAction('Filename changed', oldName, newValue)
}
return { filenames, actions, handleFilenameEdit }
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px; font-weight: bold;">File Browser (click filenames to edit):</div>
<div style="display: flex; flex-direction: column; gap: 8px;">
<div v-for="(filename, index) in filenames" :key="index"
style="display: flex; align-items: center; padding: 8px; background: #f9f9f9; border-radius: 4px;">
<i style="margin-right: 8px; color: #666;" class="pi pi-file"></i>
<EditableText
:modelValue="filename"
:isEditing="false"
@edit="(newValue) => handleFilenameEdit(index, newValue)"
/>
</div>
</div>
<div v-if="actions.length > 0" style="margin-top: 16px; padding: 12px; background: #f5f5f5; border-radius: 4px; font-family: monospace; font-size: 12px;">
<div style="font-weight: bold; margin-bottom: 8px;">Actions Log:</div>
<div v-for="action in actions" :key="action" style="margin: 2px 0;">{{ action }}</div>
</div>
</div>
`
})
}
export const LongText: Story = {
render: createEditableStoryRender(
'This is a much longer text that demonstrates how the EditableText component handles longer content with multiple words and potentially line wrapping scenarios.'
),
args: {
modelValue:
'This is a much longer text that demonstrates how the EditableText component handles longer content.',
isEditing: false
}
}
export const EmptyState: Story = {
render: createEditableStoryRender(''),
args: {
modelValue: '',
isEditing: false
}
}
export const SingleCharacter: Story = {
render: createEditableStoryRender('A'),
args: {
modelValue: 'A',
isEditing: false
}
}
// ComfyUI usage examples
export const WorkflowNaming: Story = {
render: () => ({
components: { EditableText },
setup() {
const workflows = ref([
'Portrait Enhancement',
'Landscape Generation',
'Style Transfer Workflow',
'Untitled Workflow'
])
const handleWorkflowRename = (index: number, newName: string) => {
workflows.value[index] = newName
console.log('Workflow renamed:', { index, newName })
}
return { workflows, handleWorkflowRename }
},
template: `
<div style="padding: 20px; width: 300px;">
<div style="margin-bottom: 16px; font-weight: bold;">Workflow Library</div>
<div style="display: flex; flex-direction: column; gap: 12px;">
<div v-for="(workflow, index) in workflows" :key="index"
style="padding: 12px; border: 1px solid #ddd; border-radius: 6px; background: white;">
<EditableText
:modelValue="workflow"
:isEditing="false"
@edit="(newName) => handleWorkflowRename(index, newName)"
style="font-size: 14px; font-weight: 500;"
/>
<div style="margin-top: 4px; font-size: 11px; color: #666;">
Last modified: 2 hours ago
</div>
</div>
</div>
</div>
`
})
}
export const ModelRenaming: Story = {
render: () => ({
components: { EditableText },
setup() {
const models = ref([
'stable-diffusion-v1-5.safetensors',
'controlnet_depth.pth',
'vae-ft-mse-840000-ema.ckpt'
])
const handleModelRename = (index: number, newName: string) => {
models.value[index] = newName
console.log('Model renamed:', { index, newName })
}
return { models, handleModelRename }
},
template: `
<div style="padding: 20px; width: 350px;">
<div style="margin-bottom: 16px; font-weight: bold;">Model Manager</div>
<div style="display: flex; flex-direction: column; gap: 8px;">
<div v-for="(model, index) in models" :key="index"
style="display: flex; align-items: center; padding: 8px; background: #f8f8f8; border-radius: 4px;">
<i style="margin-right: 8px; color: #4a90e2;" class="pi pi-box"></i>
<EditableText
:modelValue="model"
:isEditing="false"
@edit="(newName) => handleModelRename(index, newName)"
style="flex: 1; font-family: 'JetBrains Mono', monospace; font-size: 12px;"
/>
</div>
</div>
</div>
`
})
}

View File

@@ -0,0 +1,672 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import type { FormItem as FormItemType } from '@/types/settingTypes'
import FormItem from './FormItem.vue'
const meta: Meta = {
title: 'Components/Common/FormItem',
component: FormItem as any,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'FormItem is a generalized form component that dynamically renders different input types based on configuration. Supports text, number, boolean, combo, slider, knob, color, image, and custom renderer inputs with proper labeling and accessibility.'
}
}
},
argTypes: {
item: {
control: 'object',
description:
'FormItem configuration object defining the input type and properties'
},
formValue: {
control: 'text',
description: 'The current form value (v-model)',
defaultValue: ''
},
id: {
control: 'text',
description: 'Optional HTML id for the form input',
defaultValue: undefined
},
labelClass: {
control: 'text',
description: 'Additional CSS classes for the label',
defaultValue: undefined
}
},
tags: ['autodocs']
}
export default meta
type Story = StoryObj
export const TextInput: Story = {
render: (args: any) => ({
components: { FormItem },
setup() {
return { args }
},
data() {
return {
value: args.formValue || 'Default text value',
textItem: {
name: 'Workflow Name',
type: 'text',
tooltip: 'Enter a descriptive name for your workflow',
attrs: {
placeholder: 'e.g., SDXL Portrait Generation'
}
} as FormItemType
}
},
methods: {
updateValue(newValue: string) {
console.log('Text value updated:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px; min-width: 400px;">
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
Text input form item with tooltip:
</div>
<FormItem
:item="textItem"
:formValue="value"
@update:formValue="updateValue"
id="workflow-name"
/>
<div style="margin-top: 12px; font-size: 12px; color: #6b7280;">
Current value: "{{ value }}"
</div>
</div>
`
}),
args: {
formValue: 'My Workflow'
},
parameters: {
docs: {
description: {
story:
'Text input FormItem with tooltip and placeholder. Hover over the info icon to see the tooltip.'
}
}
}
}
export const NumberInput: Story = {
render: () => ({
components: { FormItem },
data() {
return {
value: 7.5,
numberItem: {
name: 'CFG Scale',
type: 'number',
tooltip:
'Classifier-free guidance scale controls how closely the AI follows your prompt',
attrs: {
min: 1,
max: 30,
step: 0.5,
showButtons: true
}
} as FormItemType
}
},
methods: {
updateValue(newValue: number) {
console.log('CFG scale updated:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px; min-width: 400px;">
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
Number input with controls and constraints:
</div>
<FormItem
:item="numberItem"
:formValue="value"
@update:formValue="updateValue"
id="cfg-scale"
/>
<div style="margin-top: 12px; font-size: 12px; color: #6b7280;">
Current CFG scale: {{ value }}
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Number input FormItem with min/max constraints and increment buttons for CFG scale parameter.'
}
}
}
}
export const BooleanToggle: Story = {
render: () => ({
components: { FormItem },
data() {
return {
value: false,
booleanItem: {
name: 'Enable GPU Acceleration',
type: 'boolean',
tooltip: 'Use GPU for faster processing when available'
} as FormItemType
}
},
methods: {
updateValue(newValue: boolean) {
console.log('GPU acceleration toggled:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px; min-width: 400px;">
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
Boolean toggle switch form item:
</div>
<FormItem
:item="booleanItem"
:formValue="value"
@update:formValue="updateValue"
id="gpu-accel"
/>
<div style="margin-top: 12px; font-size: 12px; color: #6b7280;">
GPU acceleration: {{ value ? 'Enabled' : 'Disabled' }}
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Boolean FormItem using ToggleSwitch component for enable/disable settings.'
}
}
}
}
export const ComboSelect: Story = {
render: () => ({
components: { FormItem },
data() {
return {
value: 'euler_a',
comboItem: {
name: 'Sampling Method',
type: 'combo',
tooltip: 'Algorithm used for denoising during generation',
options: [
'euler_a',
'euler',
'heun',
'dpm_2',
'dpm_2_ancestral',
'lms',
'dpm_fast',
'dpm_adaptive',
'dpmpp_2s_ancestral',
'dpmpp_sde',
'dpmpp_2m'
]
} as FormItemType
}
},
methods: {
updateValue(newValue: string) {
console.log('Sampling method updated:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px; min-width: 400px;">
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
Combo select with sampling methods:
</div>
<FormItem
:item="comboItem"
:formValue="value"
@update:formValue="updateValue"
id="sampling-method"
/>
<div style="margin-top: 12px; font-size: 12px; color: #6b7280;">
Selected: {{ value }}
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Combo select FormItem with ComfyUI sampling methods showing dropdown selection.'
}
}
}
}
export const SliderInput: Story = {
render: () => ({
components: { FormItem },
data() {
return {
value: 0.7,
sliderItem: {
name: 'Denoise Strength',
type: 'slider',
tooltip:
'How much to denoise the input image (0 = no change, 1 = complete redraw)',
attrs: {
min: 0,
max: 1,
step: 0.01
}
} as FormItemType
}
},
methods: {
updateValue(newValue: number) {
console.log('Denoise strength updated:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px; min-width: 400px;">
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
Slider input with precise decimal control:
</div>
<FormItem
:item="sliderItem"
:formValue="value"
@update:formValue="updateValue"
id="denoise-strength"
/>
<div style="margin-top: 12px; font-size: 12px; color: #6b7280;">
Denoise: {{ (value * 100).toFixed(0) }}%
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Slider FormItem for denoise strength with percentage display and fine-grained control.'
}
}
}
}
export const KnobInput: Story = {
render: () => ({
components: { FormItem },
data() {
return {
value: 20,
knobItem: {
name: 'Sampling Steps',
type: 'knob',
tooltip:
'Number of denoising steps - more steps = higher quality but slower generation',
attrs: {
min: 1,
max: 150,
step: 1
}
} as FormItemType
}
},
methods: {
updateValue(newValue: number) {
console.log('Steps updated:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px; min-width: 400px;">
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
Knob input for sampling steps:
</div>
<FormItem
:item="knobItem"
:formValue="value"
@update:formValue="updateValue"
id="sampling-steps"
/>
<div style="margin-top: 12px; font-size: 12px; color: #6b7280;">
Steps: {{ value }} ({{ value < 10 ? 'Very Fast' : value < 30 ? 'Fast' : value < 50 ? 'Balanced' : 'High Quality' }})
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Knob FormItem for sampling steps with quality indicator based on step count.'
}
}
}
}
export const MultipleFormItems: Story = {
render: () => ({
components: { FormItem },
data() {
return {
widthValue: 512,
heightValue: 512,
stepsValue: 20,
cfgValue: 7.5,
samplerValue: 'euler_a',
hiresValue: false
}
},
computed: {
formItems() {
return [
{
name: 'Width',
type: 'number',
tooltip: 'Image width in pixels',
attrs: { min: 64, max: 2048, step: 64 }
},
{
name: 'Height',
type: 'number',
tooltip: 'Image height in pixels',
attrs: { min: 64, max: 2048, step: 64 }
},
{
name: 'Sampling Steps',
type: 'knob',
tooltip: 'Number of denoising steps',
attrs: { min: 1, max: 150, step: 1 }
},
{
name: 'CFG Scale',
type: 'slider',
tooltip: 'Classifier-free guidance scale',
attrs: { min: 1, max: 30, step: 0.5 }
},
{
name: 'Sampler',
type: 'combo',
tooltip: 'Sampling algorithm',
options: ['euler_a', 'euler', 'heun', 'dpm_2', 'dpmpp_2m']
},
{
name: 'High-res Fix',
type: 'boolean',
tooltip: 'Enable high-resolution generation'
}
] as FormItemType[]
},
allSettings() {
return {
width: this.widthValue,
height: this.heightValue,
steps: this.stepsValue,
cfg: this.cfgValue,
sampler: this.samplerValue,
enableHires: this.hiresValue
}
}
},
template: `
<div style="padding: 20px; min-width: 500px;">
<div style="margin-bottom: 16px;">
<h3 style="margin: 0 0 8px 0; color: #374151;">ComfyUI Generation Settings</h3>
<p style="margin: 0; font-size: 14px; color: #6b7280;">
Multiple form items demonstrating different input types in a realistic settings panel.
</p>
</div>
<div style="background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px;">
<div style="display: flex; flex-direction: column; gap: 16px;">
<FormItem
:item="formItems[0]"
:formValue="widthValue"
@update:formValue="(value) => widthValue = value"
id="form-width"
/>
<FormItem
:item="formItems[1]"
:formValue="heightValue"
@update:formValue="(value) => heightValue = value"
id="form-height"
/>
<FormItem
:item="formItems[2]"
:formValue="stepsValue"
@update:formValue="(value) => stepsValue = value"
id="form-steps"
/>
<FormItem
:item="formItems[3]"
:formValue="cfgValue"
@update:formValue="(value) => cfgValue = value"
id="form-cfg"
/>
<FormItem
:item="formItems[4]"
:formValue="samplerValue"
@update:formValue="(value) => samplerValue = value"
id="form-sampler"
/>
<FormItem
:item="formItems[5]"
:formValue="hiresValue"
@update:formValue="(value) => hiresValue = value"
id="form-hires"
/>
</div>
</div>
<div style="margin-top: 16px; background: rgba(0,0,0,0.05); padding: 12px; border-radius: 4px;">
<div style="font-weight: 600; margin-bottom: 8px; font-size: 14px;">Current Settings:</div>
<div style="font-family: monospace; font-size: 12px; color: #4b5563;">
{{ JSON.stringify(allSettings, null, 2) }}
</div>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Multiple FormItems demonstrating all major input types in a realistic ComfyUI settings panel.'
}
}
}
}
export const WithCustomLabels: Story = {
render: () => ({
components: { FormItem },
data() {
return {
value: 'custom_model.safetensors',
customItem: {
name: 'Model File',
type: 'text',
tooltip: 'Select the checkpoint model file to use for generation',
attrs: {
placeholder: 'Select or enter model filename...'
}
} as FormItemType
}
},
methods: {
updateValue(newValue: string) {
console.log('Model file updated:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px; min-width: 400px;">
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
FormItem with custom label styling and slots:
</div>
<FormItem
:item="customItem"
:formValue="value"
@update:formValue="updateValue"
id="model-file"
:labelClass="{ 'font-bold': true, 'text-blue-600': true }"
>
<template #name-prefix>
<i class="pi pi-download" style="margin-right: 6px; color: #3b82f6;"></i>
</template>
<template #name-suffix>
<span style="margin-left: 6px; font-size: 10px; color: #ef4444;">*</span>
</template>
</FormItem>
<div style="margin-top: 12px; font-size: 12px; color: #6b7280;">
Selected model: {{ value || 'None' }}
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'FormItem with custom label styling and prefix/suffix slots for enhanced UI elements.'
}
}
}
}
export const ColorPicker: Story = {
render: () => ({
components: { FormItem },
data() {
return {
value: '#3b82f6',
colorItem: {
name: 'Theme Accent Color',
type: 'color',
tooltip: 'Primary accent color for the interface theme'
} as FormItemType
}
},
methods: {
updateValue(newValue: string) {
console.log('Color updated:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px; min-width: 400px;">
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
Color picker form item:
</div>
<FormItem
:item="colorItem"
:formValue="value"
@update:formValue="updateValue"
id="theme-color"
/>
<div style="margin-top: 16px; display: flex; align-items: center; gap: 12px;">
<div style="font-size: 12px; color: #6b7280;">Preview:</div>
<div
:style="{
backgroundColor: value,
width: '40px',
height: '20px',
borderRadius: '4px',
border: '1px solid #e2e8f0'
}"
></div>
<span style="font-family: monospace; font-size: 12px;">{{ value }}</span>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Color picker FormItem with live preview showing the selected color value.'
}
}
}
}
export const ComboWithComplexOptions: Story = {
render: () => ({
components: { FormItem },
data() {
return {
value: 'medium',
comboItem: {
name: 'Quality Preset',
type: 'combo',
tooltip:
'Predefined quality settings that adjust multiple parameters',
options: [
{ text: 'Draft (Fast)', value: 'draft' },
{ text: 'Medium Quality', value: 'medium' },
{ text: 'High Quality', value: 'high' },
{ text: 'Ultra (Slow)', value: 'ultra' }
]
} as FormItemType
}
},
methods: {
updateValue(newValue: string) {
console.log('Quality preset updated:', newValue)
this.value = newValue
}
},
computed: {
presetDescription() {
const descriptions = {
draft: 'Fast generation with 10 steps, suitable for previews',
medium: 'Balanced quality with 20 steps, good for most use cases',
high: 'High quality with 40 steps, slower but better results',
ultra: 'Maximum quality with 80 steps, very slow but best results'
}
return (descriptions as any)[this.value] || 'Unknown preset'
}
},
template: `
<div style="padding: 20px; min-width: 400px;">
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
Combo with complex option objects:
</div>
<FormItem
:item="comboItem"
:formValue="value"
@update:formValue="updateValue"
id="quality-preset"
/>
<div style="margin-top: 12px; padding: 8px; background: rgba(0,0,0,0.05); border-radius: 4px;">
<div style="font-size: 12px; font-weight: 600; color: #374151;">{{ presetDescription }}</div>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Complex combo FormItem with object options showing text/value pairs and descriptions.'
}
}
}
}

View File

@@ -0,0 +1,566 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import InputKnob from './InputKnob.vue'
const meta: Meta<typeof InputKnob> = {
title: 'Components/Common/InputKnob',
component: InputKnob,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'InputKnob combines a PrimeVue Knob and InputNumber for dual input methods. It features value synchronization, range validation, step constraints, and automatic decimal precision handling based on step values.'
}
}
},
argTypes: {
modelValue: {
control: { type: 'number' },
description: 'Current numeric value (v-model)',
defaultValue: 50
},
min: {
control: { type: 'number' },
description: 'Minimum allowed value',
defaultValue: 0
},
max: {
control: { type: 'number' },
description: 'Maximum allowed value',
defaultValue: 100
},
step: {
control: { type: 'number', step: 0.01 },
description: 'Step increment for both knob and input',
defaultValue: 1
},
resolution: {
control: { type: 'number', min: 0, max: 5 },
description:
'Number of decimal places to display (auto-calculated from step if not provided)',
defaultValue: undefined
},
inputClass: {
control: 'text',
description: 'Additional CSS classes for the number input',
defaultValue: undefined
},
knobClass: {
control: 'text',
description: 'Additional CSS classes for the knob',
defaultValue: undefined
}
},
tags: ['autodocs']
}
export default meta
type Story = StoryObj<typeof InputKnob>
export const Default: Story = {
render: (args) => ({
components: { InputKnob },
setup() {
return { args }
},
data() {
return {
value: args.modelValue || 50
}
},
methods: {
handleUpdate(newValue: number) {
console.log('Value updated:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px;">
<strong>Current Value: {{ value }}</strong>
</div>
<InputKnob
:modelValue="value"
:min="args.min"
:max="args.max"
:step="args.step"
:resolution="args.resolution"
:inputClass="args.inputClass"
:knobClass="args.knobClass"
@update:modelValue="handleUpdate"
/>
</div>
`
}),
args: {
modelValue: 50,
min: 0,
max: 100,
step: 1
},
parameters: {
docs: {
description: {
story:
'Default InputKnob with range 0-100 and step of 1. Use either the knob or number input to change the value.'
}
}
}
}
export const DecimalPrecision: Story = {
render: (args) => ({
components: { InputKnob },
setup() {
return { args }
},
data() {
return {
value: args.modelValue || 2.5
}
},
methods: {
handleUpdate(newValue: number) {
console.log('Decimal value updated:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px;">
<strong>Precision Value: {{ value }}</strong>
</div>
<InputKnob
:modelValue="value"
:min="args.min"
:max="args.max"
:step="args.step"
:resolution="args.resolution"
@update:modelValue="handleUpdate"
/>
</div>
`
}),
args: {
modelValue: 2.5,
min: 0,
max: 10,
step: 0.1
},
parameters: {
docs: {
description: {
story:
'InputKnob with decimal step (0.1) - automatically shows one decimal place based on step precision.'
}
}
}
}
export const HighPrecision: Story = {
render: (args) => ({
components: { InputKnob },
setup() {
return { args }
},
data() {
return {
value: args.modelValue || 1.234
}
},
methods: {
handleUpdate(newValue: number) {
console.log('High precision value updated:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px;">
<strong>High Precision: {{ value }}</strong>
</div>
<InputKnob
:modelValue="value"
:min="args.min"
:max="args.max"
:step="args.step"
:resolution="args.resolution"
@update:modelValue="handleUpdate"
/>
</div>
`
}),
args: {
modelValue: 1.234,
min: 0,
max: 5,
step: 0.001,
resolution: 3
},
parameters: {
docs: {
description: {
story:
'High precision InputKnob with step of 0.001 and 3 decimal places resolution.'
}
}
}
}
export const LargeRange: Story = {
render: (args) => ({
components: { InputKnob },
setup() {
return { args }
},
data() {
return {
value: args.modelValue || 500
}
},
methods: {
handleUpdate(newValue: number) {
console.log('Large range value updated:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px;">
<strong>Large Range Value: {{ value }}</strong>
</div>
<InputKnob
:modelValue="value"
:min="args.min"
:max="args.max"
:step="args.step"
@update:modelValue="handleUpdate"
/>
</div>
`
}),
args: {
modelValue: 500,
min: 0,
max: 1000,
step: 10
},
parameters: {
docs: {
description: {
story:
'InputKnob with large range (0-1000) and step of 10 for coarser control.'
}
}
}
}
export const NegativeRange: Story = {
render: (args) => ({
components: { InputKnob },
setup() {
return { args }
},
data() {
return {
value: args.modelValue || 0
}
},
methods: {
handleUpdate(newValue: number) {
console.log('Negative range value updated:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px;">
<strong>Negative Range: {{ value }}</strong>
</div>
<InputKnob
:modelValue="value"
:min="args.min"
:max="args.max"
:step="args.step"
@update:modelValue="handleUpdate"
/>
</div>
`
}),
args: {
modelValue: 0,
min: -50,
max: 50,
step: 5
},
parameters: {
docs: {
description: {
story:
'InputKnob with negative range (-50 to 50) demonstrating bidirectional control.'
}
}
}
}
// ComfyUI specific examples
export const CFGScale: Story = {
render: () => ({
components: { InputKnob },
data() {
return {
cfgScale: 7.5
}
},
methods: {
updateCFG(value: number) {
console.log('CFG Scale updated:', value)
this.cfgScale = value
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 12px; font-weight: 600; color: #374151;">
CFG Scale
</div>
<div style="margin-bottom: 8px; font-size: 14px; color: #6b7280;">
Controls how closely the model follows the prompt
</div>
<InputKnob
:modelValue="cfgScale"
:min="1"
:max="20"
:step="0.5"
@update:modelValue="updateCFG"
/>
<div style="margin-top: 8px; font-size: 12px; color: #9ca3af;">
Current: {{ cfgScale }} (Recommended: 6-8)
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'ComfyUI CFG Scale parameter example - common parameter for controlling prompt adherence.'
}
}
}
}
export const SamplingSteps: Story = {
render: () => ({
components: { InputKnob },
data() {
return {
steps: 20
}
},
methods: {
updateSteps(value: number) {
console.log('Sampling steps updated:', value)
this.steps = value
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 12px; font-weight: 600; color: #374151;">
Sampling Steps
</div>
<div style="margin-bottom: 8px; font-size: 14px; color: #6b7280;">
Number of denoising steps for image generation
</div>
<InputKnob
:modelValue="steps"
:min="1"
:max="150"
:step="1"
@update:modelValue="updateSteps"
/>
<div style="margin-top: 8px; font-size: 12px; color: #9ca3af;">
Current: {{ steps }} (Higher = better quality, slower)
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'ComfyUI Sampling Steps parameter example - controls generation quality vs speed.'
}
}
}
}
export const DenoiseStrength: Story = {
render: () => ({
components: { InputKnob },
data() {
return {
denoise: 1.0
}
},
methods: {
updateDenoise(value: number) {
console.log('Denoise strength updated:', value)
this.denoise = value
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 12px; font-weight: 600; color: #374151;">
Denoise Strength
</div>
<div style="margin-bottom: 8px; font-size: 14px; color: #6b7280;">
How much noise to add (1.0 = complete denoising)
</div>
<InputKnob
:modelValue="denoise"
:min="0"
:max="1"
:step="0.01"
@update:modelValue="updateDenoise"
/>
<div style="margin-top: 8px; font-size: 12px; color: #9ca3af;">
Current: {{ denoise }} (0.0 = no change, 1.0 = full generation)
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'ComfyUI Denoise Strength parameter example - high precision control for img2img workflows.'
}
}
}
}
export const CustomStyling: Story = {
render: () => ({
components: { InputKnob },
data() {
return {
value: 75
}
},
methods: {
updateValue(newValue: number) {
console.log('Custom styled value updated:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px; font-weight: 600;">
Custom Styled InputKnob
</div>
<InputKnob
:modelValue="value"
:min="0"
:max="100"
:step="1"
inputClass="custom-input"
knobClass="custom-knob"
@update:modelValue="updateValue"
/>
<style>
.custom-input {
font-weight: bold;
color: #2563eb;
}
.custom-knob {
transform: scale(1.2);
}
</style>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'InputKnob with custom CSS classes applied to both knob and input components.'
}
}
}
}
// Gallery showing different parameter types
export const ParameterGallery: Story = {
render: () => ({
components: { InputKnob },
data() {
return {
params: {
cfg: 7.5,
steps: 20,
denoise: 1.0,
temperature: 0.8
}
}
},
methods: {
updateParam(param: string, value: number) {
console.log(`${param} updated:`, value)
;(this.params as any)[param] = value
}
},
template: `
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 24px; padding: 20px; max-width: 600px;">
<div>
<div style="font-weight: 600; margin-bottom: 8px;">CFG Scale</div>
<InputKnob
:modelValue="params.cfg"
:min="1"
:max="20"
:step="0.5"
@update:modelValue="(v) => updateParam('cfg', v)"
/>
</div>
<div>
<div style="font-weight: 600; margin-bottom: 8px;">Steps</div>
<InputKnob
:modelValue="params.steps"
:min="1"
:max="100"
:step="1"
@update:modelValue="(v) => updateParam('steps', v)"
/>
</div>
<div>
<div style="font-weight: 600; margin-bottom: 8px;">Denoise</div>
<InputKnob
:modelValue="params.denoise"
:min="0"
:max="1"
:step="0.01"
@update:modelValue="(v) => updateParam('denoise', v)"
/>
</div>
<div>
<div style="font-weight: 600; margin-bottom: 8px;">Temperature</div>
<InputKnob
:modelValue="params.temperature"
:min="0"
:max="2"
:step="0.1"
@update:modelValue="(v) => updateParam('temperature', v)"
/>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Gallery showing different parameter types commonly used in ComfyUI workflows.'
}
}
}
}

View File

@@ -0,0 +1,256 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import NoResultsPlaceholder from './NoResultsPlaceholder.vue'
const meta: Meta<typeof NoResultsPlaceholder> = {
title: 'Components/Common/NoResultsPlaceholder',
component: NoResultsPlaceholder,
parameters: {
layout: 'fullscreen',
docs: {
description: {
component:
'NoResultsPlaceholder displays an empty state with optional icon, title, message, and action button. Built with PrimeVue Card component and customizable styling.'
}
}
},
argTypes: {
class: {
control: 'text',
description: 'Additional CSS classes to apply to the wrapper',
defaultValue: undefined
},
icon: {
control: 'text',
description: 'PrimeIcons icon class to display',
defaultValue: undefined
},
title: {
control: 'text',
description: 'Main heading text',
defaultValue: 'No Results'
},
message: {
control: 'text',
description: 'Descriptive message text (supports multi-line with \\n)',
defaultValue: 'No items found'
},
textClass: {
control: 'text',
description: 'Additional CSS classes for the message text',
defaultValue: undefined
},
buttonLabel: {
control: 'text',
description: 'Label for action button (button hidden if not provided)',
defaultValue: undefined
},
onAction: {
action: 'action',
description: 'Event emitted when action button is clicked'
}
},
tags: ['autodocs']
}
export default meta
type Story = StoryObj<typeof NoResultsPlaceholder>
export const Default: Story = {
args: {
title: 'No Results',
message: 'No items found'
},
parameters: {
docs: {
description: {
story: 'Basic placeholder with just title and message.'
}
}
}
}
export const WithIcon: Story = {
args: {
icon: 'pi pi-search',
title: 'No Search Results',
message: 'Try adjusting your search criteria or filters'
},
parameters: {
docs: {
description: {
story:
'Placeholder with a search icon to indicate empty search results.'
}
}
}
}
export const WithActionButton: Story = {
args: {
icon: 'pi pi-plus',
title: 'No Items',
message: 'Get started by creating your first item',
buttonLabel: 'Create Item'
},
parameters: {
docs: {
description: {
story:
'Placeholder with an action button to help users take the next step.'
}
}
}
}
export const MultilineMessage: Story = {
args: {
icon: 'pi pi-exclamation-triangle',
title: 'Connection Error',
message:
'Unable to load data from the server.\nPlease check your internet connection\nand try again.',
buttonLabel: 'Retry'
},
parameters: {
docs: {
description: {
story: 'Placeholder with multi-line message using newline characters.'
}
}
}
}
export const EmptyWorkflow: Story = {
args: {
icon: 'pi pi-sitemap',
title: 'No Workflows',
message:
'Create your first ComfyUI workflow to get started with image generation',
buttonLabel: 'New Workflow'
},
parameters: {
docs: {
description: {
story: 'Example for empty workflow state in ComfyUI context.'
}
}
}
}
export const EmptyModels: Story = {
args: {
icon: 'pi pi-download',
title: 'No Models Found',
message:
'Download models from the model manager to start generating images',
buttonLabel: 'Open Model Manager'
},
parameters: {
docs: {
description: {
story: 'Example for empty models state with download action.'
}
}
}
}
export const FilteredResults: Story = {
args: {
icon: 'pi pi-filter',
title: 'No Matching Results',
message:
'No items match your current filters.\nTry clearing some filters to see more results.',
buttonLabel: 'Clear Filters'
},
parameters: {
docs: {
description: {
story: 'Placeholder for filtered results with option to clear filters.'
}
}
}
}
export const CustomStyling: Story = {
args: {
class: 'custom-placeholder',
icon: 'pi pi-star',
title: 'No Favorites',
message: 'Mark items as favorites to see them here',
textClass: 'text-muted-foreground',
buttonLabel: 'Browse Items'
},
parameters: {
docs: {
description: {
story: 'Placeholder with custom CSS classes applied.'
}
}
}
}
// Interactive story to test action event
export const Interactive: Story = {
args: {
icon: 'pi pi-cog',
title: 'Configuration Required',
message: 'Complete the setup to continue',
buttonLabel: 'Configure'
},
parameters: {
docs: {
description: {
story:
'Interactive placeholder - click the button to see the action event in the Actions panel.'
}
}
}
}
// Gallery view showing different icon options
export const IconGallery: Story = {
render: () => ({
components: { NoResultsPlaceholder },
template: `
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 20px; padding: 20px;">
<NoResultsPlaceholder
icon="pi pi-search"
title="Search"
message="No search results"
/>
<NoResultsPlaceholder
icon="pi pi-inbox"
title="Empty Inbox"
message="No messages"
/>
<NoResultsPlaceholder
icon="pi pi-heart"
title="No Favorites"
message="No favorite items"
/>
<NoResultsPlaceholder
icon="pi pi-folder-open"
title="Empty Folder"
message="This folder is empty"
/>
<NoResultsPlaceholder
icon="pi pi-shopping-cart"
title="Empty Cart"
message="Your cart is empty"
/>
<NoResultsPlaceholder
icon="pi pi-users"
title="No Users"
message="No users found"
/>
</div>
`
}),
parameters: {
docs: {
description: {
story: 'Gallery showing different icon options and use cases.'
}
}
}
}

View File

@@ -0,0 +1,203 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import RefreshButton from './RefreshButton.vue'
const meta: Meta<typeof RefreshButton> = {
title: 'Components/Common/RefreshButton',
component: RefreshButton,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'RefreshButton is an interactive button with loading state management. It shows a refresh icon that transforms into a progress spinner when active, using v-model for state control.'
}
}
},
argTypes: {
modelValue: {
control: 'boolean',
description: 'Active/loading state of the button (v-model)'
},
disabled: {
control: 'boolean',
description: 'Whether the button is disabled'
},
outlined: {
control: 'boolean',
description: 'Whether to use outlined button style'
},
severity: {
control: 'select',
options: ['secondary', 'success', 'info', 'warn', 'help', 'danger'],
description: 'PrimeVue severity level for button styling'
}
},
tags: ['autodocs']
}
export default meta
type Story = StoryObj<typeof RefreshButton>
const createStoryRender =
(initialState = false, asyncDuration = 2000) =>
(args: any) => ({
components: { RefreshButton },
setup() {
const isActive = ref(args.modelValue ?? initialState)
const actions = ref<string[]>([])
const logAction = (action: string) => {
const timestamp = new Date().toLocaleTimeString()
actions.value.unshift(`${action} (${timestamp})`)
if (actions.value.length > 5) actions.value.pop()
console.log(action)
}
const handleRefresh = async () => {
logAction('Refresh started')
isActive.value = true
await new Promise((resolve) => setTimeout(resolve, asyncDuration))
isActive.value = false
logAction('Refresh completed')
}
return { args, isActive, actions, handleRefresh }
},
template: `
<div style="padding: 20px;">
<RefreshButton
v-model="isActive"
v-bind="args"
@refresh="handleRefresh"
/>
<div v-if="actions.length > 0" style="margin-top: 16px; padding: 12px; background: #f5f5f5; border-radius: 4px; font-family: monospace; font-size: 12px;">
<div style="font-weight: bold; margin-bottom: 8px;">Actions Log:</div>
<div v-for="action in actions" :key="action" style="margin: 2px 0;">{{ action }}</div>
</div>
</div>
`
})
export const Default: Story = {
render: createStoryRender(),
args: {
modelValue: false,
disabled: false,
outlined: true,
severity: 'secondary'
}
}
export const Active: Story = {
render: createStoryRender(true),
args: {
disabled: false,
outlined: true,
severity: 'secondary'
}
}
export const Disabled: Story = {
render: createStoryRender(),
args: {
disabled: true,
outlined: true,
severity: 'secondary'
}
}
export const Filled: Story = {
render: createStoryRender(),
args: {
disabled: false,
outlined: false,
severity: 'secondary'
}
}
export const SuccessSeverity: Story = {
render: createStoryRender(),
args: {
disabled: false,
outlined: true,
severity: 'success'
}
}
export const DangerSeverity: Story = {
render: createStoryRender(),
args: {
disabled: false,
outlined: true,
severity: 'danger'
}
}
// Simplified gallery showing all severities
export const SeverityGallery: Story = {
render: () => ({
components: { RefreshButton },
setup() {
const severities = [
'secondary',
'success',
'info',
'warn',
'help',
'danger'
]
const states = ref(Object.fromEntries(severities.map((s) => [s, false])))
const refresh = async (severity: string) => {
console.log(`Refreshing with ${severity} severity`)
states.value[severity] = true
await new Promise((resolve) => setTimeout(resolve, 2000))
states.value[severity] = false
}
return { severities, states, refresh }
},
template: `
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; padding: 20px;">
<div v-for="severity in severities" :key="severity" style="text-align: center;">
<RefreshButton
v-model="states[severity]"
:severity="severity"
@refresh="refresh(severity)"
/>
<div style="margin-top: 8px; font-size: 12px; color: #666; text-transform: capitalize;">
{{ severity }}
</div>
</div>
</div>
`
})
}
// ComfyUI usage examples
export const WorkflowRefresh: Story = {
render: () => ({
components: { RefreshButton },
setup() {
const isRefreshing = ref(false)
const refreshWorkflows = async () => {
console.log('Refreshing workflows...')
isRefreshing.value = true
await new Promise((resolve) => setTimeout(resolve, 3000))
isRefreshing.value = false
console.log('Workflows refreshed!')
}
return { isRefreshing, refreshWorkflows }
},
template: `
<div style="display: flex; align-items: center; gap: 12px; padding: 20px;">
<span>Workflows:</span>
<RefreshButton v-model="isRefreshing" @refresh="refreshWorkflows" />
</div>
`
})
}

View File

@@ -0,0 +1,265 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import SearchBox from './SearchBox.vue'
const meta: Meta = {
title: 'Components/Common/SearchBox',
component: SearchBox as any,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'SearchBox provides a comprehensive search interface with debounced input, active filter chips, and optional filter button. Features automatic clear functionality and sophisticated event handling for search workflows.'
}
}
},
argTypes: {
modelValue: {
control: 'text',
description: 'Current search query text (v-model)'
},
placeholder: {
control: 'text',
description: 'Placeholder text for the search input'
},
icon: {
control: 'text',
description: 'PrimeIcons icon class for the search icon'
},
debounceTime: {
control: { type: 'number', min: 0, max: 1000, step: 50 },
description: 'Debounce delay in milliseconds for search events'
},
filterIcon: {
control: 'text',
description: 'Optional filter button icon (button hidden if not provided)'
},
filters: {
control: 'object',
description: 'Array of active filter chips to display'
}
},
tags: ['autodocs']
}
export default meta
type Story = StoryObj
const createSearchBoxRender =
(initialFilters: any[] = []) =>
(args: any) => ({
components: { SearchBox },
setup() {
const searchQuery = ref(args.modelValue || '')
const filters = ref(args.filters || initialFilters)
const actions = ref<string[]>([])
const logAction = (action: string, data?: any) => {
const timestamp = new Date().toLocaleTimeString()
const message = data
? `${action}: "${data}" (${timestamp})`
: `${action} (${timestamp})`
actions.value.unshift(message)
if (actions.value.length > 5) actions.value.pop()
console.log(action, data)
}
const handleUpdate = (value: string) => {
searchQuery.value = value
logAction('Search text updated', value)
}
const handleSearch = (value: string, searchFilters: any[]) => {
logAction(
'Debounced search',
`"${value}" with ${searchFilters.length} filters`
)
}
const handleShowFilter = () => {
logAction('Filter button clicked')
}
const handleRemoveFilter = (filter: any) => {
const index = filters.value.findIndex((f: any) => f === filter)
if (index > -1) {
filters.value.splice(index, 1)
logAction('Filter removed', filter.label || filter)
}
}
return {
args,
searchQuery,
filters,
actions,
handleUpdate,
handleSearch,
handleShowFilter,
handleRemoveFilter
}
},
template: `
<div style="width: 400px; padding: 20px;">
<SearchBox
:modelValue="searchQuery"
v-bind="args"
:filters="filters"
@update:modelValue="handleUpdate"
@search="handleSearch"
@showFilter="handleShowFilter"
@removeFilter="handleRemoveFilter"
/>
<div v-if="actions.length > 0" style="margin-top: 16px; padding: 12px; background: #f5f5f5; border-radius: 4px; font-family: monospace; font-size: 12px;">
<div style="font-weight: bold; margin-bottom: 8px;">Actions Log:</div>
<div v-for="action in actions" :key="action" style="margin: 2px 0;">{{ action }}</div>
</div>
</div>
`
})
export const Default: Story = {
render: createSearchBoxRender(),
args: {
modelValue: '',
placeholder: 'Search nodes...',
icon: 'pi pi-search',
debounceTime: 300,
filters: []
}
}
export const WithFilters: Story = {
render: createSearchBoxRender([
{ label: 'Image', type: 'category' },
{ label: 'Sampling', type: 'category' },
{ label: 'Recent', type: 'sort' }
]),
args: {
modelValue: 'stable diffusion',
placeholder: 'Search models...',
icon: 'pi pi-search',
debounceTime: 300,
filterIcon: 'pi pi-filter'
}
}
export const WithFilterButton: Story = {
render: createSearchBoxRender(),
args: {
modelValue: '',
placeholder: 'Search workflows...',
icon: 'pi pi-search',
debounceTime: 300,
filterIcon: 'pi pi-filter',
filters: []
}
}
export const FastDebounce: Story = {
render: createSearchBoxRender(),
args: {
modelValue: '',
placeholder: 'Fast search (50ms debounce)...',
icon: 'pi pi-search',
debounceTime: 50,
filters: []
}
}
export const SlowDebounce: Story = {
render: createSearchBoxRender(),
args: {
modelValue: '',
placeholder: 'Slow search (1000ms debounce)...',
icon: 'pi pi-search',
debounceTime: 1000,
filters: []
}
}
// ComfyUI examples
export const NodeSearch: Story = {
render: () => ({
components: { SearchBox },
setup() {
const searchQuery = ref('')
const nodeFilters = ref([
{ label: 'Sampling', type: 'category' },
{ label: 'Popular', type: 'sort' }
])
const handleSearch = (value: string, filters: any[]) => {
console.log('Searching nodes:', { value, filters })
}
const handleRemoveFilter = (filter: any) => {
const index = nodeFilters.value.findIndex((f) => f === filter)
if (index > -1) {
nodeFilters.value.splice(index, 1)
}
}
return {
searchQuery,
nodeFilters,
handleSearch,
handleRemoveFilter
}
},
template: `
<div style="width: 300px;">
<div style="margin-bottom: 8px; font-weight: 600;">Node Library</div>
<SearchBox
v-model="searchQuery"
placeholder="Search nodes..."
icon="pi pi-box"
:debounceTime="300"
filterIcon="pi pi-filter"
:filters="nodeFilters"
@search="handleSearch"
@removeFilter="handleRemoveFilter"
/>
</div>
`
})
}
export const ModelSearch: Story = {
render: () => ({
components: { SearchBox },
setup() {
const searchQuery = ref('stable-diffusion')
const modelFilters = ref([
{ label: 'SDXL', type: 'version' },
{ label: 'Checkpoints', type: 'type' }
])
const handleSearch = (value: string, filters: any[]) => {
console.log('Searching models:', { value, filters })
}
return {
searchQuery,
modelFilters,
handleSearch
}
},
template: `
<div style="width: 350px;">
<div style="margin-bottom: 8px; font-weight: 600;">Model Manager</div>
<SearchBox
v-model="searchQuery"
placeholder="Search models..."
icon="pi pi-database"
:debounceTime="400"
filterIcon="pi pi-sliders-h"
:filters="modelFilters"
@search="handleSearch"
/>
</div>
`
})
}

View File

@@ -0,0 +1,279 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import SearchFilterChip from './SearchFilterChip.vue'
const meta: Meta<typeof SearchFilterChip> = {
title: 'Components/Common/SearchFilterChip',
component: SearchFilterChip,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'SearchFilterChip displays a removable chip with a badge and text, commonly used for showing active filters in search interfaces. Built with PrimeVue Chip and Badge components.'
}
}
},
argTypes: {
text: {
control: 'text',
description: 'Main text content displayed on the chip',
defaultValue: 'Filter'
},
badge: {
control: 'text',
description: 'Badge text/number displayed before the main text',
defaultValue: '1'
},
badgeClass: {
control: 'select',
options: ['i-badge', 'o-badge', 'c-badge', 's-badge'],
description:
'CSS class for badge styling (i-badge: green, o-badge: red, c-badge: blue, s-badge: yellow)',
defaultValue: 'i-badge'
},
onRemove: {
description: 'Event emitted when the chip remove button is clicked'
}
},
tags: ['autodocs']
}
export default meta
type Story = StoryObj<typeof SearchFilterChip>
export const Default: Story = {
args: {
text: 'Active Filter',
badge: '5',
badgeClass: 'i-badge'
},
parameters: {
docs: {
description: {
story: 'Default search filter chip with green badge.'
}
}
}
}
export const InputBadge: Story = {
args: {
text: 'Inputs',
badge: '3',
badgeClass: 'i-badge'
},
parameters: {
docs: {
description: {
story: 'Filter chip with green input badge (i-badge class).'
}
}
}
}
export const OutputBadge: Story = {
args: {
text: 'Outputs',
badge: '2',
badgeClass: 'o-badge'
},
parameters: {
docs: {
description: {
story: 'Filter chip with red output badge (o-badge class).'
}
}
}
}
export const CategoryBadge: Story = {
args: {
text: 'Category',
badge: '8',
badgeClass: 'c-badge'
},
parameters: {
docs: {
description: {
story: 'Filter chip with blue category badge (c-badge class).'
}
}
}
}
export const StatusBadge: Story = {
args: {
text: 'Status',
badge: '12',
badgeClass: 's-badge'
},
parameters: {
docs: {
description: {
story: 'Filter chip with yellow status badge (s-badge class).'
}
}
}
}
export const LongText: Story = {
args: {
text: 'Very Long Filter Name That Might Wrap',
badge: '999+',
badgeClass: 'i-badge'
},
parameters: {
docs: {
description: {
story:
'Filter chip with long text and large badge number to test layout.'
}
}
}
}
export const SingleCharacterBadge: Story = {
args: {
text: 'Model Type',
badge: 'A',
badgeClass: 'c-badge'
},
parameters: {
docs: {
description: {
story: 'Filter chip with single character badge.'
}
}
}
}
export const ComfyUIFilters: Story = {
render: () => ({
components: { SearchFilterChip },
methods: {
handleRemove: () => console.log('Filter removed')
},
template: `
<div style="display: flex; flex-wrap: wrap; gap: 8px; padding: 20px;">
<SearchFilterChip
text="Sampling Nodes"
badge="5"
badgeClass="i-badge"
@remove="handleRemove"
/>
<SearchFilterChip
text="Image Outputs"
badge="3"
badgeClass="o-badge"
@remove="handleRemove"
/>
<SearchFilterChip
text="Conditioning"
badge="12"
badgeClass="c-badge"
@remove="handleRemove"
/>
<SearchFilterChip
text="Advanced"
badge="7"
badgeClass="s-badge"
@remove="handleRemove"
/>
<SearchFilterChip
text="SDXL Models"
badge="24"
badgeClass="i-badge"
@remove="handleRemove"
/>
<SearchFilterChip
text="ControlNet"
badge="8"
badgeClass="o-badge"
@remove="handleRemove"
/>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Example showing multiple filter chips as they might appear in ComfyUI search interface.'
}
}
}
}
export const Interactive: Story = {
args: {
text: 'Removable Filter',
badge: '42',
badgeClass: 'i-badge'
},
parameters: {
docs: {
description: {
story:
'Interactive chip - click the X button to see the remove event in the Actions panel.'
}
}
}
}
// Gallery showing all badge styles
export const BadgeStyleGallery: Story = {
render: () => ({
components: { SearchFilterChip },
methods: {
handleRemove: () => console.log('Filter removed')
},
template: `
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; padding: 20px; max-width: 400px;">
<div style="text-align: center;">
<SearchFilterChip
text="Input Badge"
badge="I"
badgeClass="i-badge"
@remove="handleRemove"
/>
<div style="margin-top: 8px; font-size: 12px; color: #666;">Green (i-badge)</div>
</div>
<div style="text-align: center;">
<SearchFilterChip
text="Output Badge"
badge="O"
badgeClass="o-badge"
@remove="handleRemove"
/>
<div style="margin-top: 8px; font-size: 12px; color: #666;">Red (o-badge)</div>
</div>
<div style="text-align: center;">
<SearchFilterChip
text="Category Badge"
badge="C"
badgeClass="c-badge"
@remove="handleRemove"
/>
<div style="margin-top: 8px; font-size: 12px; color: #666;">Blue (c-badge)</div>
</div>
<div style="text-align: center;">
<SearchFilterChip
text="Status Badge"
badge="S"
badgeClass="s-badge"
@remove="handleRemove"
/>
<div style="margin-top: 8px; font-size: 12px; color: #666;">Yellow (s-badge)</div>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story: 'Gallery showing all available badge styles and their colors.'
}
}
}
}

View File

@@ -0,0 +1,250 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import TextDivider from './TextDivider.vue'
const meta: Meta<typeof TextDivider> = {
title: 'Components/Common/TextDivider',
component: TextDivider,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'TextDivider combines text with a PrimeVue divider to create labeled section separators. The text can be positioned on either side of the divider line with various styling options.'
}
}
},
argTypes: {
text: {
control: 'text',
description: 'Text content to display alongside the divider',
defaultValue: 'Section'
},
position: {
control: 'select',
options: ['left', 'right'],
description: 'Position of text relative to the divider',
defaultValue: 'left'
},
align: {
control: 'select',
options: ['left', 'center', 'right', 'top', 'bottom'],
description: 'Alignment of the divider line',
defaultValue: 'center'
},
type: {
control: 'select',
options: ['solid', 'dashed', 'dotted'],
description: 'Style of the divider line',
defaultValue: 'solid'
},
layout: {
control: 'select',
options: ['horizontal', 'vertical'],
description: 'Layout direction of the divider',
defaultValue: 'horizontal'
}
},
tags: ['autodocs']
}
export default meta
type Story = StoryObj<typeof TextDivider>
export const Default: Story = {
args: {
text: 'Section Title',
position: 'left',
align: 'center',
type: 'solid',
layout: 'horizontal'
},
parameters: {
docs: {
description: {
story:
'Default text divider with text on the left side of a solid horizontal line.'
}
}
},
decorators: [
() => ({
template: `
<div style="width: 400px; padding: 20px;">
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-bottom: 15px;">
Content above divider
</div>
<story />
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-top: 15px;">
Content below divider
</div>
</div>
`
})
]
}
export const RightPosition: Story = {
args: {
text: 'Section Title',
position: 'right',
align: 'center',
type: 'solid',
layout: 'horizontal'
},
parameters: {
docs: {
description: {
story:
'Text divider with text positioned on the right side of the line.'
}
}
},
decorators: [
() => ({
template: `
<div style="width: 400px; padding: 20px;">
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-bottom: 15px;">
Content above divider
</div>
<story />
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-top: 15px;">
Content below divider
</div>
</div>
`
})
]
}
export const DashedStyle: Story = {
args: {
text: 'Dashed Section',
position: 'left',
align: 'center',
type: 'dashed',
layout: 'horizontal'
},
parameters: {
docs: {
description: {
story:
'Text divider with a dashed line style for a softer visual separation.'
}
}
},
decorators: [
() => ({
template: `
<div style="width: 400px; padding: 20px;">
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-bottom: 15px;">
Content above divider
</div>
<story />
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-top: 15px;">
Content below divider
</div>
</div>
`
})
]
}
export const DottedStyle: Story = {
args: {
text: 'Dotted Section',
position: 'right',
align: 'center',
type: 'dotted',
layout: 'horizontal'
},
parameters: {
docs: {
description: {
story:
'Text divider with a dotted line style for subtle content separation.'
}
}
},
decorators: [
() => ({
template: `
<div style="width: 400px; padding: 20px;">
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-bottom: 15px;">
Content above divider
</div>
<story />
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-top: 15px;">
Content below divider
</div>
</div>
`
})
]
}
export const VerticalLayout: Story = {
args: {
text: 'Vertical',
position: 'left',
align: 'center',
type: 'solid',
layout: 'vertical'
},
parameters: {
docs: {
description: {
story:
'Text divider in vertical layout for side-by-side content separation.'
}
}
},
decorators: [
() => ({
template: `
<div style="height: 200px; display: flex; align-items: stretch; padding: 20px;">
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-right: 15px; flex: 1;">
Left Content
</div>
<story />
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-left: 15px; flex: 1;">
Right Content
</div>
</div>
`
})
]
}
export const LongText: Story = {
args: {
text: 'Configuration Settings and Options',
position: 'left',
align: 'center',
type: 'solid',
layout: 'horizontal'
},
parameters: {
docs: {
description: {
story:
'Text divider with longer text content to demonstrate text wrapping and spacing behavior.'
}
}
},
decorators: [
() => ({
template: `
<div style="width: 300px; padding: 20px;">
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-bottom: 15px;">
Content above divider
</div>
<story />
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-top: 15px;">
Content below divider
</div>
</div>
`
})
]
}

View File

@@ -0,0 +1,651 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import type { TreeExplorerNode } from '@/types/treeExplorerTypes'
import TreeExplorer from './TreeExplorer.vue'
const meta: Meta = {
title: 'Components/Common/TreeExplorer',
component: TreeExplorer as any,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'TreeExplorer provides a sophisticated tree navigation component with expandable nodes, selection, context menus, drag-and-drop support, and customizable node rendering. Features folder operations, renaming, deletion, and advanced tree manipulation capabilities.'
}
}
},
argTypes: {
root: {
control: 'object',
description: 'Root tree node with hierarchical structure'
},
expandedKeys: {
control: 'object',
description: 'Object tracking which nodes are expanded (v-model)',
defaultValue: {}
},
selectionKeys: {
control: 'object',
description: 'Object tracking which nodes are selected (v-model)',
defaultValue: {}
},
class: {
control: 'text',
description: 'Additional CSS classes for the tree',
defaultValue: undefined
}
},
tags: ['autodocs']
}
export default meta
type Story = StoryObj
export const BasicTree: Story = {
render: (args: any) => ({
components: { TreeExplorer },
setup() {
return { args }
},
data() {
return {
expanded: {},
selected: {},
treeData: {
key: 'root',
label: 'Root',
children: [
{
key: 'workflows',
label: 'Workflows',
icon: 'pi pi-sitemap',
children: [
{
key: 'portrait',
label: 'Portrait Generation.json',
icon: 'pi pi-file'
},
{
key: 'landscape',
label: 'Landscape SDXL.json',
icon: 'pi pi-file'
},
{ key: 'anime', label: 'Anime Style.json', icon: 'pi pi-file' }
]
},
{
key: 'models',
label: 'Models',
icon: 'pi pi-download',
children: [
{
key: 'checkpoints',
label: 'Checkpoints',
icon: 'pi pi-folder',
children: [
{
key: 'sdxl',
label: 'SDXL_base.safetensors',
icon: 'pi pi-file'
},
{
key: 'sd15',
label: 'SD_1.5.safetensors',
icon: 'pi pi-file'
}
]
},
{
key: 'lora',
label: 'LoRA',
icon: 'pi pi-folder',
children: [
{
key: 'portrait_lora',
label: 'portrait_enhance.safetensors',
icon: 'pi pi-file'
}
]
}
]
},
{
key: 'outputs',
label: 'Outputs',
icon: 'pi pi-images',
children: [
{
key: 'output1',
label: 'ComfyUI_00001_.png',
icon: 'pi pi-image'
},
{
key: 'output2',
label: 'ComfyUI_00002_.png',
icon: 'pi pi-image'
}
]
}
]
} as TreeExplorerNode
}
},
methods: {
handleNodeClick(node: any, _event: MouseEvent) {
console.log('Node clicked:', node.label)
},
handleNodeDelete(node: any) {
console.log('Node delete requested:', node.label)
},
handleContextMenu(node: any, _event: MouseEvent) {
console.log('Context menu on node:', node.label)
}
},
template: `
<div style="padding: 20px; width: 400px; height: 500px;">
<div style="margin-bottom: 16px;">
<h3 style="margin: 0 0 8px 0; color: #374151;">ComfyUI File Explorer</h3>
<p style="margin: 0; font-size: 14px; color: #6b7280;">
Navigate through workflows, models, and outputs
</p>
</div>
<div style="border: 1px solid #e2e8f0; border-radius: 8px; background: white; height: 400px; overflow: auto;">
<TreeExplorer
:root="treeData"
v-model:expandedKeys="expanded"
v-model:selectionKeys="selected"
@nodeClick="handleNodeClick"
@nodeDelete="handleNodeDelete"
@contextMenu="handleContextMenu"
/>
</div>
<div style="margin-top: 12px; font-size: 12px; color: #6b7280;">
Expanded: {{ Object.keys(expanded).length }} | Selected: {{ Object.keys(selected).length }}
</div>
</div>
`
}),
args: {
expandedKeys: { workflows: true, models: true },
selectionKeys: { portrait: true }
},
parameters: {
docs: {
description: {
story:
'Basic TreeExplorer with ComfyUI file structure showing workflows, models, and outputs.'
}
}
}
}
export const EmptyTree: Story = {
render: () => ({
components: { TreeExplorer },
data() {
return {
expanded: {},
selected: {},
emptyTree: {
key: 'empty-root',
label: 'Empty Workspace',
children: []
} as TreeExplorerNode
}
},
methods: {
handleNodeClick(node: any, event: MouseEvent) {
console.log('Empty tree node clicked:', node, event)
}
},
template: `
<div style="padding: 20px; width: 350px; height: 300px;">
<div style="margin-bottom: 16px;">
<h3 style="margin: 0 0 8px 0; color: #374151;">Empty Workspace</h3>
<p style="margin: 0; font-size: 14px; color: #6b7280;">
Empty tree explorer state
</p>
</div>
<div style="border: 1px solid #e2e8f0; border-radius: 8px; background: white; height: 200px; display: flex; align-items: center; justify-content: center;">
<TreeExplorer
:root="emptyTree"
v-model:expandedKeys="expanded"
v-model:selectionKeys="selected"
@nodeClick="handleNodeClick"
/>
<div style="color: #9ca3af; font-style: italic; text-align: center;">
<i class="pi pi-folder-open" style="display: block; font-size: 24px; margin-bottom: 8px;"></i>
No items in workspace
</div>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Empty TreeExplorer showing the state when no items are present in the workspace.'
}
}
}
}
export const DeepHierarchy: Story = {
render: () => ({
components: { TreeExplorer },
data() {
return {
expanded: { workflows: true, 'stable-diffusion': true },
selected: {},
deepTree: {
key: 'root',
label: 'Projects',
children: [
{
key: 'workflows',
label: 'Workflows',
icon: 'pi pi-sitemap',
children: [
{
key: 'stable-diffusion',
label: 'Stable Diffusion',
icon: 'pi pi-folder',
children: [
{
key: 'portraits',
label: 'Portraits',
icon: 'pi pi-folder',
children: [
{
key: 'realistic',
label: 'Realistic Portrait.json',
icon: 'pi pi-file'
},
{
key: 'artistic',
label: 'Artistic Portrait.json',
icon: 'pi pi-file'
}
]
},
{
key: 'landscapes',
label: 'Landscapes',
icon: 'pi pi-folder',
children: [
{
key: 'nature',
label: 'Nature Scene.json',
icon: 'pi pi-file'
},
{
key: 'urban',
label: 'Urban Environment.json',
icon: 'pi pi-file'
}
]
}
]
},
{
key: 'controlnet',
label: 'ControlNet',
icon: 'pi pi-folder',
children: [
{
key: 'canny',
label: 'Canny Edge.json',
icon: 'pi pi-file'
},
{
key: 'depth',
label: 'Depth Map.json',
icon: 'pi pi-file'
}
]
}
]
}
]
} as TreeExplorerNode
}
},
methods: {
handleNodeClick(node: any, _event: MouseEvent) {
console.log('Deep tree node clicked:', node.label)
}
},
template: `
<div style="padding: 20px; width: 400px; height: 600px;">
<div style="margin-bottom: 16px;">
<h3 style="margin: 0 0 8px 0; color: #374151;">Deep Hierarchy</h3>
<p style="margin: 0; font-size: 14px; color: #6b7280;">
Multi-level nested folder structure with organized workflows
</p>
</div>
<div style="border: 1px solid #e2e8f0; border-radius: 8px; background: white; height: 500px; overflow: auto;">
<TreeExplorer
:root="deepTree"
v-model:expandedKeys="expanded"
v-model:selectionKeys="selected"
@nodeClick="handleNodeClick"
/>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Deep hierarchical TreeExplorer showing multi-level folder organization with workflows.'
}
}
}
}
export const InteractiveOperations: Story = {
render: () => ({
components: { TreeExplorer },
data() {
return {
expanded: { workflows: true },
selected: {},
operationLog: [],
interactiveTree: {
key: 'root',
label: 'Interactive Workspace',
children: [
{
key: 'workflows',
label: 'My Workflows',
icon: 'pi pi-sitemap',
children: [
{
key: 'workflow1',
label: 'Image Generation.json',
icon: 'pi pi-file',
handleRename: function (newName: string) {
console.log(`Renaming workflow to: ${newName}`)
},
handleDelete: function () {
console.log('Deleting workflow')
}
},
{
key: 'workflow2',
label: 'Video Processing.json',
icon: 'pi pi-file',
handleRename: function (newName: string) {
console.log(`Renaming workflow to: ${newName}`)
},
handleDelete: function () {
console.log('Deleting workflow')
}
}
]
}
]
} as TreeExplorerNode
}
},
methods: {
handleNodeClick(node: any, _event: MouseEvent) {
this.operationLog.unshift(`Clicked: ${node.label}`)
if (this.operationLog.length > 8) this.operationLog.pop()
},
handleNodeDelete(node: any) {
this.operationLog.unshift(`Delete requested: ${node.label}`)
if (this.operationLog.length > 8) this.operationLog.pop()
},
handleContextMenu(node: any, _event: MouseEvent) {
this.operationLog.unshift(`Context menu: ${node.label}`)
if (this.operationLog.length > 8) this.operationLog.pop()
}
},
template: `
<div style="padding: 20px; width: 500px;">
<div style="margin-bottom: 16px;">
<h3 style="margin: 0 0 8px 0; color: #374151;">Interactive Operations</h3>
<p style="margin: 0; font-size: 14px; color: #6b7280;">
Click nodes, right-click for context menu, test selection behavior
</p>
</div>
<div style="display: flex; gap: 16px;">
<div style="flex: 1; border: 1px solid #e2e8f0; border-radius: 8px; background: white; height: 300px; overflow: auto;">
<TreeExplorer
:root="interactiveTree"
v-model:expandedKeys="expanded"
v-model:selectionKeys="selected"
@nodeClick="handleNodeClick"
@nodeDelete="handleNodeDelete"
@contextMenu="handleContextMenu"
/>
</div>
<div style="flex: 1; background: rgba(0,0,0,0.05); border-radius: 8px; padding: 12px;">
<div style="font-weight: 600; margin-bottom: 8px; font-size: 14px;">Operation Log:</div>
<div v-if="operationLog.length === 0" style="font-style: italic; color: #9ca3af; font-size: 12px;">
No operations yet...
</div>
<div v-for="(entry, index) in operationLog" :key="index" style="font-size: 12px; color: #4b5563; margin-bottom: 2px; font-family: monospace;">
{{ entry }}
</div>
</div>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Interactive TreeExplorer demonstrating click, context menu, and selection operations with live logging.'
}
}
}
}
export const WorkflowManager: Story = {
render: () => ({
components: { TreeExplorer },
data() {
return {
expanded: { 'workflow-library': true, 'my-workflows': true },
selected: {},
workflowTree: {
key: 'root',
label: 'Workflow Manager',
children: [
{
key: 'my-workflows',
label: 'My Workflows',
icon: 'pi pi-user',
children: [
{
key: 'draft1',
label: 'Draft - SDXL Portrait.json',
icon: 'pi pi-file-edit'
},
{
key: 'final1',
label: 'Final - Product Shots.json',
icon: 'pi pi-file'
},
{
key: 'temp1',
label: 'Temp - Testing.json',
icon: 'pi pi-clock'
}
]
},
{
key: 'workflow-library',
label: 'Workflow Library',
icon: 'pi pi-book',
children: [
{
key: 'community',
label: 'Community',
icon: 'pi pi-users',
children: [
{
key: 'popular1',
label: 'SDXL Ultimate.json',
icon: 'pi pi-star-fill'
},
{
key: 'popular2',
label: 'ControlNet Pro.json',
icon: 'pi pi-star-fill'
}
]
},
{
key: 'templates',
label: 'Templates',
icon: 'pi pi-clone',
children: [
{
key: 'template1',
label: 'Basic Generation.json',
icon: 'pi pi-file'
},
{
key: 'template2',
label: 'Img2Img Template.json',
icon: 'pi pi-file'
}
]
}
]
},
{
key: 'recent',
label: 'Recent',
icon: 'pi pi-history',
children: [
{
key: 'recent1',
label: 'Last Session.json',
icon: 'pi pi-clock'
},
{
key: 'recent2',
label: 'Quick Test.json',
icon: 'pi pi-clock'
}
]
}
]
} as TreeExplorerNode
}
},
methods: {
handleNodeClick(node: any, _event: MouseEvent) {
console.log('Workflow selected:', node.label)
}
},
template: `
<div style="padding: 20px; width: 450px; height: 600px;">
<div style="margin-bottom: 16px;">
<h3 style="margin: 0 0 8px 0; color: #374151;">Workflow Manager</h3>
<p style="margin: 0; font-size: 14px; color: #6b7280;">
Organized workflow library with categories, templates, and recent files
</p>
</div>
<div style="border: 1px solid #e2e8f0; border-radius: 8px; background: white; height: 500px; overflow: auto;">
<TreeExplorer
:root="workflowTree"
v-model:expandedKeys="expanded"
v-model:selectionKeys="selected"
@nodeClick="handleNodeClick"
/>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Realistic workflow manager showing organized hierarchy with categories, templates, and recent files.'
}
}
}
}
export const CompactView: Story = {
render: () => ({
components: { TreeExplorer },
data() {
return {
expanded: { models: true },
selected: {},
compactTree: {
key: 'root',
label: 'Models',
children: [
{
key: 'models',
label: 'Checkpoints',
icon: 'pi pi-download',
children: [
{
key: 'model1',
label: 'SDXL_base.safetensors',
icon: 'pi pi-file'
},
{
key: 'model2',
label: 'SD_1.5_pruned.safetensors',
icon: 'pi pi-file'
},
{
key: 'model3',
label: 'Realistic_Vision_V5.safetensors',
icon: 'pi pi-file'
},
{
key: 'model4',
label: 'AnythingV5_v3.safetensors',
icon: 'pi pi-file'
}
]
}
]
} as TreeExplorerNode
}
},
template: `
<div style="padding: 20px; width: 300px; height: 400px;">
<div style="margin-bottom: 16px;">
<h3 style="margin: 0 0 8px 0; color: #374151;">Compact Model List</h3>
<p style="margin: 0; font-size: 14px; color: #6b7280;">
Compact view for smaller spaces
</p>
</div>
<div style="border: 1px solid #e2e8f0; border-radius: 8px; background: white; height: 300px; overflow: auto;">
<TreeExplorer
:root="compactTree"
v-model:expandedKeys="expanded"
v-model:selectionKeys="selected"
class="text-sm"
/>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Compact TreeExplorer view for smaller interface areas with minimal spacing.'
}
}
}
}

View File

@@ -0,0 +1,162 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import UserAvatar from './UserAvatar.vue'
const meta: Meta<typeof UserAvatar> = {
title: 'Components/Common/UserAvatar',
component: UserAvatar,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'UserAvatar displays a circular avatar image with fallback to a user icon when no image is provided or when the image fails to load. Built on top of PrimeVue Avatar component.'
}
}
},
argTypes: {
photoUrl: {
control: 'text',
description:
'URL of the user photo to display. Falls back to user icon if null, undefined, or fails to load',
defaultValue: null
},
ariaLabel: {
control: 'text',
description: 'Accessibility label for screen readers',
defaultValue: undefined
}
},
tags: ['autodocs']
}
export default meta
type Story = StoryObj<typeof UserAvatar>
export const Default: Story = {
args: {
photoUrl: null,
ariaLabel: 'User avatar'
},
parameters: {
docs: {
description: {
story: 'Default avatar with no image - shows user icon fallback.'
}
}
}
}
export const WithValidImage: Story = {
args: {
photoUrl:
'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=100&h=100&fit=crop&crop=face',
ariaLabel: 'John Doe avatar'
},
parameters: {
docs: {
description: {
story: 'Avatar with a valid image URL displaying a user photo.'
}
}
}
}
export const WithBrokenImage: Story = {
args: {
photoUrl: 'https://example.com/nonexistent-image.jpg',
ariaLabel: 'User with broken image'
},
parameters: {
docs: {
description: {
story:
'Avatar with a broken image URL - automatically falls back to user icon when image fails to load.'
}
}
}
}
export const WithCustomAriaLabel: Story = {
args: {
photoUrl:
'https://images.unsplash.com/photo-1494790108755-2616b612b586?w=100&h=100&fit=crop&crop=face',
ariaLabel: 'Sarah Johnson, Project Manager'
},
parameters: {
docs: {
description: {
story:
'Avatar with custom accessibility label for better screen reader experience.'
}
}
}
}
export const EmptyString: Story = {
args: {
photoUrl: '',
ariaLabel: 'User with empty photo URL'
},
parameters: {
docs: {
description: {
story:
'Avatar with empty string photo URL - treats empty string as no image.'
}
}
}
}
export const UndefinedUrl: Story = {
args: {
photoUrl: undefined,
ariaLabel: 'User with undefined photo URL'
},
parameters: {
docs: {
description: {
story: 'Avatar with undefined photo URL - shows default user icon.'
}
}
}
}
// Gallery view showing different states
export const Gallery: Story = {
render: () => ({
components: { UserAvatar },
template: `
<div style="display: flex; gap: 20px; flex-wrap: wrap; align-items: center; padding: 20px;">
<div style="text-align: center;">
<UserAvatar :photoUrl="null" ariaLabel="No image" />
<div style="margin-top: 8px; font-size: 12px; color: #666;">No Image</div>
</div>
<div style="text-align: center;">
<UserAvatar photoUrl="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=100&h=100&fit=crop&crop=face" ariaLabel="Valid image" />
<div style="margin-top: 8px; font-size: 12px; color: #666;">Valid Image</div>
</div>
<div style="text-align: center;">
<UserAvatar photoUrl="https://images.unsplash.com/photo-1494790108755-2616b612b586?w=100&h=100&fit=crop&crop=face" ariaLabel="Another valid image" />
<div style="margin-top: 8px; font-size: 12px; color: #666;">Another Valid</div>
</div>
<div style="text-align: center;">
<UserAvatar photoUrl="https://example.com/broken.jpg" ariaLabel="Broken image" />
<div style="margin-top: 8px; font-size: 12px; color: #666;">Broken URL</div>
</div>
<div style="text-align: center;">
<UserAvatar photoUrl="" ariaLabel="Empty string" />
<div style="margin-top: 8px; font-size: 12px; color: #666;">Empty String</div>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Gallery showing different avatar states side by side for comparison.'
}
}
}
}

View File

@@ -0,0 +1,380 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import type { MultiSelectProps } from 'primevue/multiselect'
import { ref } from 'vue'
import MultiSelect from './MultiSelect.vue'
import { type SelectOption } from './types'
// Combine our component props with PrimeVue MultiSelect props
interface ExtendedProps extends Partial<MultiSelectProps> {
// Our custom props
label?: string
showSearchBox?: boolean
showSelectedCount?: boolean
showClearButton?: boolean
searchPlaceholder?: string
listMaxHeight?: string
popoverMinWidth?: string
popoverMaxWidth?: string
// Override modelValue type to match our Option type
modelValue?: SelectOption[]
}
const meta: Meta<ExtendedProps> = {
title: 'Components/Input/MultiSelect/Accessibility',
component: MultiSelect,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component: `
# MultiSelect Accessibility Guide
This MultiSelect component provides full keyboard accessibility and screen reader support following WCAG 2.1 AA guidelines.
## Keyboard Navigation
- **Tab** - Focus the trigger button
- **Enter/Space** - Open/close dropdown when focused
- **Arrow Up/Down** - Navigate through options when dropdown is open
- **Enter/Space** - Select/deselect options when navigating
- **Escape** - Close dropdown
## Screen Reader Support
- Uses \`role="combobox"\` to identify as dropdown
- \`aria-haspopup="listbox"\` indicates popup contains list
- \`aria-expanded\` shows dropdown state
- \`aria-label\` provides accessible name with i18n fallback
- Selected count announced to screen readers
## Testing Instructions
1. **Tab Navigation**: Use Tab key to focus the component
2. **Keyboard Opening**: Press Enter or Space to open dropdown
3. **Option Navigation**: Use Arrow keys to navigate options
4. **Selection**: Press Enter/Space to select options
5. **Closing**: Press Escape to close dropdown
6. **Screen Reader**: Test with screen reader software
Try these stories with keyboard-only navigation!
`
}
}
},
argTypes: {
label: {
control: 'text',
description: 'Label for the trigger button'
},
showSearchBox: {
control: 'boolean',
description: 'Show search box in dropdown header'
},
showSelectedCount: {
control: 'boolean',
description: 'Show selected count in dropdown header'
},
showClearButton: {
control: 'boolean',
description: 'Show clear all button in dropdown header'
}
}
}
export default meta
type Story = StoryObj<typeof meta>
const frameworkOptions = [
{ name: 'React', value: 'react' },
{ name: 'Vue', value: 'vue' },
{ name: 'Angular', value: 'angular' },
{ name: 'Svelte', value: 'svelte' },
{ name: 'TypeScript', value: 'typescript' },
{ name: 'JavaScript', value: 'javascript' }
]
export const KeyboardNavigationDemo: Story = {
render: (args) => ({
components: { MultiSelect },
setup() {
const selectedFrameworks = ref<SelectOption[]>([])
const searchQuery = ref('')
return {
args: {
...args,
options: frameworkOptions,
modelValue: selectedFrameworks,
'onUpdate:modelValue': (value: SelectOption[]) => {
selectedFrameworks.value = value
},
'onUpdate:searchQuery': (value: string) => {
searchQuery.value = value
}
},
selectedFrameworks,
searchQuery
}
},
template: `
<div class="space-y-4 p-4">
<div class="bg-blue-50 dark-theme:bg-blue-900/20 border border-blue-200 dark-theme:border-blue-700 rounded-lg p-4">
<h3 class="text-lg font-semibold mb-2">🎯 Keyboard Navigation Test</h3>
<p class="text-sm text-gray-600 dark-theme:text-gray-300 mb-4">
Use your keyboard to navigate this MultiSelect:
</p>
<ol class="text-sm text-gray-600 list-decimal list-inside space-y-1">
<li><strong>Tab</strong> to focus the dropdown</li>
<li><strong>Enter/Space</strong> to open dropdown</li>
<li><strong>Arrow Up/Down</strong> to navigate options</li>
<li><strong>Enter/Space</strong> to select options</li>
<li><strong>Escape</strong> to close dropdown</li>
</ol>
</div>
<div class="space-y-2">
<label class="block text-sm font-medium text-gray-700">
Select Frameworks (Keyboard Navigation Test)
</label>
<MultiSelect v-bind="args" class="w-80" />
<p class="text-xs text-gray-500">
Selected: {{ selectedFrameworks.map(f => f.name).join(', ') || 'None' }}
</p>
</div>
</div>
`
}),
args: {
label: 'Choose Frameworks',
showSearchBox: true,
showSelectedCount: true,
showClearButton: true
}
}
export const ScreenReaderFriendly: Story = {
render: (args) => ({
components: { MultiSelect },
setup() {
const selectedColors = ref<SelectOption[]>([])
const selectedSizes = ref<SelectOption[]>([])
const colorOptions = [
{ name: 'Red', value: 'red' },
{ name: 'Blue', value: 'blue' },
{ name: 'Green', value: 'green' },
{ name: 'Yellow', value: 'yellow' }
]
const sizeOptions = [
{ name: 'Small', value: 'sm' },
{ name: 'Medium', value: 'md' },
{ name: 'Large', value: 'lg' },
{ name: 'Extra Large', value: 'xl' }
]
return {
selectedColors,
selectedSizes,
colorOptions,
sizeOptions,
args
}
},
template: `
<div class="space-y-6 p-4">
<div class="bg-green-50 dark-theme:bg-green-900/20 border border-green-200 dark-theme:border-green-700 rounded-lg p-4">
<h3 class="text-lg font-semibold mb-2">♿ Screen Reader Test</h3>
<p class="text-sm text-gray-600 mb-2">
These dropdowns have proper ARIA attributes and labels for screen readers:
</p>
<ul class="text-sm text-gray-600 list-disc list-inside space-y-1">
<li><code>role="combobox"</code> identifies as dropdown</li>
<li><code>aria-haspopup="listbox"</code> indicates popup type</li>
<li><code>aria-expanded</code> shows open/closed state</li>
<li><code>aria-label</code> provides accessible name</li>
<li>Selection count announced to assistive technology</li>
</ul>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-2">
<label class="block text-sm font-medium text-gray-700">
Color Preferences
</label>
<MultiSelect
v-model="selectedColors"
:options="colorOptions"
label="Select colors"
:show-selected-count="true"
:show-clear-button="true"
class="w-full"
/>
<p class="text-xs text-gray-500" aria-live="polite">
{{ selectedColors.length }} color(s) selected
</p>
</div>
<div class="space-y-2">
<label class="block text-sm font-medium text-gray-700">
Size Preferences
</label>
<MultiSelect
v-model="selectedSizes"
:options="sizeOptions"
label="Select sizes"
:show-selected-count="true"
:show-search-box="true"
class="w-full"
/>
<p class="text-xs text-gray-500" aria-live="polite">
{{ selectedSizes.length }} size(s) selected
</p>
</div>
</div>
</div>
`
})
}
export const FocusManagement: Story = {
render: (args) => ({
components: { MultiSelect },
setup() {
const selectedItems = ref<SelectOption[]>([])
const focusTestOptions = [
{ name: 'Option A', value: 'a' },
{ name: 'Option B', value: 'b' },
{ name: 'Option C', value: 'c' }
]
return {
selectedItems,
focusTestOptions,
args
}
},
template: `
<div class="space-y-4 p-4">
<div class="bg-purple-50 dark-theme:bg-purple-900/20 border border-purple-200 dark-theme:border-purple-700 rounded-lg p-4">
<h3 class="text-lg font-semibold mb-2">🎯 Focus Management Test</h3>
<p class="text-sm text-gray-600 dark-theme:text-gray-300 mb-4">
Test focus behavior with multiple form elements:
</p>
</div>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Before MultiSelect
</label>
<input
type="text"
placeholder="Previous field"
class="block w-64 px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
MultiSelect (Test Focus Ring)
</label>
<MultiSelect
v-model="selectedItems"
:options="focusTestOptions"
label="Focus test dropdown"
:show-selected-count="true"
class="w-64"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
After MultiSelect
</label>
<input
type="text"
placeholder="Next field"
class="block w-64 px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<button
class="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Submit Button
</button>
</div>
<div class="text-sm text-gray-600 mt-4">
<strong>Test:</strong> Tab through all elements and verify focus rings are visible and logical.
</div>
</div>
`
})
}
export const AccessibilityChecklist: Story = {
render: () => ({
template: `
<div class="max-w-4xl mx-auto p-6 space-y-6">
<div class="bg-gray-50 dark-theme:bg-zinc-800 border border-gray-200 dark-theme:border-zinc-700 rounded-lg p-6">
<h2 class="text-2xl font-bold mb-4">♿ MultiSelect Accessibility Checklist</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h3 class="text-lg font-semibold mb-3 text-green-700">✅ Implemented Features</h3>
<ul class="space-y-2 text-sm">
<li class="flex items-start">
<span class="text-green-500 mr-2">✓</span>
<span><strong>Keyboard Navigation:</strong> Tab, Enter, Space, Arrow keys, Escape</span>
</li>
<li class="flex items-start">
<span class="text-green-500 mr-2">✓</span>
<span><strong>ARIA Attributes:</strong> role, aria-haspopup, aria-expanded, aria-label</span>
</li>
<li class="flex items-start">
<span class="text-green-500 mr-2">✓</span>
<span><strong>Focus Management:</strong> Visible focus rings and logical tab order</span>
</li>
<li class="flex items-start">
<span class="text-green-500 mr-2">✓</span>
<span><strong>Internationalization:</strong> Translatable aria-label fallbacks</span>
</li>
<li class="flex items-start">
<span class="text-green-500 mr-2">✓</span>
<span><strong>Screen Reader Support:</strong> Proper announcements and state</span>
</li>
<li class="flex items-start">
<span class="text-green-500 mr-2">✓</span>
<span><strong>Color Contrast:</strong> Meets WCAG AA requirements</span>
</li>
</ul>
</div>
<div>
<h3 class="text-lg font-semibold mb-3 text-blue-700">📋 Testing Guidelines</h3>
<ol class="space-y-2 text-sm list-decimal list-inside">
<li><strong>Keyboard Only:</strong> Navigate using only keyboard</li>
<li><strong>Screen Reader:</strong> Test with NVDA, JAWS, or VoiceOver</li>
<li><strong>Focus Visible:</strong> Ensure focus rings are always visible</li>
<li><strong>Tab Order:</strong> Verify logical progression</li>
<li><strong>Announcements:</strong> Check state changes are announced</li>
<li><strong>Escape Behavior:</strong> Escape always closes dropdown</li>
</ol>
</div>
</div>
<div class="mt-6 p-4 bg-blue-50 dark-theme:bg-blue-900/20 border border-blue-200 dark-theme:border-blue-700 rounded-lg">
<h4 class="font-semibold mb-2">🎯 Quick Test</h4>
<p class="text-sm text-gray-700 dark-theme:text-gray-300">
Close your eyes, use only the keyboard, and try to select multiple options from any dropdown above.
If you can successfully navigate and make selections, the accessibility implementation is working!
</p>
</div>
</div>
</div>
`
})
}

View File

@@ -3,6 +3,7 @@ import type { MultiSelectProps } from 'primevue/multiselect'
import { ref } from 'vue'
import MultiSelect from './MultiSelect.vue'
import { type SelectOption } from './types'
// Combine our component props with PrimeVue MultiSelect props
// Since we use v-bind="$attrs", all PrimeVue props are available
@@ -17,7 +18,7 @@ interface ExtendedProps extends Partial<MultiSelectProps> {
popoverMinWidth?: string
popoverMaxWidth?: string
// Override modelValue type to match our Option type
modelValue?: Array<{ name: string; value: string }>
modelValue?: SelectOption[]
}
const meta: Meta<ExtendedProps> = {

View File

@@ -14,6 +14,11 @@
unstyled
:max-selected-labels="0"
:pt="pt"
:aria-label="label || t('g.multiSelectDropdown')"
role="combobox"
:aria-expanded="false"
aria-haspopup="listbox"
:tabindex="0"
>
<template
v-if="showSearchBox || showSelectedCount || showClearButton"
@@ -105,14 +110,16 @@ import MultiSelect, {
MultiSelectPassThroughMethodOptions
} from 'primevue/multiselect'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchBox from '@/components/input/SearchBox.vue'
import { usePopoverSizing } from '@/composables/usePopoverSizing'
import { cn } from '@/utils/tailwindUtil'
import TextButton from '../button/TextButton.vue'
import { type SelectOption } from './types'
type Option = { name: string; value: string }
type Option = SelectOption
defineOptions({
inheritAttrs: false
@@ -153,6 +160,8 @@ const selectedItems = defineModel<Option[]>({
required: true
})
const searchQuery = defineModel<string>('searchQuery')
const { t } = useI18n()
const selectedCount = computed(() => selectedItems.value.length)
const popoverStyle = usePopoverSizing({
@@ -162,7 +171,7 @@ const popoverStyle = usePopoverSizing({
const pt = computed(() => ({
root: ({ props }: MultiSelectPassThroughMethodOptions) => ({
class: [
class: cn(
'h-10 relative inline-flex cursor-pointer select-none',
'rounded-lg bg-white dark-theme:bg-zinc-800 text-neutral dark-theme:text-white',
'transition-all duration-200 ease-in-out',
@@ -170,8 +179,9 @@ const pt = computed(() => ({
selectedCount.value > 0
? 'border-blue-400 dark-theme:border-blue-500'
: 'border-transparent',
'focus-within:border-blue-400 dark-theme:focus-within:border-blue-500',
{ 'opacity-60 cursor-default': props.disabled }
]
)
}),
labelContainer: {
class:

View File

@@ -0,0 +1,464 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import SingleSelect from './SingleSelect.vue'
interface SingleSelectProps {
label?: string
options?: Array<{ name: string; value: string }>
listMaxHeight?: string
popoverMinWidth?: string
popoverMaxWidth?: string
modelValue?: string | null
}
const meta: Meta<SingleSelectProps> = {
title: 'Components/Input/SingleSelect/Accessibility',
component: SingleSelect,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component: `
# SingleSelect Accessibility Guide
This SingleSelect component provides full keyboard accessibility and screen reader support following WCAG 2.1 AA guidelines.
## Keyboard Navigation
- **Tab** - Focus the trigger button
- **Enter/Space** - Open/close dropdown when focused
- **Arrow Up/Down** - Navigate through options when dropdown is open
- **Enter/Space** - Select option when navigating
- **Escape** - Close dropdown
## Screen Reader Support
- Uses \`role="combobox"\` to identify as dropdown
- \`aria-haspopup="listbox"\` indicates popup contains list
- \`aria-expanded\` shows dropdown state
- \`aria-label\` provides accessible name with i18n fallback
- Selected option announced to screen readers
## Testing Instructions
1. **Tab Navigation**: Use Tab key to focus the component
2. **Keyboard Opening**: Press Enter or Space to open dropdown
3. **Option Navigation**: Use Arrow keys to navigate options
4. **Selection**: Press Enter/Space to select an option
5. **Closing**: Press Escape to close dropdown
6. **Screen Reader**: Test with screen reader software
Try these stories with keyboard-only navigation!
`
}
}
},
argTypes: {
label: {
control: 'text',
description: 'Label for the trigger button'
},
listMaxHeight: {
control: 'text',
description: 'Maximum height of dropdown list'
}
}
}
export default meta
type Story = StoryObj<typeof meta>
const sortOptions = [
{ name: 'Name A → Z', value: 'name-asc' },
{ name: 'Name Z → A', value: 'name-desc' },
{ name: 'Most Popular', value: 'popular' },
{ name: 'Most Recent', value: 'recent' },
{ name: 'File Size', value: 'size' }
]
const priorityOptions = [
{ name: 'High Priority', value: 'high' },
{ name: 'Medium Priority', value: 'medium' },
{ name: 'Low Priority', value: 'low' },
{ name: 'No Priority', value: 'none' }
]
export const KeyboardNavigationDemo: Story = {
render: (args) => ({
components: { SingleSelect },
setup() {
const selectedSort = ref<string | null>(null)
const selectedPriority = ref<string | null>('medium')
return {
args,
selectedSort,
selectedPriority,
sortOptions,
priorityOptions
}
},
template: `
<div class="space-y-6 p-4">
<div class="bg-blue-50 dark-theme:bg-blue-900/20 border border-blue-200 dark-theme:border-blue-700 rounded-lg p-4">
<h3 class="text-lg font-semibold mb-2">🎯 Keyboard Navigation Test</h3>
<p class="text-sm text-gray-600 dark-theme:text-gray-300 mb-4">
Use your keyboard to navigate these SingleSelect dropdowns:
</p>
<ol class="text-sm text-gray-600 dark-theme:text-gray-300 list-decimal list-inside space-y-1">
<li><strong>Tab</strong> to focus the dropdown</li>
<li><strong>Enter/Space</strong> to open dropdown</li>
<li><strong>Arrow Up/Down</strong> to navigate options</li>
<li><strong>Enter/Space</strong> to select option</li>
<li><strong>Escape</strong> to close dropdown</li>
</ol>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-2">
<label class="block text-sm font-medium text-gray-700 dark-theme:text-gray-200">
Sort Order
</label>
<SingleSelect
v-model="selectedSort"
:options="sortOptions"
label="Choose sort order"
class="w-full"
/>
<p class="text-xs text-gray-500">
Selected: {{ selectedSort ? sortOptions.find(o => o.value === selectedSort)?.name : 'None' }}
</p>
</div>
<div class="space-y-2">
<label class="block text-sm font-medium text-gray-700 dark-theme:text-gray-200">
Task Priority (With Icon)
</label>
<SingleSelect
v-model="selectedPriority"
:options="priorityOptions"
label="Set priority level"
class="w-full"
>
<template #icon>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z" clip-rule="evenodd" />
</svg>
</template>
</SingleSelect>
<p class="text-xs text-gray-500">
Selected: {{ selectedPriority ? priorityOptions.find(o => o.value === selectedPriority)?.name : 'None' }}
</p>
</div>
</div>
</div>
`
})
}
export const ScreenReaderFriendly: Story = {
render: (args) => ({
components: { SingleSelect },
setup() {
const selectedLanguage = ref<string | null>('en')
const selectedTheme = ref<string | null>(null)
const languageOptions = [
{ name: 'English', value: 'en' },
{ name: 'Spanish', value: 'es' },
{ name: 'French', value: 'fr' },
{ name: 'German', value: 'de' },
{ name: 'Japanese', value: 'ja' }
]
const themeOptions = [
{ name: 'Light Theme', value: 'light' },
{ name: 'Dark Theme', value: 'dark' },
{ name: 'Auto (System)', value: 'auto' },
{ name: 'High Contrast', value: 'contrast' }
]
return {
selectedLanguage,
selectedTheme,
languageOptions,
themeOptions,
args
}
},
template: `
<div class="space-y-6 p-4">
<div class="bg-green-50 dark-theme:bg-green-900/20 border border-green-200 dark-theme:border-green-700 rounded-lg p-4">
<h3 class="text-lg font-semibold mb-2">♿ Screen Reader Test</h3>
<p class="text-sm text-gray-600 dark-theme:text-gray-300 mb-2">
These dropdowns have proper ARIA attributes and labels for screen readers:
</p>
<ul class="text-sm text-gray-600 dark-theme:text-gray-300 list-disc list-inside space-y-1">
<li><code>role="combobox"</code> identifies as dropdown</li>
<li><code>aria-haspopup="listbox"</code> indicates popup type</li>
<li><code>aria-expanded</code> shows open/closed state</li>
<li><code>aria-label</code> provides accessible name</li>
<li>Selected option value announced to assistive technology</li>
</ul>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-2">
<label class="block text-sm font-medium text-gray-700 dark-theme:text-gray-200" id="language-label">
Preferred Language
</label>
<SingleSelect
v-model="selectedLanguage"
:options="languageOptions"
label="Select language"
class="w-full"
aria-labelledby="language-label"
/>
<p class="text-xs text-gray-500" aria-live="polite">
Current: {{ selectedLanguage ? languageOptions.find(o => o.value === selectedLanguage)?.name : 'None selected' }}
</p>
</div>
<div class="space-y-2">
<label class="block text-sm font-medium text-gray-700 dark-theme:text-gray-200" id="theme-label">
Interface Theme
</label>
<SingleSelect
v-model="selectedTheme"
:options="themeOptions"
label="Select theme"
class="w-full"
aria-labelledby="theme-label"
/>
<p class="text-xs text-gray-500" aria-live="polite">
Current: {{ selectedTheme ? themeOptions.find(o => o.value === selectedTheme)?.name : 'No theme selected' }}
</p>
</div>
</div>
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<h4 class="font-semibold mb-2">🎧 Screen Reader Testing Tips</h4>
<ul class="text-sm text-gray-600 dark-theme:text-gray-300 space-y-1">
<li>• Listen for role announcements when focusing</li>
<li>• Verify dropdown state changes are announced</li>
<li>• Check that selected values are spoken clearly</li>
<li>• Ensure option navigation is announced</li>
</ul>
</div>
</div>
`
})
}
export const FormIntegration: Story = {
render: (args) => ({
components: { SingleSelect },
setup() {
const formData = ref({
category: null as string | null,
status: 'draft' as string | null,
assignee: null as string | null
})
const categoryOptions = [
{ name: 'Bug Report', value: 'bug' },
{ name: 'Feature Request', value: 'feature' },
{ name: 'Documentation', value: 'docs' },
{ name: 'Question', value: 'question' }
]
const statusOptions = [
{ name: 'Draft', value: 'draft' },
{ name: 'Review', value: 'review' },
{ name: 'Approved', value: 'approved' },
{ name: 'Published', value: 'published' }
]
const assigneeOptions = [
{ name: 'Alice Johnson', value: 'alice' },
{ name: 'Bob Smith', value: 'bob' },
{ name: 'Carol Davis', value: 'carol' },
{ name: 'David Wilson', value: 'david' }
]
const handleSubmit = () => {
alert('Form submitted with: ' + JSON.stringify(formData.value, null, 2))
}
return {
formData,
categoryOptions,
statusOptions,
assigneeOptions,
handleSubmit,
args
}
},
template: `
<div class="max-w-2xl mx-auto p-6">
<div class="bg-purple-50 dark-theme:bg-purple-900/20 border border-purple-200 dark-theme:border-purple-700 rounded-lg p-4 mb-6">
<h3 class="text-lg font-semibold mb-2">📝 Form Integration Test</h3>
<p class="text-sm text-gray-600 dark-theme:text-gray-300">
Test keyboard navigation through a complete form with SingleSelect components.
Tab order should be logical and all elements should be accessible.
</p>
</div>
<form @submit.prevent="handleSubmit" class="space-y-6">
<div>
<label class="block text-sm font-medium text-gray-700 dark-theme:text-gray-200 mb-1">
Title *
</label>
<input
type="text"
required
placeholder="Enter a title"
class="block w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark-theme:text-gray-200 mb-1">
Category *
</label>
<SingleSelect
v-model="formData.category"
:options="categoryOptions"
label="Select category"
required
class="w-full"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark-theme:text-gray-200 mb-1">
Status
</label>
<SingleSelect
v-model="formData.status"
:options="statusOptions"
label="Select status"
class="w-full"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark-theme:text-gray-200 mb-1">
Assignee
</label>
<SingleSelect
v-model="formData.assignee"
:options="assigneeOptions"
label="Select assignee"
class="w-full"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark-theme:text-gray-200 mb-1">
Description
</label>
<textarea
rows="4"
placeholder="Enter description"
class="block w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div class="flex gap-3">
<button
type="submit"
class="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Submit
</button>
<button
type="button"
class="px-4 py-2 bg-gray-300 dark-theme:bg-gray-600 text-gray-700 dark-theme:text-gray-200 rounded-md hover:bg-gray-400 dark-theme:hover:bg-gray-500 focus:ring-2 focus:ring-gray-500 focus:ring-offset-2"
>
Cancel
</button>
</div>
</form>
<div class="mt-6 p-4 bg-gray-50 dark-theme:bg-zinc-800 border border-gray-200 dark-theme:border-zinc-700 rounded-lg">
<h4 class="font-semibold mb-2">Current Form Data:</h4>
<pre class="text-xs text-gray-600 dark-theme:text-gray-300">{{ JSON.stringify(formData, null, 2) }}</pre>
</div>
</div>
`
})
}
export const AccessibilityChecklist: Story = {
render: () => ({
template: `
<div class="max-w-4xl mx-auto p-6 space-y-6">
<div class="bg-gray-50 dark-theme:bg-zinc-800 border border-gray-200 dark-theme:border-zinc-700 rounded-lg p-6">
<h2 class="text-2xl font-bold mb-4">♿ SingleSelect Accessibility Checklist</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h3 class="text-lg font-semibold mb-3 text-green-700">✅ Implemented Features</h3>
<ul class="space-y-2 text-sm">
<li class="flex items-start">
<span class="text-green-500 mr-2">✓</span>
<span><strong>Keyboard Navigation:</strong> Tab, Enter, Space, Arrow keys, Escape</span>
</li>
<li class="flex items-start">
<span class="text-green-500 mr-2">✓</span>
<span><strong>ARIA Attributes:</strong> role, aria-haspopup, aria-expanded, aria-label</span>
</li>
<li class="flex items-start">
<span class="text-green-500 mr-2">✓</span>
<span><strong>Focus Management:</strong> Visible focus rings and logical tab order</span>
</li>
<li class="flex items-start">
<span class="text-green-500 mr-2">✓</span>
<span><strong>Internationalization:</strong> Translatable aria-label fallbacks</span>
</li>
<li class="flex items-start">
<span class="text-green-500 mr-2">✓</span>
<span><strong>Screen Reader Support:</strong> Proper announcements and state</span>
</li>
<li class="flex items-start">
<span class="text-green-500 mr-2">✓</span>
<span><strong>Form Integration:</strong> Works properly in forms with other elements</span>
</li>
</ul>
</div>
<div>
<h3 class="text-lg font-semibold mb-3 text-blue-700">📋 Testing Guidelines</h3>
<ol class="space-y-2 text-sm list-decimal list-inside">
<li><strong>Keyboard Only:</strong> Navigate using only keyboard</li>
<li><strong>Screen Reader:</strong> Test with NVDA, JAWS, or VoiceOver</li>
<li><strong>Focus Visible:</strong> Ensure focus rings are always visible</li>
<li><strong>Tab Order:</strong> Verify logical progression in forms</li>
<li><strong>Announcements:</strong> Check state changes are announced</li>
<li><strong>Selection:</strong> Verify selected value is announced</li>
</ol>
</div>
</div>
<div class="mt-6 p-4 bg-blue-50 dark-theme:bg-blue-900/20 border border-blue-200 dark-theme:border-blue-700 rounded-lg">
<h4 class="font-semibold mb-2">🎯 Quick Test</h4>
<p class="text-sm text-gray-700 dark-theme:text-gray-200">
Close your eyes, use only the keyboard, and try to select different options from any dropdown above.
If you can successfully navigate and make selections, the accessibility implementation is working!
</p>
</div>
<div class="mt-4 p-4 bg-orange-50 border border-orange-200 rounded-lg">
<h4 class="font-semibold mb-2">⚡ Performance Note</h4>
<p class="text-sm text-gray-700 dark-theme:text-gray-200">
These accessibility features are built into the component with minimal performance impact.
The ARIA attributes and keyboard handlers add less than 1KB to the bundle size.
</p>
</div>
</div>
</div>
`
})
}

View File

@@ -14,6 +14,11 @@
option-value="value"
unstyled
:pt="pt"
:aria-label="label || t('g.singleSelectDropdown')"
role="combobox"
:aria-expanded="false"
aria-haspopup="listbox"
:tabindex="0"
>
<!-- Trigger value -->
<template #value="slotProps">
@@ -55,9 +60,12 @@
<script setup lang="ts">
import Select, { SelectPassThroughMethodOptions } from 'primevue/select'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@/utils/tailwindUtil'
import { type SelectOption } from './types'
defineOptions({
inheritAttrs: false
})
@@ -75,10 +83,7 @@ const {
* Cannot rely on $attrs alone because we need to access options
* in getLabel() to map values to their display names.
*/
options?: {
name: string
value: string
}[]
options?: SelectOption[]
/** Maximum height of the dropdown panel (default: 28rem) */
listMaxHeight?: string
/** Minimum width of the popover (default: auto) */
@@ -89,6 +94,8 @@ const {
const selectedItem = defineModel<string | null>({ required: true })
const { t } = useI18n()
/**
* Maps a value to its display label.
* Necessary because PrimeVue's value slot doesn't provide the selected item's label,
@@ -118,16 +125,16 @@ const optionStyle = computed(() => {
* - Text/icon scale: compact size matching MultiSelect
*/
const pt = computed(() => ({
root: ({
props
}: SelectPassThroughMethodOptions<{ name: string; value: string }>) => ({
root: ({ props }: SelectPassThroughMethodOptions<SelectOption>) => ({
class: [
// container
'h-10 relative inline-flex cursor-pointer select-none items-center',
// trigger surface
'rounded-md',
'bg-transparent text-neutral dark-theme:text-white',
'border-0',
'rounded-lg',
'bg-white dark-theme:bg-zinc-800 text-neutral dark-theme:text-white',
'border-[2.5px] border-solid border-transparent',
'transition-all duration-200 ease-in-out',
'focus-within:border-blue-400 dark-theme:focus-within:border-blue-500',
// disabled
{ 'opacity-60 cursor-default': props.disabled }
]
@@ -158,9 +165,7 @@ const pt = computed(() => ({
// Same list tone/size as MultiSelect
'flex flex-col gap-0 p-0 m-0 list-none border-none text-sm'
},
option: ({
context
}: SelectPassThroughMethodOptions<{ name: string; value: string }>) => ({
option: ({ context }: SelectPassThroughMethodOptions<SelectOption>) => ({
class: [
// Row layout
'flex items-center justify-between gap-3 px-2 py-3 rounded',

View File

@@ -0,0 +1 @@
export type SelectOption = { name: string; value: string }

View File

@@ -789,19 +789,27 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
const currentData = vueNodeData.get(nodeId)
if (currentData) {
if (event.property === 'title') {
vueNodeData.set(nodeId, {
...currentData,
title: String(event.newValue)
})
} else if (event.property === 'flags.collapsed') {
vueNodeData.set(nodeId, {
...currentData,
flags: {
...currentData.flags,
collapsed: Boolean(event.newValue)
}
})
switch (event.property) {
case 'title':
vueNodeData.set(nodeId, {
...currentData,
title: String(event.newValue)
})
break
case 'flags.collapsed':
vueNodeData.set(nodeId, {
...currentData,
flags: {
...currentData.flags,
collapsed: Boolean(event.newValue)
}
})
break
case 'mode':
vueNodeData.set(nodeId, {
...currentData,
mode: typeof event.newValue === 'number' ? event.newValue : 0
})
}
}
}

View File

@@ -6314,7 +6314,14 @@ export class LGraphCanvas
}
// that.graph.beforeChange();
const newNode = LiteGraph.createNode(nodeNewType)
const xSizeFix = opts.posSizeFix[0] * LiteGraph.NODE_WIDTH
const ySizeFix = opts.posSizeFix[1] * LiteGraph.NODE_SLOT_HEIGHT
const nodeX = opts.position[0] + opts.posAdd[0] + xSizeFix
const nodeY = opts.position[1] + opts.posAdd[1] + ySizeFix
const pos = [nodeX, nodeY]
const newNode = LiteGraph.createNode(nodeNewType, nodeNewOpts.title, {
pos
})
if (newNode) {
// if is object pass options
if (nodeNewOpts) {
@@ -6341,9 +6348,6 @@ export class LGraphCanvas
)
}
}
if (nodeNewOpts.title) {
newNode.title = nodeNewOpts.title
}
if (nodeNewOpts.json) {
newNode.configure(nodeNewOpts.json)
}
@@ -6353,14 +6357,6 @@ export class LGraphCanvas
if (!this.graph) throw new NullGraphError()
this.graph.add(newNode)
newNode.pos = [
opts.position[0] +
opts.posAdd[0] +
(opts.posSizeFix[0] ? opts.posSizeFix[0] * newNode.size[0] : 0),
opts.position[1] +
opts.posAdd[1] +
(opts.posSizeFix[1] ? opts.posSizeFix[1] * newNode.size[1] : 0)
]
// Interim API - allow the link connection to be canceled.
// TODO: https://github.com/Comfy-Org/litegraph.js/issues/946

View File

@@ -3,7 +3,11 @@ import type { LGraphNode } from './LGraphNode'
/**
* Default properties to track
*/
const DEFAULT_TRACKED_PROPERTIES: string[] = ['title', 'flags.collapsed']
const DEFAULT_TRACKED_PROPERTIES: string[] = [
'title',
'flags.collapsed',
'mode'
]
/**
* Manages node properties with optional change tracking and instrumentation.

View File

@@ -156,6 +156,8 @@
"releaseTitle": "{package} {version} Release",
"itemSelected": "{selectedCount} item selected",
"itemsSelected": "{selectedCount} items selected",
"multiSelectDropdown": "Multi-select dropdown",
"singleSelectDropdown": "Single-select dropdown",
"progressCountOf": "of",
"keybindingAlreadyExists": "Keybinding already exists on",
"commandProhibited": "Command {command} is prohibited. Contact an administrator for more information.",
@@ -493,6 +495,8 @@
"reportIssue": "Report Issue",
"openLogs": "Open Logs",
"showTerminal": "Show Terminal",
"copySelectionTooltip": "Copy selection",
"copyAllTooltip": "Copy all",
"process": {
"initial-state": "Loading...",
"python-setup": "Setting up Python Environment...",

View File

@@ -5,7 +5,7 @@ import {
type ModelFile,
type ModelFolder,
assetResponseSchema
} from '@/schemas/assetSchema'
} from '@/platform/assets/schemas/assetSchema'
import { api } from '@/scripts/api'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'

View File

@@ -7,6 +7,7 @@ import type {
IBaseWidget,
IComboWidget
} from '@/lib/litegraph/src/types/widgets'
import { assetService } from '@/platform/assets/services/assetService'
import { useSettingStore } from '@/platform/settings/settingStore'
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
import {
@@ -23,7 +24,6 @@ import {
type ComfyWidgetConstructorV2,
addValueControlWidgets
} from '@/scripts/widgets'
import { assetService } from '@/services/assetService'
import { useRemoteWidget } from './useRemoteWidget'

View File

@@ -2,6 +2,10 @@ import axios from 'axios'
import { get } from 'es-toolkit/compat'
import defaultClientFeatureFlags from '@/config/clientFeatureFlags.json' with { type: 'json' }
import type {
ModelFile,
ModelFolderInfo
} from '@/platform/assets/schemas/assetSchema'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { WorkflowTemplates } from '@/platform/workflow/templates/types/template'
import type {
@@ -37,7 +41,6 @@ import type {
User,
UserDataFullInfo
} from '@/schemas/apiSchema'
import type { ModelFile, ModelFolderInfo } from '@/schemas/assetSchema'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import type { NodeExecutionId } from '@/types/nodeIdentification'

View File

@@ -1,10 +1,10 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import type { ModelFile } from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { ModelFile } from '@/schemas/assetSchema'
import { api } from '@/scripts/api'
import { assetService } from '@/services/assetService'
/** (Internal helper) finds a value in a metadata object from any of a list of keys. */
function _findInMetadata(metadata: any, ...keys: string[]): string | null {

View File

@@ -0,0 +1,218 @@
import { createTestingPinia } from '@pinia/testing'
import { VueWrapper, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import BaseTerminal from '@/components/bottomPanel/tabs/terminal/BaseTerminal.vue'
// Mock xterm and related modules
vi.mock('@xterm/xterm', () => ({
Terminal: vi.fn().mockImplementation(() => ({
open: vi.fn(),
dispose: vi.fn(),
onSelectionChange: vi.fn(() => {
// Return a disposable
return {
dispose: vi.fn()
}
}),
hasSelection: vi.fn(() => false),
getSelection: vi.fn(() => ''),
selectAll: vi.fn(),
clearSelection: vi.fn(),
loadAddon: vi.fn()
})),
IDisposable: vi.fn()
}))
vi.mock('@xterm/addon-fit', () => ({
FitAddon: vi.fn().mockImplementation(() => ({
fit: vi.fn(),
proposeDimensions: vi.fn(() => ({ rows: 24, cols: 80 }))
}))
}))
const mockTerminal = {
open: vi.fn(),
dispose: vi.fn(),
onSelectionChange: vi.fn(() => ({
dispose: vi.fn()
})),
hasSelection: vi.fn(() => false),
getSelection: vi.fn(() => ''),
selectAll: vi.fn(),
clearSelection: vi.fn()
}
vi.mock('@/composables/bottomPanelTabs/useTerminal', () => ({
useTerminal: vi.fn(() => ({
terminal: mockTerminal,
useAutoSize: vi.fn(() => ({ resize: vi.fn() }))
}))
}))
vi.mock('@/utils/envUtil', () => ({
isElectron: vi.fn(() => false),
electronAPI: vi.fn(() => null)
}))
// Mock clipboard API
Object.defineProperty(navigator, 'clipboard', {
value: {
writeText: vi.fn().mockResolvedValue(undefined)
},
configurable: true
})
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
serverStart: {
copySelectionTooltip: 'Copy selection',
copyAllTooltip: 'Copy all'
}
}
}
})
const mountBaseTerminal = () => {
return mount(BaseTerminal, {
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn
}),
i18n
],
stubs: {
Button: {
template: '<button v-bind="$attrs"><slot /></button>',
props: ['icon', 'severity', 'size']
}
}
}
})
}
describe('BaseTerminal', () => {
let wrapper: VueWrapper<InstanceType<typeof BaseTerminal>> | undefined
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
wrapper?.unmount()
})
it('emits created event on mount', () => {
wrapper = mountBaseTerminal()
expect(wrapper.emitted('created')).toBeTruthy()
expect(wrapper.emitted('created')![0]).toHaveLength(2)
})
it('emits unmounted event on unmount', () => {
wrapper = mountBaseTerminal()
wrapper.unmount()
expect(wrapper.emitted('unmounted')).toBeTruthy()
})
it('button exists and has correct initial state', async () => {
wrapper = mountBaseTerminal()
const button = wrapper.find('button[aria-label]')
expect(button.exists()).toBe(true)
expect(button.classes()).toContain('opacity-0')
expect(button.classes()).toContain('pointer-events-none')
})
it('shows correct tooltip when no selection', async () => {
mockTerminal.hasSelection.mockReturnValue(false)
wrapper = mountBaseTerminal()
await wrapper.trigger('mouseenter')
await nextTick()
const button = wrapper.find('button[aria-label]')
expect(button.attributes('aria-label')).toBe('Copy all')
})
it('shows correct tooltip when selection exists', async () => {
mockTerminal.hasSelection.mockReturnValue(true)
wrapper = mountBaseTerminal()
// Trigger the selection change callback that was registered during mount
expect(mockTerminal.onSelectionChange).toHaveBeenCalled()
// Access the mock calls - TypeScript can't infer the mock structure dynamically
const selectionCallback = (mockTerminal.onSelectionChange as any).mock
.calls[0][0]
selectionCallback()
await nextTick()
await wrapper.trigger('mouseenter')
await nextTick()
const button = wrapper.find('button[aria-label]')
expect(button.attributes('aria-label')).toBe('Copy selection')
})
it('copies selected text when selection exists', async () => {
const selectedText = 'selected text'
mockTerminal.hasSelection.mockReturnValue(true)
mockTerminal.getSelection.mockReturnValue(selectedText)
wrapper = mountBaseTerminal()
await wrapper.trigger('mouseenter')
await nextTick()
const button = wrapper.find('button[aria-label]')
await button.trigger('click')
expect(mockTerminal.selectAll).not.toHaveBeenCalled()
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(selectedText)
expect(mockTerminal.clearSelection).not.toHaveBeenCalled()
})
it('copies all text when no selection exists', async () => {
const allText = 'all terminal content'
mockTerminal.hasSelection.mockReturnValue(false)
mockTerminal.getSelection
.mockReturnValueOnce('') // First call returns empty (no selection)
.mockReturnValueOnce(allText) // Second call after selectAll returns all text
wrapper = mountBaseTerminal()
await wrapper.trigger('mouseenter')
await nextTick()
const button = wrapper.find('button[aria-label]')
await button.trigger('click')
expect(mockTerminal.selectAll).toHaveBeenCalled()
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(allText)
expect(mockTerminal.clearSelection).toHaveBeenCalled()
})
it('does not copy when no text available', async () => {
mockTerminal.hasSelection.mockReturnValue(false)
mockTerminal.getSelection.mockReturnValue('')
wrapper = mountBaseTerminal()
await wrapper.trigger('mouseenter')
await nextTick()
const button = wrapper.find('button[aria-label]')
await button.trigger('click')
expect(mockTerminal.selectAll).toHaveBeenCalled()
expect(navigator.clipboard.writeText).not.toHaveBeenCalled()
})
})

View File

@@ -2,9 +2,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { assetService } from '@/platform/assets/services/assetService'
import { useComboWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useComboWidget'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { assetService } from '@/services/assetService'
vi.mock('@/scripts/widgets', () => ({
addValueControlWidgets: vi.fn()
@@ -23,7 +23,7 @@ vi.mock('@/i18n', () => ({
)
}))
vi.mock('@/services/assetService', () => ({
vi.mock('@/platform/assets/services/assetService', () => ({
assetService: {
isAssetBrowserEligible: vi.fn(() => false)
}

View File

@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { assetService } from '@/platform/assets/services/assetService'
import { api } from '@/scripts/api'
import { assetService } from '@/services/assetService'
vi.mock('@/stores/modelToNodeStore', () => ({
useModelToNodeStore: vi.fn(() => ({

View File

@@ -1,9 +1,9 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { assetService } from '@/platform/assets/services/assetService'
import { useSettingStore } from '@/platform/settings/settingStore'
import { api } from '@/scripts/api'
import { assetService } from '@/services/assetService'
import { useModelStore } from '@/stores/modelStore'
// Mock the api
@@ -11,12 +11,15 @@ vi.mock('@/scripts/api', () => ({
api: {
getModels: vi.fn(),
getModelFolders: vi.fn(),
viewMetadata: vi.fn()
viewMetadata: vi.fn(),
apiURL: vi.fn((path: string) => `http://localhost:8188${path}`),
addEventListener: vi.fn(),
removeEventListener: vi.fn()
}
}))
// Mock the assetService
vi.mock('@/services/assetService', () => ({
vi.mock('@/platform/assets/services/assetService', () => ({
assetService: {
getAssetModelFolders: vi.fn(),
getAssetModels: vi.fn()