diff --git a/assets/css/_trip-plan-form.scss b/assets/css/_trip-plan-form.scss index fa22eba50d..54b169405a 100644 --- a/assets/css/_trip-plan-form.scss +++ b/assets/css/_trip-plan-form.scss @@ -145,7 +145,7 @@ .m-trip-plan__optimize-for { @include icon-size-inline(1em); - margin-bottom: 0; + margin-bottom: .75rem; margin-top: $base-spacing; .c-svg__icon-accessible-default { @@ -173,3 +173,180 @@ .m-trip-plan__hidden { display: none; } + +.c-trip-plan-widget__inputs { + label { + margin-bottom: .75rem; + } +} + +#trip-planner-form { + .c-accordion-ui { + border-radius: $border-radius; + margin-top: .75rem; + } + + .c-accordion-ui__content { + background: white; + border-bottom: solid 1px $brand-primary; + border-bottom-left-radius: $border-radius; + border-bottom-right-radius: $border-radius; + } + + .c-accordion-ui__target { + border-bottom: none; + } + + .c-accordion-ui__trigger { + border-top-left-radius: $border-radius; + border-top-right-radius: $border-radius; + + &.collapsed { + border-bottom-left-radius: $border-radius; + border-bottom-right-radius: $border-radius; + } + } +} + +.trip-planner-form { + padding-top: 1rem; + + h2 { + font-size: 16px; + font-weight: bold; + line-height: 1.5; + margin-bottom: .5rem; + margin-top: .5rem; + } +} + +#trip-planner-inputs { + margin: .75rem 0; + width: 100%; + + #trip-plan-datepicker { + margin-bottom: 1rem; + margin-top: .75rem; + } + + .btn-group { + width: 100%; + + .btn+.btn { + margin-left: 0; + } + + label:has(:checked) { + @extend .active; + } + } + + input { + padding: calc(#{$base-spacing} / 2); + } + + label { + border-color: $brand-primary; + border-width: 1px 0; + font-weight: 400; + padding: calc(#{$base-spacing} / 2); + text-transform: capitalize; + width: 33.33%; + } + + label.active { + background-color: $brand-primary-lightest; + color: $brand-primary; + font-weight: 700; + } + + label:first-child, + label:last-child { + border-left-width: 1px; + border-right-width: 1px; + } + + input[type='text'] { + border: 1px solid $brand-primary; + border-radius: $border-radius; + width: 100%; + } + + i.fa-calendar { + color: $brand-primary; + } + + .flatpickr { + position: relative; + + .form-control[readonly] { + background-color: $body-bg; + } + + a[data-toggle] { + position: absolute; + right: .75rem; + top: .5rem; + } + } + + .flatpickr-mobile { + border: 1px solid $brand-primary; + border-radius: $border-radius; + width: calc(100% - 2.5rem); + + & ~ a[data-toggle] { + position: unset; + } + } +} + +.flatpickr-months { + font-family: $headings-font-family; + font-weight: $headings-font-weight; + line-height: $headings-line-height; + + .flatpickr-month { + color: inherit; + } +} + +.flatpickr-current-month { + font-size: $font-size-base-xxl; + font-weight: inherit; + + .flatpickr-monthDropdown-months { + font-weight: inherit; + } + + input.cur-year[disabled], + input.cur-year[disabled]:hover { + color: inherit; + } +} + +.flatpickr-time input { + font-size: inherit; +} + +.flatpickr-day { + font-weight: $font-weight-medium; +} + +.flatpickr-calendar { + font-family: $font-family-base; + font-size: inherit; + line-height: inherit; +} + +.flatpickr-day.selected { + background-color: $brand-primary; + border-color: $white; + color: $white; + + &:hover { + background-color: $brand-primary-lightest; + border-color: $white; + color: $brand-primary; + } +} diff --git a/assets/js/app.js b/assets/js/app.js index 2d8ab60184..3fa94abdb8 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -44,6 +44,7 @@ import tabbedNav from "./tabbed-nav.js"; import { accordionInit } from "../ts/ui/accordion"; import initializeSentry from "../ts/sentry"; import setupAlgoliaAutocomplete from "../ts/ui/autocomplete/index"; +import setupTripPlannerForm from "./trip-planner-form.js"; // Establish Phoenix Socket and LiveView configuration. import { Socket } from "phoenix"; @@ -52,17 +53,27 @@ import { LiveSocket } from "phoenix_live_view"; let csrfToken = document .querySelector("meta[name='csrf-token']") .getAttribute("content"); + let Hooks = {}; + Hooks.AlgoliaAutocomplete = { mounted() { setupAlgoliaAutocomplete(this.el); } }; + Hooks.ScrollIntoView = { mounted() { this.el.scrollIntoView({ behavior: "smooth" }); } }; + +Hooks.TripPlannerForm = { + mounted() { + setupTripPlannerForm(this.el); + } +}; + let liveSocket = new LiveSocket("/live", Socket, { params: { _csrf_token: csrfToken }, hooks: Hooks diff --git a/assets/js/test/time-controls-test.js b/assets/js/test/time-controls-test.js deleted file mode 100644 index 5f7abac323..0000000000 --- a/assets/js/test/time-controls-test.js +++ /dev/null @@ -1,46 +0,0 @@ -import { assert } from "chai"; -import jsdom from "mocha-jsdom"; -import { - TimeControls, - getSelectorFields -} from "../time-controls/time-controls"; -import testConfig from "../../ts/jest.config"; - -const { testURL } = testConfig; - -describe("getSelectorFields", () => { - let $; - jsdom({ - url: testURL - }); - beforeEach(() => { - $ = jsdom.rerequire("jquery"); - window.$ = $; - window.jQuery = $; - }); -}); -describe("TimeControls.getFriendlyTime", () => { - it("returns a friendly string given a JavaScript date", () => { - const date = new Date(2017, 10, 9, 8, 7); - - assert.equal(TimeControls.getFriendlyTime(date), "8:07 AM"); - }); - - it("converts times after 13:00 to PM", () => { - const date = new Date(2017, 10, 9, 18, 19); - - assert.equal(TimeControls.getFriendlyTime(date), "6:19 PM"); - }); - - it("interprets 12:00 as 12:00 PM", () => { - const date = new Date(2017, 10, 9, 12, 7); - - assert.equal(TimeControls.getFriendlyTime(date), "12:07 PM"); - }); - - it("interprets 0:00 as 12:00 AM", () => { - const date = new Date(2017, 10, 9, 0, 7); - - assert.equal(TimeControls.getFriendlyTime(date), "12:07 AM"); - }); -}); diff --git a/assets/js/trip-planner-form.js b/assets/js/trip-planner-form.js new file mode 100644 index 0000000000..1ac2b17822 --- /dev/null +++ b/assets/js/trip-planner-form.js @@ -0,0 +1,97 @@ +/* eslint no-unused-vars: ["error", { "args": "none" }] */ + +import flatpickr from "flatpickr"; +import { format } from "date-fns"; + +/** + * Formats a date into a string in the user's locale. + */ +function i18nDate(date, locale = navigator.language) { + const formatter = new Intl.DateTimeFormat(locale, { + month: "long", + weekday: "long", + day: "numeric", + hour: "numeric", + minute: "numeric" + }); + + return formatter.format(date); +} + +/** + * Updates the title of the accordion based on the mode selections. + */ +function updateAccordionTitle(elem, modeCheckboxes) { + const checkedModes = Array.prototype.slice + .call(modeCheckboxes) + .filter(checkbox => checkbox.checked) + .map(checkbox => checkbox.labels[0].textContent); + + const title = elem.querySelector(".c-accordion-ui__title"); + + if (checkedModes.length === 0) { + title.textContent = "Walking Only"; + } else if (checkedModes.length === modeCheckboxes.length) { + title.textContent = "All Modes"; + } else { + title.textContent = checkedModes.join(", "); + } +} + +const SERVER_FORMAT = "Y-m-d G:i K"; + +/** + * Initializes the trip planner inputs and sets all listeners. + */ +export default function setupTripPlannerForm(elem) { + // Gets data that is injected into the template. + // This is how we get the user selections from the query params to set controls. + let data = elem.querySelector("#data").innerHTML; + data = JSON.parse(data); + + const maxDate = new Date(data.maxDate); + const minDate = new Date(data.minDate); + + // Sets the initial value of the date input display. + // Default to 'now' and uses the server date if 'now.' + // Otherwise, we use the chosen time. + const time = data.chosenTime || "now"; + const dateTime = new Date( + time === "now" ? data.dateTime : data.chosenDateTime + ); + + // Initializes the date picker. + flatpickr(elem.querySelector("#trip-plan-datepicker .flatpickr"), { + allowInvalidPreload: true, // needed on mobile to prevent the input from becoming blank when selecting a date outside the min/max + altInput: true, // allow different format to be sent to server + dateFormat: "Y-m-d G:i K", // this gets sent to the server + defaultDate: dateTime, + enableTime: true, + maxDate, + minDate, + formatDate: (date, formatString, locale) => { + if (formatString === SERVER_FORMAT) { + // Formats a date into a string in the format util.ex parse/1 expects. + return format(date, "yyyy-MM-dd HH:mm aa"); + } + + // if not being sent to the server, use localized format + return i18nDate(date, locale); + }, + wrap: true // works with adjacent icon + }); + + // When someone makes mode selections, we update the title of the accordion. + const modeCheckboxes = elem.querySelectorAll( + ".c-accordion-ui input[type='checkbox']" + ); + + modeCheckboxes.forEach(checkbox => { + checkbox.addEventListener("click", _event => { + updateAccordionTitle(elem, modeCheckboxes); + }); + }); + + // Setup the correct title when the page is loaded. + updateAccordionTitle(elem, modeCheckboxes); +} diff --git a/assets/package-lock.json b/assets/package-lock.json index 5aa99f3410..016351bf11 100644 --- a/assets/package-lock.json +++ b/assets/package-lock.json @@ -26,6 +26,7 @@ "dot-prop-immutable": "^1.5.0", "fast-deep-equal": "^3.1.3", "filesize": "^3.3.0", + "flatpickr": "^4.6.13", "focus-trap": "^7.5.2", "form-data": "^1.0.1", "formdata-polyfill": "^3.0.20", @@ -11100,6 +11101,11 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/flatpickr": { + "version": "4.6.13", + "resolved": "https://registry.npmjs.org/flatpickr/-/flatpickr-4.6.13.tgz", + "integrity": "sha512-97PMG/aywoYpB4IvbvUJi0RQi8vearvU0oov1WW3k0WZPBMrTQVqekSX5CjSG/M4Q3i6A/0FKXC7RyAoAUUSPw==" + }, "node_modules/flatted": { "version": "3.2.7", "dev": true, diff --git a/assets/package.json b/assets/package.json index 3ddffd6d95..875d1886ea 100644 --- a/assets/package.json +++ b/assets/package.json @@ -21,6 +21,7 @@ "dot-prop-immutable": "^1.5.0", "fast-deep-equal": "^3.1.3", "filesize": "^3.3.0", + "flatpickr": "^4.6.13", "focus-trap": "^7.5.2", "form-data": "^1.0.1", "formdata-polyfill": "^3.0.20", diff --git a/cypress/e2e/smoke.cy.js b/cypress/e2e/smoke.cy.js index bd0f9ca663..fe26317ba6 100644 --- a/cypress/e2e/smoke.cy.js +++ b/cypress/e2e/smoke.cy.js @@ -167,14 +167,8 @@ describe("passes smoke test", () => { // opens the date picker cy.contains("#trip-plan-datepicker").should("not.exist"); - cy.get("#trip-plan-departure-title").click(); - cy.get("#trip-plan-datepicker"); - - // updates title with selected departure option cy.get('label[for="arrive"]').click(); - cy.get("#trip-plan-departure-title").should("include.text", "Arrive by"); - cy.get('label[for="depart"]').click(); - cy.get("#trip-plan-departure-title").should("include.text", "Depart at"); + cy.get("#trip-plan-datepicker"); // shortcut /from/ - marker A prepopulated cy.visit("/trip-planner/from/North+Station"); diff --git a/lib/dotcom_web.ex b/lib/dotcom_web.ex index f35a789185..30ce05a33a 100644 --- a/lib/dotcom_web.ex +++ b/lib/dotcom_web.ex @@ -137,6 +137,7 @@ defmodule DotcomWeb do # Import LiveView and .heex helpers (live_render, live_patch, <.form>, etc) import Phoenix.LiveView.Helpers + alias Phoenix.LiveView.JS # Import basic rendering functionality (render, render_layout, etc) import Phoenix.View diff --git a/lib/dotcom_web/controllers/trip_plan_controller.ex b/lib/dotcom_web/controllers/trip_plan_controller.ex index ff7f9a0ba9..3c0b8b02f7 100644 --- a/lib/dotcom_web/controllers/trip_plan_controller.ex +++ b/lib/dotcom_web/controllers/trip_plan_controller.ex @@ -16,38 +16,13 @@ defmodule DotcomWeb.TripPlanController do plug(:modes) plug(:wheelchair) plug(:meta_description) - plug(:assign_datetime_selector_fields) + plug(:assign_params) @type route_map :: %{optional(Route.id_t()) => Route.t()} @type route_mapper :: (Route.id_t() -> Route.t() | nil) @location_service Application.compile_env!(:dotcom, :location_service) - @plan_datetime_selector_fields %{ - depart: "depart", - leaveNow: "leave-now", - arrive: "arrive", - controls: "trip-plan-datepicker", - year: "plan_date_time_year", - month: "plan_date_time_month", - day: "plan_date_time_day", - hour: "plan_date_time_hour", - minute: "plan_date_time_minute", - amPm: "plan_date_time_am_pm", - dateEl: %{ - container: "plan-date", - input: "plan-date-input", - select: "plan-date-select", - label: "plan-date-label" - }, - timeEl: %{ - container: "plan-time", - select: "plan-time-select", - label: "plan-time-label" - }, - title: "trip-plan-departure-title" - } - def index(conn, %{"plan" => %{"to" => _to, "from" => _fr} = plan}) do conn |> assign(:expanded, conn.query_params["expanded"]) @@ -198,6 +173,12 @@ defmodule DotcomWeb.TripPlanController do |> render(:index) end + defp assign_params(conn, _) do + conn + |> assign(:chosen_date_time, conn.params["plan"]["date_time"]) + |> assign(:chosen_time, conn.params["plan"]["time"]) + end + @spec check_address(String.t()) :: String.t() defp check_address(address) do # address can be a String containing "lat,lon" so we check for that case @@ -271,12 +252,6 @@ defmodule DotcomWeb.TripPlanController do ) end - @spec assign_datetime_selector_fields(Plug.Conn.t(), Keyword.t()) :: Plug.Conn.t() - defp assign_datetime_selector_fields(conn, _) do - conn - |> assign(:plan_datetime_selector_fields, @plan_datetime_selector_fields) - end - @spec with_fares_and_passes([Itinerary.t()]) :: [Itinerary.t()] defp with_fares_and_passes(itineraries) do Enum.map(itineraries, fn itinerary -> @@ -435,11 +410,7 @@ defmodule DotcomWeb.TripPlanController do ) end - @doc """ - if other plan params are filled, such as from or to, but no modes, set all - modes to true. this can happen when getting trip plans from the homepage. - """ - def modes(%Plug.Conn{params: %{"plan" => _}} = conn, _) do + def modes(%Plug.Conn{} = conn, _) do assign( conn, :modes, @@ -447,10 +418,6 @@ defmodule DotcomWeb.TripPlanController do ) end - def modes(%Plug.Conn{} = conn, _) do - assign(conn, :modes, %{}) - end - @spec breadcrumbs(Plug.Conn.t(), Keyword.t()) :: Plug.Conn.t() defp breadcrumbs(conn, _) do assign(conn, :breadcrumbs, [Breadcrumb.build("Trip Planner")]) diff --git a/lib/dotcom_web/templates/layout/root.html.eex b/lib/dotcom_web/templates/layout/root.html.eex index 5b8e525634..cbd1103ce8 100644 --- a/lib/dotcom_web/templates/layout/root.html.eex +++ b/lib/dotcom_web/templates/layout/root.html.eex @@ -27,6 +27,7 @@ " type="image/png"> " sizes="32x32" type="image/png"> " sizes="16x16" type="image/vnd.microsoft.icon"> + <%= if google_tag_manager_id() do %> diff --git a/lib/dotcom_web/templates/trip_plan/_departure.html.eex b/lib/dotcom_web/templates/trip_plan/_departure.html.eex deleted file mode 100644 index 9978744249..0000000000 --- a/lib/dotcom_web/templates/trip_plan/_departure.html.eex +++ /dev/null @@ -1,29 +0,0 @@ -
-