From 530a6786d8fd842da8363373687f4abcec53af3e Mon Sep 17 00:00:00 2001 From: vasavk Date: Tue, 2 Apr 2024 14:37:02 +0530 Subject: [PATCH] Digital ACT-191 ONEAPP-7135 story: design changes --- .../DropdownSelect/DropdownSelect.swift | 439 ++++++++++++++++++ 1 file changed, 439 insertions(+) diff --git a/VDS/Components/DropdownSelect/DropdownSelect.swift b/VDS/Components/DropdownSelect/DropdownSelect.swift index 35c48a4f..d7613f94 100644 --- a/VDS/Components/DropdownSelect/DropdownSelect.swift +++ b/VDS/Components/DropdownSelect/DropdownSelect.swift @@ -11,6 +11,7 @@ import VDSColorTokens import VDSFormControlsTokens import Combine +/// A dropdown select is an expandable menu of predefined options that allows a customer to make a single selection. @objc(VDSDropdownSelect) open class DropdownSelect: Control { @@ -29,4 +30,442 @@ open class DropdownSelect: Control { super.init(coder: coder) } + //-------------------------------------------------- + // MARK: - Public Properties + //-------------------------------------------------- + + ///Boolean value that determines if component should show the error state/error message. + open var error: Bool = false { didSet { setNeedsUpdate() }} + + /// Message displayed when there is an error. + open var errorText: String? { didSet { setNeedsUpdate() }} + + /// If provided, will be used as text for the helper label. + open var helperText: String? { didSet { setNeedsUpdate() }} + + /// used if the component is enabled or not. + open override var isEnabled: Bool { didSet { setNeedsUpdate() }} + + /// If true, the label will be displayed inside the dropdown container. Otherwise, the label will be above the dropdown container like a normal text input. + open var inlineLabel: Bool = false { didSet { setNeedsUpdate() }} + + /// If provided, will be used as context for the label on the input field. + open var label: String? { didSet { setNeedsUpdate() }} + + /// Not allowed the user interaction to select/change input if it is true. + open var readOnly: Bool = false { didSet { setNeedsUpdate() }} + + /// Used to show optional indicator for the label. + open var required: Bool = false { didSet { setNeedsUpdate() }} + + /// Allows unique ID to be passed to the element. + open var selectId: String? { didSet { setNeedsUpdate() }} + + // TO DO: either have model or individual title and content. + /// Config object for tooltip option, is optional. + open var tooltipModel: Tooltip.TooltipModel? { didSet { setNeedsUpdate() } } + + /// Used to set tooltip title. + open var tooltipTitle: String { + get { return _tooltipTitle } + set { + _tooltipTitle = newValue + updateTooltip() + setNeedsUpdate() + } + } + + /// Used to set tooltip content. + open var tooltipContent: String { + get { return _tooltipContent } + set { + _tooltipContent = newValue + updateTooltip() + setNeedsUpdate() + } + } + + /// If provided, will render with trnasparent background. + open var transparentBackground: Bool = false { didSet { setNeedsUpdate() }} + + /// Used to set width for the Dropdown Select. + open var width: Int? { didSet { setNeedsUpdate() }} + + // TO DO: create model for options + open var options: [String]? { didSet { setNeedsUpdate() }} + + ///Boolean or a Function that returns a boolean value that determines if component should show the error state/error message.Functon receives the 'event' object on input change. + open var showError: Bool = false { didSet { setNeedsUpdate() }} + + open override var state: UIControl.State { + get { + var state = super.state + if showError { + state.insert(.error) + } + return state + } + } + + //-------------------------------------------------- + // MARK: - Private Properties + //-------------------------------------------------- + internal var _tooltipTitle: String = "" + + internal var _tooltipContent: String = "" + + internal var minWidthDefault = 66.0 + internal var minWidthInlineLabel = 102.0 + + var stackView: UIStackView = UIStackView().with { + $0.translatesAutoresizingMaskIntoConstraints = false + $0.axis = .vertical + } + + private var eyebrowLabel = TrailingTooltipLabel().with { + $0.setContentCompressionResistancePriority(.required, for: .vertical) + $0.labelTextAlignment = .left + $0.labelTextStyle = .bodySmall + } + + var container: UIView = UIView().with { + $0.translatesAutoresizingMaskIntoConstraints = false + } + + var containerStack: UIStackView = UIStackView().with { + $0.translatesAutoresizingMaskIntoConstraints = false + $0.axis = .horizontal + $0.spacing = VDSFormControls.spaceInset + } + + private var dropdownField = UITextField().with { + $0.translatesAutoresizingMaskIntoConstraints = false + $0.tintColor = UIColor.clear + $0.font = TextStyle.bodyLarge.font + } + + private var inlineDisplayLabel = Label().with { + $0.textAlignment = .left + $0.textStyle = .boldBodyLarge + $0.sizeToFit() + } + + private var selectedOptionLabel = Label().with { + $0.setContentCompressionResistancePriority(.required, for: .vertical) + $0.textAlignment = .left + $0.textStyle = .bodyLarge + } + + private var icon: Icon = Icon().with { + $0.size = .medium + $0.name = Icon.Name(name: "down-caret") + } + + private var errorLabel = Label().with { + $0.setContentCompressionResistancePriority(.required, for: .vertical) + $0.textAlignment = .left + $0.textStyle = .bodySmall + } + + private var helperLabel = Label().with { + $0.setContentCompressionResistancePriority(.required, for: .vertical) + $0.textAlignment = .left + $0.textStyle = .bodySmall + } + + private var optionsPicker = UIPickerView() + + //-------------------------------------------------- + // MARK: - Constraints + //-------------------------------------------------- + internal var widthConstraint: NSLayoutConstraint? + internal var inlineWidthConstraint: NSLayoutConstraint? + + //-------------------------------------------------- + // MARK: - Configuration Properties + //-------------------------------------------------- + internal var containerSize: CGSize { CGSize(width: 45, height: 44) } + + /// Color configuration for error icon. + internal let primaryColorConfig = ViewColorConfiguration().with { + $0.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forDisabled: true) + $0.setSurfaceColors(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark, forDisabled: false) + } + + internal let secondaryColorConfig = ViewColorConfiguration().with { + $0.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forDisabled: true) + $0.setSurfaceColors(VDSColor.elementsSecondaryOnlight, VDSColor.elementsSecondaryOndark, forDisabled: false) + } + + internal var backgroundColorConfiguration = ControlColorConfiguration().with { + $0.setSurfaceColors(VDSFormControlsColor.backgroundOnlight, VDSFormControlsColor.backgroundOndark, forState: .normal) + $0.setSurfaceColors(VDSFormControlsColor.backgroundOnlight, VDSFormControlsColor.backgroundOndark, forState: .disabled) + $0.setSurfaceColors(VDSColor.feedbackErrorBackgroundOnlight, VDSColor.feedbackErrorBackgroundOndark, forState: .error) + } + + internal var containerBorderColorConfiguration = ControlColorConfiguration().with { + $0.setSurfaceColors(VDSFormControlsColor.borderOnlight, VDSFormControlsColor.borderOnlight, forState: .normal) + $0.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forState: .disabled) + $0.setSurfaceColors(VDSColor.feedbackErrorOnlight, VDSColor.feedbackErrorOndark, forState: .error) + } + + //-------------------------------------------------- + // MARK: - Lifecycle + //-------------------------------------------------- + + /// Called once when a view is initialized and is used to Setup additional UI or other constants and configurations. + open override func setup() { + super.setup() + + isAccessibilityElement = true + accessibilityLabel = "Dropdown Select" + + addSubview(stackView) + + container.addSubview(containerStack) + stackView.heightAnchor.constraint(greaterThanOrEqualToConstant: containerSize.height).isActive = true + + dropdownField.width(0) + + let spacing = VDSFormControls.spaceInset + containerStack.pinToSuperView(.init(top: spacing, left: spacing, bottom: spacing, right: spacing)) + containerStack.addArrangedSubview(dropdownField) + containerStack.addArrangedSubview(inlineDisplayLabel) + containerStack.addArrangedSubview(selectedOptionLabel) + containerStack.addArrangedSubview(icon) + + containerStack.setCustomSpacing(0, after: dropdownField) + containerStack.setCustomSpacing(VDSLayout.Spacing.space1X.value, after: inlineDisplayLabel) + containerStack.setCustomSpacing(VDSLayout.Spacing.space3X.value, after: selectedOptionLabel) + + stackView.addArrangedSubview(eyebrowLabel) + stackView.addArrangedSubview(container) + stackView.addArrangedSubview(errorLabel) + stackView.addArrangedSubview(helperLabel) + + stackView.setCustomSpacing(4, after: eyebrowLabel) + stackView.setCustomSpacing(8, after: container) + stackView.setCustomSpacing(8, after: errorLabel) + + stackView.pinToSuperView() + inlineWidthConstraint = inlineDisplayLabel.widthAnchor.constraint(equalToConstant: 0) + inlineWidthConstraint?.isActive = true + + widthConstraint = stackView.widthAnchor.constraint(greaterThanOrEqualToConstant: minWidthDefault) + widthConstraint?.isActive = true + + eyebrowLabel.textColorConfiguration = primaryColorConfig.eraseToAnyColorable() + errorLabel.textColorConfiguration = primaryColorConfig.eraseToAnyColorable() + helperLabel.textColorConfiguration = secondaryColorConfig.eraseToAnyColorable() + inlineDisplayLabel.textColorConfiguration = primaryColorConfig.eraseToAnyColorable() + + optionsPicker.delegate = self + optionsPicker.dataSource = self + + optionsPicker.isHidden = true + + dropdownField.inputView = optionsPicker + dropdownField.inputAccessoryView = toolBarForPicker() + + containerStack.publisher(for: UITapGestureRecognizer()).sink { [weak self] _ in + self?.launchPicker() + }.store(in: &subscribers) + } + + open override func reset() { + super.reset() + + eyebrowLabel.reset() + inlineDisplayLabel.reset() + selectedOptionLabel.reset() + errorLabel.reset() + helperLabel.reset() + + eyebrowLabel.labelTextAlignment = .left + eyebrowLabel.labelTextStyle = .bodySmall + inlineDisplayLabel.textStyle = .boldBodyLarge + inlineDisplayLabel.textAlignment = .left + selectedOptionLabel.textStyle = .bodyLarge + selectedOptionLabel.textAlignment = .left + errorLabel.textAlignment = .left + errorLabel.textStyle = .bodySmall + helperLabel.textAlignment = .left + helperLabel.textStyle = .bodySmall + tooltipModel = nil + tooltipTitle = "" + tooltipContent = "" + label = nil + errorText = nil + error = false + isEnabled = false + readOnly = false + inlineLabel = false + helperText = nil + transparentBackground = false + required = false + options = [] + } + + open override func updateView() { + container.backgroundColor = backgroundColorConfiguration.getColor(self) + container.layer.borderColor = containerBorderColorConfiguration.getColor(self).cgColor + container.layer.borderWidth = VDSFormControls.widthBorder + container.layer.cornerRadius = VDSFormControls.borderradius + + updateTitleLabel() + updateInlineLabel() + updateErrorLabel() + updateHelperLabel() + + selectedOptionLabel.surface = surface + backgroundColor = surface.color + } + + /// Used to update any Accessibility properties. + open override func updateAccessibility() { + super.updateAccessibility() + if showError { + setAccessibilityLabel(for: [eyebrowLabel, selectedOptionLabel, errorLabel, helperLabel]) + } else { + setAccessibilityLabel(for: [eyebrowLabel, selectedOptionLabel, helperLabel]) + } + } + + open func updateTitleLabel() { + + var attributes: [any LabelAttributeModel] = [] + var updatedLabelText = label + + updatedLabelText = inlineLabel ? "" : updatedLabelText + + if let oldText = updatedLabelText, !required, !oldText.hasSuffix("Optional") { + let optionColorAttr = ColorLabelAttribute(location: oldText.count + 2, + length: 8, + color: secondaryColorConfig.getColor(self)) + + updatedLabelText = "\(oldText) Optional" + attributes.append(optionColorAttr) + } + +// updateTooltip() + if let tooltipModel { + attributes.append(TooltipLabelAttribute(surface: surface, model: tooltipModel, presenter: self)) + } + + eyebrowLabel.labelText = updatedLabelText + eyebrowLabel.labelAttributes = attributes + eyebrowLabel.tooltipModel = tooltipModel + eyebrowLabel.surface = surface + eyebrowLabel.isEnabled = isEnabled + + } + + //-------------------------------------------------- + // MARK: - Public Methods + //-------------------------------------------------- + + open func updateInlineLabel() { + + ///Minimum width with inline text as per design + widthConstraint?.constant = inlineLabel ? minWidthInlineLabel : minWidthDefault + widthConstraint?.isActive = true + +// inlineDisplayLabel.text = inlineLabel ? (label!.isEmpty ? ((label ?? "") + ":") : label) : "" + inlineDisplayLabel.text = inlineLabel ? label : "" + inlineDisplayLabel.surface = surface + inlineWidthConstraint?.constant = inlineDisplayLabel.intrinsicContentSize.width + inlineWidthConstraint?.isActive = !inlineLabel + } + + open func updateSelectedOptionLabel(text: String? = nil) { + selectedOptionLabel.text = text ?? "" + } + + open func updateErrorLabel(){ + if showError, let errorText { + errorLabel.text = errorText + errorLabel.surface = surface + errorLabel.isEnabled = isEnabled + errorLabel.isHidden = false + icon.name = .error + icon.size = .medium + icon.color = .black + icon.surface = surface + } else { + icon.name = Icon.Name(name: "down-caret") + icon.surface = surface + errorLabel.isHidden = true + } + } + + open func updateHelperLabel(){ + + if let helperText { + helperLabel.text = helperText + helperLabel.surface = surface + helperLabel.isEnabled = isEnabled + helperLabel.isHidden = false + } else { + helperLabel.isHidden = true + } + } + + @objc open func pickerDoneClicked() { + dropdownField.resignFirstResponder() + } + + //-------------------------------------------------- + // MARK: - Private Methods + //-------------------------------------------------- + + private func toolBarForPicker() -> UIToolbar { + + let inputToolbar: UIToolbar = UIToolbar() + inputToolbar.barStyle = .default + inputToolbar.isTranslucent = true + + let doneButton = UIBarButtonItem(title: "Done", style: .plain, target: self, action: #selector(pickerDoneClicked)) + let flexibleSpaceButton = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil) + + inputToolbar.setItems([flexibleSpaceButton, doneButton], animated: false) + inputToolbar.isUserInteractionEnabled = true + inputToolbar.sizeToFit() + return inputToolbar + } + + func updateTooltip() { + self.tooltipModel = .init(title: tooltipTitle, content: tooltipContent) + } + + +} + +extension DropdownSelect: UIPickerViewDelegate, UIPickerViewDataSource, UITextFieldDelegate { + + func launchPicker() { + if optionsPicker.isHidden { + dropdownField.becomeFirstResponder() + } else { + dropdownField.resignFirstResponder() + } + optionsPicker.isHidden = !optionsPicker.isHidden + } + + public func numberOfComponents(in pickerView: UIPickerView) -> Int { + return 1 + } + + public func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int { + return options?.count ?? 0 + } + + public func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? { + guard let options, options.count > row else { return nil } + return options[row] + } + + public func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) { + guard let options, options.count > row else { return } + updateSelectedOptionLabel(text: options[row]) + } }