diff --git a/valdi_modules/playground/src/WidgetsCatalog.tsx b/valdi_modules/playground/src/WidgetsCatalog.tsx index a9d43b2..0e6d3e9 100644 --- a/valdi_modules/playground/src/WidgetsCatalog.tsx +++ b/valdi_modules/playground/src/WidgetsCatalog.tsx @@ -3,6 +3,7 @@ import { WithInsets } from 'widgets/src/components/util/WithInsets'; import { DatePicker } from 'widgets/src/components/pickers/DatePicker'; import { TimePicker, TimePickerTime } from 'widgets/src/components/pickers/TimePicker'; import { IndexPicker } from 'widgets/src/components/pickers/IndexPicker'; +import { FilePicker, FilePickerOnSelectEvent } from 'widgets/src/components/pickers/FilePicker'; import { EmojiLabel } from 'widgets/src/components/text/EmojiLabel'; import { Section } from 'widgets/src/components/section/Section'; import { SectionSeparator } from 'widgets/src/components/section/SectionSeparator'; @@ -15,6 +16,7 @@ interface CatalogState { date: Date; time: TimePickerTime; fruitIndex: number; + selectedFileName: string; } export class WidgetsCatalog extends StatefulComponent<{}, CatalogState, {}> { @@ -22,10 +24,11 @@ export class WidgetsCatalog extends StatefulComponent<{}, CatalogState, {}> { date: new Date(), time: { hourOfDay: new Date().getHours(), minuteOfHour: 0 }, fruitIndex: 0, + selectedFileName: '', }; onRender(): void { - const { date, time, fruitIndex } = this.state; + const { date, time, fruitIndex, selectedFileName } = this.state; const dateStr = date.toLocaleDateString(); const timeStr = `${String(time.hourOfDay).padStart(2, '0')}:${String(time.minuteOfHour).padStart(2, '0')}`; const fruitLabel = FRUIT_LABELS[fruitIndex]; @@ -79,6 +82,20 @@ export class WidgetsCatalog extends StatefulComponent<{}, CatalogState, {}> { +
+ this.setState({ selectedFileName: e.fileName })} + /> +
+ + +
diff --git a/valdi_modules/widgets/BUILD.bazel b/valdi_modules/widgets/BUILD.bazel index b355c2b..1313934 100644 --- a/valdi_modules/widgets/BUILD.bazel +++ b/valdi_modules/widgets/BUILD.bazel @@ -2,7 +2,7 @@ load("@aspect_rules_ts//ts:defs.bzl", "ts_project") load("@valdi//bzl/valdi:valdi_module.bzl", "valdi_module") load("@valdi//bzl/valdi:valdi_android_library.bzl", "valdi_android_library") -# Web polyglot implementations (DatePicker, TimePicker, IndexPicker, EmojiLabel). +# Web polyglot implementations (DatePicker, TimePicker, IndexPicker, FilePicker, EmojiLabel). # The build system's generate_register_native_modules picks up the compiled JS # and checks for webPolyglotViews exports to register custom view factories. ts_project( @@ -13,30 +13,30 @@ ts_project( visibility = ["//visibility:public"], ) -# Android attribute binders for polyglot custom views (IndexPicker, DatePicker, TimePicker). +# Android attribute binders for polyglot custom views (IndexPicker, DatePicker, TimePicker, FilePicker). valdi_android_library( name = "widgets_android", srcs = glob(["android/*.kt"]), deps = ["@valdi//valdi:valdi_java"], ) -# iOS native views for polyglot custom views (IndexPicker, DatePicker, TimePicker, EmojiLabel). +# iOS native views for polyglot custom views (IndexPicker, DatePicker, TimePicker, FilePicker, EmojiLabel). objc_library( name = "widgets_ios_impl", srcs = glob(["ios/**/*.m"]), hdrs = glob(["ios/**/*.h"]), - sdk_frameworks = ["UIKit"], + sdk_frameworks = ["UIKit", "UniformTypeIdentifiers"], deps = [ "@valdi//valdi_core:valdi_core_objc", ], ) -# macOS native views for polyglot custom views (DatePicker, TimePicker, IndexPicker, EmojiLabel). +# macOS native views for polyglot custom views (DatePicker, TimePicker, IndexPicker, FilePicker, EmojiLabel). objc_library( name = "widgets_macos_impl", srcs = glob(["macos/**/*.m"]), hdrs = glob(["macos/**/*.h"]), - sdk_frameworks = ["Cocoa"], + sdk_frameworks = ["Cocoa", "UniformTypeIdentifiers"], deps = ["@valdi//valdi:valdi_macos"], ) diff --git a/valdi_modules/widgets/android/ValdiFilePicker.kt b/valdi_modules/widgets/android/ValdiFilePicker.kt new file mode 100644 index 0000000..dc1476a --- /dev/null +++ b/valdi_modules/widgets/android/ValdiFilePicker.kt @@ -0,0 +1,82 @@ +package com.snap.widgets.pickers + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.content.Intent +import android.graphics.Color +import android.view.Gravity +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.LinearLayout +import com.snap.valdi.callable.ValdiFunction +import com.snap.valdi.views.ValdiTouchEventResult +import com.snap.valdi.views.ValdiTouchTarget + +class ValdiFilePicker(context: Context) : LinearLayout(context), ValdiTouchTarget { + + var onSelectFunction: ValdiFunction? = null + var allowMultiple: Boolean = false + var accept: String? = null + + private val pickButton: Button + + init { + orientation = HORIZONTAL + gravity = Gravity.CENTER + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + minimumHeight = 56 + setBackgroundColor(Color.TRANSPARENT) + + pickButton = Button(context).apply { + text = "Choose file…" + setOnClickListener { launchFilePicker() } + } + addView(pickButton) + } + + private fun resolveActivity(): Activity? { + var ctx: Context? = context + while (ctx != null) { + if (ctx is Activity) return ctx + ctx = (ctx as? ContextWrapper)?.baseContext + } + var v: View? = this + while (v != null) { + var c: Context? = v.context + while (c != null) { + if (c is Activity) return c + c = (c as? ContextWrapper)?.baseContext + } + v = (v.parent as? View) + } + return null + } + + private fun launchFilePicker() { + val activity = resolveActivity() ?: return + try { + val intent = Intent(Intent.ACTION_GET_CONTENT).apply { + type = accept ?: "*/*" + addCategory(Intent.CATEGORY_OPENABLE) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + putExtra(Intent.EXTRA_ALLOW_MULTIPLE, allowMultiple) + } + activity.startActivity(Intent.createChooser(intent, "Select file")) + } catch (_: Exception) { } + } + + override fun processTouchEvent(event: MotionEvent): ValdiTouchEventResult { + val consumed = dispatchTouchEvent(event) + return if (consumed) { + ValdiTouchEventResult.ConsumeEventAndCancelOtherGestures + } else { + ValdiTouchEventResult.IgnoreEvent + } + } +} diff --git a/valdi_modules/widgets/android/ValdiFilePickerAttributesBinder.kt b/valdi_modules/widgets/android/ValdiFilePickerAttributesBinder.kt new file mode 100644 index 0000000..c13bdbe --- /dev/null +++ b/valdi_modules/widgets/android/ValdiFilePickerAttributesBinder.kt @@ -0,0 +1,33 @@ +package com.snap.widgets.pickers + +import android.content.Context +import com.snap.valdi.attributes.AttributesBinder +import com.snap.valdi.attributes.AttributesBindingContext +import com.snap.valdi.attributes.RegisterAttributesBinder + +@RegisterAttributesBinder +class ValdiFilePickerAttributesBinder(private val context: Context) : AttributesBinder { + + override val viewClass: Class + get() = ValdiFilePicker::class.java + + override fun bindAttributes(attributesBindingContext: AttributesBindingContext) { + attributesBindingContext.bindFunctionAttribute("onSelect", { view, fn -> + view.onSelectFunction = fn + }, { view -> + view.onSelectFunction = null + }) + + attributesBindingContext.bindBooleanAttribute("allowMultiple", false, { view, value, _ -> + view.allowMultiple = value + }, { view, _ -> + view.allowMultiple = false + }) + + attributesBindingContext.bindStringAttribute("accept", false, { view, value, _ -> + view.accept = value + }, { view, _ -> + view.accept = null + }) + } +} diff --git a/valdi_modules/widgets/ios/SCWidgetsFilePicker.h b/valdi_modules/widgets/ios/SCWidgetsFilePicker.h new file mode 100644 index 0000000..79e3967 --- /dev/null +++ b/valdi_modules/widgets/ios/SCWidgetsFilePicker.h @@ -0,0 +1,9 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface SCWidgetsFilePicker : UIView + +@end + +NS_ASSUME_NONNULL_END diff --git a/valdi_modules/widgets/ios/SCWidgetsFilePicker.m b/valdi_modules/widgets/ios/SCWidgetsFilePicker.m new file mode 100644 index 0000000..4516dda --- /dev/null +++ b/valdi_modules/widgets/ios/SCWidgetsFilePicker.m @@ -0,0 +1,125 @@ +#import "SCWidgetsFilePicker.h" + +#import "valdi_core/SCValdiAttributesBinderBase.h" +#import "valdi_core/SCValdiFunction.h" +#import "valdi_core/SCValdiMarshaller.h" + +#import + +@interface SCWidgetsFilePicker () +@property (nonatomic) UIButton *pickButton; +@property (nonatomic) id onSelect; +@property (nonatomic) BOOL allowMultiple; +@property (nonatomic, copy, nullable) NSString *accept; +@end + +@implementation SCWidgetsFilePicker + +- (instancetype)initWithFrame:(CGRect)frame { + self = [super initWithFrame:frame]; + if (self) { + _pickButton = [UIButton buttonWithType:UIButtonTypeSystem]; + [_pickButton setTitle:@"Choose file…" forState:UIControlStateNormal]; + [_pickButton addTarget:self action:@selector(_openPicker) forControlEvents:UIControlEventTouchUpInside]; + _pickButton.translatesAutoresizingMaskIntoConstraints = NO; + [self addSubview:_pickButton]; + + [NSLayoutConstraint activateConstraints:@[ + [_pickButton.centerXAnchor constraintEqualToAnchor:self.centerXAnchor], + [_pickButton.centerYAnchor constraintEqualToAnchor:self.centerYAnchor], + ]]; + } + return self; +} + +- (CGSize)sizeThatFits:(CGSize)size { + return CGSizeMake(size.width, MAX(56, size.height)); +} + +#pragma mark - File picker + +- (void)_openPicker { + NSArray *types = [self _resolveContentTypes]; + UIDocumentPickerViewController *picker = + [[UIDocumentPickerViewController alloc] initForOpeningContentTypes:types]; + picker.allowsMultipleSelection = self.allowMultiple; + picker.delegate = self; + + UIViewController *vc = [self _findViewController]; + if (vc) { + [vc presentViewController:picker animated:YES completion:nil]; + } +} + +- (NSArray *)_resolveContentTypes { + if (self.accept.length == 0) { + return @[UTTypeItem]; + } + NSMutableArray *types = [NSMutableArray array]; + for (NSString *raw in [self.accept componentsSeparatedByString:@","]) { + NSString *trimmed = [raw stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceCharacterSet]; + UTType *type = [UTType typeWithMIMEType:trimmed]; + if (type) { + [types addObject:type]; + } + } + return types.count > 0 ? types : @[UTTypeItem]; +} + +- (UIViewController *)_findViewController { + UIResponder *responder = self; + while (responder) { + if ([responder isKindOfClass:[UIViewController class]]) { + return (UIViewController *)responder; + } + responder = responder.nextResponder; + } + return nil; +} + +#pragma mark - UIDocumentPickerDelegate + +- (void)documentPicker:(UIDocumentPickerViewController *)controller + didPickDocumentsAtURLs:(NSArray *)urls { + if (!self.onSelect) return; + for (NSURL *url in urls) { + SCValdiMarshallerScoped(marshaller, { + NSInteger objectIndex = SCValdiMarshallerPushMap(marshaller, 1); + SCValdiMarshallerPushString(marshaller, url.lastPathComponent); + SCValdiMarshallerPutMapPropertyUninterned(marshaller, @"fileName", objectIndex); + [self.onSelect performWithMarshaller:marshaller]; + }); + } +} + +#pragma mark - Attribute binding + ++ (void)bindAttributes:(id)attributesBinder { + [attributesBinder bindAttribute:@"onSelect" + withFunctionBlock:^(SCWidgetsFilePicker *view, id fn) { + view.onSelect = fn; + } + resetBlock:^(SCWidgetsFilePicker *view) { + view.onSelect = nil; + }]; + + [attributesBinder bindAttribute:@"allowMultiple" + invalidateLayoutOnChange:NO + withBoolBlock:^BOOL(SCWidgetsFilePicker *view, BOOL value, id animator) { + view.allowMultiple = value; + return YES; + } resetBlock:^(SCWidgetsFilePicker *view, id animator) { + view.allowMultiple = NO; + }]; + + [attributesBinder bindAttribute:@"accept" + invalidateLayoutOnChange:NO + withStringBlock:^BOOL(SCWidgetsFilePicker *view, NSString *value, id animator) { + view.accept = value; + return YES; + } resetBlock:^(SCWidgetsFilePicker *view, id animator) { + view.accept = nil; + }]; +} + +@end diff --git a/valdi_modules/widgets/macos/SCWidgetsMacOSFilePicker.h b/valdi_modules/widgets/macos/SCWidgetsMacOSFilePicker.h new file mode 100644 index 0000000..8c49f6e --- /dev/null +++ b/valdi_modules/widgets/macos/SCWidgetsMacOSFilePicker.h @@ -0,0 +1,12 @@ +#import "valdi/macos/SCValdiMacOSAttributesBinder.h" +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface SCWidgetsMacOSFilePicker : NSView + ++ (void)bindAttributes:(SCValdiMacOSAttributesBinder *)attributesBinder; + +@end + +NS_ASSUME_NONNULL_END diff --git a/valdi_modules/widgets/macos/SCWidgetsMacOSFilePicker.m b/valdi_modules/widgets/macos/SCWidgetsMacOSFilePicker.m new file mode 100644 index 0000000..e1a06a7 --- /dev/null +++ b/valdi_modules/widgets/macos/SCWidgetsMacOSFilePicker.m @@ -0,0 +1,82 @@ +#import "SCWidgetsMacOSFilePicker.h" +#import "valdi/macos/SCValdiMacOSFunction.h" + +#import + +@implementation SCWidgetsMacOSFilePicker { + NSButton *_pickButton; + SCValdiMacOSFunction *_onSelect; + BOOL _allowMultiple; + NSString *_accept; +} + +- (instancetype)initWithFrame:(NSRect)frameRect { + self = [super initWithFrame:frameRect]; + if (self) { + _pickButton = [NSButton buttonWithTitle:@"Choose file…" target:self action:@selector(_openPanel:)]; + _pickButton.bezelStyle = NSBezelStyleRounded; + _pickButton.translatesAutoresizingMaskIntoConstraints = NO; + [self addSubview:_pickButton]; + + [NSLayoutConstraint activateConstraints:@[ + [_pickButton.centerXAnchor constraintEqualToAnchor:self.centerXAnchor], + [_pickButton.centerYAnchor constraintEqualToAnchor:self.centerYAnchor], + ]]; + } + return self; +} + +- (void)_openPanel:(id)sender { + (void)sender; + NSOpenPanel *panel = [NSOpenPanel openPanel]; + panel.allowsMultipleSelection = _allowMultiple; + panel.canChooseDirectories = NO; + panel.canChooseFiles = YES; + panel.canCreateDirectories = NO; + panel.title = @"Choose a file"; + + if (_accept.length > 0) { + NSMutableArray *types = [NSMutableArray array]; + for (NSString *raw in [_accept componentsSeparatedByString:@","]) { + NSString *trimmed = [raw stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceCharacterSet]; + UTType *type = [UTType typeWithMIMEType:trimmed]; + if (type) [types addObject:type]; + } + if (types.count > 0) { + panel.allowedContentTypes = types; + } + } + + NSInteger result = [panel runModal]; + if (result != NSModalResponseOK || !_onSelect) return; + + for (NSURL *url in panel.URLs) { + [_onSelect performWithParameters:@[@{@"fileName": url.lastPathComponent ?: @""}]]; + } +} + +- (void)valdi_setOnSelect:(id)value { + _onSelect = value; +} + +- (void)valdi_setAllowMultiple:(id)value { + _allowMultiple = [value boolValue]; +} + +- (void)valdi_setAccept:(id)value { + _accept = [value isKindOfClass:[NSString class]] ? value : nil; +} + ++ (void)bindAttributes:(SCValdiMacOSAttributesBinder *)attributesBinder { + [attributesBinder bindUntypedAttribute:@"onSelect" + invalidateLayoutOnChange:NO + selector:@selector(valdi_setOnSelect:)]; + [attributesBinder bindUntypedAttribute:@"allowMultiple" + invalidateLayoutOnChange:NO + selector:@selector(valdi_setAllowMultiple:)]; + [attributesBinder bindUntypedAttribute:@"accept" + invalidateLayoutOnChange:NO + selector:@selector(valdi_setAccept:)]; +} + +@end diff --git a/valdi_modules/widgets/src/components/pickers/FilePicker.tsx b/valdi_modules/widgets/src/components/pickers/FilePicker.tsx new file mode 100644 index 0000000..8fdde82 --- /dev/null +++ b/valdi_modules/widgets/src/components/pickers/FilePicker.tsx @@ -0,0 +1,27 @@ +import { Component } from 'valdi_core/src/Component'; + +export interface FilePickerOnSelectEvent { + fileName: string; +} + +export interface FilePickerViewModel { + onSelect?: (event: FilePickerOnSelectEvent) => void; + allowMultiple?: boolean; + accept?: string; +} + +export class FilePicker extends Component { + onRender(): void { + ; + } +} diff --git a/valdi_modules/widgets/test/components/pickers/FilePickerTest.ts b/valdi_modules/widgets/test/components/pickers/FilePickerTest.ts new file mode 100644 index 0000000..31db72a --- /dev/null +++ b/valdi_modules/widgets/test/components/pickers/FilePickerTest.ts @@ -0,0 +1,60 @@ +import { FilePicker } from 'widgets/src/components/pickers/FilePicker'; +import { componentGetElements } from 'foundation/test/util/componentGetElements'; +import { untilRenderComplete } from 'foundation/test/util/untilRenderComplete'; +import 'jasmine/src/jasmine'; +import { createComponent } from 'valdi_test/test/JSXTestUtils'; + +describe('FilePicker', () => { + it('exports FilePicker class', () => { + expect(FilePicker).toBeDefined(); + expect(typeof FilePicker).toBe('function'); + }); + + it('has onRender on prototype', () => { + expect(FilePicker.prototype.onRender).toBeDefined(); + }); + + it('renders a custom-view element', async () => { + const component = createComponent( + FilePicker, + {}, + {}, + ).getComponent(); + + await untilRenderComplete(component); + + const elements = componentGetElements(component); + expect(elements.length).toBeGreaterThan(0); + }); + + it('renders with onSelect callback', async () => { + const onSelect = jasmine.createSpy('onSelect'); + const component = createComponent( + FilePicker, + { onSelect }, + {}, + ).getComponent(); + + await untilRenderComplete(component); + + const elements = componentGetElements(component); + expect(elements.length).toBeGreaterThan(0); + }); + + it('renders with allowMultiple and accept', async () => { + const component = createComponent( + FilePicker, + { + onSelect: () => {}, + allowMultiple: true, + accept: 'image/*', + }, + {}, + ).getComponent(); + + await untilRenderComplete(component); + + const elements = componentGetElements(component); + expect(elements.length).toBeGreaterThan(0); + }); +}); diff --git a/valdi_modules/widgets/web/src/WidgetsWeb.ts b/valdi_modules/widgets/web/src/WidgetsWeb.ts index 9e6b5ef..1d97bec 100644 --- a/valdi_modules/widgets/web/src/WidgetsWeb.ts +++ b/valdi_modules/widgets/web/src/WidgetsWeb.ts @@ -8,6 +8,7 @@ * DatePicker β†’ SCWidgetsDatePickerWeb * TimePicker β†’ SCWidgetsTimePickerWeb * IndexPicker β†’ SCWidgetsIndexPickerWeb + * FilePicker β†’ SCWidgetsFilePickerWeb * EmojiLabel β†’ SCWidgetsLabelWeb * * Each factory returns an object with a changeAttribute(name, value) method so the @@ -178,6 +179,75 @@ function createIndexPickerFactory(): ViewFactory { }; } +// ─── FilePicker ───────────────────────────────────────────────────────────── + +function createFilePickerFactory(): ViewFactory { + return (container: HTMLElement): AttributeHandler => { + container.style.display = 'flex'; + container.style.alignItems = 'center'; + container.style.justifyContent = 'center'; + container.style.pointerEvents = 'auto'; + + const wrapper = document.createElement('div'); + wrapper.style.position = 'relative'; + wrapper.style.display = 'inline-flex'; + wrapper.style.alignItems = 'center'; + + const button = document.createElement('button'); + button.textContent = 'Choose file…'; + button.style.fontSize = '14px'; + button.style.padding = '8px 16px'; + button.style.border = '1px solid #ccc'; + button.style.borderRadius = '6px'; + button.style.cursor = 'pointer'; + button.style.backgroundColor = '#f8f9fa'; + wrapper.appendChild(button); + + const label = document.createElement('span'); + label.style.marginLeft = '8px'; + label.style.fontSize = '13px'; + label.style.color = '#555'; + wrapper.appendChild(label); + + const input = document.createElement('input'); + input.type = 'file'; + input.style.display = 'none'; + wrapper.appendChild(input); + + container.appendChild(wrapper); + + let onSelect: ((event: { fileName: string }) => void) | null = null; + + button.addEventListener('click', () => { + input.click(); + }); + + input.addEventListener('change', () => { + const files = Array.from(input.files ?? []); + if (files.length === 0) return; + label.textContent = + files.length === 1 ? files[0].name : `${files.length} files selected`; + if (onSelect) { + for (const file of files) { + onSelect({ fileName: file.name }); + } + } + }); + + return { + changeAttribute(name: string, value: unknown): void { + if (name === 'onSelect') { + onSelect = typeof value === 'function' ? (value as (event: { fileName: string }) => void) : null; + } else if (name === 'allowMultiple' && typeof value === 'boolean') { + input.multiple = value; + } else if (name === 'accept' && (typeof value === 'string' || value == null)) { + input.accept = (value as string) ?? ''; + } + }, + }; + }; +} + // ─── EmojiLabel ────────────────────────────────────────────────────────────── function createLabelFactory(): ViewFactory { @@ -224,5 +294,6 @@ export const webPolyglotViews: Record = { SCWidgetsDatePickerWeb: createDatePickerFactory(), SCWidgetsTimePickerWeb: createTimePickerFactory(), SCWidgetsIndexPickerWeb: createIndexPickerFactory(), + SCWidgetsFilePickerWeb: createFilePickerFactory(), SCWidgetsLabelWeb: createLabelFactory(), };