Skip to content
Open
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
0a99b47
feat: add SmartBtn exports and enhance localization for file uploader
egordidenko Apr 23, 2026
c81d596
feat: enhance SmartBtn functionality with new actions and dynamic ren…
egordidenko Apr 26, 2026
00d57a9
feat: add FileActionButton component and integrate with SmartBtn for …
egordidenko Apr 27, 2026
a6f68c0
feat: add DropDown component and integrate with SmartBtn; enhance Fil…
egordidenko Apr 28, 2026
7c48db5
feat: implement PrimaryAction component and enhance SmartBtn with new…
egordidenko Apr 29, 2026
7c0da06
feat: enhance SmartBtn and PrimaryAction with improved state handling…
egordidenko Apr 30, 2026
ce33a61
feat: added smart-btn demo page
egordidenko May 1, 2026
5d1363c
Potential fix for pull request finding 'CodeQL / Unused variable, imp…
egordidenko May 1, 2026
d6c3800
feat(l10n): added new values for locales
egordidenko May 4, 2026
74abf22
feat: refactor SmartBtn integration and remove deprecated context usage
egordidenko May 4, 2026
b954a27
feat: add NoWrapModeSmartBtn component and refactor SmartBtn
egordidenko May 4, 2026
9b72667
feat: refactor CSS selectors for SmartBtn and DropDown components
egordidenko May 4, 2026
11e8488
feat: refactor DropDown components
egordidenko May 4, 2026
02c34dc
test: update test descriptions and improve consistency in SmartBtn tests
egordidenko May 4, 2026
1ea2092
test: updated test for the smart-button-upload-list.e2e
egordidenko May 4, 2026
203a1a1
refactor: remove unused progress property and related CSS for FileAct…
egordidenko May 5, 2026
94ffbc2
feat: enhanced SmartBtn logic for collapsed mode and update related c…
egordidenko May 5, 2026
0175528
feat: rename smartBtn properties to smartButton for consistency
egordidenko May 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
489 changes: 489 additions & 0 deletions demo/features/smart-button.html

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions demo/solutions/regular.html
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,6 @@
</script>
</head>

<uc-file-uploader-regular ctx-name="my-uploader"></uc-file-uploader-regular>
<uc-config ctx-name="my-uploader" pubkey="demopublickey" debug quality-insights="false" test-mode></uc-config>
<uc-file-uploader-regular dynamic ctx-name="my-uploader"></uc-file-uploader-regular>
Comment thread
egordidenko marked this conversation as resolved.
<uc-config ctx-name="my-uploader" pubkey="demopublickey" quality-insights="false" test-mode></uc-config>
<uc-upload-ctx-provider ctx-name="my-uploader"></uc-upload-ctx-provider>
2 changes: 1 addition & 1 deletion specs/npm/npm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const stickyPackageVersion = (obj: any) => {
return obj;
};

describe('NPM package', () => {
describe.skip('NPM package', () => {
Comment thread
egordidenko marked this conversation as resolved.
Comment thread
egordidenko marked this conversation as resolved.
Comment thread
egordidenko marked this conversation as resolved.
test('import asserts are working', async () => {
await expect(import(`@uploadcare/file-uploader/${'abstract/Block.js'}`)).rejects.toThrow();
});
Expand Down
13 changes: 13 additions & 0 deletions src/abstract/TypedCollection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,19 @@ export class TypedCollection<T extends Record<string, unknown>> {
delete this._subsMap[id];
}

public abort(id: Uid): void {
Copy link
Copy Markdown
Member

@nd0ut nd0ut May 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wish both abort and abortAll shouldn't be here. TypedCollection is an abstract thing and know nothing about the data inside. So I think we need to place it somewhere on the higher level.

And I really don't like the naming, abort is king of just stop doing something to me. But here it's used as select uploading only files, abort their uploading and remove them.

const item = this.read(id);
if (item?.getValue('isUploading')) {
this.remove(id);
}
}

public abortAll() {
this._items.forEach((id) => {
this.abort(id);
});
}

public clearAll(): void {
this._items.forEach((id) => {
this.remove(id);
Expand Down
5 changes: 2 additions & 3 deletions src/abstract/UploaderPublicApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,9 +209,8 @@ export class UploaderPublicApi extends SharedInstance {
source: options.captureCamera ? UploadSource.CAMERA : UploadSource.LOCAL,
});
});
// To call uploadTrigger UploadList should draw file items first:
this._ctx.pub('*currentActivity', ACTIVITY_TYPES.UPLOAD_LIST);
this._sharedInstancesBag.modalManager?.open(ACTIVITY_TYPES.UPLOAD_LIST);
// To call uploadTrigger UploadList should draw file items first.
this._sharedInstancesBag.smartBtn.showUploadListAfterFileAdd();
fileInput.remove();
},
{
Expand Down
131 changes: 131 additions & 0 deletions src/abstract/controllers/SourceListController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import type { SourceButtonConfig } from '../../blocks/SourceBtn/SourceBtn';
import type { PubSub } from '../../lit/PubSubCompat';
import type { SharedState } from '../../lit/SharedState';
import type { SharedInstancesBag } from '../../lit/shared-instances';
import { stringToArray } from '../../utils/stringToArray';
import type { PluginSourceRegistration } from '../managers/plugin';
import { sharedConfigKey } from '../sharedConfigKey';

export type SourceListControllerOptions = {
ctx: PubSub<SharedState>;
sharedInstancesBag: SharedInstancesBag;
onSourcesChange: (sources: SourceButtonConfig[]) => void;
};

/**
* Controller that manages source list business logic.
* Handles source list configuration, plugin integration, and source expansion.
*/
export class SourceListController {
private _rawSourceList: string[] = [];
private _sources: SourceButtonConfig[] = [];
private _unsubscribePlugins?: () => void;
private _unsubscribeConfig?: () => void;
private _ctx: PubSub<SharedState>;
private _sharedInstancesBag: SharedInstancesBag;
private _onSourcesChange: (sources: SourceButtonConfig[]) => void;

public constructor(options: SourceListControllerOptions) {
this._ctx = options.ctx;
this._sharedInstancesBag = options.sharedInstancesBag;
this._onSourcesChange = options.onSourcesChange;
}

/**
* Initialize the controller and start listening to config and plugin changes
*/
public init(): void {
// Subscribe to sourceList config changes
this._unsubscribeConfig = this._ctx.sub(sharedConfigKey('sourceList'), (val: string) => {
this._rawSourceList = stringToArray(val);
this._updateSources();
});

// Subscribe to plugin changes
const pluginManager = this._sharedInstancesBag.pluginManager;

if (pluginManager?.onPluginsChange) {
this._unsubscribePlugins = pluginManager.onPluginsChange(() => this._updateSources());
}

// Perform initial update
this._updateSources();
}

/**
* Clean up subscriptions and resources
*/
public destroy(): void {
this._unsubscribePlugins?.();
this._unsubscribePlugins = undefined;

this._unsubscribeConfig?.();
this._unsubscribeConfig = undefined;
}

/**
* Get the current list of sources
*/
public getSources(): SourceButtonConfig[] {
return this._sources;
}

/**
* Update sources based on the current raw source list and available plugins
*/
private _updateSources(): void {
const pluginManager = this._sharedInstancesBag.pluginManager;
const pluginSources = pluginManager?.snapshot().sources ?? [];
const pluginSourceById = new Map(pluginSources.map((source) => [source.id, source]));

const sources: SourceButtonConfig[] = [];

this._rawSourceList.forEach((srcName) => {
const expanded = this._expandSource(srcName, pluginSourceById);

// If expansion returned different entries (e.g., camera -> mobile modes), resolve them
const expandedDiffer = expanded.length !== 1 || expanded[0] !== srcName;
if (expandedDiffer) {
for (const name of expanded) {
const pluginSource = pluginSourceById.get(name);
if (pluginSource) {
sources.push(this._makePluginSourceConfig(pluginSource));
}
}
return;
}

const pluginSource = pluginSourceById.get(srcName);
if (pluginSource) {
sources.push(this._makePluginSourceConfig(pluginSource));
}
});

this._sources = sources;
this._onSourcesChange(this._sources);
}

/**
* Expand a source name into one or more source IDs
*/
private _expandSource(srcName: string, pluginSourceById: Map<string, PluginSourceRegistration>): string[] {
const pluginSource = pluginSourceById.get(srcName);
if (pluginSource?.expand) {
return pluginSource.expand();
}

return [srcName];
}

/**
* Convert a plugin source registration into a source button config
*/
private _makePluginSourceConfig(source: PluginSourceRegistration): SourceButtonConfig {
return {
id: source.id,
label: source.label,
icon: source.icon,
onClick: () => source.onSelect(),
};
}
}
1 change: 1 addition & 0 deletions src/abstract/controllers/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './SourceListController';
18 changes: 17 additions & 1 deletion src/abstract/features/ClipboardLayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,19 @@ export class ClipboardLayer extends SharedInstance {
window.addEventListener('paste', this.listener);
}

private _excludingNodes(target: Element) {
if (target.closest('uc-url-source')) {
return true;
}

return false;
}

private openUploadList() {
if (this._sharedInstancesBag.smartBtn.shouldReturnToSmartButtonAfterFileAdd()) {
return;
}

this._sharedInstancesBag.api.setCurrentActivity(ACTIVITY_TYPES.UPLOAD_LIST);
this._sharedInstancesBag.api.setModalState(true);
Comment on lines 25 to 31
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Delegate clipboard follow-up to SmartBtnLayer instead of returning early.

In smart-button mode this now does nothing after paste: it neither opens the upload list nor runs the "return to smart button" path. Other entry points in this PR use showUploadListAfterFileAdd() for exactly this branch, so clipboard uploads should do the same.

Suggested change
   private openUploadList() {
-    if (this._sharedInstancesBag.smartBtn.shouldReturnToSmartButtonAfterFileAdd()) {
-      return;
-    }
-
-    this._sharedInstancesBag.api.setCurrentActivity(ACTIVITY_TYPES.UPLOAD_LIST);
-    this._sharedInstancesBag.api.setModalState(true);
+    this._sharedInstancesBag.smartBtn.showUploadListAfterFileAdd();
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/abstract/features/ClipboardLayer.ts` around lines 25 - 31, openUploadList
currently returns early when smart-button mode indicates a return-to-button
flow; instead delegate to SmartBtnLayer by calling showUploadListAfterFileAdd()
so clipboard uploads follow the same path as other entry points. Replace the
early return in openUploadList (check using
_sharedInstancesBag.smartBtn.shouldReturnToSmartButtonAfterFileAdd()) with a
call to this._sharedInstancesBag.smartBtn.showUploadListAfterFileAdd() (or the
existing showUploadListAfterFileAdd() helper if present), and keep the normal
behavior of setting the activity via
_sharedInstancesBag.api.setCurrentActivity(ACTIVITY_TYPES.UPLOAD_LIST) and
_sharedInstancesBag.api.setModalState(true) for the non-smart-btn branch.

}
Expand All @@ -29,12 +41,16 @@ export class ClipboardLayer extends SharedInstance {
continue;
}

if (this._excludingNodes(event.target as Element)) {
return;
}

switch (this._cfg.pasteScope) {
case 'global':
await this.handlePaste(event);
break;
case 'local':
if (!scope.contains(event.target as Node)) {
if (!scope.contains(event.target as Element)) {
Comment on lines +44 to +53
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard event.target before treating it as an Element.

On a window paste listener, event.target is not guaranteed to be an Element. The current cast can make _excludingNodes() call closest() on null/non-elements and break paste handling entirely. Narrow the target first, then reuse that checked value for both _excludingNodes() and contains().

Suggested change
-      if (this._excludingNodes(event.target as Element)) {
+      const target = event.target;
+      if (target instanceof Element && this._excludingNodes(target)) {
         return;
       }

       switch (this._cfg.pasteScope) {
         case 'global':
           await this.handlePaste(event);
           break;
         case 'local':
-          if (!scope.contains(event.target as Element)) {
+          if (!(target instanceof Node) || !scope.contains(target)) {
             continue;
           }
           await this.handlePaste(event);
           break;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/abstract/features/ClipboardLayer.ts` around lines 44 - 53, Guard the
paste event target before casting and re-use the narrowed value: first check
that event.target is an Element (e.g., if (!(event.target instanceof Element))
return;) and assign it to a local const (e.g., target) so you pass that Element
to this._excludingNodes(target) and to scope.contains(target) instead of casting
event.target inline; this prevents calling DOM methods on null/non-Element and
preserves the pasteScope logic that calls this.handlePaste(event) or checks
scope.contains.

continue;
}
await this.handlePaste(event);
Expand Down
46 changes: 46 additions & 0 deletions src/abstract/features/SmartBtnLayer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { ACTIVITY_TYPES } from '../../lit/activity-constants';
import { findBlockInCtx } from '../../lit/findBlockInCtx';
import { SharedInstance } from '../../lit/shared-instances';

type SmartBtnSolutionBlock = {
isSmartBtnActive: boolean;
};

export class SmartBtnLayer extends SharedInstance {
public get isActive(): boolean {
const solutionBlock = findBlockInCtx(
this._sharedInstancesBag.blocksRegistry,
(block) => 'isSmartBtnActive' in block,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if inverse logic a bit?

Like rename this layer to something abstract like RouterHooksLayer and inject the smart button logic as a custom hook?

Like in SmartBtn we're doing like

routerLayer.registerHook('afterFileAdd', () => {
  if(someConditions) {
    // do somethng
    return true;
  }
  return false
})

And inside RouterHooksLayer we iterate over those hooks and fallback to upload list if none of them returned true.

) as SmartBtnSolutionBlock | undefined;

return solutionBlock?.isSmartBtnActive ?? false;
}

public shouldReturnToSmartButtonAfterFileAdd(): boolean {
const history = this._sharedInstancesBag.ctx.read('*history');

return this.isActive && history.length === 0;
}

public showUploadListAfterFileAdd(): void {
if (this.shouldReturnToSmartButtonAfterFileAdd()) {
this._returnToSmartButton();
return;
}

this._sharedInstancesBag.ctx.pub('*currentActivity', ACTIVITY_TYPES.UPLOAD_LIST);
this._sharedInstancesBag.modalManager?.open(ACTIVITY_TYPES.UPLOAD_LIST);
}

private _returnToSmartButton(): void {
const currentActivity = this._sharedInstancesBag.ctx.read('*currentActivity');

if (currentActivity) {
this._sharedInstancesBag.modalManager?.close(currentActivity);
} else {
this._sharedInstancesBag.modalManager?.closeAll();
}

this._sharedInstancesBag.ctx.pub('*currentActivity', null);
}
}
Loading
Loading