141 lines
6.3 KiB
Swift
141 lines
6.3 KiB
Swift
import SourceKittenFramework
|
|
|
|
/// The accessibility button and link traits are used to tell assistive technologies that an element is tappable. When
|
|
/// an element has one of these traits, VoiceOver will automatically read "button" or "link" after the element's label
|
|
/// to let the user know that they can activate it. When using a UIKit `UIButton` or SwiftUI `Button` or
|
|
/// `Link`, the button trait is added by default, but when you manually add a tap gesture recognizer to an
|
|
/// element, you need to explicitly add the button or link trait. In most cases the button trait should be used, but for
|
|
/// buttons that open a URL in an external browser we use the link trait instead. This rule attempts to catch uses of
|
|
/// the SwiftUI `.onTapGesture` modifier where the `.isButton` or `.isLink` trait is not explicitly applied.
|
|
struct AccessibilityTraitForButtonRule: ASTRule, ConfigurationProviderRule, OptInRule {
|
|
var configuration = SeverityConfiguration(.warning)
|
|
|
|
init() {}
|
|
|
|
static let description = RuleDescription(
|
|
identifier: "accessibility_trait_for_button",
|
|
name: "Accessibility Trait for Button",
|
|
description: "All views with tap gestures added should include the .isButton " +
|
|
"accessibility trait. If a tap opens an external link the .isLink " +
|
|
"trait should be used instead.",
|
|
kind: .lint,
|
|
minSwiftVersion: .fiveDotOne,
|
|
nonTriggeringExamples: AccessibilityTraitForButtonRuleExamples.nonTriggeringExamples,
|
|
triggeringExamples: AccessibilityTraitForButtonRuleExamples.triggeringExamples
|
|
)
|
|
|
|
// MARK: AST Rule
|
|
|
|
func validate(file: SwiftLintFile, kind: SwiftDeclarationKind,
|
|
dictionary: SourceKittenDictionary) -> [StyleViolation] {
|
|
// Only proceed to check View structs.
|
|
guard kind == .struct,
|
|
dictionary.inheritedTypes.contains("View"),
|
|
dictionary.substructure.isNotEmpty else {
|
|
return []
|
|
}
|
|
|
|
return findButtonTraitViolations(file: file, substructure: dictionary.substructure)
|
|
}
|
|
|
|
/// Recursively check a file for image violations, and return all such violations.
|
|
private func findButtonTraitViolations(
|
|
file: SwiftLintFile,
|
|
substructure: [SourceKittenDictionary]
|
|
) -> [StyleViolation] {
|
|
var violations = [StyleViolation]()
|
|
for dictionary in substructure {
|
|
guard let offset: ByteCount = dictionary.offset else {
|
|
continue
|
|
}
|
|
|
|
// If it has a tap gesture and does not have a button or link trait, it's a violation.
|
|
// Also allowing ones that are hidden from accessibility, though that's not recommended.
|
|
if dictionary.hasOnSingleTapModifier(in: file) {
|
|
if dictionary.hasAccessibilityTrait(".isButton", in: file) ||
|
|
dictionary.hasAccessibilityTrait(".isLink", in: file) ||
|
|
dictionary.hasAccessibilityHiddenModifier(in: file) {
|
|
continue
|
|
}
|
|
|
|
violations.append(
|
|
StyleViolation(ruleDescription: Self.description,
|
|
severity: configuration.severity,
|
|
location: Location(file: file, byteOffset: offset))
|
|
)
|
|
}
|
|
|
|
// If dictionary did not represent a View with a tap gesture, recursively check substructure,
|
|
// unless it's a container that hides its children from accessibility.
|
|
else if dictionary.substructure.isNotEmpty {
|
|
if dictionary.hasAccessibilityHiddenModifier(in: file) ||
|
|
dictionary.hasAccessibilityElementChildrenIgnoreModifier(in: file) {
|
|
continue
|
|
}
|
|
|
|
violations.append(
|
|
contentsOf: findButtonTraitViolations(file: file, substructure: dictionary.substructure)
|
|
)
|
|
}
|
|
}
|
|
|
|
return violations
|
|
}
|
|
}
|
|
|
|
// MARK: SourceKittenDictionary extensions
|
|
|
|
private extension SourceKittenDictionary {
|
|
/// Whether or not the dictionary represents a SwiftUI View with a tap gesture where the `count` argument is 1.
|
|
/// A single tap can be represented by an `onTapGesture` modifier with a count of 1 (default value is 1),
|
|
/// or by a `gesture`, `simultaneousGesture`, or `highPriorityGesture` modifier with an argument
|
|
/// starting with a `TapGesture` object with a count of 1 (default value is 1).
|
|
func hasOnSingleTapModifier(in file: SwiftLintFile) -> Bool {
|
|
return hasModifier(
|
|
anyOf: [
|
|
SwiftUIModifier(
|
|
name: "onTapGesture",
|
|
arguments: [.init(name: "count", required: false, values: ["1"])]
|
|
),
|
|
SwiftUIModifier(
|
|
name: "gesture",
|
|
arguments: [
|
|
.init(name: "", values: ["TapGesture()", "TapGesture(count: 1)"], matchType: .prefix)
|
|
]
|
|
),
|
|
SwiftUIModifier(
|
|
name: "simultaneousGesture",
|
|
arguments: [
|
|
.init(name: "", values: ["TapGesture()", "TapGesture(count: 1)"], matchType: .prefix)
|
|
]
|
|
),
|
|
SwiftUIModifier(
|
|
name: "highPriorityGesture",
|
|
arguments: [
|
|
.init(name: "", values: ["TapGesture()", "TapGesture(count: 1)"], matchType: .prefix)
|
|
]
|
|
)
|
|
],
|
|
in: file
|
|
)
|
|
}
|
|
|
|
/// Whether or not the dictionary represents a SwiftUI View with an `accessibilityAddTraits()` or
|
|
/// `accessibility(addTraits:)` modifier with the specified trait (specify trait as a String).
|
|
func hasAccessibilityTrait(_ trait: String, in file: SwiftLintFile) -> Bool {
|
|
return hasModifier(
|
|
anyOf: [
|
|
SwiftUIModifier(
|
|
name: "accessibilityAddTraits",
|
|
arguments: [.init(name: "", values: [trait], matchType: .substring)]
|
|
),
|
|
SwiftUIModifier(
|
|
name: "accessibility",
|
|
arguments: [.init(name: "addTraits", values: [trait], matchType: .substring)]
|
|
)
|
|
],
|
|
in: file
|
|
)
|
|
}
|
|
}
|