Skip to content

Commit

Permalink
Add DatePicker to the TokamakDOM module (#394)
Browse files Browse the repository at this point in the history
This fixes #320 by adding a SwiftUI-compatible `DatePicker`. However, `DatePickerStyle` is not supported. 

This uses the HTML inputs `date`, `time`, or `datetime-local`, depending on the given `displayedComponents`. This means that not all browsers show the picker, as Mac Safari currently does not support them. Safari on Mac will just show an ISO-format text field. If the date is in an invalid format, the binding will not receive updates until it becomes parseable by JSDate.

On supported browsers, the binding gets updated in real time, as you would expect, with a Foundation.Date, just like SwiftUI.

* Add DatePicker to TokamakCore and TokamakDOM

* Fix crash on invalid date

* Update progress.md and add credit

* Fix time zone related issues with the DatePicker

* Add DatePickerDemo to the TokamakDemo

* Fix overview for DatePicker

* Fix NativeDemo build
  • Loading branch information
Snowy1803 authored Mar 28, 2021
1 parent bde7de9 commit 5c458f9
Show file tree
Hide file tree
Showing 7 changed files with 351 additions and 1 deletion.
6 changes: 6 additions & 0 deletions NativeDemo/TokamakDemo.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
objects = {

/* Begin PBXBuildFile section */
207C05702610E16E00BBBE54 /* DatePickerDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 207C056F2610E16E00BBBE54 /* DatePickerDemo.swift */; };
207C05712610E16E00BBBE54 /* DatePickerDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 207C056F2610E16E00BBBE54 /* DatePickerDemo.swift */; };
3DCDE44424CA6AD400910F17 /* SidebarDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DCDE44324CA6AD400910F17 /* SidebarDemo.swift */; };
3DCDE44524CA6AD400910F17 /* SidebarDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DCDE44324CA6AD400910F17 /* SidebarDemo.swift */; };
4550BD5225B642B80088F4EA /* ShadowDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4550BD5125B642B80088F4EA /* ShadowDemo.swift */; };
Expand Down Expand Up @@ -92,6 +94,7 @@
/* End PBXCopyFilesBuildPhase section */

/* Begin PBXFileReference section */
207C056F2610E16E00BBBE54 /* DatePickerDemo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DatePickerDemo.swift; sourceTree = "<group>"; };
3DCDE44324CA6AD400910F17 /* SidebarDemo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SidebarDemo.swift; sourceTree = "<group>"; };
4550BD5125B642B80088F4EA /* ShadowDemo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShadowDemo.swift; sourceTree = "<group>"; };
8500293E24D2FF3E001A2E84 /* SliderDemo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SliderDemo.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -191,6 +194,7 @@
B5C76E4924C73ED4003EABB2 /* AppStorageDemo.swift */,
B56F22DF24BC89FD001738DF /* ColorDemo.swift */,
85ED189E24AD425E0085DFA0 /* Counter.swift */,
207C056F2610E16E00BBBE54 /* DatePickerDemo.swift */,
85ED18A024AD425E0085DFA0 /* EnvironmentDemo.swift */,
85ED189C24AD425E0085DFA0 /* ForEachDemo.swift */,
B56F22E224BD1C26001738DF /* GridDemo.swift */,
Expand Down Expand Up @@ -352,6 +356,7 @@
buildActionMask = 2147483647;
files = (
85ED186A24AD38F20085DFA0 /* UIAppDelegate.swift in Sources */,
207C05702610E16E00BBBE54 /* DatePickerDemo.swift in Sources */,
B56F22E324BD1C26001738DF /* GridDemo.swift in Sources */,
D1B4229224B3B9BB00682F74 /* OutlineGroupDemo.swift in Sources */,
D1D6B62324D817350041E1D9 /* GeometryReaderDemo.swift in Sources */,
Expand Down Expand Up @@ -383,6 +388,7 @@
buildActionMask = 2147483647;
files = (
85ED18AA24AD425E0085DFA0 /* TokamakDemo.swift in Sources */,
207C05712610E16E00BBBE54 /* DatePickerDemo.swift in Sources */,
B56F22E424BD1C26001738DF /* GridDemo.swift in Sources */,
D1B4229324B3B9BB00682F74 /* OutlineGroupDemo.swift in Sources */,
D1D6B62424D817350041E1D9 /* GeometryReaderDemo.swift in Sources */,
Expand Down
177 changes: 177 additions & 0 deletions Sources/TokamakCore/Views/Selectors/DatePicker.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
// Copyright 2020 Tokamak contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Created by Emil Pedersen on 2021-03-26.
//

import struct Foundation.Date

/// A control for selecting an absolute date.
///
/// Available when `Label` conform to `View`.
public struct DatePicker<Label>: PrimitiveView where Label: View {
let label: Label
let valueBinding: Binding<Date>
let displayedComponents: DatePickerComponents
let min: Date?
let max: Date?

public typealias Components = DatePickerComponents
}

public extension DatePicker {
init(
selection: Binding<Date>,
in range: ClosedRange<Date>,
displayedComponents: DatePickerComponents = [.hourAndMinute, .date],
@ViewBuilder label: () -> Label
) {
self.init(
label: label(),
valueBinding: selection,
displayedComponents: displayedComponents,
min: range.lowerBound,
max: range.upperBound
)
}

init(
selection: Binding<Date>,
displayedComponents: DatePickerComponents = [.hourAndMinute, .date],
@ViewBuilder label: () -> Label
) {
self.init(
label: label(),
valueBinding: selection,
displayedComponents: displayedComponents,
min: nil,
max: nil
)
}

init(
selection: Binding<Date>,
in range: PartialRangeFrom<Date>,
displayedComponents: DatePickerComponents = [.hourAndMinute, .date],
@ViewBuilder label: () -> Label
) {
self.init(
label: label(),
valueBinding: selection,
displayedComponents: displayedComponents,
min: range.lowerBound,
max: nil
)
}

init(
selection: Binding<Date>,
in range: PartialRangeThrough<Date>,
displayedComponents: DatePickerComponents = [.hourAndMinute, .date],
@ViewBuilder label: () -> Label
) {
self.init(
label: label(),
valueBinding: selection,
displayedComponents: displayedComponents,
min: nil,
max: range.upperBound
)
}
}

public extension DatePicker where Label == Text {
init<S>(
_ title: S,
selection: Binding<Date>,
in range: ClosedRange<Date>,
displayedComponents: DatePickerComponents = [.hourAndMinute, .date]
) where S: StringProtocol {
self.init(
label: Text(title),
valueBinding: selection,
displayedComponents: displayedComponents,
min: range.lowerBound,
max: range.upperBound
)
}

init<S>(
_ title: S,
selection: Binding<Date>,
displayedComponents: DatePickerComponents = [.hourAndMinute, .date]
) where S: StringProtocol {
self.init(
label: Text(title),
valueBinding: selection,
displayedComponents: displayedComponents,
min: nil,
max: nil
)
}

init<S>(
_ title: S,
selection: Binding<Date>,
in range: PartialRangeFrom<Date>,
displayedComponents: DatePickerComponents = [.hourAndMinute, .date]
) where S: StringProtocol {
self.init(
label: Text(title),
valueBinding: selection,
displayedComponents: displayedComponents,
min: range.lowerBound,
max: nil
)
}

init<S>(
_ title: S,
selection: Binding<Date>,
in range: PartialRangeThrough<Date>,
displayedComponents: DatePickerComponents = [.hourAndMinute, .date]
) where S: StringProtocol {
self.init(
label: Text(title),
valueBinding: selection,
displayedComponents: displayedComponents,
min: nil,
max: range.upperBound
)
}
}

public struct DatePickerComponents: OptionSet {
public static let hourAndMinute = DatePickerComponents(rawValue: 1 << 0)
public static let date = DatePickerComponents(rawValue: 1 << 1)

public let rawValue: UInt

public init(rawValue: UInt) {
self.rawValue = rawValue
}
}

/// This is a helper type that works around absence of "package private" access control in Swift
public struct _DatePickerProxy<Label> where Label: View {
public let subject: DatePicker<Label>

public init(_ subject: DatePicker<Label>) { self.subject = subject }

public var label: Label { subject.label }
public var valueBinding: Binding<Date> { subject.valueBinding }
public var displayedComponents: DatePickerComponents { subject.displayedComponents }
public var min: Date? { subject.min }
public var max: Date? { subject.max }
}
1 change: 1 addition & 0 deletions Sources/TokamakDOM/Core.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ public typealias Edge = TokamakCore.Edge

public typealias Alignment = TokamakCore.Alignment
public typealias Button = TokamakCore.Button
public typealias DatePicker = TokamakCore.DatePicker
public typealias DisclosureGroup = TokamakCore.DisclosureGroup
public typealias Divider = TokamakCore.Divider
public typealias ForEach = TokamakCore.ForEach
Expand Down
128 changes: 128 additions & 0 deletions Sources/TokamakDOM/Views/Selectors/DatePicker.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// Copyright 2020 Tokamak contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Created by Emil Pedersen on 2021-03-26.
//

import struct Foundation.Date
import JavaScriptKit
import TokamakCore
import TokamakStaticHTML

extension DatePicker: ViewDeferredToRenderer {
@_spi(TokamakCore)
public var deferredBody: AnyView {
let proxy = _DatePickerProxy(self)

let type = proxy.displayedComponents

let attributes: [HTMLAttribute: String] = [
"type": type.inputType,
"min": proxy.min.map { type.format(date: $0) } ?? "",
"max": proxy.max.map { type.format(date: $0) } ?? "",
.value: type.format(date: proxy.valueBinding.wrappedValue),
]

return AnyView(
HStack {
proxy.label
Text(" ")
DynamicHTML(
"input",
attributes,
listeners: [
"input": { event in
let current = JSDate(
millisecondsSinceEpoch: proxy.valueBinding.wrappedValue
.timeIntervalSince1970 * 1000
)
let str = event.target.object!.value.string!
let decomposed = type.parse(date: str)
if let date = decomposed.date {
let components = date.components(separatedBy: "-")
if components.count == 3 {
current.fullYear = Int(components[0]) ?? 0
current.month = (Int(components[1]) ?? 0) - 1
current.date = Int(components[2]) ?? 0
}
}
if let time = decomposed.time {
let components = time.components(separatedBy: ":")
if components.count == 2 {
current.hours = Int(components[0]) ?? 0
current.minutes = Int(components[1]) ?? 0
}
}
let ms = current.valueOf()
if ms.isFinite {
proxy.valueBinding.wrappedValue = Date(timeIntervalSince1970: ms / 1000)
}
},
]
)
}
)
}
}

extension DatePickerComponents {
var inputType: String {
switch (contains(.hourAndMinute), contains(.date)) {
case (true, true): return "datetime-local"
case (true, false): return "time"
case (false, true): return "date"
case (false, false):
fatalError("invalid date components: must select at least date or hourAndMinute")
}
}

func format(date: Date) -> String {
let date = JSDate(millisecondsSinceEpoch: date.timeIntervalSince1970 * 1000)
var partial: [String] = []
if contains(.date) {
let y = date.fullYear
let year: String
if y < 0 {
year = "-" + "00000\(-y)".suffix(6)
} else if y > 9999 {
year = "+" + "00000\(y)".suffix(6)
} else {
year = String("000\(y)".suffix(4))
}
partial.append("\(year)-\("0\(date.month + 1)".suffix(2))-\("0\(date.date)".suffix(2))")
}
if contains(.hourAndMinute) {
partial.append("\("0\(date.hours)".suffix(2)):\("0\(date.minutes)".suffix(2))")
}
return partial.joined(separator: "T")
}

/// Decomposes a formatted string into a date and a time component
func parse(date: String) -> (date: String?, time: String?) {
switch (contains(.hourAndMinute), contains(.date)) {
case (true, true):
let components = date.components(separatedBy: "T")
if components.count == 2 {
return (components[0], components[1])
}
return (nil, nil)
case (true, false):
return (nil, date)
case (false, true):
return (date, nil)
case (false, false):
return (nil, nil)
}
}
}
37 changes: 37 additions & 0 deletions Sources/TokamakDemo/DatePickerDemo.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Copyright 2020 Tokamak contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Created by Emil Pedersen on 2021-03-27.
//

import struct Foundation.Date
import TokamakShim

struct DatePickerDemo: View {
@State private var date = Date()

var body: some View {
VStack {
DatePicker(selection: $date, displayedComponents: .date) {
Text("Appointment date:")
}
DatePicker(selection: $date, displayedComponents: .hourAndMinute) {
Text("Appointment time:")
}
DatePicker(selection: $date) {
Text("Confirm:")
}
}
}
}
Loading

0 comments on commit 5c458f9

Please sign in to comment.