Prechádzať zdrojové kódy

first pass as Preact refactor

Adam Shaw 6 rokov pred
rodič
commit
a314ecc82a
67 zmenil súbory, kde vykonal 3251 pridanie a 3445 odobranie
  1. 1 1
      packages-premium
  2. 171 246
      packages/core/src/Calendar.tsx
  3. 164 107
      packages/core/src/CalendarComponent.tsx
  4. 83 171
      packages/core/src/Toolbar.tsx
  5. 9 4
      packages/core/src/View.ts
  6. 0 61
      packages/core/src/common/DayHeader.ts
  7. 65 0
      packages/core/src/common/DayHeader.tsx
  8. 1 1
      packages/core/src/common/DayTableModel.ts
  9. 44 21
      packages/core/src/common/Scroller.tsx
  10. 86 0
      packages/core/src/common/TableDateCell.tsx
  11. 0 75
      packages/core/src/common/table-utils.ts
  12. 32 3
      packages/core/src/component/ComponentContext.ts
  13. 7 3
      packages/core/src/component/DateComponent.ts
  14. 63 0
      packages/core/src/component/GotoAnchor.tsx
  15. 0 96
      packages/core/src/component/date-rendering.ts
  16. 43 0
      packages/core/src/component/date-rendering.tsx
  17. 11 11
      packages/core/src/component/renderers/FgEventRenderer.ts
  18. 3 3
      packages/core/src/component/renderers/FillRenderer.ts
  19. 1 1
      packages/core/src/interactions/EventClicking.ts
  20. 1 1
      packages/core/src/interactions/EventHovering.ts
  21. 9 7
      packages/core/src/main.ts
  22. 2 3
      packages/core/src/plugin-system.ts
  23. 2 4
      packages/core/src/reducers/eventSources.ts
  24. 2 3
      packages/core/src/structs/event-source.ts
  25. 4 5
      packages/core/src/structs/event.ts
  26. 88 0
      packages/core/src/toolbar-parse.ts
  27. 5 0
      packages/core/src/util/array.ts
  28. 17 6
      packages/core/src/util/dom-event.ts
  29. 3 45
      packages/core/src/util/dom-manip.ts
  30. 7 35
      packages/core/src/util/misc.ts
  31. 51 15
      packages/core/src/util/runner.ts
  32. 12 12
      packages/core/src/util/scrollbars.ts
  33. 266 0
      packages/core/src/view-framework-util.tsx
  34. 0 680
      packages/core/src/view-framework.ts
  35. 0 68
      packages/daygrid/src/DayBgRow.ts
  36. 75 0
      packages/daygrid/src/DayBgRow.tsx
  37. 33 27
      packages/daygrid/src/DayTable.tsx
  38. 0 87
      packages/daygrid/src/DayTableView.ts
  39. 85 0
      packages/daygrid/src/DayTableView.tsx
  40. 0 0
      packages/daygrid/src/DayTile.tsx
  41. 1 1
      packages/daygrid/src/DayTileEvents.ts
  42. 8 12
      packages/daygrid/src/Popover.tsx
  43. 136 323
      packages/daygrid/src/Table.tsx
  44. 23 23
      packages/daygrid/src/TableEvents.ts
  45. 14 14
      packages/daygrid/src/TableFills.ts
  46. 5 5
      packages/daygrid/src/TableMirrorEvents.ts
  47. 231 0
      packages/daygrid/src/TableSkeleton.tsx
  48. 97 109
      packages/daygrid/src/TableView.tsx
  49. 1 1
      packages/daygrid/src/main.ts
  50. 1 2
      packages/interaction/src/interactions/DateClicking.ts
  51. 1 1
      packages/interaction/src/interactions/DateSelecting.ts
  52. 1 1
      packages/interaction/src/interactions/EventDragging.ts
  53. 1 1
      packages/interaction/src/interactions/EventResizing.ts
  54. 57 54
      packages/list/src/ListView.tsx
  55. 79 45
      packages/list/src/ListViewEvents.tsx
  56. 34 32
      packages/timegrid/src/DayTimeCols.tsx
  57. 0 110
      packages/timegrid/src/DayTimeColsView.ts
  58. 100 0
      packages/timegrid/src/DayTimeColsView.tsx
  59. 0 759
      packages/timegrid/src/TimeCols.ts
  60. 534 0
      packages/timegrid/src/TimeCols.tsx
  61. 74 0
      packages/timegrid/src/TimeColsBg.tsx
  62. 97 0
      packages/timegrid/src/TimeColsContentSkeleton.tsx
  63. 29 31
      packages/timegrid/src/TimeColsEvents.ts
  64. 2 2
      packages/timegrid/src/TimeColsFills.ts
  65. 150 0
      packages/timegrid/src/TimeColsSlats.tsx
  66. 127 115
      packages/timegrid/src/TimeColsView.tsx
  67. 2 2
      packages/timegrid/src/main.ts

+ 1 - 1
packages-premium

@@ -1 +1 @@
-Subproject commit 6d3c485d27de0ba4b87a5facfaebcf228670d14e
+Subproject commit 38253c6389306afddd61936d989de85a1a1162a0

+ 171 - 246
packages/core/src/Calendar.ts → packages/core/src/Calendar.tsx

@@ -1,15 +1,12 @@
-import { listenBySelector } from './util/dom-event'
-import { capitaliseFirstLetter, debounce } from './util/misc'
 import { default as EmitterMixin, EmitterInterface } from './common/EmitterMixin'
 import OptionsManager from './OptionsManager'
 import View from './View'
-import Theme from './theme/Theme'
 import { OptionsInput, EventHandlerName, EventHandlerArgs } from './types/input-types'
-import { Locale, buildLocale, parseRawLocales, RawLocaleMap } from './datelib/locale'
+import { buildLocale, parseRawLocales, RawLocaleMap } from './datelib/locale'
 import { DateEnv, DateInput } from './datelib/env'
 import { DateMarker, startOfDay, diffWholeDays } from './datelib/marker'
 import { createFormatter } from './datelib/formatting'
-import { Duration, createDuration, DurationInput } from './datelib/duration'
+import { createDuration, DurationInput, Duration } from './datelib/duration'
 import reduce from './reducers/main'
 import { parseDateSpan, DateSpanInput, DateSpan, buildDateSpanApi, DateSpanApi, buildDatePointApi, DatePointApi } from './structs/date-span'
 import { memoize, memoizeOutput } from './util/memoize'
@@ -21,10 +18,10 @@ import { EventInput, parseEvent, EventDefHash } from './structs/event'
 import { CalendarState, Action } from './reducers/types'
 import EventSourceApi from './api/EventSourceApi'
 import EventApi from './api/EventApi'
-import { createEmptyEventStore, EventStore, eventTupleToStore } from './structs/event-store'
+import { createEmptyEventStore, eventTupleToStore, EventStore } from './structs/event-store'
 import { processScopedUiProps, EventUiHash, EventUi } from './component/event-ui'
 import { buildViewSpecs, ViewSpecHash, ViewSpec } from './structs/view-spec'
-import { PluginSystem } from './plugin-system'
+import { PluginSystem, PluginHooks } from './plugin-system'
 import CalendarComponent from './CalendarComponent'
 import { __assign } from 'tslib'
 import { refinePluginDefs } from './options'
@@ -34,10 +31,8 @@ import { InteractionSettingsInput, parseInteractionSettings, Interaction, intera
 import EventClicking from './interactions/EventClicking'
 import EventHovering from './interactions/EventHovering'
 import StandardTheme from './theme/StandardTheme'
-import { CmdFormatterFunc } from './datelib/formatting-cmd'
-import { NamedTimeZoneImplClass } from './datelib/timezone'
-import { computeContextProps } from './component/ComponentContext'
-import { renderer } from './view-framework'
+import ComponentContext, { ComponentContextType, buildContext } from './component/ComponentContext'
+import { render, h, createRef } from 'preact'
 import { TaskRunner, DelayedRunner } from './util/runner'
 import ViewApi from './ViewApi'
 
@@ -75,54 +70,51 @@ export default class Calendar {
   triggerWith: EmitterInterface['triggerWith']
   hasHandlers: EmitterInterface['hasHandlers']
 
-  private computeContextProps = memoize(computeContextProps)
+  // option-processing internals
+  // TODO: make these all private
+  public pluginSystem: PluginSystem
+  public optionsManager: OptionsManager
+  public viewSpecs: ViewSpecHash
+  public dateProfileGenerators: { [viewName: string]: DateProfileGenerator }
+
+  // derived state
+  // TODO: make these all private
   private parseRawLocales = memoize(parseRawLocales)
-  private buildLocale = memoize(buildLocale)
   private buildDateEnv = memoize(buildDateEnv)
+  private computeTitle = memoize(computeTitle)
+  private buildViewApi = memoize(buildViewApi)
   private buildTheme = memoize(buildTheme)
-  private buildEventUiSingleBase = memoize(this._buildEventUiSingleBase)
-  private buildSelectionConfig = memoize(this._buildSelectionConfig)
+  private buildContext = memoize(buildContext)
+  private buildEventUiSingleBase = memoize(buildEventUiSingleBase)
+  private buildSelectionConfig = memoize(buildSelectionConfig)
   private buildEventUiBySource = memoizeOutput(buildEventUiBySource, isPropsEqual)
   private buildEventUiBases = memoize(buildEventUiBases)
-  private buildViewApi = memoize(buildViewApi)
-  private computeTitle = memoize(computeTitle)
-
-  eventUiBases: EventUiHash // solely for validation system
-  selectionConfig: EventUi // doesn't need all the info EventUi provides. only validation-related. TODO: separate data structs
-
-  optionsManager: OptionsManager
-  viewSpecs: ViewSpecHash
-  dateProfileGenerators: { [viewName: string]: DateProfileGenerator }
-  theme: Theme
-  dateEnv: DateEnv
-  availableRawLocales: RawLocaleMap
-  pluginSystem: PluginSystem
-  defaultAllDayEventDuration: Duration
-  defaultTimedEventDuration: Duration
-
+  private renderableEventStore: EventStore
+  public eventUiBases: EventUiHash // needed for validation system
+  public selectionConfig: EventUi // needed for validation system. doesn't need all the info EventUi provides. only validation-related
+  private availableRawLocales: RawLocaleMap
+  public context: ComponentContext
+  public dateEnv: DateEnv
+  public defaultAllDayEventDuration: Duration
+  public defaultTimedEventDuration: Duration
+
+  // interaction
   calendarInteractions: CalendarInteraction[]
   interactionsStore: { [componentUid: string]: Interaction[] } = {}
-  removeNavLinkListener: any
-
-  windowResizeProxy: any // TODO: use DelayedRunner for this instead of debounce!
-  isHandlingWindowResize: boolean
 
   state: CalendarState
   renderRunner: DelayedRunner
-  actionRunner: TaskRunner<Action>
-  renderableEventStore: EventStore
-
+  actionRunner: TaskRunner<Action> // for reducer. bad name
   afterSizingTriggers: any = {}
   isViewUpdated: boolean = false
   isDatesUpdated: boolean = false
   isEventsUpdated: boolean = false
-
   el: HTMLElement
-  renderCalendarComponent = renderer(CalendarComponent)
-  component: CalendarComponent
-
+  componentRef = createRef<CalendarComponent>()
   view: ViewApi // public API
 
+  get component() { return this.componentRef.current }
+
 
   constructor(el: HTMLElement, overrides?: OptionsInput) {
     this.el = el
@@ -133,21 +125,23 @@ export default class Calendar {
     let renderRunner = this.renderRunner = new DelayedRunner(
       this.updateComponent.bind(this)
     )
+    renderRunner.pause() // start out paused, until .render() is called
 
-    this.actionRunner = new TaskRunner(
+    let actionRunner = this.actionRunner = new TaskRunner(
       this.runAction.bind(this),
-      (actions) => {
-        let doDelay = computeDoDelay(actions)
-        renderRunner.request(doDelay ? optionsManager.computed.rerenderDelay : null)
+      () => {
+        this.updateDerivedState()
+        renderRunner.request(optionsManager.computed.rerenderDelay)
       }
     )
+    actionRunner.pause()
 
-    // only do once. don't do in handleOptions. because can't remove plugins
-    this.addPluginInputs(optionsManager.computed.plugins || [])
+    this.addPluginInputs(optionsManager.computed.plugins || []) // only do once. don't do in onOptionsChange. because can't remove plugins
+    this.onOptionsChange()
 
-    this.handleOptions(optionsManager.computed)
     this.publiclyTrigger('_init') // for tests
     this.hydrate()
+    actionRunner.resume()
 
     this.calendarInteractions = this.pluginSystem.hooks.calendarInteractions
       .map((calendarInteractionClass) => {
@@ -172,18 +166,17 @@ export default class Calendar {
   render() {
     if (!this.component) {
       this.renderableEventStore = createEmptyEventStore()
-      this.bindHandlers() // TODO: have CalendarComponent handle this?
     }
 
-    this.updateComponent()
+    this.renderRunner.resume() // will run tasks immediately and ignore any delay
   }
 
 
   destroy() {
+    this.renderRunner.pause()
+
     if (this.component) {
-      this.unbindHandlers()
-      this.renderCalendarComponent(false)
-      this.component = null
+      render(null, this.el)
 
       for (let interaction of this.calendarInteractions) {
         interaction.destroy()
@@ -194,54 +187,6 @@ export default class Calendar {
   }
 
 
-  // Handlers
-  // -----------------------------------------------------------------------------------------------------------------
-
-
-  bindHandlers() {
-
-    // event delegation for nav links
-    this.removeNavLinkListener = listenBySelector(this.el, 'click', 'a[data-goto]', (ev, anchorEl) => {
-      let gotoOptions: any = anchorEl.getAttribute('data-goto')
-      gotoOptions = gotoOptions ? JSON.parse(gotoOptions) : {}
-
-      let { dateEnv } = this
-      let dateMarker = dateEnv.createMarker(gotoOptions.date)
-      let viewType = gotoOptions.type
-
-      // property like "navLinkDayClick". might be a string or a function
-      let customAction = this.viewOpt('navLink' + capitaliseFirstLetter(viewType) + 'Click')
-
-      if (typeof customAction === 'function') {
-        customAction(dateEnv.toDate(dateMarker), ev)
-      } else {
-        if (typeof customAction === 'string') {
-          viewType = customAction
-        }
-        this.zoomTo(dateMarker, viewType)
-      }
-    })
-
-    if (this.opt('handleWindowResize')) {
-      window.addEventListener('resize',
-        this.windowResizeProxy = debounce( // prevents rapid calls
-          this.windowResize.bind(this),
-          this.opt('windowResizeDelay')
-        )
-      )
-    }
-  }
-
-  unbindHandlers() {
-    this.removeNavLinkListener()
-
-    if (this.windowResizeProxy) {
-      window.removeEventListener('resize', this.windowResizeProxy)
-      this.windowResizeProxy = null
-    }
-  }
-
-
   // Dispatcher
   // -----------------------------------------------------------------------------------------------------------------
 
@@ -264,13 +209,11 @@ export default class Calendar {
       }
     }
 
-    this.batchRendering(() => {
-      this.dispatch({ type: 'INIT' }) // pass in sources here?
-      this.dispatch({ type: 'ADD_EVENT_SOURCES', sources })
-      this.dispatch({
-        type: 'SET_VIEW_TYPE',
-        viewType: this.opt('defaultView') || this.pluginSystem.hooks.defaultView
-      })
+    this.dispatch({ type: 'INIT' }) // pass in sources here?
+    this.dispatch({ type: 'ADD_EVENT_SOURCES', sources })
+    this.dispatch({
+      type: 'SET_VIEW_TYPE',
+      viewType: this.opt('defaultView') || this.pluginSystem.hooks.defaultView
     })
   }
 
@@ -294,6 +237,13 @@ export default class Calendar {
 
   dispatch(action: Action) {
     this.actionRunner.request(action)
+
+    // actions we know we want to render immediately. TODO: another param in dispatch instead?
+    switch (action.type) {
+      case 'SET_EVENT_DRAG':
+      case 'SET_EVENT_RESIZE':
+        this.renderRunner.drain()
+    }
   }
 
 
@@ -307,8 +257,9 @@ export default class Calendar {
       this.publiclyTrigger('loading', [ false ])
     }
 
-    let viewComponent = this.component && this.component.view
-    let viewApi = this.view
+    let calendarComponent = this.component
+    let viewComponent = calendarComponent && calendarComponent.view
+    let viewApi = this.view // bad name
 
     if (oldState.eventStore !== newState.eventStore) {
       if (oldState.eventStore) {
@@ -321,7 +272,7 @@ export default class Calendar {
         this.publiclyTrigger('datesDestroy', [
           {
             view: viewApi,
-            el: viewComponent.rootEl
+            el: viewComponent.getRootEl()
           }
         ])
       }
@@ -333,7 +284,7 @@ export default class Calendar {
         this.publiclyTrigger('viewSkeletonDestroy', [
           {
             view: viewApi,
-            el: viewComponent.rootEl
+            el: viewComponent.getRootEl()
           }
         ])
       }
@@ -347,11 +298,7 @@ export default class Calendar {
 
 
   batchRendering(func) {
-    let { renderRunner } = this
-
-    renderRunner.pause()
-    func()
-    renderRunner.resume()
+    this.renderRunner.whilePaused(func)
   }
 
 
@@ -359,14 +306,10 @@ export default class Calendar {
   don't call this directly. use executeRender instead
   */
   updateComponent() {
-    let { state } = this
+    let { context, state } = this
     let { viewType } = state
     let viewSpec = this.viewSpecs[viewType]
-    let rawOptions = this.optionsManager.computed
-
-    if (!viewSpec) {
-      throw new Error(`View type "${viewType}" is not valid`)
-    }
+    let viewApi = context.view
 
     // if event sources are still loading and progressive rendering hasn't been enabled,
     // keep rendering the last fully loaded set of events
@@ -379,38 +322,39 @@ export default class Calendar {
     let eventUiBySource = this.buildEventUiBySource(state.eventSources)
     let eventUiBases = this.eventUiBases = this.buildEventUiBases(renderableEventStore.defs, eventUiSingleBase, eventUiBySource)
 
-    let title = this.computeTitle(state.dateProfile, this.dateEnv, viewSpec.options)
-    let viewApi = this.view = this.buildViewApi(viewSpec.type, title, state.dateProfile, this.dateEnv)
-
-    let component = this.renderCalendarComponent({
-      parentEl: this.el,
-      ...state,
-      viewSpec,
-      dateProfileGenerator: this.dateProfileGenerators[viewType],
-      dateProfile: state.dateProfile,
-      eventStore: renderableEventStore,
-      eventUiBases,
-      dateSelection: state.dateSelection,
-      eventSelection: state.eventSelection,
-      eventDrag: state.eventDrag,
-      eventResize: state.eventResize,
-      title
-    }, {
-      calendar: this,
-      view: viewApi,
-      pluginHooks: this.pluginSystem.hooks,
-      theme: this.theme,
-      dateEnv: this.dateEnv,
-      options: rawOptions,
-      ...this.computeContextProps(rawOptions)
-    })
+    render(
+      <ComponentContextType.Provider value={context}>
+        <CalendarComponent
+          ref={this.componentRef}
+          rootEl={this.el}
+          { ...state }
+          viewSpec={viewSpec}
+          dateProfileGenerator={this.dateProfileGenerators[viewType]}
+          dateProfile={state.dateProfile}
+          eventStore={renderableEventStore}
+          eventUiBases={eventUiBases}
+          dateSelection={state.dateSelection}
+          eventSelection={state.eventSelection}
+          eventDrag={state.eventDrag}
+          eventResize={state.eventResize}
+          title={viewApi.title}
+          />
+      </ComponentContextType.Provider>,
+      this.el
+    )
+
+    let calendarComponent = this.component
+    let viewComponent = calendarComponent.view
+
+    calendarComponent.updateSize(false)
+    this.releaseAfterSizingTriggers()
 
     if (this.isViewUpdated) {
       this.isViewUpdated = false
       this.publiclyTrigger('viewSkeletonRender', [
         {
           view: viewApi,
-          el: component.view.rootEl
+          el: viewComponent.getRootEl()
         }
       ])
     }
@@ -420,7 +364,7 @@ export default class Calendar {
       this.publiclyTrigger('datesRender', [
         {
           view: viewApi,
-          el: component.view.rootEl
+          el: viewComponent.getRootEl()
         }
       ])
     }
@@ -466,7 +410,7 @@ export default class Calendar {
     let changeHandlers = this.pluginSystem.hooks.optionChangeHandlers
     let normalUpdates = {}
     let specialUpdates = {}
-    let oldDateEnv = this.dateEnv // do this before handleOptions
+    let oldDateEnv = this.dateEnv // do this before onOptionsChange
     let isTimeZoneDirty = false
     let isSizeDirty = false
     let anyDifficultOptions = Boolean(removals.length)
@@ -496,7 +440,7 @@ export default class Calendar {
     this.optionsManager.mutate(normalUpdates, removals, isDynamic)
 
     if (anyDifficultOptions) {
-      this.handleOptions(this.optionsManager.computed)
+      this.onOptionsChange()
     }
 
     this.batchRendering(() => {
@@ -533,60 +477,62 @@ export default class Calendar {
     })
   }
 
+
   /*
   rebuilds things based off of a complete set of refined options
+  TODO: move all this to updateDerivedState, but hard because reducer depends on some values
   */
-  handleOptions(options) {
+  onOptionsChange() {
     let pluginHooks = this.pluginSystem.hooks
+    let rawOptions = this.optionsManager.computed
 
-    this.defaultAllDayEventDuration = createDuration(options.defaultAllDayEventDuration)
-    this.defaultTimedEventDuration = createDuration(options.defaultTimedEventDuration)
-    this.theme = this.buildTheme(options)
-
-    let available = this.parseRawLocales(options.locales)
-    this.availableRawLocales = available.map
-    let locale = this.buildLocale(options.locale || available.defaultCode, available.map)
-
-    this.dateEnv = this.buildDateEnv(
-      locale,
-      options.timeZone,
-      pluginHooks.namedTimeZonedImpl,
-      options.firstDay,
-      options.weekNumberCalculation,
-      options.weekLabel,
-      pluginHooks.cmdFormatter
-    )
+    let availableLocaleData = this.parseRawLocales(rawOptions.locales)
+    let dateEnv = this.buildDateEnv(rawOptions, pluginHooks, availableLocaleData)
 
-    this.selectionConfig = this.buildSelectionConfig(options) // needs dateEnv. do after :(
+    this.availableRawLocales = availableLocaleData.map
+    this.dateEnv = dateEnv
 
-    // ineffecient to do every time?
-    this.viewSpecs = buildViewSpecs(
-      pluginHooks.views,
-      this.optionsManager
-    )
+    // TODO: don't do every time
+    this.viewSpecs = buildViewSpecs(pluginHooks.views, this.optionsManager)
 
-    // ineffecient to do every time?
+    // needs to happen after dateEnv assigned :( because DateProfileGenerator grabs onto reference
+    // TODO: don't do every time
     this.dateProfileGenerators = mapHash(this.viewSpecs, (viewSpec) => {
       return new viewSpec.class.prototype.dateProfileGeneratorClass(viewSpec, this)
     })
+
+    // TODO: don't do every time
+    this.defaultAllDayEventDuration = createDuration(rawOptions.defaultAllDayEventDuration)
+    this.defaultTimedEventDuration = createDuration(rawOptions.defaultTimedEventDuration)
   }
 
 
-  getAvailableLocaleCodes() {
-    return Object.keys(this.availableRawLocales)
-  }
+  /*
+  always executes after onOptionsChange
+  */
+  updateDerivedState() {
+    let pluginHooks = this.pluginSystem.hooks
+    let rawOptions = this.optionsManager.computed
+    let { dateEnv } = this
+    let { viewType, dateProfile } = this.state
+    let viewSpec = this.viewSpecs[viewType]
 
+    if (!viewSpec) {
+      throw new Error(`View type "${viewType}" is not valid`)
+    }
+
+    let title = this.computeTitle(dateProfile, dateEnv, viewSpec.options)
+    let theme = this.buildTheme(rawOptions, pluginHooks)
+    let viewApi = this.buildViewApi(viewType, title, dateProfile, dateEnv)
+    let context = this.buildContext(this, pluginHooks, dateEnv, theme, viewApi, rawOptions)
 
-  _buildSelectionConfig(rawOpts) {
-    return processScopedUiProps('select', rawOpts, this)
+    this.context = context
+    this.selectionConfig = this.buildSelectionConfig(rawOptions) // MUST happen after dateEnv assigned :(
   }
 
 
-  _buildEventUiSingleBase(rawOpts) {
-    if (rawOpts.editable) { // so 'editable' affected events
-      rawOpts = { ...rawOpts, eventEditable: true }
-    }
-    return processScopedUiProps('event', rawOpts, this)
+  getAvailableLocaleCodes() {
+    return Object.keys(this.availableRawLocales)
   }
 
 
@@ -647,7 +593,7 @@ export default class Calendar {
     if (dateOrRange) {
       if ((dateOrRange as DateRangeInput).start && (dateOrRange as DateRangeInput).end) { // a range
         this.optionsManager.mutate({ visibleRange: dateOrRange }, []) // will not rerender
-        this.handleOptions(this.optionsManager.computed) // ...but yuck
+        this.onOptionsChange() // ...but yuck
       } else { // a date
         dateMarker = this.dateEnv.createMarker(dateOrRange as DateInput) // just like gotoDate
       }
@@ -669,8 +615,7 @@ export default class Calendar {
     let spec
 
     viewType = viewType || 'day' // day is default zoom
-    spec = this.viewSpecs[viewType] ||
-      this.getUnitViewSpec(viewType)
+    spec = this.viewSpecs[viewType] || this.getUnitViewSpec(viewType)
 
     this.unselect()
 
@@ -692,19 +637,10 @@ export default class Calendar {
   // Given a duration singular unit, like "week" or "day", finds a matching view spec.
   // Preference is given to views that have corresponding buttons.
   getUnitViewSpec(unit: string): ViewSpec | null {
-    let { component } = this
-    let viewTypes = []
+    let viewTypes = [].concat(this.context.viewsWithButtons)
     let i
     let spec
 
-    // put views that have buttons first. there will be duplicates, but oh
-    if (component.header) {
-      viewTypes.push(...component.header.viewsWithButtons)
-    }
-    if (component.footer) {
-      viewTypes.push(...component.footer.viewsWithButtons)
-    }
-
     for (let viewType in this.viewSpecs) {
       viewTypes.push(viewType)
     }
@@ -808,7 +744,7 @@ export default class Calendar {
 
 
   formatDate(d: DateInput, formatter): string {
-    const { dateEnv } = this
+    let { dateEnv } = this
     return dateEnv.format(
       dateEnv.createMarker(d),
       createFormatter(formatter)
@@ -818,7 +754,7 @@ export default class Calendar {
 
   // `settings` is for formatter AND isEndExclusive
   formatRange(d0: DateInput, d1: DateInput, settings) {
-    const { dateEnv } = this
+    let { dateEnv } = this
     return dateEnv.formatRange(
       dateEnv.createMarker(d0),
       dateEnv.createMarker(d1),
@@ -829,7 +765,7 @@ export default class Calendar {
 
 
   formatIso(d: DateInput, omitTime?: boolean) {
-    const { dateEnv } = this
+    let { dateEnv } = this
     return dateEnv.formatIso(dateEnv.createMarker(d), { omitTime })
   }
 
@@ -838,20 +774,6 @@ export default class Calendar {
   // -----------------------------------------------------------------------------------------------------------------
 
 
-  windowResize(ev: Event) {
-    if (
-      !this.isHandlingWindowResize &&
-      this.component && // why?
-      (ev as any).target === window // not a jqui resize event
-    ) {
-      this.isHandlingWindowResize = true
-      this.updateSize()
-      this.publiclyTrigger('windowResize', [ this.view ])
-      this.isHandlingWindowResize = false
-    }
-  }
-
-
   updateSize() { // public
     if (this.component) {
       this.component.updateSize(true)
@@ -1236,23 +1158,44 @@ EmitterMixin.mixInto(Calendar)
 // -----------------------------------------------------------------------------------------------------------------
 
 
-function buildDateEnv(locale: Locale, timeZone, namedTimeZoneImpl: NamedTimeZoneImplClass, firstDay, weekNumberCalculation, weekLabel, cmdFormatter: CmdFormatterFunc) {
+function buildDateEnv(rawOptions: any, pluginHooks: PluginHooks, availableLocaleData) {
+  let locale = buildLocale(rawOptions.locale || availableLocaleData.defaultCode, availableLocaleData.map)
+
   return new DateEnv({
     calendarSystem: 'gregory', // TODO: make this a setting
-    timeZone,
-    namedTimeZoneImpl,
+    timeZone: rawOptions.timeZone,
+    namedTimeZoneImpl: pluginHooks.namedTimeZonedImpl,
     locale,
-    weekNumberCalculation,
-    firstDay,
-    weekLabel,
-    cmdFormatter
+    weekNumberCalculation: rawOptions.weekNumberCalculation,
+    firstDay: rawOptions.firstDay,
+    weekLabel: rawOptions.weekLabel,
+    cmdFormatter: pluginHooks.cmdFormatter
   })
 }
 
 
-function buildTheme(this: Calendar, calendarOptions) {
-  let themeClass = this.pluginSystem.hooks.themeClasses[calendarOptions.themeSystem] || StandardTheme
-  return new themeClass(calendarOptions)
+function buildTheme(rawOptions, pluginHooks: PluginHooks) {
+  let themeClass = pluginHooks.themeClasses[rawOptions.themeSystem] || StandardTheme
+
+  return new themeClass(rawOptions)
+}
+
+
+function buildViewApi(type: string, title: string, dateProfile: DateProfile, dateEnv: DateEnv) {
+  return new ViewApi(type, title, dateProfile, dateEnv)
+}
+
+
+function buildSelectionConfig(this: Calendar, rawOptions) { // DANGEROUS: `this` context must be a Calendar
+  return processScopedUiProps('select', rawOptions, this)
+}
+
+
+function buildEventUiSingleBase(this: Calendar, rawOptions) { // DANGEROUS: `this` context must be a Calendar
+  if (rawOptions.editable) { // so 'editable' affected events
+    rawOptions = { ...rawOptions, eventEditable: true }
+  }
+  return processScopedUiProps('event', rawOptions, this)
 }
 
 
@@ -1278,11 +1221,6 @@ function buildEventUiBases(eventDefs: EventDefHash, eventUiSingleBase: EventUi,
 }
 
 
-function buildViewApi(type: string, title: string, dateProfile: DateProfile, dateEnv: DateEnv) {
-  return new ViewApi(type, title, dateProfile, dateEnv)
-}
-
-
 // Title and Date Formatting
 // -----------------------------------------------------------------------------------------------------------------
 
@@ -1333,16 +1271,3 @@ function computeTitleFormat(dateProfile) {
     }
   }
 }
-
-
-function computeDoDelay(actions: Action[]) {
-  for (let action of actions) {
-    switch (action.type) {
-      case 'INIT':
-      case 'SET_EVENT_DRAG':
-      case 'SET_EVENT_RESIZE':
-        return false
-    }
-  }
-  return true
-}

+ 164 - 107
packages/core/src/CalendarComponent.tsx

@@ -1,10 +1,9 @@
-import ComponentContext, { computeContextProps } from './component/ComponentContext'
-import { Component, renderer } from './view-framework'
+import ComponentContext, { ComponentContextType, buildContext } from './component/ComponentContext'
 import { ViewSpec } from './structs/view-spec'
 import View, { ViewProps } from './View'
 import Toolbar from './Toolbar'
 import DateProfileGenerator, { DateProfile } from './DateProfileGenerator'
-import { createElement, applyStyle } from './util/dom-manip'
+import { applyStyle } from './util/dom-manip'
 import { rangeContainsMarker } from './datelib/date-range'
 import { EventUiHash } from './component/event-ui'
 import { parseBusinessHours } from './structs/business-hours'
@@ -14,140 +13,155 @@ import { DateMarker } from './datelib/marker'
 import { CalendarState } from './reducers/types'
 import { ViewPropsTransformerClass } from './plugin-system'
 import { __assign } from 'tslib'
-import { listRenderer } from './view-framework'
+import { h, Fragment, createRef } from 'preact'
+import { BaseComponent, subrenderer } from './view-framework-util'
+import { buildDelegationHandler } from './util/dom-event'
+import { capitaliseFirstLetter } from './util/misc'
+import { DelayedRunner } from './util/runner'
 
 
 export interface CalendarComponentProps extends CalendarState {
+  rootEl: HTMLElement
   viewSpec: ViewSpec
   dateProfileGenerator: DateProfileGenerator // for the current view
   eventUiBases: EventUiHash
   title: string
 }
 
-export default class CalendarComponent extends Component<CalendarComponentProps, ComponentContext> {
+export default class CalendarComponent extends BaseComponent<CalendarComponentProps> {
 
-  view: View
-  header: Toolbar
-  footer: Toolbar
+  private buildViewContext = memoize(buildContext)
+  private parseBusinessHours = memoize((input) => parseBusinessHours(input, this.context.calendar))
+  private buildViewPropTransformers = memoize(buildViewPropTransformers)
+  private buildToolbarProps = memoize(buildToolbarProps)
+  private updateClassNames = subrenderer(setClassNames, unsetClassNames)
+  private handleNavLinkClick = buildDelegationHandler('a[data-goto]', this._handleNavLinkClick.bind(this))
+
+  headerRef = createRef<Toolbar>()
+  footerRef = createRef<Toolbar>()
+  viewRef = createRef<View>()
   viewContainerEl: HTMLElement
+
+  isSizingDirty = false
   isHeightAuto: boolean
   viewHeight: number
 
-  private parseBusinessHours = memoize((input) => {
-    return parseBusinessHours(input, this.context.calendar)
-  })
-  private computeViewContextProps = memoize(computeContextProps)
-  private buildViewPropTransformers = memoize(buildViewPropTransformers)
-  private updateClassNames = renderer(this._setClassNames, this._unsetClassNames)
-  private renderViewContainer = renderer(this._renderViewContainer)
-  private buildToolbarProps = memoize(buildToolbarProps)
-  private renderHeader = renderer(Toolbar)
-  private renderFooter = renderer(Toolbar)
-  private renderViews = listRenderer()
+  get view() { return this.viewRef.current }
 
 
   /*
   renders INSIDE of an outer div
   */
-  render(props: CalendarComponentProps, context: ComponentContext) {
+  render(props: CalendarComponentProps, state: {}, context: ComponentContext) {
+    let { calendar, header, footer } = context
+
     let toolbarProps = this.buildToolbarProps(
       props.viewSpec,
       props.dateProfile,
       props.dateProfileGenerator,
       props.currentDate,
-      context.calendar.getNow(),
+      calendar.getNow(),
       props.title
     )
-    let innerEls: HTMLElement[] = []
 
     this.freezeHeight() // thawed after render
-    this.updateClassNames({})
-
-    if (context.options.header) {
-      let header = this.renderHeader({
-        extraClassName: 'fc-header-toolbar',
-        layout: context.options.header,
-        ...toolbarProps
-      })
-      innerEls.push(header.rootEl)
-    } else {
-      this.renderHeader(false)
-    }
+    this.isSizingDirty = true
+
+    this.updateClassNames({ rootEl: props.rootEl })
+
+    return (
+      <Fragment>
+        {header ?
+          <Toolbar
+            ref={this.headerRef}
+            extraClassName='fc-header-toolbar'
+            model={header}
+            { ...toolbarProps }
+            /> :
+          null
+        }
+        <div class='fc-view-container' ref={this.setViewContainerEl} onClick={this.handleNavLinkClick}>
+          {this.renderView(props, this.context)}
+        </div>
+        {footer ?
+          <Toolbar
+            ref={this.footerRef}
+            extraClassName='fc-footer-toolbar'
+            model={footer}
+            { ...toolbarProps }
+            /> :
+          null
+        }
+      </Fragment>
+    )
+  }
 
-    let viewContainerEl = this.renderViewContainer({})
-    this.renderView(props, viewContainerEl, context)
-    innerEls.push(viewContainerEl)
-
-    if (context.options.footer) {
-      let footer = this.renderFooter({
-        extraClassName: 'fc-footer-toolbar',
-        layout: context.options.footer,
-        ...toolbarProps
-      })
-      innerEls.push(footer.rootEl)
-    } else {
-      this.renderFooter(false)
-    }
 
-    this.viewContainerEl = viewContainerEl
-    return innerEls
-  }
+  resizeRunner = new DelayedRunner(() => {
+    this.updateSize(true)
+    let { calendar, view } = this.context
+    calendar.publiclyTrigger('windowResize', [ view ])
+  })
 
 
   componentDidMount() {
-    this.afterRender()
+    window.addEventListener('resize', this.handleWindowResize)
   }
 
 
-  componentDidUpdate() {
-    this.afterRender()
+  componentWillUnmount() {
+    this.resizeRunner.clear()
+    window.removeEventListener('resize', this.handleWindowResize)
   }
 
 
-  afterRender() {
-    this.thawHeight()
-    this.updateSize()
-    this.context.calendar.releaseAfterSizingTriggers()
+  handleWindowResize = (ev: UIEvent) => {
+    if (ev.target === window) { // avoid jqui events
+      let { options } = this.context
+      this.resizeRunner.request(options.windowResizeDelay)
+    }
   }
 
 
-  _setClassNames(props: {}, context: ComponentContext) {
-    let classList = this.location.parentEl.classList
-    let classNames: string[] = [
-      'fc',
-      'fc-' + context.options.dir,
-      context.theme.getClass('widget')
-    ]
+  _handleNavLinkClick(ev: UIEvent, anchorEl: HTMLElement) {
+    let { dateEnv, calendar } = this.context
 
-    for (let className of classNames) {
-      classList.add(className)
-    }
+    let gotoOptions: any = anchorEl.getAttribute('data-goto')
+    gotoOptions = gotoOptions ? JSON.parse(gotoOptions) : {}
 
-    return classNames
-  }
+    let dateMarker = dateEnv.createMarker(gotoOptions.date)
+    let viewType = gotoOptions.type
+
+    // property like "navLinkDayClick". might be a string or a function
+    let customAction = calendar.viewOpt('navLink' + capitaliseFirstLetter(viewType) + 'Click')
 
+    if (typeof customAction === 'function') {
+      customAction(dateEnv.toDate(dateMarker), ev)
 
-  _unsetClassNames(classNames: string[]) {
-    let classList = this.location.parentEl.classList
+    } else {
+      if (typeof customAction === 'string') {
+        viewType = customAction
+      }
 
-    for (let className of classNames) {
-      classList.remove(className)
+      calendar.zoomTo(dateMarker, viewType)
     }
   }
 
 
-  _renderViewContainer(props: {}, context: ComponentContext) {
-    let viewContainerEl = createElement('div', { className: 'fc-view-container' })
+  setViewContainerEl = (viewContainerEl: HTMLElement | null) => {
+    let { pluginHooks, calendar } = this.context
 
-    for (let modifyViewContainer of context.pluginHooks.viewContainerModifiers) {
-      modifyViewContainer(viewContainerEl, context.calendar)
-    }
+    if (viewContainerEl) {
+      this.viewContainerEl = viewContainerEl
 
-    return viewContainerEl
+      for (let modifyViewContainer of pluginHooks.viewContainerModifiers) {
+        modifyViewContainer(viewContainerEl, calendar)
+      }
+    }
   }
 
 
-  renderView(props: CalendarComponentProps, viewContainerEl: HTMLElement, context: ComponentContext) {
+  renderView(props: CalendarComponentProps, context: ComponentContext) {
     let { pluginHooks, options } = context
     let { viewSpec } = props
 
@@ -173,21 +187,25 @@ export default class CalendarComponent extends Component<CalendarComponentProps,
       )
     }
 
-    let views = this.renderViews({
-      parentEl: viewContainerEl
-    }, [
-      {
-        id: '',
-        componentClass: viewSpec.class,
-        props: viewProps
-      }
-    ], {
-      ...context,
-      options: viewSpec.options,
-      ...this.computeViewContextProps(viewSpec.options)
-    })
+    let viewContext = this.buildViewContext(
+      context.calendar,
+      context.pluginHooks,
+      context.dateEnv,
+      context.theme,
+      context.view,
+      viewSpec.options
+    )
+
+    let ViewClass = viewSpec.class
 
-    this.view = views[0] as View
+    return (
+      <ComponentContextType.Provider value={viewContext}>
+        <ViewClass
+          ref={this.viewRef}
+          { ...viewProps }
+          />
+      </ComponentContextType.Provider>
+    )
   }
 
 
@@ -196,13 +214,21 @@ export default class CalendarComponent extends Component<CalendarComponentProps,
 
 
   updateSize(isResize = false) {
+    this.resizeRunner.whilePaused(() => {
+      if (isResize || this.isSizingDirty) {
 
-    if (isResize || this.isHeightAuto == null) {
-      this.computeHeightVars()
-    }
+        if (isResize || this.isHeightAuto == null) {
+          this.computeHeightVars()
+        }
+
+        let view = this.viewRef.current
+        view.updateSize(isResize, this.viewHeight, this.isHeightAuto)
+        view.updateNowIndicator()
 
-    this.view.updateSize(isResize, this.viewHeight, this.isHeightAuto)
-    this.view.updateNowIndicator() // we need to guarantee this will run after updateSize
+        this.thawHeight()
+        this.isSizingDirty = true
+      }
+    })
   }
 
 
@@ -222,7 +248,7 @@ export default class CalendarComponent extends Component<CalendarComponentProps,
     } else if (typeof heightInput === 'function') { // exists and is a function
       this.viewHeight = heightInput() - this.queryToolbarsHeight()
     } else if (heightInput === 'parent') { // set to height of parent element
-      let parentEl = this.location.parentEl.parentNode as HTMLElement
+      let parentEl = this.props.rootEl.parentNode as HTMLElement
       this.viewHeight = parentEl.getBoundingClientRect().height - this.queryToolbarsHeight()
     } else {
       this.viewHeight = Math.round(
@@ -234,14 +260,16 @@ export default class CalendarComponent extends Component<CalendarComponentProps,
 
 
   queryToolbarsHeight() {
+    let header = this.headerRef.current
+    let footer = this.footerRef.current
     let height = 0
 
-    if (this.header) {
-      height += computeHeightAndMargins(this.header.rootEl)
+    if (header) {
+      height += computeHeightAndMargins(header.rootEl)
     }
 
-    if (this.footer) {
-      height += computeHeightAndMargins(this.footer.rootEl)
+    if (footer) {
+      height += computeHeightAndMargins(footer.rootEl)
     }
 
     return height
@@ -253,7 +281,7 @@ export default class CalendarComponent extends Component<CalendarComponentProps,
 
 
   freezeHeight() {
-    let rootEl = this.location.parentEl
+    let { rootEl } = this.props
 
     applyStyle(rootEl, {
       height: rootEl.getBoundingClientRect().height,
@@ -263,7 +291,7 @@ export default class CalendarComponent extends Component<CalendarComponentProps,
 
 
   thawHeight() {
-    let rootEl = this.location.parentEl
+    let { rootEl } = this.props
 
     applyStyle(rootEl, {
       height: '',
@@ -295,6 +323,35 @@ function buildToolbarProps(
 }
 
 
+// Outer Div Rendering
+// -----------------------------------------------------------------------------------------------------------------
+
+
+function setClassNames({ rootEl }: { rootEl: HTMLElement }, context: ComponentContext) {
+  let classList = rootEl.classList
+  let classNames: string[] = [
+    'fc',
+    'fc-' + context.options.dir,
+    context.theme.getClass('widget')
+  ]
+
+  for (let className of classNames) {
+    classList.add(className)
+  }
+
+  return { rootEl, classNames }
+}
+
+
+function unsetClassNames({ rootEl, classNames }: { rootEl: HTMLElement, classNames: string[] }) {
+  let classList = rootEl.classList
+
+  for (let className of classNames) {
+    classList.remove(className)
+  }
+}
+
+
 // Plugin
 // -----------------------------------------------------------------------------------------------------------------
 

+ 83 - 171
packages/core/src/Toolbar.tsx

@@ -1,18 +1,14 @@
-import { htmlEscape } from './util/html'
-import { htmlToElement, appendToElement, findElements, createElement } from './util/dom-manip'
-import ComponentContext from './component/ComponentContext'
-import { Component, renderer } from './view-framework'
-import { ViewSpec } from './structs/view-spec'
-import Calendar from './Calendar'
-import Theme from './theme/Theme'
+import { h, createRef } from 'preact'
+import { BaseComponent } from './view-framework-util'
+import { ToolbarModel, ToolbarWidget } from './toolbar-parse'
 
 
-/* Toolbar with buttons and title
-----------------------------------------------------------------------------------------------------------------------*/
-
-export interface ToolbarRenderProps {
+export interface ToolbarProps extends ToolbarContent {
   extraClassName: string
-  layout: any
+  model: ToolbarModel
+}
+
+export interface ToolbarContent {
   title: string
   activeButton: string
   isTodayEnabled: boolean
@@ -20,192 +16,108 @@ export interface ToolbarRenderProps {
   isNextEnabled: boolean
 }
 
-export default class Toolbar extends Component<ToolbarRenderProps, ComponentContext> {
 
-  private renderBase = renderer(this._renderBase)
-  private renderTitle = renderer(renderTitle)
-  private renderActiveButton = renderer(renderActiveButton, unrenderActiveButton)
-  private renderToday = renderer(toggleButtonEnabled.bind(null, 'today'))
-  private renderPrev = renderer(toggleButtonEnabled.bind(null, 'prev'))
-  private renderNext = renderer(toggleButtonEnabled.bind(null, 'next'))
+export default class Toolbar extends BaseComponent<ToolbarProps> {
 
-  public viewsWithButtons: string[]
+  private rootElRef = createRef<HTMLDivElement>()
 
+  public get rootEl() { return this.rootElRef.current }
 
-  render(props: ToolbarRenderProps) {
 
-    let el = this.renderBase({
-      extraClassName: props.extraClassName,
-      layout: props.layout
-    })
+  render(props: ToolbarProps) {
+    let { model } = props
 
-    this.renderTitle({ el, text: props.title })
-    this.renderActiveButton({ el, buttonName: props.activeButton })
-    this.renderToday({ el, isEnabled: props.isTodayEnabled })
-    this.renderPrev({ el, isEnabled: props.isPrevEnabled })
-    this.renderNext({ el, isEnabled: props.isNextEnabled })
-
-    return el
+    return (
+      <div ref={this.rootElRef} class={'fc-toolbar ' + props.extraClassName}>
+        {this.renderSection('left', model.left)}
+        {this.renderSection('center', model.center)}
+        {this.renderSection('right', model.right)}
+      </div>
+    )
   }
 
 
-  /*
-  the wrapper el and the left/center/right layout
-  */
-  _renderBase({ extraClassName , layout }, context: ComponentContext) {
-    let { theme, calendar } = context
+  renderSection(position: string, widgetGroups: ToolbarWidget[][] | null) {
+    let { props } = this
+
+    if (widgetGroups) {
+      return (
+        <ToolbarSection
+          position={position}
+          widgetGroups={widgetGroups}
+          title={props.title}
+          activeButton={props.activeButton}
+          isTodayEnabled={props.isTodayEnabled}
+          isPrevEnabled={props.isPrevEnabled}
+          isNextEnabled={props.isNextEnabled}
+        />
+      )
+    }
+  }
 
-    let el = createElement('div', { className: 'fc-toolbar ' + extraClassName }, [
-      this.renderSection('left', layout.left, theme, calendar),
-      this.renderSection('center', layout.center, theme, calendar),
-      this.renderSection('right', layout.right, theme, calendar)
-    ])
+}
 
-    this.viewsWithButtons = []
 
-    return el
-  }
+interface ToolbarSectionProps extends ToolbarContent {
+  position: string
+  widgetGroups: ToolbarWidget[][]
+}
 
+class ToolbarSection extends BaseComponent<ToolbarSectionProps> {
 
-  renderSection(position, buttonStr, theme: Theme, calendar: Calendar) {
-    let optionsManager = calendar.optionsManager
-    let viewSpecs = calendar.viewSpecs
-    let sectionEl = createElement('div', { className: 'fc-' + position })
-    let calendarCustomButtons = optionsManager.computed.customButtons || {}
-    let calendarButtonTextOverrides = optionsManager.overrides.buttonText || {}
-    let calendarButtonText = optionsManager.computed.buttonText || {}
-
-    if (buttonStr) {
-      buttonStr.split(' ').forEach((buttonGroupStr, i) => {
-        let groupChildren = []
-        let isOnlyButtons = true
-        let groupEl
-
-        buttonGroupStr.split(',').forEach((buttonName, j) => {
-          let customButtonProps
-          let viewSpec: ViewSpec
-          let buttonClick
-          let buttonIcon // only one of these will be set
-          let buttonText // "
-          let buttonInnerHtml
-          let buttonClasses
-          let buttonEl: HTMLElement
-          let buttonAriaAttr
-
-          if (buttonName === 'title') {
-            groupChildren.push(htmlToElement('<h2>&nbsp;</h2>')) // we always want it to take up height
-            isOnlyButtons = false
-          } else {
+  render(props: ToolbarSectionProps) {
+    let { theme } = this.context
 
-            if ((customButtonProps = calendarCustomButtons[buttonName])) {
-              buttonClick = function(ev) {
-                if (customButtonProps.click) {
-                  customButtonProps.click.call(buttonEl, ev)
-                }
-              };
-              (buttonIcon = theme.getCustomButtonIconClass(customButtonProps)) ||
-              (buttonIcon = theme.getIconClass(buttonName)) ||
-              (buttonText = customButtonProps.text)
-            } else if ((viewSpec = viewSpecs[buttonName])) {
-              this.viewsWithButtons.push(buttonName)
-              buttonClick = function() {
-                calendar.changeView(buttonName)
-              };
-              (buttonText = viewSpec.buttonTextOverride) ||
-              (buttonIcon = theme.getIconClass(buttonName)) ||
-              (buttonText = viewSpec.buttonTextDefault)
-            } else if (calendar[buttonName]) { // a calendar method
-              buttonClick = function() {
-                calendar[buttonName]()
-              };
-              (buttonText = calendarButtonTextOverrides[buttonName]) ||
-              (buttonIcon = theme.getIconClass(buttonName)) ||
-              (buttonText = calendarButtonText[buttonName])
-              //            ^ everything else is considered default
-            }
+    return (
+      <div class={'fc-' + props.position}>
+        {props.widgetGroups.map((widgetGroup: ToolbarWidget[]) => {
+          let children = []
+          let isOnlyButtons = true
 
-            if (buttonClick) {
+          for (let widget of widgetGroup) {
+            let { buttonName, buttonClick, buttonText, buttonIcon } = widget
+
+            if (buttonName === 'title') {
+              isOnlyButtons = false
+              children.push(
+                <h2>{props.title}</h2>
+              )
 
-              buttonClasses = [
-                'fc-' + buttonName + '-button',
-                theme.getClass('button')
-              ]
+            } else {
+              let ariaAttrs = buttonIcon ? { 'aria-label': buttonName } : {}
 
-              if (buttonText) {
-                buttonInnerHtml = htmlEscape(buttonText)
-                buttonAriaAttr = ''
-              } else if (buttonIcon) {
-                buttonInnerHtml = "<span class='" + buttonIcon + "'></span>"
-                buttonAriaAttr = ' aria-label="' + buttonName + '"'
+              let buttonClasses = [ 'fc-' + buttonName + '-button', theme.getClass('button') ]
+              if (buttonName === props.activeButton) {
+                buttonClasses.push(theme.getClass('buttonActive'))
               }
 
-              buttonEl = htmlToElement( // type="button" so that it doesn't submit a form
-                '<button type="button" class="' + buttonClasses.join(' ') + '"' +
-                  buttonAriaAttr +
-                '>' + buttonInnerHtml + '</button>'
+              let isDisabled =
+                (!props.isTodayEnabled && buttonName === 'today') ||
+                (!props.isPrevEnabled && buttonName === 'prev') ||
+                (!props.isNextEnabled && buttonName === 'next')
+
+              children.push(
+                <button
+                  disabled={isDisabled}
+                  class={buttonClasses.join(' ')}
+                  onClick={buttonClick}
+                  { ...ariaAttrs }
+                >{ buttonText || (buttonIcon ? <span class={buttonIcon} /> : '')}</button>
               )
-
-              buttonEl.addEventListener('click', buttonClick)
-
-              groupChildren.push(buttonEl)
             }
           }
-        })
 
-        if (groupChildren.length > 1) {
-          groupEl = document.createElement('div')
+          if (children.length > 1) {
+            let groupClasses = (isOnlyButtons && theme.getClass('buttonGroup')) || ''
 
-          let buttonGroupClassName = theme.getClass('buttonGroup')
-          if (isOnlyButtons && buttonGroupClassName) {
-            groupEl.classList.add(buttonGroupClassName)
+            return (<div class={groupClasses}>{children}</div>)
+          } else {
+            return children[0]
           }
 
-          appendToElement(groupEl, groupChildren)
-          sectionEl.appendChild(groupEl)
-        } else {
-          appendToElement(sectionEl, groupChildren) // 1 or 0 children
-        }
-      })
-    }
-
-    return sectionEl
+        })}
+      </div>
+    )
   }
 
 }
-
-
-function renderTitle(props: { el: HTMLElement, text: string }) {
-  findElements(props.el, 'h2').forEach(function(titleEl) {
-    titleEl.innerText = props.text
-  })
-}
-
-
-function renderActiveButton(props: { el: HTMLElement, buttonName: string }, context: ComponentContext) {
-  let { buttonName } = props
-  let className = context.theme.getClass('buttonActive')
-
-  findElements(props.el, 'button').forEach((buttonEl) => { // fyi, themed buttons don't have .fc-button
-    if (buttonEl.classList.contains('fc-' + buttonName + '-button')) {
-      buttonEl.classList.add(className)
-    }
-  })
-
-  return props
-}
-
-
-function unrenderActiveButton(props: { el: HTMLElement, buttonName: string }, context: ComponentContext) {
-  let className = context.theme.getClass('buttonActive')
-
-  findElements(props.el, 'button').forEach((buttonEl) => { // fyi, themed buttons don't have .fc-button
-    buttonEl.classList.remove(className)
-  })
-}
-
-
-function toggleButtonEnabled(buttonName: string, props: { el: HTMLElement, isEnabled: boolean }) {
-  findElements(props.el, '.fc-' + buttonName + '-button').forEach((buttonEl: HTMLButtonElement) => {
-    buttonEl.disabled = !props.isEnabled
-  })
-}

+ 9 - 4
packages/core/src/View.ts

@@ -10,7 +10,7 @@ import { sliceEventStore, EventRenderRange } from './component/event-rendering'
 import { DateSpan } from './structs/date-span'
 import { EventInteractionState } from './interactions/event-interaction-state'
 import { __assign } from 'tslib'
-import { createElement } from './util/dom-manip'
+
 
 export interface ViewProps {
   viewSpec: ViewSpec
@@ -25,7 +25,7 @@ export interface ViewProps {
   eventResize: EventInteractionState | null
 }
 
-export default abstract class View extends DateComponent<ViewProps> {
+export default abstract class View<State={}> extends DateComponent<ViewProps, State> {
 
   // config properties, initialized after class on prototype
   usesMinMaxTime: boolean // whether minTime/maxTime will affect the activeRange. Views must opt-in.
@@ -45,6 +45,8 @@ export default abstract class View extends DateComponent<ViewProps> {
   nowIndicatorTimeoutID: any // for refresh timing of now indicator
   nowIndicatorIntervalID: any // "
 
+  abstract getRootEl(): HTMLElement
+
 
   // Sizing
   // -----------------------------------------------------------------------------------------------------------------
@@ -259,6 +261,9 @@ View.prototype.usesMinMaxTime = false
 View.prototype.dateProfileGeneratorClass = DateProfileGenerator
 
 
-export function renderViewEl(type: string) {
-  return createElement('div', { className: 'fc-view fc-' + type + '-view' })
+export function getViewClassNames(viewSpec: ViewSpec) {
+  return [
+    'fc-view',
+    'fc-' + viewSpec.type + '-view'
+  ]
 }

+ 0 - 61
packages/core/src/common/DayHeader.ts

@@ -1,61 +0,0 @@
-import { Component } from '../view-framework'
-import ComponentContext from '../component/ComponentContext'
-import { htmlToElement } from '../util/dom-manip'
-import { DateMarker } from '../datelib/marker'
-import { DateProfile } from '../DateProfileGenerator'
-import { createFormatter } from '../datelib/formatting'
-import { computeFallbackHeaderFormat, renderDateCell } from './table-utils'
-
-export interface DayHeaderProps {
-  dates: DateMarker[]
-  dateProfile: DateProfile
-  datesRepDistinctDays: boolean
-  renderIntroHtml?: () => string
-}
-
-export default class DayHeader extends Component<DayHeaderProps> {
-
-
-  render(props: DayHeaderProps, context: ComponentContext) {
-    let { theme } = context
-    let { dates, datesRepDistinctDays } = props
-    let parts = []
-
-    if (props.renderIntroHtml) {
-      parts.push(props.renderIntroHtml())
-    }
-
-    let colHeadFormat = createFormatter(
-      context.options.columnHeaderFormat ||
-      computeFallbackHeaderFormat(datesRepDistinctDays, dates.length)
-    )
-
-    for (let date of dates) {
-      parts.push(
-        renderDateCell(
-          date,
-          props.dateProfile,
-          datesRepDistinctDays,
-          dates.length,
-          colHeadFormat,
-          context
-        )
-      )
-    }
-
-    if (context.isRtl) {
-      parts.reverse()
-    }
-
-    return htmlToElement(
-      '<div class="fc-row ' + theme.getClass('headerRow') + '">' +
-        '<table class="' + theme.getClass('tableGrid') + '">' +
-          '<thead>' +
-            '<tr>' + parts.join('') + '</tr>' +
-          '</thead>' +
-        '</table>' +
-      '</div>'
-    )
-  }
-
-}

+ 65 - 0
packages/core/src/common/DayHeader.tsx

@@ -0,0 +1,65 @@
+import { BaseComponent } from '../view-framework-util'
+import ComponentContext from '../component/ComponentContext'
+import { DateMarker } from '../datelib/marker'
+import { DateProfile } from '../DateProfileGenerator'
+import { createFormatter } from '../datelib/formatting'
+import { computeFallbackHeaderFormat } from './table-utils'
+import { VNode, h, createRef } from 'preact'
+import { TableDateCell } from '@fullcalendar/core'
+
+export interface DayHeaderProps {
+  dates: DateMarker[]
+  dateProfile: DateProfile
+  datesRepDistinctDays: boolean
+  renderIntro?: () => VNode[]
+}
+
+export default class DayHeader extends BaseComponent<DayHeaderProps> {
+
+  private rootElRef = createRef<HTMLDivElement>()
+
+  get rootEl() { return this.rootElRef.current }
+
+
+  render(props: DayHeaderProps, state: {}, context: ComponentContext) {
+    let { theme } = context
+    let { dates, datesRepDistinctDays } = props
+    let cells: VNode[] = []
+
+    if (props.renderIntro) {
+      cells = props.renderIntro()
+    }
+
+    let colHeadFormat = createFormatter(
+      context.options.columnHeaderFormat ||
+      computeFallbackHeaderFormat(datesRepDistinctDays, dates.length)
+    )
+
+    for (let date of dates) {
+      cells.push(
+        <TableDateCell
+          dateMarker={date}
+          dateProfile={props.dateProfile}
+          datesRepDistinctDays={datesRepDistinctDays}
+          colCnt={dates.length}
+          colHeadFormat={colHeadFormat}
+        />
+      )
+    }
+
+    if (context.isRtl) {
+      cells.reverse()
+    }
+
+    return (
+      <div ref={this.rootElRef} class={'fc-row ' + theme.getClass('headerRow')}>
+        <table class={theme.getClass('tableGrid')}>
+          <thead>
+            <tr>{cells}</tr>
+          </thead>
+        </table>
+      </div>
+    )
+  }
+
+}

+ 1 - 1
packages/core/src/common/DayTableModel.ts

@@ -11,7 +11,7 @@ export interface DayTableSeg extends Seg {
 
 export interface DayTableCell {
   date: DateMarker
-  htmlAttrs?: string
+  htmlAttrs?: object
 }
 
 export default class DayTableModel {

+ 44 - 21
packages/core/src/common/Scroller.ts → packages/core/src/common/Scroller.tsx

@@ -1,7 +1,7 @@
 import { computeEdges } from '../util/dom-geom'
-import { createElement, applyStyle, applyStyleProp } from '../util/dom-manip'
 import { ElementScrollController } from './scroll-controller'
-import { Component } from '../view-framework'
+import { Component, h, ComponentChildren } from 'preact'
+import { __assign } from 'tslib'
 
 export interface ScrollbarWidths {
   left: number
@@ -12,43 +12,63 @@ export interface ScrollbarWidths {
 export interface ScrollerProps {
   overflowX: string
   overflowY: string
+  children?: ComponentChildren
+  extraClassName?: string
 }
 
 /*
 Embodies a div that has potential scrollbars
 */
-export default class Scroller extends Component<ScrollerProps> {
+export default class Scroller extends Component<ScrollerProps> { // TODO: why not BaseComponent???
 
-  el = createElement('div', { className: 'fc-scroller' })
-  controller = new ElementScrollController(this.el)
+  private forcedStyles = {} as any
 
+  rootEl: HTMLDivElement
+  controller: ElementScrollController
 
-  render(props: ScrollerProps) {
-    this.applyOverflow(props)
 
-    return this.el
+  render(props: ScrollerProps) {
+    let { forcedStyles } = this
+
+    return (
+      <div ref={this.setRootEl} class={'fc-scroller ' + (props.extraClassName || '')} style={{
+        height: forcedStyles.height,
+        overflowX: forcedStyles.overflowX || props.overflowX,
+        overflowY: forcedStyles.overflowY || props.overflowY
+      }}>
+        {props.children}
+      </div>
+    )
   }
 
 
-  // sets to natural height, unlocks overflow
-  clear() {
-    this.setHeight('auto')
-    this.applyOverflow(this.props)
+  setRootEl = (rootEl: HTMLDivElement | null) => {
+    if (rootEl) {
+      this.rootEl = rootEl
+      this.controller = rootEl ? new ElementScrollController(rootEl) : null
+    }
   }
 
 
-  // Overflow
-  // -----------------------------------------------------------------------------------------------------------------
+  private forceStyles(forcedStyles: any) {
+    __assign(this.forcedStyles, forcedStyles)
+    __assign(this.rootEl.style, forcedStyles)
+  }
 
 
-  applyOverflow(props: ScrollerProps) {
-    applyStyle(this.el, {
-      overflowX: props.overflowX,
-      overflowY: props.overflowY
+  clear() {
+    this.forceStyles({
+      height: 'auto',
+      overflowXOverride: '',
+      overflowYOverride: ''
     })
   }
 
 
+  // Overflow
+  // -----------------------------------------------------------------------------------------------------------------
+
+
   // Causes any 'auto' overflow values to resolves to 'scroll' or 'hidden'.
   // Useful for preserving scrollbar widths regardless of future resizes.
   // Can pass in scrollbarWidths for optimization.
@@ -72,17 +92,20 @@ export default class Scroller extends Component<ScrollerProps> {
         ) ? 'scroll' : 'hidden'
     }
 
-    applyStyle(this.el, { overflowX, overflowY })
+    this.forceStyles({ overflowX, overflowY })
   }
 
 
   setHeight(height: number | string) {
-    applyStyleProp(this.el, 'height', height)
+    this.forceStyles({
+      height: typeof height === 'number' ? height + 'px' : height // TODO: util for this
+    })
   }
 
 
   getScrollbarWidths(): ScrollbarWidths {
-    let edges = computeEdges(this.el)
+    let edges = computeEdges(this.rootEl)
+
     return {
       left: edges.scrollbarLeft,
       right: edges.scrollbarRight,

+ 86 - 0
packages/core/src/common/TableDateCell.tsx

@@ -0,0 +1,86 @@
+import { rangeContainsMarker } from '../datelib/date-range'
+import { getDayClasses } from '../component/date-rendering'
+import GotoAnchor from '../component/GotoAnchor'
+import { DateMarker, DAY_IDS } from '../datelib/marker'
+import { DateProfile } from '../DateProfileGenerator'
+import ComponentContext from '../component/ComponentContext'
+import { h } from 'preact'
+import { __assign } from 'tslib'
+import { DateFormatter } from '../datelib/formatting'
+import { BaseComponent } from '../view-framework-util'
+
+
+export interface TableDateCellProps {
+  dateMarker: DateMarker
+  dateProfile: DateProfile
+  datesRepDistinctDays: boolean
+  colCnt: number
+  colHeadFormat: DateFormatter
+  colSpan?: number
+  otherAttrs?: object
+}
+
+export default class TableDateCell extends BaseComponent<TableDateCellProps> {
+
+  render(props: TableDateCellProps, state: {}, context: ComponentContext) {
+    let { dateEnv, theme, options } = context
+    let { dateMarker, dateProfile, datesRepDistinctDays } = props
+    let isDateValid = rangeContainsMarker(dateProfile.activeRange, dateMarker) // TODO: called too frequently. cache somehow.
+    let classNames = [
+      'fc-day-header',
+      theme.getClass('widgetHeader')
+    ]
+    let innerText
+    let innerHtml
+
+    if (typeof options.columnHeaderHtml === 'function') {
+      innerHtml = options.columnHeaderHtml(
+        dateEnv.toDate(dateMarker)
+      )
+    } else if (typeof options.columnHeaderText === 'function') {
+      innerText = options.columnHeaderText(
+        dateEnv.toDate(dateMarker)
+      )
+    } else {
+      innerText = dateEnv.format(dateMarker, props.colHeadFormat)
+    }
+
+    // if only one row of days, the classNames on the header can represent the specific days beneath
+    if (datesRepDistinctDays) {
+      classNames = classNames.concat(
+        // includes the day-of-week class
+        // noThemeHighlight=true (don't highlight the header)
+        getDayClasses(dateMarker, dateProfile, context, true)
+      )
+    } else {
+      classNames.push('fc-' + DAY_IDS[dateMarker.getUTCDay()]) // only add the day-of-week class
+    }
+
+    let attrs = {} as any
+
+    if (isDateValid && datesRepDistinctDays) {
+      attrs['data-date'] = dateEnv.formatIso(dateMarker, { omitTime: true })
+    }
+
+    if (props.colSpan > 1) {
+      attrs.colSpan = props.colSpan
+    }
+
+    if (props.otherAttrs) {
+      __assign(attrs, props.otherAttrs)
+    }
+
+    return (
+      <th class={classNames.join(' ')} {...attrs}>
+        {isDateValid &&
+          <GotoAnchor
+            navLinks={options.navLinks}
+            gotoOptions={{ date: dateMarker, forceOff: isDateValid && (!datesRepDistinctDays || props.colCnt === 1) }}
+            htmlContent={innerHtml}
+          >{innerText}</GotoAnchor>
+        }
+      </th>
+    )
+  }
+
+}

+ 0 - 75
packages/core/src/common/table-utils.ts

@@ -1,9 +1,3 @@
-import { rangeContainsMarker } from '../datelib/date-range'
-import { htmlEscape } from '../util/html'
-import { buildGotoAnchorHtml, getDayClasses } from '../component/date-rendering'
-import { DateMarker, DAY_IDS } from '../datelib/marker'
-import { DateProfile } from '../DateProfileGenerator'
-import ComponentContext from '../component/ComponentContext'
 
 // Computes a default column header formatting string if `colFormat` is not explicitly defined
 export function computeFallbackHeaderFormat(datesRepDistinctDays: boolean, dayCnt: number) {
@@ -17,72 +11,3 @@ export function computeFallbackHeaderFormat(datesRepDistinctDays: boolean, dayCn
     return { weekday: 'long' } // "Saturday"
   }
 }
-
-export function renderDateCell(
-  dateMarker: DateMarker,
-  dateProfile: DateProfile,
-  datesRepDistinctDays,
-  colCnt,
-  colHeadFormat,
-  context: ComponentContext,
-  colspan?,
-  otherAttrs?
-): string {
-  let { dateEnv, theme, options } = context
-  let isDateValid = rangeContainsMarker(dateProfile.activeRange, dateMarker) // TODO: called too frequently. cache somehow.
-  let classNames = [
-    'fc-day-header',
-    theme.getClass('widgetHeader')
-  ]
-  let innerHtml
-
-  if (typeof options.columnHeaderHtml === 'function') {
-    innerHtml = options.columnHeaderHtml(
-      dateEnv.toDate(dateMarker)
-    )
-  } else if (typeof options.columnHeaderText === 'function') {
-    innerHtml = htmlEscape(
-      options.columnHeaderText(
-        dateEnv.toDate(dateMarker)
-      )
-    )
-  } else {
-    innerHtml = htmlEscape(dateEnv.format(dateMarker, colHeadFormat))
-  }
-
-  // if only one row of days, the classNames on the header can represent the specific days beneath
-  if (datesRepDistinctDays) {
-    classNames = classNames.concat(
-      // includes the day-of-week class
-      // noThemeHighlight=true (don't highlight the header)
-      getDayClasses(dateMarker, dateProfile, context, true)
-    )
-  } else {
-    classNames.push('fc-' + DAY_IDS[dateMarker.getUTCDay()]) // only add the day-of-week class
-  }
-
-  return '' +
-    '<th class="' + classNames.join(' ') + '"' +
-      ((isDateValid && datesRepDistinctDays) ?
-        ' data-date="' + dateEnv.formatIso(dateMarker, { omitTime: true }) + '"' :
-        '') +
-        (colspan > 1 ?
-          ' colspan="' + colspan + '"' :
-          '') +
-        (otherAttrs ?
-          ' ' + otherAttrs :
-          '') +
-      '>' +
-      (isDateValid ?
-        // don't make a link if the heading could represent multiple days, or if there's only one day (forceOff)
-        buildGotoAnchorHtml(
-          options,
-          dateEnv,
-          { date: dateMarker, forceOff: !datesRepDistinctDays || colCnt === 1 },
-          innerHtml
-        ) :
-        // if not valid, display text, but no link
-        innerHtml
-      ) +
-    '</th>'
-}

+ 32 - 3
packages/core/src/component/ComponentContext.ts

@@ -5,25 +5,54 @@ import { DateEnv } from '../datelib/env'
 import { parseFieldSpecs } from '../util/misc'
 import { createDuration, Duration } from '../datelib/duration'
 import { PluginHooks } from '../plugin-system'
+import { createContext } from 'preact'
+import { parseToolbars, ToolbarModel } from '../toolbar-parse'
+
+
+export const ComponentContextType = createContext({}) // for Components
 
 
 export default interface ComponentContext {
   calendar: Calendar
   pluginHooks: PluginHooks
-  view?: ViewApi
   dateEnv: DateEnv
   theme: Theme
+  view: ViewApi
   options: any
   isRtl: boolean
   eventOrderSpecs: any
   nextDayThreshold: Duration
+  header: ToolbarModel | null
+  footer: ToolbarModel | null
+  viewsWithButtons: string[]
+}
+
+
+export function buildContext(
+  calendar: Calendar,
+  pluginHooks: PluginHooks,
+  dateEnv: DateEnv,
+  theme: Theme,
+  view: ViewApi,
+  options: any
+): ComponentContext {
+  return {
+    calendar,
+    pluginHooks,
+    dateEnv,
+    theme,
+    view,
+    options,
+    ...computeContextProps(options, theme, calendar)
+  }
 }
 
 
-export function computeContextProps(options: any) {
+function computeContextProps(options: any, theme: Theme, calendar: Calendar) {
   return {
     isRtl: options.dir === 'rtl',
     eventOrderSpecs: parseFieldSpecs(options.eventOrder),
-    nextDayThreshold: createDuration(options.nextDayThreshold)
+    nextDayThreshold: createDuration(options.nextDayThreshold),
+    ...parseToolbars(options, theme, calendar)
   }
 }

+ 7 - 3
packages/core/src/component/DateComponent.ts

@@ -1,4 +1,4 @@
-import { Component } from '../view-framework'
+import { BaseComponent } from '../view-framework-util'
 import { EventRenderRange } from './event-rendering'
 import { DateSpan } from '../structs/date-span'
 import { EventInstanceHash } from '../structs/event'
@@ -7,7 +7,7 @@ import { Hit } from '../interactions/hit'
 import { elementClosest } from '../util/dom-manip'
 import { isDateSelectionValid, isInteractionValid } from '../validation'
 import { EventInteractionState } from '../interactions/event-interaction-state'
-import ComponentContext from '../component/ComponentContext'
+import { guid } from '../util/misc'
 
 export type DateComponentHash = { [uid: string]: DateComponent<any, any> }
 
@@ -30,11 +30,15 @@ export interface EventSegUiInteractionState {
 }
 
 /*
+an INTERACTABLE date component
+
 PURPOSES:
 - hook up to fg, fill, and mirror renderers
 - interface for dragging and hits
 */
-export default abstract class DateComponent<Props, State={}, RenderResult=void, Snapshot={}> extends Component<Props, ComponentContext, State, RenderResult, Snapshot> {
+export default abstract class DateComponent<Props={}, State={}> extends BaseComponent<Props, State> {
+
+  uid = guid()
 
   // self-config, overridable by subclasses. must set on prototype
   fgSegSelector: string // lets eventRender produce elements without fc-event class

+ 63 - 0
packages/core/src/component/GotoAnchor.tsx

@@ -0,0 +1,63 @@
+import { h, ComponentChildren } from 'preact'
+import { BaseComponent } from '../view-framework-util'
+import ComponentContext from './ComponentContext'
+import { __assign } from 'tslib'
+
+export interface GotoAnchorProps {
+  navLinks: any
+  gotoOptions: any
+  extraAttrs?: object
+  children: ComponentChildren
+  htmlContent?: string // fold into extraAttrs?
+}
+
+export default class GotoAnchor extends BaseComponent<GotoAnchorProps> {
+
+  // Generates HTML for an anchor to another view into the calendar.
+  // Will either generate an <a> tag or a non-clickable <span> tag, depending on enabled settings.
+  // `gotoOptions` can either be a DateMarker, or an object with the form:
+  // { date, type, forceOff }
+  // `type` is a view-type like "day" or "week". default value is "day".
+  // `attrs` and `innerHtml` are use to generate the rest of the HTML tag.
+  render(props: GotoAnchorProps, state: {}, context: ComponentContext) {
+    let { gotoOptions } = props
+    let date
+    let type
+    let forceOff
+    let finalOptions
+
+    if (gotoOptions instanceof Date) {
+      date = gotoOptions // a single date-like input
+    } else {
+      date = gotoOptions.date
+      type = gotoOptions.type
+      forceOff = gotoOptions.forceOff
+    }
+
+    finalOptions = { // for serialization into the link
+      date: context.dateEnv.formatIso(date, { omitTime: true }),
+      type: type || 'day'
+    }
+
+    let attrs = {} as any
+
+    if (props.extraAttrs) {
+      __assign(attrs, props.extraAttrs)
+    }
+
+    if (typeof props.htmlContent === 'string') {
+      attrs.dangerouslySetInnerHTML = { __html: props.htmlContent }
+    }
+
+    if (!forceOff && props.navLinks) {
+      return (
+        <a {...attrs} data-goto={JSON.stringify(finalOptions)}>{props.children}</a>
+      )
+    } else {
+      return (
+        <span {...attrs}>{props.children}</span>
+      )
+    }
+  }
+
+}

+ 0 - 96
packages/core/src/component/date-rendering.ts

@@ -1,96 +0,0 @@
-import { htmlEscape, attrsToStr } from '../util/html'
-import { DateMarker, startOfDay, addDays, DAY_IDS } from '../datelib/marker'
-import { rangeContainsMarker } from '../datelib/date-range'
-import ComponentContext from '../component/ComponentContext'
-import { DateProfile } from '../DateProfileGenerator'
-import { DateEnv } from '../datelib/env'
-
-
-// Generates HTML for an anchor to another view into the calendar.
-// Will either generate an <a> tag or a non-clickable <span> tag, depending on enabled settings.
-// `gotoOptions` can either be a DateMarker, or an object with the form:
-// { date, type, forceOff }
-// `type` is a view-type like "day" or "week". default value is "day".
-// `attrs` and `innerHtml` are use to generate the rest of the HTML tag.
-export function buildGotoAnchorHtml(allOptions: any, dateEnv: DateEnv, gotoOptions, attrs, innerHtml?) {
-  let date
-  let type
-  let forceOff
-  let finalOptions
-
-  if (gotoOptions instanceof Date) {
-    date = gotoOptions // a single date-like input
-  } else {
-    date = gotoOptions.date
-    type = gotoOptions.type
-    forceOff = gotoOptions.forceOff
-  }
-
-  finalOptions = { // for serialization into the link
-    date: dateEnv.formatIso(date, { omitTime: true }),
-    type: type || 'day'
-  }
-
-  if (typeof attrs === 'string') {
-    innerHtml = attrs
-    attrs = null
-  }
-
-  attrs = attrs ? ' ' + attrsToStr(attrs) : '' // will have a leading space
-  innerHtml = innerHtml || ''
-
-  if (!forceOff && allOptions.navLinks) {
-    return '<a' + attrs +
-      ' data-goto="' + htmlEscape(JSON.stringify(finalOptions)) + '">' +
-      innerHtml +
-      '</a>'
-  } else {
-    return '<span' + attrs + '>' +
-      innerHtml +
-      '</span>'
-  }
-}
-
-
-export function getAllDayHtml(allOptions: any) {
-  return allOptions.allDayHtml || htmlEscape(allOptions.allDayText)
-}
-
-
-// Computes HTML classNames for a single-day element
-export function getDayClasses(date: DateMarker, dateProfile: DateProfile, context: ComponentContext, noThemeHighlight?) {
-  let { calendar, options, theme, dateEnv } = context
-  let classes = []
-  let todayStart: DateMarker
-  let todayEnd: DateMarker
-
-  if (!rangeContainsMarker(dateProfile.activeRange, date)) {
-    classes.push('fc-disabled-day')
-  } else {
-    classes.push('fc-' + DAY_IDS[date.getUTCDay()])
-
-    if (
-      options.monthMode &&
-      dateEnv.getMonth(date) !== dateEnv.getMonth(dateProfile.currentRange.start)
-    ) {
-      classes.push('fc-other-month')
-    }
-
-    todayStart = startOfDay(calendar.getNow())
-    todayEnd = addDays(todayStart, 1)
-
-    if (date < todayStart) {
-      classes.push('fc-past')
-    } else if (date >= todayEnd) {
-      classes.push('fc-future')
-    } else {
-      classes.push('fc-today')
-
-      if (noThemeHighlight !== true) {
-        classes.push(theme.getClass('today'))
-      }
-    }
-  }
-
-  return classes
-}

+ 43 - 0
packages/core/src/component/date-rendering.tsx

@@ -0,0 +1,43 @@
+import { DateMarker, startOfDay, addDays, DAY_IDS } from '../datelib/marker'
+import { rangeContainsMarker } from '../datelib/date-range'
+import ComponentContext from '../component/ComponentContext'
+import { DateProfile } from '../DateProfileGenerator'
+
+
+// Computes HTML classNames for a single-day element
+export function getDayClasses(date: DateMarker, dateProfile: DateProfile, context: ComponentContext, noThemeHighlight?) {
+  let { calendar, options, theme, dateEnv } = context
+  let classes = []
+  let todayStart: DateMarker
+  let todayEnd: DateMarker
+
+  if (!rangeContainsMarker(dateProfile.activeRange, date)) {
+    classes.push('fc-disabled-day')
+  } else {
+    classes.push('fc-' + DAY_IDS[date.getUTCDay()])
+
+    if (
+      options.monthMode &&
+      dateEnv.getMonth(date) !== dateEnv.getMonth(dateProfile.currentRange.start)
+    ) {
+      classes.push('fc-other-month')
+    }
+
+    todayStart = startOfDay(calendar.getNow())
+    todayEnd = addDays(todayStart, 1)
+
+    if (date < todayStart) {
+      classes.push('fc-past')
+    } else if (date >= todayEnd) {
+      classes.push('fc-future')
+    } else {
+      classes.push('fc-today')
+
+      if (noThemeHighlight !== true) {
+        classes.push(theme.getClass('today'))
+      }
+    }
+  }
+
+  return classes
+}

+ 11 - 11
packages/core/src/component/renderers/FgEventRenderer.ts

@@ -5,10 +5,9 @@ import { compareByFieldSpecs } from '../../util/misc'
 import { EventUi } from '../event-ui'
 import { EventRenderRange, filterSegsViaEls, triggerPositionedSegs, triggerWillRemoveSegs } from '../event-rendering'
 import { Seg } from '../DateComponent'
-import { Component } from '../../view-framework'
 import ComponentContext from '../ComponentContext'
 import { memoize } from '../../util/memoize'
-import { renderer } from '../../view-framework'
+import { subrenderer, SubRenderer } from '../../view-framework-util'
 
 
 export interface BaseFgEventRendererProps {
@@ -20,12 +19,12 @@ export interface BaseFgEventRendererProps {
 
 export default abstract class FgEventRenderer<
   FgEventRendererProps extends BaseFgEventRendererProps = BaseFgEventRendererProps
-> extends Component<FgEventRendererProps, ComponentContext> {
+> extends SubRenderer<FgEventRendererProps> {
 
   private updateComputedOptions = memoize(this._updateComputedOptions)
-  private renderSegsPlain = renderer(this._renderSegsPlain, this._unrenderSegsPlain)
-  private renderSelectedInstance = renderer(renderSelectedInstance, unrenderSelectedInstance)
-  private renderHiddenInstances = renderer(renderHiddenInstances, unrenderHiddenInstances)
+  private renderSegsPlain = subrenderer(this._renderSegsPlain, this._unrenderSegsPlain)
+  private renderSelectedInstance = subrenderer(renderSelectedInstance, unrenderSelectedInstance)
+  private renderHiddenInstances = subrenderer(renderHiddenInstances, unrenderHiddenInstances)
 
   // internal state
   private segs: Seg[] = [] // for sizing funcs
@@ -37,8 +36,8 @@ export default abstract class FgEventRenderer<
   protected displayEventEnd: boolean
 
 
-  renderSegs(props: BaseFgEventRendererProps, context: ComponentContext) {
-    this.updateComputedOptions(context.options)
+  renderSegs(props: BaseFgEventRendererProps) {
+    this.updateComputedOptions(this.context.options)
 
     let { segs } = this.renderSegsPlain({
       segs: props.segs,
@@ -56,12 +55,13 @@ export default abstract class FgEventRenderer<
     })
 
     this.segs = segs
+    this.isSizeDirty = true
 
     return segs
   }
 
 
-  _updateComputedOptions(options: any) {
+  private _updateComputedOptions(options: any) {
     let eventTimeFormat = createFormatter(
       options.eventTimeFormat || this.computeEventTimeFormat(),
       options.defaultRangeSeparator
@@ -271,11 +271,11 @@ export default abstract class FgEventRenderer<
   }
 
 
-  computeSegSizes(segs: Seg[], userComponent: any) {
+  protected computeSegSizes(segs: Seg[], userComponent: any) {
   }
 
 
-  assignSegSizes(segs: Seg[], userComponent: any) {
+  protected assignSegSizes(segs: Seg[], userComponent: any) {
   }
 
 }

+ 3 - 3
packages/core/src/component/renderers/FillRenderer.ts

@@ -3,7 +3,7 @@ import { htmlToElements, elementMatches } from '../../util/dom-manip'
 import { Seg } from '../DateComponent'
 import { filterSegsViaEls, triggerPositionedSegs, triggerWillRemoveSegs } from '../event-rendering'
 import ComponentContext from '../ComponentContext'
-import { Component, renderer} from '../../view-framework'
+import { SubRenderer, subrenderer } from '../../view-framework-util'
 
 export interface BaseFillRendererProps {
   segs: Seg[]
@@ -11,9 +11,9 @@ export interface BaseFillRendererProps {
 }
 
 // use for highlight, background events, business hours
-export default abstract class FillRenderer<FillRendererProps extends BaseFillRendererProps> extends Component<FillRendererProps, ComponentContext> {
+export default abstract class FillRenderer<FillRendererProps extends BaseFillRendererProps> extends SubRenderer<FillRendererProps> {
 
-  renderSegs = renderer(this._renderSegs, this._unrenderSegs)
+  renderSegs = subrenderer(this._renderSegs, this._unrenderSegs)
 
   fillSegTag: string = 'div'
 

+ 1 - 1
packages/core/src/interactions/EventClicking.ts

@@ -14,7 +14,7 @@ export default class EventClicking extends Interaction {
     let { component } = settings
 
     this.destroy = listenBySelector(
-      component.rootEl,
+      settings.el,
       'click',
       component.fgSegSelector + ',' + component.bgSegSelector,
       this.handleSegClick

+ 1 - 1
packages/core/src/interactions/EventHovering.ts

@@ -17,7 +17,7 @@ export default class EventHovering extends Interaction {
     let { component } = settings
 
     this.removeHoverListeners = listenToHoverBySelector(
-      component.rootEl,
+      settings.el,
       component.fgSegSelector + ',' + component.bgSegSelector,
       this.handleSegEnter,
       this.handleSegLeave

+ 9 - 7
packages/core/src/main.ts

@@ -14,7 +14,6 @@ export { BusinessHoursInput, parseBusinessHours } from './structs/business-hours
 
 export {
   applyAll,
-  debounce,
   padStart,
   isInt,
   capitaliseFirstLetter,
@@ -30,7 +29,8 @@ export {
   undistributeHeight,
   preventSelection, allowSelection, preventContextMenu, allowContextMenu,
   compareNumbers, enableCursor, disableCursor,
-  diffDates
+  diffDates,
+  guid
 } from './util/misc'
 
 export {
@@ -60,7 +60,6 @@ export {
   findDirectChildren,
   htmlToElement,
   htmlToElements,
-  createElement,
   insertAfterElement,
   prependToElement,
   removeElement,
@@ -75,7 +74,8 @@ export {
 export { EventStore, filterEventStoreDefs, createEmptyEventStore, mergeEventStores, getRelevantEvents, eventTupleToStore } from './structs/event-store'
 export { EventUiHash, EventUi, processScopedUiProps, combineEventUis } from './component/event-ui'
 export { default as Splitter, SplittableProps } from './component/event-splitting'
-export { buildGotoAnchorHtml, getAllDayHtml, getDayClasses } from './component/date-rendering'
+export { getDayClasses } from './component/date-rendering'
+export { default as GotoAnchor } from './component/GotoAnchor'
 
 export {
   preventDefault,
@@ -104,7 +104,7 @@ export { default as Theme } from './theme/Theme'
 export { default as ComponentContext } from './component/ComponentContext'
 export { default as DateComponent, Seg, EventSegUiInteractionState } from './component/DateComponent'
 export { default as Calendar, DatePointTransform, DateSpanTransform, DateSelectionApi } from './Calendar'
-export { default as View, ViewProps, renderViewEl } from './View'
+export { default as View, ViewProps, getViewClassNames } from './View'
 export { default as ViewApi } from './ViewApi'
 export { default as FgEventRenderer, buildSegCompareObj, BaseFgEventRendererProps, sortEventSegs } from './component/renderers/FgEventRenderer'
 export { default as FillRenderer, BaseFillRendererProps } from './component/renderers/FillRenderer'
@@ -155,7 +155,8 @@ export { reducerFunc, Action, CalendarState } from './reducers/types'
 export { CalendarComponentProps } from './CalendarComponent'
 
 export { default as DayHeader } from './common/DayHeader'
-export { computeFallbackHeaderFormat, renderDateCell } from './common/table-utils'
+export { computeFallbackHeaderFormat } from './common/table-utils'
+export { default as TableDateCell } from './common/TableDateCell'
 
 export { default as DaySeries } from './common/DaySeriesModel'
 
@@ -172,4 +173,5 @@ export { default as EventApi } from './api/EventApi'
 
 export { default as requestJson } from './util/requestJson'
 
-export { Component, renderer, DomLocation, listRenderer, ListRendererItem } from './view-framework'
+export { subrenderer, SubRenderer, BaseComponent, setRef, renderVNodes } from './view-framework-util'
+export { DelayedRunner } from './util/runner'

+ 2 - 3
packages/core/src/plugin-system.ts

@@ -18,6 +18,7 @@ import { CmdFormatterFunc } from './datelib/formatting-cmd'
 import { RecurringType } from './structs/recurring-event'
 import { NamedTimeZoneImplClass } from './datelib/timezone'
 import { ElementDraggingClass } from './interactions/ElementDragging'
+import { guid } from './util/misc'
 
 // TODO: easier way to add new hooks? need to update a million things
 
@@ -92,11 +93,9 @@ export interface ViewPropsTransformer {
 export type ViewContainerModifier = (contentEl: HTMLElement, calendar: Calendar) => void
 
 
-let uid = 0
-
 export function createPlugin(input: PluginDefInput): PluginDef {
   return {
-    id: String(uid++),
+    id: guid(),
     deps: input.deps || [],
     reducers: input.reducers || [],
     eventDefParsers: input.eventDefParsers || [],

+ 2 - 4
packages/core/src/reducers/eventSources.ts

@@ -4,6 +4,7 @@ import { arrayToHash, filterHash } from '../util/object'
 import { DateRange } from '../datelib/date-range'
 import { DateProfile } from '../DateProfileGenerator'
 import { Action } from './types'
+import { guid } from '../util/misc'
 
 export default function(eventSources: EventSourceHash, action: Action, dateProfile: DateProfile | null, calendar: Calendar): EventSourceHash {
   switch (action.type) {
@@ -48,9 +49,6 @@ export default function(eventSources: EventSourceHash, action: Action, dateProfi
 }
 
 
-let uid = 0
-
-
 function addSources(eventSourceHash: EventSourceHash, sources: EventSource[], fetchRange: DateRange | null, calendar: Calendar): EventSourceHash {
   let hash: EventSourceHash = {}
 
@@ -122,7 +120,7 @@ function fetchSourcesByIds(
 
 function fetchSource(eventSource: EventSource, fetchRange: DateRange, calendar: Calendar) {
   let sourceDef = calendar.pluginSystem.hooks.eventSourceDefs[eventSource.sourceDefId]
-  let fetchId = String(uid++)
+  let fetchId = guid()
 
   sourceDef.fetch(
     {

+ 2 - 3
packages/core/src/structs/event-source.ts

@@ -1,4 +1,4 @@
-import { refineProps } from '../util/misc'
+import { refineProps, guid } from '../util/misc'
 import { EventInput } from './event'
 import Calendar from '../Calendar'
 import { DateRange } from '../datelib/date-range'
@@ -107,7 +107,6 @@ const SIMPLE_SOURCE_PROPS = {
   failure: Function
 }
 
-let uid = 0
 
 export function doesSourceNeedRange(eventSource: EventSource, calendar: Calendar) {
   let defs = calendar.pluginSystem.hooks.eventSourceDefs
@@ -148,7 +147,7 @@ function parseEventSourceProps(raw: ExtendedEventSourceInput, meta: object, sour
   props.latestFetchId = ''
   props.fetchRange = null
   props.publicId = String(raw.id || '')
-  props.sourceId = String(uid++)
+  props.sourceId = guid()
   props.sourceDefId = sourceDefId
   props.meta = meta
   props.ui = ui

+ 4 - 5
packages/core/src/structs/event.ts

@@ -1,4 +1,4 @@
-import { refineProps } from '../util/misc'
+import { refineProps, guid } from '../util/misc'
 import { DateInput } from '../datelib/env'
 import Calendar from '../Calendar'
 import { DateRange } from '../datelib/date-range'
@@ -81,8 +81,6 @@ export const DATE_PROPS = {
   allDay: null
 }
 
-let uid = 0
-
 
 export function parseEvent(raw: EventInput, sourceId: string, calendar: Calendar, allowOpenRange?: boolean): EventTuple | null {
   let allDayDefault = computeIsAllDayDefault(sourceId, calendar)
@@ -131,7 +129,7 @@ export function parseEventDef(raw: EventNonDateInput, sourceId: string, allDay:
   let leftovers = {}
   let def = pluckNonDateProps(raw, calendar, leftovers) as EventDef
 
-  def.defId = String(uid++)
+  def.defId = guid()
   def.sourceId = sourceId
   def.allDay = allDay
   def.hasEnd = hasEnd
@@ -161,7 +159,8 @@ export function createEventInstance(
   forcedEndTzo?: number
 ): EventInstance {
   return {
-    instanceId: String(uid++),
+    instanceId: guid()
+,
     defId,
     range,
     forcedStartTzo: forcedStartTzo == null ? null : forcedStartTzo,

+ 88 - 0
packages/core/src/toolbar-parse.ts

@@ -0,0 +1,88 @@
+import { ViewSpec } from './structs/view-spec'
+import Calendar from './Calendar'
+import Theme from './theme/Theme'
+
+export interface ToolbarModel {
+  left: ToolbarWidget[][] | null
+  center: ToolbarWidget[][] | null
+  right: ToolbarWidget[][] | null
+}
+
+export interface ToolbarWidget {
+  buttonName: string
+  buttonClick?: any
+  buttonIcon?: any
+  buttonText?: any
+}
+
+export function parseToolbars(allOptions, theme: Theme, calendar: Calendar) {
+  let viewsWithButtons: string[] = []
+  let header = allOptions.header ? parseToolbar(allOptions.header, theme, calendar, viewsWithButtons) : null
+  let footer = allOptions.footer ? parseToolbar(allOptions.footer, theme, calendar, viewsWithButtons) : null
+
+  return { header, footer, viewsWithButtons }
+}
+
+function parseToolbar(raw, theme: Theme, calendar: Calendar, viewsWithButtons: string[]): ToolbarModel {
+  return {
+    left: raw.left ? parseSection(raw.left, theme, calendar, viewsWithButtons) : null,
+    center: raw.center ? parseSection(raw.center, theme, calendar, viewsWithButtons) : null,
+    right: raw.right ? parseSection(raw.right, theme, calendar, viewsWithButtons) : null
+  }
+}
+
+function parseSection(sectionStr: string, theme: Theme, calendar: Calendar, viewsWithButtons: string[]): ToolbarWidget[][] {
+  let optionsManager = calendar.optionsManager
+  let viewSpecs = calendar.viewSpecs
+  let calendarCustomButtons = optionsManager.computed.customButtons || {}
+  let calendarButtonTextOverrides = optionsManager.overrides.buttonText || {}
+  let calendarButtonText = optionsManager.computed.buttonText || {}
+
+  return sectionStr.split(' ').map((buttonGroupStr, i): ToolbarWidget[] => {
+    return buttonGroupStr.split(',').map((buttonName, j): ToolbarWidget => {
+
+      if (buttonName === 'title') {
+        return { buttonName }
+
+      } else {
+        let customButtonProps
+        let viewSpec: ViewSpec
+        let buttonClick
+        let buttonIcon // only one of these will be set
+        let buttonText // "
+
+        if ((customButtonProps = calendarCustomButtons[buttonName])) {
+          buttonClick = function(ev: UIEvent) {
+            if (customButtonProps.click) {
+              customButtonProps.click.call(ev.target, ev) // TODO: correct to use `target`?
+            }
+          };
+          (buttonIcon = theme.getCustomButtonIconClass(customButtonProps)) ||
+          (buttonIcon = theme.getIconClass(buttonName)) ||
+          (buttonText = customButtonProps.text)
+
+        } else if ((viewSpec = viewSpecs[buttonName])) {
+          viewsWithButtons.push(buttonName)
+
+          buttonClick = function() {
+            calendar.changeView(buttonName)
+          };
+          (buttonText = viewSpec.buttonTextOverride) ||
+          (buttonIcon = theme.getIconClass(buttonName)) ||
+          (buttonText = viewSpec.buttonTextDefault)
+
+        } else if (calendar[buttonName]) { // a calendar method
+          buttonClick = function() {
+            calendar[buttonName]()
+          };
+          (buttonText = calendarButtonTextOverrides[buttonName]) ||
+          (buttonIcon = theme.getIconClass(buttonName)) ||
+          (buttonText = calendarButtonText[buttonName])
+          //            ^ everything else is considered default
+        }
+
+        return { buttonName, buttonClick, buttonIcon, buttonText }
+      }
+    })
+  })
+}

+ 5 - 0
packages/core/src/util/array.ts

@@ -1,4 +1,9 @@
 
+
+// TODO: new util arrayify?
+// Array.prototype.slice.call(
+
+
 export function removeMatching(array, testFunc) {
   let removeCnt = 0
   let i = 0

+ 17 - 6
packages/core/src/util/dom-event.ts

@@ -10,26 +10,37 @@ export function preventDefault(ev) {
 // Event Delegation
 // ----------------------------------------------------------------------------------------------------------------
 
-export function listenBySelector(
-  container: HTMLElement,
-  eventType: string,
+
+export function buildDelegationHandler(
   selector: string,
   handler: (ev: Event, matchedTarget: HTMLElement) => void
 ) {
-  function realHandler(ev: Event) {
+  return function(ev: Event) {
     let matchedChild = elementClosest(ev.target as HTMLElement, selector)
+
     if (matchedChild) {
       handler.call(matchedChild, ev, matchedChild)
     }
   }
+}
 
-  container.addEventListener(eventType, realHandler)
+
+export function listenBySelector(
+  container: HTMLElement,
+  eventType: string,
+  selector: string,
+  handler: (ev: Event, matchedTarget: HTMLElement) => void
+) {
+  let attachedHandler = buildDelegationHandler(selector, handler)
+
+  container.addEventListener(eventType, attachedHandler)
 
   return function() {
-    container.removeEventListener(eventType, realHandler)
+    container.removeEventListener(eventType, attachedHandler)
   }
 }
 
+
 export function listenToHoverBySelector(
   container: HTMLElement,
   selector: string,

+ 3 - 45
packages/core/src/util/dom-manip.ts

@@ -2,44 +2,9 @@
 // Creating
 // ----------------------------------------------------------------------------------------------------------------
 
-const elementPropHash = { // when props given to createElement should be treated as props, not attributes
-  className: true,
-  colSpan: true,
-  rowSpan: true
-}
-
-const containerTagHash = {
-  '<tr': 'tbody',
-  '<td': 'tr'
-}
-
-export function createElement(tagName: string, attrs: object | null, content?: ElementContent): HTMLElement {
-  let el: HTMLElement = document.createElement(tagName)
-
-  if (attrs) {
-    for (let attrName in attrs) {
-      if (attrName === 'style') {
-        applyStyle(el, attrs[attrName])
-      } else if (elementPropHash[attrName]) {
-        el[attrName] = attrs[attrName]
-      } else {
-        el.setAttribute(attrName, attrs[attrName])
-      }
-    }
-  }
-
-  if (typeof content === 'string') {
-    el.innerHTML = content // shortcut. no need to process HTML in any way
-  } else if (content != null) {
-    appendToElement(el, content)
-  }
-
-  return el
-}
-
 export function htmlToElement(html: string): HTMLElement {
   html = html.trim()
-  let container = document.createElement(computeContainerTag(html))
+  let container = document.createElement('div')
   container.innerHTML = html
   return container.firstChild as HTMLElement
 }
@@ -50,18 +15,11 @@ export function htmlToElements(html: string): HTMLElement[] {
 
 function htmlToNodeList(html: string): NodeList {
   html = html.trim()
-  let container = document.createElement(computeContainerTag(html))
+  let container = document.createElement('div')
   container.innerHTML = html
   return container.childNodes
 }
 
-// assumes html already trimmed and tag names are lowercase
-function computeContainerTag(html: string) {
-  return containerTagHash[
-    html.substr(0, 3) // faster than using regex
-  ] || 'div'
-}
-
 
 // Inserting / Removing
 // ----------------------------------------------------------------------------------------------------------------
@@ -187,7 +145,7 @@ export function findDirectChildren(parent: HTMLElement[] | HTMLElement, selector
 // Attributes
 // ----------------------------------------------------------------------------------------------------------------
 
-export function forceClassName(el: HTMLElement, className: string, bool) { // might not be used anywhere
+export function forceClassName(el: HTMLElement, className: string, bool) { // instead of classList.toggle, which IE doesn't support
   if (bool) {
     el.classList.add(className)
   } else {

+ 7 - 35
packages/core/src/util/misc.ts

@@ -7,6 +7,13 @@ import { DateEnv } from '../datelib/env'
 import { DateRange, OpenDateRange } from '../datelib/date-range'
 
 
+let guidNumber = 0
+
+export function guid() {
+  return String(guidNumber++)
+}
+
+
 /* FullCalendar-specific DOM Utilities
 ----------------------------------------------------------------------------------------------------------------------*/
 
@@ -331,41 +338,6 @@ export function firstDefined(...args) {
 }
 
 
-// Returns a function, that, as long as it continues to be invoked, will not
-// be triggered. The function will be called after it stops being called for
-// N milliseconds.
-// leading edge, instead of the trailing.
-// https://github.com/jashkenas/underscore/blob/1.6.0/underscore.js#L714
-export function debounce(func, wait) {
-  let timeout
-  let args
-  let context
-  let timestamp
-  let result
-
-  let later = function() {
-    let last = new Date().valueOf() - timestamp
-    if (last < wait) {
-      timeout = setTimeout(later, wait - last)
-    } else {
-      timeout = null
-      result = func.apply(context, args)
-      context = args = null
-    }
-  }
-
-  return function() {
-    context = this
-    args = arguments
-    timestamp = new Date().valueOf()
-    if (!timeout) {
-      timeout = setTimeout(later, wait)
-    }
-    return result
-  }
-}
-
-
 /* Object Parsing
 ----------------------------------------------------------------------------------------------------------------------*/
 

+ 51 - 15
packages/core/src/util/runner.ts

@@ -1,5 +1,4 @@
 
-
 export class DelayedRunner {
 
   private isDirty: boolean = false
@@ -13,23 +12,45 @@ export class DelayedRunner {
 
   request(delay?: number) {
     this.isDirty = true
-    this.clearTimeout()
 
-    if (delay == null) {
-      this.tryDrain()
-    } else {
-      this.timeoutId = setTimeout(this.tryDrain.bind(this), delay) as unknown as number // NOT OPTIMAL! TODO: look at debounce
+    if (!this.pauseDepth) {
+      this.clearTimeout()
+
+      if (delay == null) {
+        this.drain()
+      } else {
+        this.timeoutId = setTimeout(this.drain.bind(this), delay) as unknown as number // NOT OPTIMAL! TODO: look at debounce
+      }
     }
   }
 
   pause() {
-    this.clearTimeout()
-    this.pauseDepth++
+    this.setPauseDepth(1)
   }
 
   resume() {
-    this.pauseDepth--
-    this.tryDrain()
+    this.setPauseDepth(0)
+  }
+
+  whilePaused(func) {
+    this.setPauseDepth(this.pauseDepth + 1)
+    func()
+    this.setPauseDepth(this.pauseDepth - 1)
+  }
+
+  private setPauseDepth(depth: number) {
+    let oldDepth = this.pauseDepth
+    this.pauseDepth = depth // for this.drain() call
+
+    if (depth) { // wants to pause
+      if (!oldDepth) {
+        this.clearTimeout()
+      }
+    } else { // wants to unpause
+      if (oldDepth) {
+        this.drain()
+      }
+    }
   }
 
   private clearTimeout() {
@@ -39,8 +60,8 @@ export class DelayedRunner {
     }
   }
 
-  private tryDrain() {
-    if (!this.pauseDepth && this.isDirty) {
+  drain() {
+    if (this.isDirty && !this.pauseDepth) {
       this.isDirty = false
       this.drained()
     }
@@ -52,12 +73,18 @@ export class DelayedRunner {
     }
   }
 
+  clear() {
+    this.pause()
+    this.isDirty = false
+  }
+
 }
 
 
 export class TaskRunner<Task> {
 
   private isRunning = false
+  private isPaused = false
   private queue: Task[] = []
   private delayedRunner: DelayedRunner
 
@@ -65,7 +92,7 @@ export class TaskRunner<Task> {
     private runTaskOption?: (task: Task) => void,
     private drainedOption?: (completedTasks: Task[]) => void
   ) {
-    this.delayedRunner = new DelayedRunner(this.tryDrain.bind(this))
+    this.delayedRunner = new DelayedRunner(this.drain.bind(this))
   }
 
   request(task: Task, delay?: number) {
@@ -73,10 +100,10 @@ export class TaskRunner<Task> {
     this.delayedRunner.request(delay)
   }
 
-  private tryDrain() {
+  drain() {
     let { queue } = this
 
-    if (!this.isRunning && queue.length) {
+    if (!this.isRunning && !this.isPaused && queue.length) {
       this.isRunning = true
 
       let completedTasks: Task[] = []
@@ -92,6 +119,15 @@ export class TaskRunner<Task> {
     }
   }
 
+  pause() {
+    this.isPaused = true
+  }
+
+  resume() {
+    this.isPaused = false
+    this.drain()
+  }
+
   protected runTask(task: Task) {
     if (this.runTaskOption) {
       this.runTaskOption(task)

+ 12 - 12
packages/core/src/util/scrollbars.ts

@@ -1,4 +1,4 @@
-import { createElement, removeElement } from './dom-manip'
+import { removeElement, applyStyle } from './dom-manip'
 
 
 // Logic for determining if, when the element is right-to-left, the scrollbar appears on the left side
@@ -13,17 +13,17 @@ export function getIsRtlScrollbarOnLeft() { // responsible for caching the compu
 }
 
 function computeIsRtlScrollbarOnLeft() { // creates an offscreen test element, then removes it
-  let outerEl = createElement('div', {
-    style: {
-      position: 'absolute',
-      top: -1000,
-      left: 0,
-      border: 0,
-      padding: 0,
-      overflow: 'scroll',
-      direction: 'rtl'
-    }
-  }, '<div></div>')
+  let outerEl = document.createElement('div')
+  applyStyle(outerEl, {
+    position: 'absolute',
+    top: -1000,
+    left: 0,
+    border: 0,
+    padding: 0,
+    overflow: 'scroll',
+    direction: 'rtl'
+  })
+  outerEl.innerHTML = '<div></div>'
 
   document.body.appendChild(outerEl)
   let innerEl = outerEl.firstChild as HTMLElement

+ 266 - 0
packages/core/src/view-framework-util.tsx

@@ -0,0 +1,266 @@
+import { Component, h, Fragment, Ref, ComponentChildren, render } from 'preact'
+import ComponentContext, { ComponentContextType } from './component/ComponentContext'
+import { __assign } from 'tslib'
+
+
+export type EqualityFuncs<ObjType> = {
+  [K in keyof ObjType]?: (a: ObjType[K], b: ObjType[K]) => boolean
+}
+
+interface SubRendererOwner {
+  context: ComponentContext
+  subrendererDestroys: (() => void)[]
+}
+
+
+export abstract class BaseComponent<Props={}, State={}> extends Component<Props, State> implements SubRendererOwner {
+
+  static addPropsEquality = addPropsEquality
+  static addStateEquality = addStateEquality
+  static contextType = ComponentContextType
+
+  context: ComponentContext
+  propEquality: EqualityFuncs<Props>
+  stateEquality: EqualityFuncs<State>
+  subrendererDestroys: (() => void)[] = []
+
+  abstract render(props: Props, state: State, context: ComponentContext) // why aren't arg types being enforced!?
+
+  shouldComponentUpdate(nextProps: Props, nextState: State, nextContext: ComponentContext) {
+    return !compareObjs(this.props, nextProps, this.propEquality) ||
+      !compareObjs(this.state, nextState, this.stateEquality) ||
+      !compareObjs(this.context, nextContext)
+  }
+
+  subrenderDestroy: typeof subrenderDestroy
+
+}
+
+BaseComponent.prototype.propEquality = {}
+BaseComponent.prototype.stateEquality = {}
+BaseComponent.prototype.subrenderDestroy = subrenderDestroy
+
+
+export abstract class SubRenderer<Props={}, RenderRes=void> implements SubRendererOwner {
+
+  static addPropsEquality = addPropsEquality
+
+  propEquality: EqualityFuncs<Props>
+  subrendererDestroys: (() => void)[] = []
+
+  constructor(
+    public props: Props,
+    public context: ComponentContext
+  ) {
+  }
+
+  abstract render(props: Props, context: ComponentContext): RenderRes
+
+  unrender(renderRes: RenderRes, context: ComponentContext) {
+  }
+
+  subrenderDestroy: typeof subrenderDestroy
+
+  willDestroy() {
+    this.subrenderDestroy()
+  }
+
+}
+
+SubRenderer.prototype.propEquality = {}
+SubRenderer.prototype.subrenderDestroy = subrenderDestroy
+
+
+export type SubRendererClass<SubRendererType> = (
+  new(
+    props: SubRendererType extends SubRenderer<infer Props> ? Props : never,
+    context: ComponentContext
+  ) => SubRendererType
+) & {
+  prototype: {
+    render(
+      props: SubRendererType extends SubRenderer<infer Props> ? Props : never,
+      context: ComponentContext
+    )
+  }
+}
+
+
+function addPropsEquality(this: { prototype: { propEquality: any } }, propEquality) {
+  let hash = Object.create(this.prototype.propEquality)
+  __assign(hash, propEquality)
+  this.prototype.propEquality = hash
+}
+
+
+function addStateEquality(this: { prototype: { stateEquality: any } }, stateEquality) {
+  let hash = Object.create(this.prototype.stateEquality)
+  __assign(hash, stateEquality)
+  this.prototype.stateEquality = hash
+}
+
+
+function subrenderDestroy(this: SubRendererOwner) {
+  for (let destroy of this.subrendererDestroys) {
+    destroy()
+  }
+  this.subrendererDestroys = []
+}
+
+
+export function subrenderer<SubRendererType>(subRendererClass: SubRendererClass<SubRendererType>): ((
+  props: (SubRendererType extends SubRenderer<infer Props> ? Props : never) | false,
+  context?: ComponentContext
+) => SubRendererType)
+export function subrenderer<FuncProps, RenderRes>(
+  renderFunc: (funcProps: FuncProps, context?: ComponentContext) => RenderRes,
+  unrenderFunc?: (funcState: RenderRes, context?: ComponentContext) => void
+): ((
+  funcProps: FuncProps | false,
+  context?: ComponentContext
+) => RenderRes)
+export function subrenderer(worker, unrender?) {
+  if (worker.prototype.render) {
+    return buildClassSubRenderer(worker)
+  } else {
+    return buildFuncSubRenderer(worker, unrender)
+  }
+}
+
+
+function buildClassSubRenderer(subRendererClass: SubRendererClass<any>) {
+  let instance: SubRenderer
+  let renderRes
+
+  function destroy() {
+    if (instance) {
+      instance.unrender(renderRes, instance.context)
+      instance.willDestroy()
+      instance = null
+    }
+  }
+
+  return function(this: SubRendererOwner, props: any) {
+    let context = this.context
+
+    if (!props) {
+      destroy()
+
+    } else if (!instance) {
+      instance = new subRendererClass(props, context) // will set internal props/context
+      renderRes = instance.render(props, context)
+      this.subrendererDestroys.push(destroy)
+
+    } else if (
+      !compareObjs(props, instance.props, instance.propEquality) ||
+      !compareObjs(context, instance.context)
+    ) {
+      instance.unrender(renderRes, context)
+      instance.props = props
+      instance.context = context
+      renderRes = instance.render(props, context)
+    }
+
+    return instance
+  }
+}
+
+
+function buildFuncSubRenderer(renderFunc, unrenderFunc) {
+  let thisContext
+  let currentProps
+  let currentContext
+  let renderRes
+
+  function destroy() {
+    if (currentProps) {
+      unrenderFunc && unrenderFunc.call(thisContext, renderRes, currentContext)
+      currentProps = null
+      currentContext = null
+      renderRes = null
+    }
+  }
+
+  return function(this: SubRendererOwner, props: any) {
+    thisContext = this
+    let context = thisContext.context
+
+    if (!props) {
+      destroy()
+
+    } else {
+
+      if (!currentProps) {
+        renderRes = renderFunc.call(thisContext, props, context)
+        this.subrendererDestroys.push(destroy)
+
+      } else if (
+        !compareObjs(props, currentProps) || (
+          renderFunc.length > 1 && // has second arg? cares about context?
+          !compareObjs(context, currentContext)
+        )
+      ) {
+        unrenderFunc && unrenderFunc.call(thisContext, renderRes, context)
+        renderRes = renderFunc.call(thisContext, props, context)
+      }
+
+      currentProps = props
+      currentContext = context
+    }
+
+    return renderRes
+  }
+}
+
+
+function compareObjs(oldProps, newProps, equalityFuncs: EqualityFuncs<any> = {}) {
+
+  if (oldProps === newProps) {
+    return true
+  }
+
+  for (let key in newProps) {
+    if (
+      key in oldProps && (
+        oldProps[key] === newProps[key] ||
+        (equalityFuncs[key] && equalityFuncs[key](oldProps[key], newProps[key]))
+      )
+    ) {
+      ; // equal
+    } else {
+      return false
+    }
+  }
+
+  // check for props that were omitted in the new
+  for (let key in oldProps) {
+    if (!(key in newProps)) {
+      return false
+    }
+  }
+
+  return true
+}
+
+
+export function setRef<RefType>(ref: Ref<RefType> | void, current: RefType) {
+  if (typeof ref === 'function') {
+    ref(current)
+  } else if (ref) {
+    ref.current = current
+  }
+}
+
+
+export function renderVNodes(children: ComponentChildren, context: ComponentContext): Node[] {
+  let containerEl = document.createElement('div')
+
+  render(
+    <ComponentContextType.Provider value={context}>
+      <Fragment>{children}</Fragment>
+    </ComponentContextType.Provider>,
+    containerEl
+  )
+
+  return Array.prototype.slice.call(containerEl.childNodes)
+}

+ 0 - 680
packages/core/src/view-framework.ts

@@ -1,680 +0,0 @@
-import { removeElement } from './util/dom-manip'
-import { isArraysEqual, removeMatching } from './util/array'
-import { isPropsEqual, filterHash } from './util/object'
-import { __assign } from 'tslib'
-
-let guid = 0
-
-
-// TODO: accept no args to render method. if no args, never rerenders!!!!
-// htmlToNodes
-// htmlToEl
-// PRO over preact: guaranteed to update children first
-// document this.state/props
-// TODO: id should be optional. only warn on collision
-
-// top-level renderer
-// ----------------------------------------------------------------------------------------------------
-
-
-export type DomRenderResult =
-  Node |
-  Node[] |
-  { rootEl: Node } |  // like a Component
-  { rootEls: Node[] } // "
-
-export type LocationAndProps<RenderRes, Props> = (RenderRes extends DomRenderResult ? Partial<DomLocation> : {}) & Props
-
-
-export function renderer<ComponentType>(componentClass: ComponentClass<ComponentType>): ((
-  inputProps: (ComponentType extends Component<infer Props, infer Context, infer State, infer RenderRes> ? LocationAndProps<RenderRes, Props> : never) | false,
-  context?: ComponentType extends Component<infer Props, infer Context> ? Context : never
-) => ComponentType) & {
-  current: ComponentType | null
-}
-export function renderer<FuncProps, Context, FuncState>(
-  renderFunc: (funcProps: FuncProps, context?: Context) => FuncState,
-  unrenderFunc?: (funcState: FuncState, context?: Context) => void
-): ((
-  funcProps: LocationAndProps<FuncState, FuncProps> | false,
-  context?: Context
-) => FuncState) & {
-  current: FuncState | null
-}
-export function renderer(worker: any, unrenderFunc?: any) {
-  if (worker.prototype) { // a class
-    return componentRenderer(worker)
-  } else {
-    return funcRenderer(worker, unrenderFunc)
-  }
-}
-
-
-// function renderer
-// ----------------------------------------------------------------------------------------------------
-
-
-type FuncRenderer =
-  ((funcProps: any, context?: any) => any) &
-  { current: any | null }
-
-
-function funcRenderer(
-  renderFunc: (funcProps: any, context?: any) => any,
-  unrenderFunc?: (funcState: any, context?: any) => void
-): FuncRenderer {
-  let currentProps // used as a flag for ever-rendered
-  let currentContext
-  let currentState
-  let currentLocation
-  let currentRootEls = []
-
-  function render(location, props, context) {
-    let newRootEls
-
-    if (!currentProps) { // first time?
-      currentState = renderFunc(currentProps, currentContext)
-      newRootEls = normalizeRenderEls(currentState)
-
-    } else if ( // any changes?
-      !isPropsEqual(currentProps, props) ||
-      renderFunc.length > 1 && !isPropsEqual(currentContext, context)
-    ) {
-      if (unrenderFunc) {
-        unrenderFunc(currentState, context)
-      }
-
-      currentState = renderFunc(currentProps, currentContext)
-      update.current = currentState
-      newRootEls = normalizeRenderEls(currentState)
-    }
-
-    if (newRootEls && !isArraysEqual(newRootEls, currentRootEls)) {
-      currentRootEls.forEach(removeElement)
-      insertNodesAtLocation(newRootEls, location)
-
-    } else if (!isPropsEqual(location, currentLocation)) {
-      insertNodesAtLocation(currentRootEls, location)
-    }
-
-    currentProps = props
-    currentContext = context
-    currentLocation = location
-    currentRootEls = newRootEls
-  }
-
-  function unrender() {
-    if (currentProps && unrenderFunc) {
-      unrenderFunc(currentState, currentContext)
-    }
-
-    currentProps = null
-    currentContext = null
-    currentState = null
-    currentLocation = null
-    currentRootEls = []
-    update.current = null
-  }
-
-  let update = function(this: Component<any> | any, propsAndLocation: any, contextOverride?: any) {
-    handleUpdate(this, propsAndLocation, contextOverride, render, unrender)
-    return currentState
-  } as ComponentRenderer
-
-  return update
-}
-
-
-// component renderer
-// ----------------------------------------------------------------------------------------------------
-
-
-type ComponentRenderer =
-  ((propsAndLocation: any, context?: any) => any) &
-  { current: Component<any> | null }
-
-
-function componentRenderer(componentClass: ComponentClass<any>): ComponentRenderer {
-  let renderEngine: RenderEngine
-  let component: Component<any> | null = null
-
-  function render(location, props, context, isTopLevel) {
-    if (!renderEngine) {
-      renderEngine = isTopLevel ? new RenderEngine() : this.renderEngine
-    }
-
-    if (!component) {
-      component = update.current = new componentClass(props, context)
-      component.renderEngine = renderEngine
-    }
-
-    renderEngine.updateComponentExternal(
-      component,
-      location,
-      props,
-      context
-    )
-  }
-
-  function unrender() {
-    if (component) {
-      renderEngine.unmountComponent(component)
-      update.current = null
-      component = null
-    }
-  }
-
-  let update = function(this: Component<any> | any, propsAndLocation: any, contextOverride?: any) {
-    handleUpdate(this, propsAndLocation, contextOverride, render, unrender)
-    return component
-  } as ComponentRenderer
-
-  return update
-}
-
-
-// component class
-// ----------------------------------------------------------------------------------------------------
-
-
-export type PropEqualityFuncs<ComponentType> = ComponentType extends Component<infer Props> ? EqualityFuncs<Props> : never
-export type StateEqualityFuncs<ComponentType> = ComponentType extends Component<infer Props, infer State> ? EqualityFuncs<State> : never
-export type EqualityFuncs<ObjType> = {
-  [K in keyof ObjType]?: (a: ObjType[K], b: ObjType[K]) => boolean
-}
-
-
-export abstract class Component<Props, Context={}, State={}, RenderResult=void, Snapshot={}> {
-
-  propEquality: EqualityFuncs<Props>
-  stateEquality: EqualityFuncs<State>
-  renderEngine: RenderEngine
-  childUnmounts: (() => void)[] = []
-
-  uid = String(guid++) // not used internally here. but other places can use it
-  isMounted = false
-  location: Partial<DomLocation> = {}
-  rootEls: Node[] = [] // TODO: rename to rootNodes?
-  rootEl: HTMLElement | null = null // TODO: rename to rootNode?
-  state: State = {} as State
-
-  constructor(
-    public props: Props,
-    public context: Context
-  ) {
-  }
-
-  abstract render(props: Props, context: Context, state: State): RenderResult
-
-  unrender() {}
-
-  setState(stateUpdates: Partial<State>) {
-    this.renderEngine.requestUpdateComponentInternal(this, stateUpdates)
-  }
-
-  componentDidMount() {
-  }
-
-  shouldComponentUpdate(nextProps: Props, nextState: State, nextContext: Context) {
-    return true
-  }
-
-  getSnapshotBeforeUpdate(prevProps: Props, prevState: State, prevContext: Context) {
-    return {} as Snapshot
-  }
-
-  componentDidUpdate(prevProps: Props, prevState: State, snapshot: Snapshot) {
-  }
-
-  componentWillUnmount() {
-  }
-
-  static addPropEquality<ComponentType>(this: ComponentClass<ComponentType>, propEquality: PropEqualityFuncs<ComponentType>) {
-    let hash = Object.create(this.prototype.propEquality)
-    __assign(hash, propEquality)
-    this.prototype.propEquality = hash
-  }
-
-  static addStateEquality<ComponentType>(this: ComponentClass<ComponentType>, stateEquality: StateEqualityFuncs<ComponentType>) {
-    let hash = Object.create(this.prototype.stateEquality)
-    __assign(hash, stateEquality)
-    this.prototype.stateEquality = hash
-  }
-
-}
-
-Component.prototype.propEquality = {}
-Component.prototype.stateEquality = {}
-
-
-export type ComponentClass<ComponentType> = (
-  new(
-    props: ComponentType extends Component<infer Props> ? Props : never,
-    context: ComponentType extends Component<infer Props, infer Context> ? Context : never,
-  ) => ComponentType
-) & {
-  prototype: {
-    render(
-      props: ComponentType extends Component<infer Props> ? Props : never,
-      context: ComponentType extends Component<infer Props, infer Context> ? Context : never,
-      state: ComponentType extends Component<infer Props, infer Context, infer State> ? State : never
-    )
-  }
-}
-
-
-// component rendering engine
-// ----------------------------------------------------------------------------------------------------
-
-
-interface StateUpdate {
-  component: Component<any>
-  updates: any
-}
-
-interface AfterRender {
-  component: Component<any>
-  prevProps?: any
-  prevState?: any
-  prevContext?: any
-  snapshot?: any
-}
-
-
-class RenderEngine {
-
-  externalUpdateDepth = 0
-  stateUpdates: StateUpdate[] = []
-  afterRenders: AfterRender[] = []
-
-
-  updateComponentExternal(component: Component<any>, location: Partial<DomLocation>, props: any, context: any) {
-    this.externalUpdateDepth++
-
-    let { isMounted } = component
-    let prevProps = component.props
-    let prevContext = component.context
-
-    let massagedProps = isMounted ? recycleProps(prevProps, props, false, component.propEquality) : prevProps
-    let massagedContext = isMounted ? recycleProps(prevContext, context, false, {}) : prevContext
-
-    if (massagedProps || massagedContext) {
-      this.updateComponent(
-        component,
-        location,
-        massagedProps || prevProps,
-        massagedContext || prevContext,
-        component.state
-      )
-    } else if (location.parentEl && !isPropsEqual(component.location, location)) {
-      this.afterRenders.push(
-        relocateComponent(component, location as DomLocation)
-      )
-    }
-
-    this.externalUpdateDepth--
-    this.drain()
-  }
-
-
-  requestUpdateComponentInternal(component: Component<any>, stateUpdates: any) {
-    this.stateUpdates.push({ component, updates: stateUpdates })
-    this.drain()
-  }
-
-
-  unmountComponent(component: Component<any>) {
-    unmountComponent(component)
-
-    removeFromComponentQueue(this.stateUpdates, component)
-    removeFromComponentQueue(this.afterRenders, component)
-  }
-
-
-  private drain() {
-    if (!this.externalUpdateDepth) {
-      while (
-        drainQueue(this.stateUpdates, this.runStateUpdate) ||
-        drainQueue(this.afterRenders, this.runAfterRender)
-      ) {
-      }
-    }
-  }
-
-
-  private runStateUpdate = (task: StateUpdate) => {
-    let { component, updates } = task
-    let massagedState = recycleProps(component.state, updates, true, component.stateEquality) // additions=true
-
-    if (massagedState) {
-      this.updateComponent(component, component.location, component.props, component.context, massagedState)
-    }
-  }
-
-
-  private runAfterRender = (task: AfterRender) => {
-    let { component, prevProps, prevState, snapshot } = task
-
-    if (prevProps) {
-      component.componentDidUpdate(prevProps, prevState, snapshot)
-    } else {
-      component.componentDidMount()
-    }
-  }
-
-
-  private updateComponent(component: Component<any>, location: any, nextProps: any, nextContext: any, nextState: any) {
-    if (component.shouldComponentUpdate(nextProps, nextState, nextContext)) {
-      this.afterRenders.push(
-        updateComponent(component, location, nextProps, nextContext, nextState)
-      )
-    }
-  }
-
-}
-
-
-// component lifecycle executors
-// ----------------------------------------------------------------------------------------------------
-
-
-function updateComponent(component: Component<any>, location: Partial<DomLocation>, nextProps: any, nextContext: any, nextState: any): AfterRender {
-  component.childUnmounts = []
-
-  if (!component.isMounted) {
-
-    // component already has props/context from constructor
-    runRender(component, location, nextProps, nextContext, nextState)
-    component.isMounted = true
-
-    return { component }
-
-  } else {
-    let prevProps = component.props
-    let prevContext = component.context
-    let prevState = component.state
-    let snapshot = component.getSnapshotBeforeUpdate(prevProps, prevState, prevContext) || {}
-
-    component.unrender()
-    component.props = nextProps
-    component.context = nextContext
-    component.state = nextState
-    runRender(component, location, nextProps, nextContext, nextState)
-
-    return { component, prevProps, prevState, prevContext, snapshot }
-  }
-}
-
-
-function runRender(component: Component<any>, location: Partial<DomLocation>, nextProps: any, nextContext: any, nextState: any) {
-  let renderRes = component.render(nextProps, nextContext, nextState)
-  let rootEls = normalizeRenderEls(renderRes)
-
-  if (
-    !isArraysEqual(rootEls, component.rootEls) ||
-    !isPropsEqual(location, component.location)
-  ) {
-    component.rootEls.forEach(removeElement)
-
-    if (location.parentEl) {
-      insertNodesAtLocation(rootEls, location as DomLocation)
-    }
-
-    component.location = location
-    component.rootEls = rootEls
-    component.rootEl = rootEls[0] as HTMLElement || null
-  }
-}
-
-
-function relocateComponent(component: Component<any>, location: DomLocation): AfterRender {
-  let prevProps = component.props
-  let prevContext = component.context
-  let prevState = component.state
-  let snapshot = component.getSnapshotBeforeUpdate(prevProps, prevState, prevContext) || {}
-
-  insertNodesAtLocation(component.rootEls, location) // dont need to remove first
-
-  component.location = location
-
-  return { component, prevProps, prevState, prevContext, snapshot }
-}
-
-
-function unmountComponent(component: Component<any>) {
-  component.unrender()
-  component.componentWillUnmount()
-
-  let { childUnmounts } = component
-  for (let i = childUnmounts.length - 1; i >= 0; i--) {
-    childUnmounts[i]()
-  }
-
-  component.rootEls.forEach(removeElement)
-  component.rootEls = null
-  component.rootEl = null
-}
-
-
-// function/component rendering helpers
-// ----------------------------------------------------------------------------------------------------
-
-
-const DOM_LOCATION_KEYS: { [P in keyof DomLocation]-?: true } = {
-  parentEl: true,
-  previousSibling: true,
-  nextSibling: true,
-  prepend: true
-}
-
-
-function handleUpdate(caller, propsAndLocation, contextOverride, update, unmount) {
-  let isTopLevel = !caller.renderEngine // TODO: naming collision for caller?
-
-  if (!propsAndLocation) {
-    unmount()
-
-  } else {
-    let location = whitelistProps(propsAndLocation, DOM_LOCATION_KEYS)
-
-    if (('parentEl' in location) && location.parentEl == null) {
-      unmount()
-
-    } else {
-      let props = blacklistProps(propsAndLocation, DOM_LOCATION_KEYS)
-
-      update(location, props, contextOverride || (isTopLevel ? {} : caller.context), isTopLevel)
-
-      if (!isTopLevel) {
-        ;(caller as Component<any>).childUnmounts.push(unmount)
-      }
-    }
-  }
-}
-
-
-function normalizeRenderEls(input: any): Node[] {
-  if (!input) {
-    return []
-
-  } else if (Array.isArray(input)) {
-    return input.filter(function(item) {
-      return item instanceof Node
-    })
-
-  } else if (input.rootEls) {
-    return input.rootEls as Node[]
-
-  } else if (input.rootEl) {
-    return [ input.rootEl as Node ]
-
-  } else if (input instanceof Node) {
-    return [ input ]
-  }
-}
-
-
-// list rendering (TODO)
-// ----------------------------------------------------------------------------------------------------
-
-
-export interface ListRendererItem<ComponentType> {
-  id: string
-  componentClass: ComponentClass<ComponentType>
-  props: ComponentType extends Component<infer Props> ? Omit<Props, keyof DomLocation> : never
-}
-
-
-export function listRenderer(): (location: DomLocation, inputs: ListRendererItem<any>[], contextOverride?: any) => Component<any>[] {
-  let theRenderer
-  let currentComponentClass
-  let currentComponent
-
-  // STUB. works for one element
-  return function(location: DomLocation, inputs: ListRendererItem<any>[], contextOverride?: any): Component<any>[] {
-    let input = inputs[0]
-
-    if (!location || !location.parentEl) {
-      if (theRenderer) {
-        theRenderer.call(this, false)
-        theRenderer = null
-        currentComponentClass = null
-        currentComponent = null
-      }
-
-    } else {
-      if (input.componentClass !== currentComponentClass) {
-        if (theRenderer) {
-          theRenderer.call(this, false)
-          theRenderer = null
-          currentComponentClass = null
-          currentComponent = null
-        }
-      }
-
-      if (!theRenderer) {
-        currentComponentClass = input.componentClass
-        theRenderer = renderer(currentComponentClass)
-      }
-
-      currentComponent = theRenderer.call(this, { ...location, ...input.props }, contextOverride)
-    }
-
-    return currentComponent ? [ currentComponent ] : []
-  }
-}
-
-
-// queue
-// ----------------------------------------------------------------------------------------------------
-
-
-function removeFromComponentQueue(queue: { component: Component<any> }[], component: Component<any>) {
-  return removeMatching(queue, function(task) {
-    return task.component === component
-  })
-}
-
-
-function drainQueue(queue: any[], runnerFunc) {
-  let completedCnt = 0
-  let task
-
-  while (task = queue.shift()) {
-    runnerFunc(task)
-    completedCnt++
-  }
-
-  return completedCnt
-}
-
-
-// dom util
-// ----------------------------------------------------------------------------------------------------
-
-
-export interface DomLocation {
-  parentEl: HTMLElement
-  previousSibling?: Node
-  nextSibling?: Node
-  prepend?: boolean
-}
-
-
-export function insertNodesAtLocation(nodes: Node[], location: DomLocation) {
-  let { parentEl, previousSibling, nextSibling } = location
-
-  if (location.prepend) {
-    nextSibling = parentEl.firstChild as HTMLElement
-
-  } else if (previousSibling) {
-    nextSibling = previousSibling.nextSibling
-
-  } else if (!nextSibling) {
-    nextSibling = null // important for insertBefore
-  }
-
-  for (let node of nodes) {
-    parentEl.insertBefore(node, nextSibling)
-  }
-}
-
-
-// object util
-// ----------------------------------------------------------------------------------------------------
-
-
-function whitelistProps<ObjType>(props: ObjType, whitelist): Partial<ObjType> {
-  return filterHash(props, function(val, key) { // TODO: give typings
-    return whitelist[key]
-  })
-}
-
-
-function blacklistProps<ObjType>(props: ObjType, blacklist): Partial<ObjType> {
-  return filterHash(props, function(val, key) { // TODO: give typings
-    return !blacklist[key]
-  })
-}
-
-
-function recycleProps(oldProps, newProps, isReset: boolean, equalityFuncs: EqualityFuncs<any>) {
-  let comboProps = {} as any // some old, some new
-  let anyChanges = false
-
-  if (isReset && oldProps === newProps) {
-    return null
-  }
-
-  for (let key in newProps) {
-    if (
-      key in oldProps && (
-        oldProps[key] === newProps[key] ||
-        (equalityFuncs[key] && equalityFuncs[key](oldProps[key], newProps[key]))
-      )
-    ) {
-      // equal to old? use old prop
-      comboProps[key] = oldProps[key]
-    } else {
-      comboProps[key] = newProps[key]
-      anyChanges = true
-    }
-  }
-
-  // of new object is resetting the old object,
-  // check for props that were omitted in the new
-  if (isReset) {
-    for (let key in oldProps) {
-      if (!(key in newProps)) {
-        anyChanges = true
-        break
-      }
-    }
-  }
-
-  if (anyChanges) {
-    return comboProps
-  }
-
-  return null
-}

+ 0 - 68
packages/daygrid/src/DayBgRow.ts

@@ -1,68 +0,0 @@
-import {
-  ComponentContext,
-  DateMarker,
-  getDayClasses,
-  rangeContainsMarker,
-  DateProfile
-} from '@fullcalendar/core'
-
-export interface DayBgCell {
-  date: DateMarker
-  htmlAttrs?: string
-}
-
-export interface DayBgRowProps {
-  cells: DayBgCell[]
-  dateProfile: DateProfile
-  renderIntroHtml?: () => string
-}
-
-/*
-not a real component! was easier this way
-*/
-export function renderDayBgRowHtml(props: DayBgRowProps, context: ComponentContext) {
-  let parts = []
-
-  if (props.renderIntroHtml) {
-    parts.push(props.renderIntroHtml())
-  }
-
-  for (let cell of props.cells) {
-    parts.push(
-      renderCellHtml(
-        cell.date,
-        props.dateProfile,
-        context,
-        cell.htmlAttrs
-      )
-    )
-  }
-
-  if (!props.cells.length) {
-    parts.push('<td class="fc-day ' + context.theme.getClass('widgetContent') + '"></td>')
-  }
-
-  if (context.options.dir === 'rtl') {
-    parts.reverse()
-  }
-
-  return '<tr>' + parts.join('') + '</tr>'
-}
-
-
-function renderCellHtml(date: DateMarker, dateProfile: DateProfile, context: ComponentContext, otherAttrs?) {
-  let { dateEnv, theme } = context
-  let isDateValid = rangeContainsMarker(dateProfile.activeRange, date) // TODO: called too frequently. cache somehow.
-  let classes = getDayClasses(date, dateProfile, context)
-
-  classes.unshift('fc-day', theme.getClass('widgetContent'))
-
-  return '<td class="' + classes.join(' ') + '"' +
-    (isDateValid ?
-      ' data-date="' + dateEnv.formatIso(date, { omitTime: true }) + '"' :
-      '') +
-    (otherAttrs ?
-      ' ' + otherAttrs :
-      '') +
-    '></td>'
-}

+ 75 - 0
packages/daygrid/src/DayBgRow.tsx

@@ -0,0 +1,75 @@
+import {
+  ComponentContext,
+  DateMarker,
+  getDayClasses,
+  rangeContainsMarker,
+  DateProfile,
+  BaseComponent
+} from '@fullcalendar/core'
+import { h, VNode } from 'preact'
+
+export interface DayBgCell {
+  date: DateMarker
+  htmlAttrs?: object
+}
+
+export interface DayBgRowProps {
+  cells: DayBgCell[]
+  dateProfile: DateProfile
+  renderIntro?: () => VNode[]
+}
+
+
+export default class DayBgRow extends BaseComponent<DayBgRowProps> {
+
+  render(props: DayBgRowProps, state: {}, context: ComponentContext) {
+    let parts: VNode[] = []
+
+    if (props.renderIntro) {
+      parts.push(...props.renderIntro())
+    }
+
+    for (let cell of props.cells) {
+      parts.push(
+        renderCell(
+          cell.date,
+          props.dateProfile,
+          context,
+          cell.htmlAttrs
+        )
+      )
+    }
+
+    if (!props.cells.length) {
+      parts.push(
+        <td class={'fc-day ' + context.theme.getClass('widgetContent')}></td>
+      )
+    }
+
+    if (context.options.dir === 'rtl') {
+      parts.reverse()
+    }
+
+    return (
+      <tr>{parts}</tr>
+    )
+  }
+
+}
+
+
+function renderCell(date: DateMarker, dateProfile: DateProfile, context: ComponentContext, otherAttrs?: object) {
+  let { dateEnv, theme } = context
+  let isDateValid = rangeContainsMarker(dateProfile.activeRange, date) // TODO: called too frequently. cache somehow.
+  let classes = getDayClasses(date, dateProfile, context)
+  let dataAttrs = isDateValid ? { 'data-date': dateEnv.formatIso(date, { omitTime: true }) } : {}
+
+  classes.unshift('fc-day', theme.getClass('widgetContent'))
+
+  return (
+    <td
+      class={classes.join(' ')}
+      { ...dataAttrs }
+      {...otherAttrs }></td>
+  )
+}

+ 33 - 27
packages/daygrid/src/DayTable.ts → packages/daygrid/src/DayTable.tsx

@@ -10,13 +10,12 @@ import {
   DateRange,
   Slicer,
   Hit,
-  ComponentContext,
-  renderer
+  ComponentContext
 } from '@fullcalendar/core'
-import { default as Table, TableSeg, TableRenderProps } from './Table'
+import Table, { TableSeg  } from './Table'
+import { h, createRef, VNode } from 'preact'
 
 export interface DayTableProps {
-  renderProps: TableRenderProps
   dateProfile: DateProfile | null
   dayTableModel: DayTableModel
   nextDayThreshold: Duration
@@ -28,43 +27,50 @@ export interface DayTableProps {
   eventDrag: EventInteractionState | null
   eventResize: EventInteractionState | null
   isRigid: boolean
+  renderNumberIntro: (row: number, cells: any) => VNode[]
+  renderBgIntro: () => VNode[]
+  renderIntro: () => VNode[]
+  colWeekNumbersVisible: boolean // week numbers render in own column? (caller does HTML via intro)
+  cellWeekNumbersVisible: boolean // display week numbers in day cell?
 }
 
 export default class DayTable extends DateComponent<DayTableProps, ComponentContext> {
 
-  private renderTable = renderer(Table)
-  private registerInteractive = renderer(this._registerInteractive, this._unregisterInteractive)
   private slicer = new DayTableSlicer()
+  private tableRef = createRef<Table>()
 
-  table: Table
+  get table() { return this.tableRef.current }
 
 
-  render(props: DayTableProps, context: ComponentContext) {
+  render(props: DayTableProps, state: {}, context: ComponentContext) {
     let { dateProfile, dayTableModel } = props
 
-    let table = this.table = this.renderTable({
-      ...this.slicer.sliceProps(props, dateProfile, props.nextDayThreshold, context.calendar, dayTableModel),
-      dateProfile,
-      cells: dayTableModel.cells,
-      isRigid: props.isRigid,
-      renderProps: props.renderProps
-    })
-
-    this.registerInteractive({
-      el: table.rootEl
-    })
-
-    return table
+    return (
+      <Table
+        ref={this.tableRef}
+        rootElRef={this.handleRootEl}
+        { ...this.slicer.sliceProps(props, dateProfile, props.nextDayThreshold, context.calendar, dayTableModel) }
+        dateProfile={dateProfile}
+        cells={dayTableModel.cells}
+        isRigid={props.isRigid}
+        renderNumberIntro={props.renderNumberIntro}
+        renderBgIntro={props.renderBgIntro}
+        renderIntro={props.renderIntro}
+        colWeekNumbersVisible={props.colWeekNumbersVisible}
+        cellWeekNumbersVisible={props.cellWeekNumbersVisible}
+      />
+    )
   }
 
 
-  _registerInteractive({ el }: { el: HTMLElement }, context: ComponentContext) {
-    context.calendar.registerInteractiveComponent(this, { el })
-  }
+  handleRootEl = (rootEl: HTMLDivElement | null) => {
+    let { calendar } = this.context
 
-
-  _unregisterInteractive(funcState: void, context: ComponentContext) {
-    context.calendar.unregisterInteractiveComponent(this)
+    if (rootEl) {
+      calendar.registerInteractiveComponent(this, { el: rootEl })
+    } else {
+      calendar.unregisterInteractiveComponent(this)
+    }
   }
 
 

+ 0 - 87
packages/daygrid/src/DayTableView.ts

@@ -1,87 +0,0 @@
-import {
-  DayHeader,
-  ComponentContext,
-  DateProfileGenerator,
-  DateProfile,
-  ViewProps,
-  memoize,
-  DaySeries,
-  DayTableModel,
-  renderer
-} from '@fullcalendar/core'
-import TableView, { hasRigidRows } from './TableView'
-import DayTable from './DayTable'
-
-
-export default class DayTableView extends TableView {
-
-  private buildDayTableModel = memoize(buildDayTableModel)
-  private renderHeader = renderer(DayHeader)
-  private renderTable = renderer(DayTable)
-
-  private header: DayHeader | null
-  private table: DayTable
-
-
-  render(props: ViewProps, context: ComponentContext) {
-    let { dateProfile } = props
-    let dayTableModel = this.buildDayTableModel(dateProfile, props.dateProfileGenerator)
-
-    let { rootEl, headerWrapEl, contentWrapEl } = this.renderLayout({
-      type: props.viewSpec.type
-    }, context)
-
-    this.header = this.renderHeader({
-      parentEl: headerWrapEl, // might be null
-      dateProfile,
-      dates: dayTableModel.headerDates,
-      datesRepDistinctDays: dayTableModel.rowCnt === 1,
-      renderIntroHtml: this.renderHeadIntroHtml
-    })
-
-    this.table = this.renderTable({
-      parentEl: contentWrapEl,
-      renderProps: this.tableRenderProps,
-      dateProfile,
-      dayTableModel,
-      businessHours: props.businessHours,
-      dateSelection: props.dateSelection,
-      eventStore: props.eventStore,
-      eventUiBases: props.eventUiBases,
-      eventSelection: props.eventSelection,
-      eventDrag: props.eventDrag,
-      eventResize: props.eventResize,
-      isRigid: hasRigidRows(context.options),
-      nextDayThreshold: context.nextDayThreshold
-    })
-
-    return rootEl
-  }
-
-
-  updateSize(isResize: boolean, viewHeight: number, isAuto: boolean) {
-
-    if (this.isLayoutSizeDirty()) {
-      this.updateLayoutHeight(
-        this.header ? this.header.rootEl : null,
-        this.table.table,
-        viewHeight,
-        isAuto,
-        this.context.options
-      )
-    }
-
-    this.table.updateSize(isResize)
-  }
-
-}
-
-
-export function buildDayTableModel(dateProfile: DateProfile, dateProfileGenerator: DateProfileGenerator) {
-  let daySeries = new DaySeries(dateProfile.renderRange, dateProfileGenerator)
-
-  return new DayTableModel(
-    daySeries,
-    /year|month|week/.test(dateProfile.currentRangeUnit)
-  )
-}

+ 85 - 0
packages/daygrid/src/DayTableView.tsx

@@ -0,0 +1,85 @@
+import {
+  DayHeader,
+  ComponentContext,
+  DateProfileGenerator,
+  DateProfile,
+  ViewProps,
+  memoize,
+  DaySeries,
+  DayTableModel,
+} from '@fullcalendar/core'
+import TableView, { hasRigidRows } from './TableView'
+import DayTable from './DayTable'
+import { h, createRef } from 'preact'
+
+
+export default class DayTableView extends TableView {
+
+  private buildDayTableModel = memoize(buildDayTableModel)
+  private headerRef = createRef<DayHeader>()
+  private tableRef = createRef<DayTable>()
+
+
+  render(props: ViewProps, state: {}, context: ComponentContext) {
+    let { dateProfile } = props
+    let dayTableModel = this.buildDayTableModel(dateProfile, props.dateProfileGenerator)
+
+    return this.renderLayout(
+      <DayHeader
+        ref={this.headerRef}
+        dateProfile={dateProfile}
+        dates={dayTableModel.headerDates}
+        datesRepDistinctDays={dayTableModel.rowCnt === 1}
+        renderIntro={this.renderHeadIntro}
+      />,
+      <DayTable
+        ref={this.tableRef}
+        dateProfile={dateProfile}
+        dayTableModel={dayTableModel}
+        businessHours={props.businessHours}
+        dateSelection={props.dateSelection}
+        eventStore={props.eventStore}
+        eventUiBases={props.eventUiBases}
+        eventSelection={props.eventSelection}
+        eventDrag={props.eventDrag}
+        eventResize={props.eventResize}
+        isRigid={hasRigidRows(context.options)}
+        nextDayThreshold={context.nextDayThreshold}
+        renderNumberIntro={this.renderNumberIntro}
+        renderBgIntro={this.renderBgIntro}
+        renderIntro={this.renderIntro}
+        colWeekNumbersVisible={this.colWeekNumbersVisible}
+        cellWeekNumbersVisible={this.cellWeekNumbersVisible}
+      />
+    )
+  }
+
+
+  updateSize(isResize: boolean, viewHeight: number, isAuto: boolean) {
+    let header = this.headerRef.current
+    let table = this.tableRef.current
+
+    if (this.isLayoutSizeDirty()) {
+      this.updateLayoutHeight(
+        header ? header.rootEl : null,
+        table.table,
+        viewHeight,
+        isAuto,
+        this.context.options
+      )
+    }
+
+    table.updateSize(isResize)
+  }
+
+}
+
+
+export function buildDayTableModel(dateProfile: DateProfile, dateProfileGenerator: DateProfileGenerator) {
+  let daySeries = new DaySeries(dateProfile.renderRange, dateProfileGenerator)
+
+  return new DayTableModel(
+    daySeries,
+    /year|month|week/.test(dateProfile.currentRangeUnit)
+  )
+}

+ 0 - 0
packages/daygrid/src/DayTile.ts → packages/daygrid/src/DayTile.tsx


+ 1 - 1
packages/daygrid/src/DayTileEvents.ts

@@ -22,7 +22,7 @@ export default class DayTileEvents extends CellEvents<DayTileEventsProps> {
       mirrorInfo: props.mirrorInfo,
       selectedInstanceId: props.selectedInstanceId,
       hiddenInstances: props.hiddenInstances
-    }, context)
+    })
 
     this.attachSegs({
       parentEl: props.segContainerEl,

+ 8 - 12
packages/daygrid/src/Popover.ts → packages/daygrid/src/Popover.tsx

@@ -3,7 +3,6 @@
 ------------------------------------------------------------------------------------------------------------------------*/
 
 import {
-  createElement,
   applyStyle,
   listenBySelector,
   computeClippingRect, computeRect, Component, ComponentContext
@@ -22,17 +21,14 @@ export default class Popover extends Component<PopoverProps, ComponentContext> {
 
 
   render(props: PopoverProps, context: ComponentContext) {
-    let el = createElement('div', {
-      className: [
-        'fc-popover',
-        context.theme.getClass('popover'),
-        props.extraClassName || ''
-      ].join(' '),
-      style: {
-        top: '0',
-        left: '0'
-      }
-    })
+    let el = document.createElement('div')
+    el.className = [
+      'fc-popover',
+      context.theme.getClass('popover'),
+      props.extraClassName || ''
+    ].join(' ')
+    el.style.top = '0'
+    el.style.left = '0'
 
     if (props.onClose) {
       // when a click happens on anything inside with a 'fc-close' className, hide the popover

+ 136 - 323
packages/daygrid/src/Table.ts → packages/daygrid/src/Table.tsx

@@ -1,57 +1,40 @@
 import {
-  createElement,
   insertAfterElement,
-  findElements,
   findDirectChildren,
   removeElement,
   computeRect,
   PositionCache,
   addDays,
-  DateMarker,
-  createFormatter,
-  Component,
   EventSegUiInteractionState,
   Seg,
-  rangeContainsMarker,
   intersectRanges,
   EventRenderRange,
-  buildGotoAnchorHtml,
-  getDayClasses,
-  DateProfile,
+  BaseComponent,
   ComponentContext,
-  renderer
+  subrenderer,
+  setRef
 } from '@fullcalendar/core'
-import Popover from './Popover'
 import TableEvents from './TableEvents'
 import TableMirrorEvents from './TableMirrorEvents'
 import TableFills from './TableFills'
-import DayTile from './DayTile'
-import { renderDayBgRowHtml } from './DayBgRow'
-
-const DAY_NUM_FORMAT = createFormatter({ day: 'numeric' })
-const WEEK_NUM_FORMAT = createFormatter({ week: 'numeric' })
+// import Popover from './Popover'
+// import DayTile from './DayTile'
+import { h, Ref } from 'preact'
+import TableSkeleton, { TableSkeletonProps } from './TableSkeleton'
 
 
 /* A component that renders a grid of whole-days that runs horizontally. There can be multiple rows, one per week.
 ----------------------------------------------------------------------------------------------------------------------*/
 
-export interface TableRenderProps {
-  renderNumberIntroHtml: (row: number, dayGrid: Table) => string
-  renderBgIntroHtml: () => string
-  renderIntroHtml: () => string
-  colWeekNumbersVisible: boolean // week numbers render in own column? (caller does HTML via intro)
-  cellWeekNumbersVisible: boolean // display week numbers in day cell?
-}
-
-interface TableState {
-  segPopover: {
-    origFgSegs: Seg[]
-    date: Date
-    fgSegs: Seg[]
-    top: number
-    left?: number
-    right? :number
-  }
+export interface TableProps extends TableSkeletonProps {
+  businessHourSegs: TableSeg[]
+  bgEventSegs: TableSeg[]
+  fgEventSegs: TableSeg[]
+  dateSelectionSegs: TableSeg[]
+  eventSelection: string
+  eventDrag: EventSegUiInteractionState | null
+  eventResize: EventSegUiInteractionState | null
+  rootElRef?: Ref<HTMLDivElement>
 }
 
 export interface TableSeg extends Seg {
@@ -60,65 +43,129 @@ export interface TableSeg extends Seg {
   lastCol: number
 }
 
-export interface CellModel {
-  date: DateMarker
-  htmlAttrs?: string
+interface TableState {
+  segPopover: SegPopoverState
 }
 
-export interface TableProps {
-  renderProps: TableRenderProps
-  dateProfile: DateProfile
-  cells: CellModel[][]
-  businessHourSegs: TableSeg[]
-  bgEventSegs: TableSeg[]
-  fgEventSegs: TableSeg[]
-  dateSelectionSegs: TableSeg[]
-  eventSelection: string
-  eventDrag: EventSegUiInteractionState | null
-  eventResize: EventSegUiInteractionState | null
-
-  // isRigid determines whether the individual rows should ignore the contents and be a constant height.
-  // Relies on the view's colCnt and rowCnt. In the future, this component should probably be self-sufficient.
-  isRigid: boolean
+interface SegPopoverState {
+  origFgSegs: Seg[]
+  date: Date
+  fgSegs: Seg[]
+  top: number
+  left?: number
+  right? :number
 }
 
-export default class Table extends Component<TableProps, ComponentContext, TableState> {
 
-  private renderCells = renderer(this._renderCells)
-  private renderFgEvents = renderer(TableEvents)
-  private renderMirrorEvents = renderer(TableMirrorEvents)
-  private renderBgEvents = renderer(TableFills)
-  private renderBusinessHours = renderer(TableFills)
-  private renderHighlight = renderer(TableFills)
-  private renderPopover = renderer(Popover)
-  private renderTileForPopover = renderer(DayTile)
+export default class Table extends BaseComponent<TableProps, TableState> {
 
-  bottomCoordPadding: number = 0 // hack for extending the hit area for the last row of the coordinate grid
+  private renderFgEvents = subrenderer(TableEvents)
+  private renderMirrorEvents = subrenderer(TableMirrorEvents)
+  private renderBgEvents = subrenderer(TableFills)
+  private renderBusinessHours = subrenderer(TableFills)
+  private renderHighlight = subrenderer(TableFills)
 
-  rowStructs: any
+  rootEl: HTMLElement
   rowEls: HTMLElement[] // set of fake row elements
   cellEls: HTMLElement[] // set of whole-day elements comprising the row's background
+  rowStructs: any
 
   isCellSizesDirty: boolean = false
   rowPositions: PositionCache
   colPositions: PositionCache
+  bottomCoordPadding: number = 0 // hack for extending the hit area for the last row of the coordinate grid
+
+
+  render(props: TableProps) {
+    return (
+      <TableSkeleton
+        handleDom={this.handleSkeletonDom}
+        dateProfile={props.dateProfile}
+        cells={props.cells}
+        isRigid={props.isRigid}
+        renderNumberIntro={props.renderNumberIntro}
+        renderBgIntro={props.renderBgIntro}
+        renderIntro={props.renderIntro}
+        colWeekNumbersVisible={props.colWeekNumbersVisible}
+        cellWeekNumbersVisible={props.cellWeekNumbersVisible}
+      />
+    )
+  }
 
 
-  render(props: TableProps, context: ComponentContext, state: TableState) {
+  /*
     let segPopoverState = state.segPopover
-    let colCnt = props.cells[0].length
 
-    let { rootEl, rowEls, cellEls } = this.renderCells({
-      renderProps: props.renderProps,
-      dateProfile: props.dateProfile,
-      cells: props.cells,
-      isRigid: props.isRigid
-    })
+    {(segPopoverState && segPopoverState.origFgSegs === props.fgEventSegs) && // clear on new event segs
+      <Popover // not high enough z-index!!!???
+        top={segPopoverState.top}
+        left={segPopoverState.left}
+        right={segPopoverState.right}
+        onClose={this.onPopoverClose} // TODO: onCloseRequest
+      >
+        <DayTile // TODO: what about content being different than title!!!!
+          date={segPopoverState.date}
+          fgSegs={segPopoverState.fgSegs}
+          selectedInstanceId={props.eventSelection}
+          hiddenInstances={ // TODO: more convenient
+            (props.eventDrag ? props.eventDrag.affectedInstances : null) ||
+            (props.eventResize ? props.eventResize.affectedInstances : null)
+          }
+          />
+      </Popover>
+    }
+  */
+
+
+ handleSkeletonDom = (rootEl: HTMLDivElement | null, rowEls: HTMLElement[] | null, cellEls: HTMLElement[] | null) => {
+    setRef(this.props.rootElRef, rootEl)
+
+    if (!rootEl) {
+      this.subrenderDestroy()
+
+    } else {
+      let { cells } = this.props
+      let colCnt = cells[0].length
+
+      this.rowPositions = new PositionCache(
+        rootEl,
+        rowEls,
+        false,
+        true // vertical
+      )
+
+      this.colPositions = new PositionCache(
+        rootEl,
+        cellEls.slice(0, colCnt), // only the first row
+        true, // horizontal
+        false
+      )
+
+      this.rowEls = rowEls
+      this.cellEls = cellEls
+      this.isCellSizesDirty = true
+    }
+  }
+
+
+  componentDidMount() {
+    this.subrender()
+  }
+
+
+  componentDidUpdate() {
+    this.subrender()
+  }
+
+
+  subrender() {
+    let { props, rowEls } = this
+    let colCnt = props.cells[0].length
 
     if (props.eventDrag) {
       this.renderHighlight({
         type: 'highlight',
-        renderProps: props.renderProps,
+        renderIntro: props.renderIntro,
         segs: props.eventDrag.segs,
         rowEls,
         colCnt
@@ -126,7 +173,7 @@ export default class Table extends Component<TableProps, ComponentContext, Table
     } else {
       this.renderHighlight({
         type: 'highlight',
-        renderProps: props.renderProps,
+        renderIntro: props.renderIntro,
         segs: props.dateSelectionSegs,
         rowEls,
         colCnt
@@ -135,7 +182,7 @@ export default class Table extends Component<TableProps, ComponentContext, Table
 
     this.renderBusinessHours({
       type: 'businessHours',
-      renderProps: props.renderProps,
+      renderIntro: props.renderIntro,
       segs: props.businessHourSegs,
       rowEls,
       colCnt
@@ -143,14 +190,14 @@ export default class Table extends Component<TableProps, ComponentContext, Table
 
     this.renderBgEvents({
       type: 'bgEvent',
-      renderProps: props.renderProps,
+      renderIntro: props.renderIntro,
       segs: props.bgEventSegs,
       rowEls,
       colCnt
     })
 
     let eventsRenderer = this.renderFgEvents({
-      renderProps: props.renderProps,
+      renderIntro: props.renderIntro,
       segs: props.fgEventSegs,
       rowEls,
       colCnt,
@@ -160,9 +207,11 @@ export default class Table extends Component<TableProps, ComponentContext, Table
         (props.eventResize ? props.eventResize.affectedInstances : null)
     })
 
+    this.rowStructs = eventsRenderer.rowStructs
+
     if (props.eventResize) {
       this.renderMirrorEvents({
-        renderProps: props.renderProps,
+        renderIntro: props.renderIntro,
         segs: props.eventResize.segs,
         rowEls,
         colCnt,
@@ -171,42 +220,6 @@ export default class Table extends Component<TableProps, ComponentContext, Table
     } else {
       this.renderMirrorEvents(false)
     }
-
-    if (
-      segPopoverState &&
-      segPopoverState.origFgSegs === props.fgEventSegs // will close popover when events change
-    ) {
-      let viewEl = context.calendar.component.view.rootEl as HTMLElement // yuck
-
-      let popover = this.renderPopover({ // will be outside of all scrollers within the view
-        parentEl: viewEl,
-        top: segPopoverState.top,
-        left: segPopoverState.left,
-        right: segPopoverState.right,
-        onClose: this.onPopoverClose,
-        clippingEl: viewEl
-      })
-
-      this.renderTileForPopover({ // renders the close icon too, for clicking
-        parentEl: popover.rootEl,
-        date: state.segPopover.date,
-        fgSegs: state.segPopover.fgSegs,
-        selectedInstanceId: props.eventSelection,
-        hiddenInstances: // TODO: more convenient
-          (props.eventDrag ? props.eventDrag.affectedInstances : null) ||
-          (props.eventResize ? props.eventResize.affectedInstances : null)
-      })
-
-    } else {
-      this.renderPopover(false)
-      this.renderTileForPopover(false)
-    }
-
-    this.rowEls = rowEls
-    this.cellEls = cellEls
-    this.rowStructs = eventsRenderer.rowStructs
-
-    return rootEl
   }
 
 
@@ -215,212 +228,6 @@ export default class Table extends Component<TableProps, ComponentContext, Table
   }
 
 
-  /* Date Rendering
-  ------------------------------------------------------------------------------------------------------------------*/
-
-
-  _renderCells(
-    { cells, isRigid, dateProfile, renderProps }: { cells: CellModel[][], isRigid: boolean, dateProfile: DateProfile, renderProps: any },
-    context: ComponentContext
-  ) {
-    let { calendar, view, isRtl, dateEnv } = context
-    let rowCnt = cells.length
-    let colCnt = cells[0].length
-    let html = ''
-    let row
-    let col
-
-    for (row = 0; row < rowCnt; row++) {
-      html += this.renderDayRowHtml(row, isRigid, cells, dateProfile, renderProps, context)
-    }
-
-    let el = createElement('div', { className: 'fc-day-grid' }, html)
-
-    let rowEls = findElements(el, '.fc-row')
-    let cellEls = findElements(el, '.fc-day, .fc-disabled-day')
-
-    if (isRtl) {
-      cellEls.reverse()
-    }
-
-    this.rowPositions = new PositionCache(
-      el,
-      rowEls,
-      false,
-      true // vertical
-    )
-
-    this.colPositions = new PositionCache(
-      el,
-      cellEls.slice(0, colCnt), // only the first row
-      true,
-      false // horizontal
-    )
-
-    // trigger dayRender with each cell's element
-    for (row = 0; row < rowCnt; row++) {
-      for (col = 0; col < colCnt; col++) {
-        calendar.publiclyTrigger('dayRender', [
-          {
-            date: dateEnv.toDate(cells[row][col].date),
-            el: this.getCellEl(row, col),
-            view
-          }
-        ])
-      }
-    }
-
-    this.isCellSizesDirty = true
-
-    return {
-      rootEl: el,
-      rowEls,
-      cellEls
-    }
-  }
-
-
-  // Generates the HTML for a single row, which is a div that wraps a table.
-  // `row` is the row number.
-  renderDayRowHtml(row, isRigid, cells, dateProfile, renderProps, context: ComponentContext) {
-    let { theme } = context
-    let classes = [ 'fc-row', 'fc-week', theme.getClass('dayRow') ]
-
-    if (isRigid) {
-      classes.push('fc-rigid')
-    }
-
-    return '' +
-      '<div class="' + classes.join(' ') + '">' +
-        '<div class="fc-bg">' +
-          '<table class="' + theme.getClass('tableGrid') + '">' +
-            renderDayBgRowHtml({
-              cells: cells[row],
-              dateProfile,
-              renderIntroHtml: renderProps.renderBgIntroHtml
-            }, context) +
-          '</table>' +
-        '</div>' +
-        '<div class="fc-content-skeleton">' +
-          '<table>' +
-            (this.getIsNumbersVisible(renderProps, cells.length) ?
-              '<thead>' +
-                this.renderNumberTrHtml(row, cells, dateProfile, renderProps, context) +
-              '</thead>' :
-              ''
-              ) +
-          '</table>' +
-        '</div>' +
-      '</div>'
-  }
-
-
-  getIsNumbersVisible(renderProps, rowCnt) {
-    return this.getIsDayNumbersVisible(rowCnt) ||
-      renderProps.cellWeekNumbersVisible ||
-      renderProps.colWeekNumbersVisible
-  }
-
-
-  getIsDayNumbersVisible(rowCnt) {
-    return rowCnt > 1
-  }
-
-
-  /* Grid Number Rendering
-  ------------------------------------------------------------------------------------------------------------------*/
-
-
-  renderNumberTrHtml(row: number, cells, dateProfile, renderProps, context: ComponentContext) {
-    let intro = renderProps.renderNumberIntroHtml(row, this)
-
-    return '' +
-      '<tr>' +
-        (context.isRtl ? '' : intro) +
-        this.renderNumberCellsHtml(row, cells, dateProfile, renderProps, context) +
-        (context.isRtl ? intro : '') +
-      '</tr>'
-  }
-
-
-  renderNumberCellsHtml(row, cells, dateProfile: DateProfile, renderProps, context: ComponentContext) {
-    let rowCnt = cells.length
-    let colCnt = cells[row].length
-    let htmls = []
-    let col
-    let date
-
-    for (col = 0; col < colCnt; col++) {
-      date = cells[row][col].date
-
-      htmls.push(
-        this.renderNumberCellHtml(date, dateProfile, renderProps, rowCnt, context)
-      )
-    }
-
-    if (context.isRtl) {
-      htmls.reverse()
-    }
-
-    return htmls.join('')
-  }
-
-
-  // Generates the HTML for the <td>s of the "number" row in the DayGrid's content skeleton.
-  // The number row will only exist if either day numbers or week numbers are turned on.
-  renderNumberCellHtml(date, dateProfile: DateProfile, renderProps, rowCnt, context: ComponentContext) {
-    let { dateEnv, options } = context
-    let html = ''
-    let isDateValid = rangeContainsMarker(dateProfile.activeRange, date) // TODO: called too frequently. cache somehow.
-    let isDayNumberVisible = this.getIsDayNumbersVisible(rowCnt) && isDateValid
-    let classes
-    let weekCalcFirstDow
-
-    if (!isDayNumberVisible && !renderProps.cellWeekNumbersVisible) {
-      // no numbers in day cell (week number must be along the side)
-      return '<td></td>' //  will create an empty space above events :(
-    }
-
-    classes = getDayClasses(date, dateProfile, context)
-    classes.unshift('fc-day-top')
-
-    if (renderProps.cellWeekNumbersVisible) {
-      weekCalcFirstDow = dateEnv.weekDow
-    }
-
-    html += '<td class="' + classes.join(' ') + '"' +
-      (isDateValid ?
-        ' data-date="' + dateEnv.formatIso(date, { omitTime: true }) + '"' :
-        ''
-        ) +
-      '>'
-
-    if (renderProps.cellWeekNumbersVisible && (date.getUTCDay() === weekCalcFirstDow)) {
-      html += buildGotoAnchorHtml(
-        options,
-        dateEnv,
-        { date, type: 'week' },
-        { 'class': 'fc-week-number' },
-        dateEnv.format(date, WEEK_NUM_FORMAT) // inner HTML
-      )
-    }
-
-    if (isDayNumberVisible) {
-      html += buildGotoAnchorHtml(
-        options,
-        dateEnv,
-        date,
-        { 'class': 'fc-day-number' },
-        dateEnv.format(date, DAY_NUM_FORMAT) // inner HTML
-      )
-    }
-
-    html += '</td>'
-
-    return html
-  }
-
-
   /* Sizing
   ------------------------------------------------------------------------------------------------------------------*/
 
@@ -461,6 +268,7 @@ export default class Table extends Component<TableProps, ComponentContext, Table
   /* Hit System
   ------------------------------------------------------------------------------------------------------------------*/
 
+
   positionToHit(leftPosition, topPosition) {
     let { colPositions, rowPositions } = this
 
@@ -598,7 +406,8 @@ export default class Table extends Component<TableProps, ComponentContext, Table
         if (segsBelow.length) {
           td = cellMatrix[levelLimit - 1][col]
           moreLink = this.renderMoreLink(row, col, segsBelow, cells, rowEls, rowStructs, context)
-          moreWrap = createElement('div', null, moreLink)
+          moreWrap = document.createElement('div')
+          moreWrap.appendChild(moreLink)
           td.appendChild(moreWrap)
           moreNodes.push(moreWrap)
         }
@@ -640,7 +449,9 @@ export default class Table extends Component<TableProps, ComponentContext, Table
 
           // make a replacement <td> for each column the segment occupies. will be one for each colspan
           for (j = 0; j < colSegsBelow.length; j++) {
-            moreTd = createElement('td', { className: 'fc-more-cell', rowSpan }) as HTMLTableCellElement
+            moreTd = document.createElement('td')
+            moreTd.className = 'fc-more-cell'
+            moreTd.rowSpan = rowSpan
             segsBelow = colSegsBelow[j]
             moreLink = this.renderMoreLink(
               row,
@@ -651,7 +462,8 @@ export default class Table extends Component<TableProps, ComponentContext, Table
               rowStructs,
               context
             )
-            moreWrap = createElement('div', null, moreLink)
+            moreWrap = document.createElement('div')
+            moreWrap.appendChild(moreLink)
             moreTd.appendChild(moreWrap)
             segMoreNodes.push(moreTd)
             moreNodes.push(moreTd)
@@ -697,7 +509,8 @@ export default class Table extends Component<TableProps, ComponentContext, Table
     let rowCnt = cells.length
     let colCnt = cells[0].length
 
-    let a = createElement('a', { className: 'fc-more' })
+    let a = document.createElement('a')
+    a.className = 'fc-more'
     a.innerText = getMoreLinkText(hiddenSegs.length, options)
     a.addEventListener('click', (ev) => {
       let clickOption = options.eventLimitClick
@@ -730,7 +543,7 @@ export default class Table extends Component<TableProps, ComponentContext, Table
       if (clickOption === 'popover') {
         let _col = isRtl ? colCnt - col - 1 : col // HACK: props.cells has different dir system?
         let topEl = rowCnt === 1
-          ? context.calendar.component.view.rootEl // will cause the popover to cover any sort of header
+          ? context.calendar.component.viewContainerEl // will cause the popover to cover any sort of header
           : rowEls[row] // will align with top of row
         let left, right
 

+ 23 - 23
packages/daygrid/src/TableEvents.ts

@@ -1,5 +1,4 @@
 import {
-  createElement,
   removeElement,
   appendToElement,
   prependToElement,
@@ -7,23 +6,25 @@ import {
   BaseFgEventRendererProps,
   ComponentContext,
   sortEventSegs,
-  renderer
+  subrenderer,
+  renderVNodes
 } from '@fullcalendar/core'
 import CellEvents from './CellEvents'
+import { VNode } from 'preact'
 
 
 /* Event-rendering methods for the Table class
 ----------------------------------------------------------------------------------------------------------------------*/
 
 export interface TableEventsProps extends BaseFgEventRendererProps {
-  renderProps: any
   rowEls: HTMLElement[]
   colCnt: number
+  renderIntro: () => VNode[]
 }
 
 export default class TableEvents extends CellEvents<TableEventsProps> {
 
-  protected attachSegs = renderer(attachSegs, detachSegs)
+  protected attachSegs = subrenderer(attachSegs, detachSegs)
 
   rowStructs: any
 
@@ -34,16 +35,14 @@ export default class TableEvents extends CellEvents<TableEventsProps> {
       mirrorInfo: props.mirrorInfo,
       selectedInstanceId: props.selectedInstanceId,
       hiddenInstances: props.hiddenInstances
-    }, context)
+    })
 
-    let rowStructs = this.attachSegs({
-      renderProps: props.renderProps,
+    this.rowStructs = this.attachSegs({
       segs,
       rowEls: props.rowEls,
-      colCnt: props.colCnt
+      colCnt: props.colCnt,
+      renderIntro: props.renderIntro
     })
-
-    this.rowStructs = rowStructs
   }
 
 
@@ -56,9 +55,9 @@ export default class TableEvents extends CellEvents<TableEventsProps> {
 
 
 // Renders the given foreground event segments onto the grid
-function attachSegs({ segs, rowEls, colCnt, renderProps }: TableEventsProps, context: ComponentContext) {
+function attachSegs({ segs, rowEls, colCnt, renderIntro }: TableEventsProps, context: ComponentContext) {
 
-  let rowStructs = renderSegRows(segs, rowEls.length, colCnt, renderProps, context)
+  let rowStructs = renderSegRows(segs, rowEls.length, colCnt, renderIntro, context)
 
   // append to each row's content skeleton
   rowEls.forEach(function(rowNode, i) {
@@ -82,7 +81,7 @@ function detachSegs(rowStructs) {
 // Uses the given events array to generate <tbody> elements that should be appended to each row's content skeleton.
 // Returns an array of rowStruct objects (see the bottom of `renderSegRow`).
 // PRECONDITION: each segment shoud already have a rendered and assigned `.el`
-export function renderSegRows(segs: Seg[], rowCnt: number, colCnt: number, renderProps, context: ComponentContext) {
+export function renderSegRows(segs: Seg[], rowCnt: number, colCnt: number, renderIntro, context: ComponentContext) {
   let rowStructs = []
   let segRows
   let row
@@ -92,7 +91,7 @@ export function renderSegRows(segs: Seg[], rowCnt: number, colCnt: number, rende
   // iterate each row of segment groupings
   for (row = 0; row < segRows.length; row++) {
     rowStructs.push(
-      renderSegRow(row, segRows[row], colCnt, renderProps, context)
+      renderSegRow(row, segRows[row], colCnt, renderIntro, context)
     )
   }
 
@@ -103,7 +102,7 @@ export function renderSegRows(segs: Seg[], rowCnt: number, colCnt: number, rende
 // Given a row # and an array of segments all in the same row, render a <tbody> element, a skeleton that contains
 // the segments. Returns object with a bunch of internal data about how the render was calculated.
 // NOTE: modifies rowSegs
-function renderSegRow(row, rowSegs, colCnt: number, renderProps, context: ComponentContext) {
+function renderSegRow(row, rowSegs, colCnt: number, renderIntro, context: ComponentContext) {
   let { isRtl } = context
   let segLevels = buildSegLevels(rowSegs, colCnt, context) // group into sub-arrays of levels
   let levelCnt = Math.max(1, segLevels.length) // ensure at least one level
@@ -156,7 +155,9 @@ function renderSegRow(row, rowSegs, colCnt: number, renderProps, context: Compon
         emptyCellsUntil(leftCol)
 
         // create a container that occupies or more columns. append the event element.
-        td = createElement('td', { className: 'fc-event-container' }, seg.el) as HTMLTableCellElement
+        td = document.createElement('td')
+        td.className = 'fc-event-container'
+        td.appendChild(seg.el)
         if (leftCol !== rightCol) {
           td.colSpan = rightCol - leftCol + 1
         } else { // a single-column segment
@@ -175,13 +176,12 @@ function renderSegRow(row, rowSegs, colCnt: number, renderProps, context: Compon
 
     emptyCellsUntil(colCnt) // finish off the row
 
-    let introHtml = renderProps.renderIntroHtml()
-    if (introHtml) {
-      if (isRtl) {
-        appendToElement(tr, introHtml)
-      } else {
-        prependToElement(tr, introHtml)
-      }
+    let introEls = renderVNodes(renderIntro(), context)
+
+    if (isRtl) {
+      appendToElement(tr, introEls)
+    } else {
+      prependToElement(tr, introEls)
     }
 
     tbody.appendChild(tr)

+ 14 - 14
packages/daygrid/src/TableFills.ts

@@ -7,9 +7,10 @@ import {
   ComponentContext,
   removeElement,
   BaseFillRendererProps,
-  renderer
+  subrenderer,
+  renderVNodes
 } from '@fullcalendar/core'
-import { TableRenderProps } from './Table'
+import { VNode } from 'preact'
 
 
 const EMPTY_CELL_HTML = '<td style="pointer-events:none"></td>'
@@ -17,16 +18,16 @@ const EMPTY_CELL_HTML = '<td style="pointer-events:none"></td>'
 
 export interface TableFillsProps extends BaseFillRendererProps {
   type: string
-  renderProps: TableRenderProps
   rowEls: HTMLElement[]
   colCnt: number
+  renderIntro: () => VNode[]
 }
 
 export default class TableFills extends FillRenderer<TableFillsProps> {
 
   fillSegTag: string = 'td' // override the default tag name
 
-  private attachSegs = renderer(attachSegs, detachSegs)
+  private attachSegs = subrenderer(attachSegs, detachSegs)
 
 
   render(props: TableFillsProps) {
@@ -46,10 +47,10 @@ export default class TableFills extends FillRenderer<TableFillsProps> {
 
     this.attachSegs({
       type: props.type,
-      renderProps: props.renderProps,
       segs,
       rowEls: props.rowEls,
-      colCnt: props.colCnt
+      colCnt: props.colCnt,
+      renderIntro: props.renderIntro
     })
   }
 
@@ -80,7 +81,7 @@ function detachSegs(els: HTMLElement[]) {
 
 
 // Generates the HTML needed for one row of a fill. Requires the seg's el to be rendered.
-function renderFillRow(seg: Seg, { colCnt, type, renderProps }: TableFillsProps, context: ComponentContext): HTMLElement {
+function renderFillRow(seg: Seg, { colCnt, type, renderIntro }: TableFillsProps, context: ComponentContext): HTMLElement {
   let { isRtl } = context
   let leftCol = isRtl ? (colCnt - 1 - seg.lastCol) : seg.firstCol
   let rightCol = isRtl ? (colCnt - 1 - seg.firstCol) : seg.lastCol
@@ -120,13 +121,12 @@ function renderFillRow(seg: Seg, { colCnt, type, renderProps }: TableFillsProps,
     )
   }
 
-  let introHtml = renderProps.renderIntroHtml()
-  if (introHtml) {
-    if (isRtl) {
-      appendToElement(trEl, introHtml)
-    } else {
-      prependToElement(trEl, introHtml)
-    }
+  let introEls = renderVNodes(renderIntro(), context)
+
+  if (isRtl) {
+    appendToElement(trEl, introEls)
+  } else {
+    prependToElement(trEl, introEls)
   }
 
   return skeletonEl

+ 5 - 5
packages/daygrid/src/TableMirrorEvents.ts

@@ -1,21 +1,21 @@
 import {
-  htmlToElement, renderer, ComponentContext, removeElement
+  htmlToElement, subrenderer, ComponentContext, removeElement
 } from '@fullcalendar/core'
 import TableEvents, { renderSegRows, TableEventsProps } from './TableEvents'
 
 
 export default class TableMirrorEvents extends TableEvents {
 
-  protected attachSegs = renderer(attachSegs, detachSegs)
+  protected attachSegs = subrenderer(attachSegs, detachSegs)
 
 }
 
 
 // Renders the given foreground event segments onto the grid
-function attachSegs({ segs, rowEls, colCnt, renderProps, mirrorInfo }: TableEventsProps, context: ComponentContext) {
-  let { sourceSeg } = mirrorInfo
+function attachSegs({ segs, rowEls, colCnt, renderIntro, mirrorInfo }: TableEventsProps, context: ComponentContext) {
+  let sourceSeg = mirrorInfo && mirrorInfo.sourceSeg
 
-  let rowStructs = renderSegRows(segs, rowEls.length, colCnt, renderProps, context)
+  let rowStructs = renderSegRows(segs, rowEls.length, colCnt, renderIntro, context)
 
   // inject each new event skeleton into each associated row
   rowEls.forEach(function(rowNode, row) {

+ 231 - 0
packages/daygrid/src/TableSkeleton.tsx

@@ -0,0 +1,231 @@
+import { VNode, h } from 'preact'
+import {
+  DateProfile,
+  DateMarker,
+  BaseComponent,
+  rangeContainsMarker,
+  getDayClasses,
+  createFormatter,
+  findElements,
+  GotoAnchor,
+  guid
+} from '@fullcalendar/core'
+import DayBgRow from './DayBgRow'
+
+
+export interface TableSkeletonProps {
+  handleDom?: (rootEl: HTMLElement | null, rowEls: HTMLElement[] | null, cellEls: HTMLElement[] | null) => void
+  dateProfile: DateProfile
+  cells: CellModel[][]
+  isRigid: boolean
+    // isRigid determines whether the individual rows should ignore the contents and be a constant height.
+    // Relies on the view's colCnt and rowCnt. In the future, this component should probably be self-sufficient.
+  renderNumberIntro: (row: number, cells: any) => VNode[]
+  renderBgIntro: () => VNode[]
+  renderIntro: () => VNode[]
+  colWeekNumbersVisible: boolean // week numbers render in own column? (caller does HTML via intro)
+  cellWeekNumbersVisible: boolean // display week numbers in day cell?
+}
+
+export interface CellModel {
+  date: DateMarker
+  htmlAttrs?: object
+}
+
+const DAY_NUM_FORMAT = createFormatter({ day: 'numeric' })
+const WEEK_NUM_FORMAT = createFormatter({ week: 'numeric' })
+
+
+export default class TableSkeleton extends BaseComponent<TableSkeletonProps> {
+
+
+  render(props: TableSkeletonProps) {
+    let rowCnt = this.props.cells.length
+    let rowNodes: VNode[] = []
+
+    for (let row = 0; row < rowCnt; row++) {
+      rowNodes.push(
+        this.renderDayRow(row)
+      )
+    }
+
+    return ( // guid rerenders whole DOM every time
+      <div class='fc-day-grid' ref={this.handleRootEl} key={guid()}>
+        {rowNodes}
+      </div>
+    )
+  }
+
+
+  // Generates the HTML for a single row, which is a div that wraps a table.
+  // `row` is the row number.
+  renderDayRow(row) {
+    let { theme } = this.context
+    let { props } = this
+    let classes = [ 'fc-row', 'fc-week', theme.getClass('dayRow') ]
+
+    if (props.isRigid) {
+      classes.push('fc-rigid')
+    }
+
+    return (
+      <div class={classes.join(' ')}>
+        <div class='fc-bg'>
+          <table class={theme.getClass('tableGrid')}>
+            <DayBgRow
+              cells={props.cells[row]}
+              dateProfile={props.dateProfile}
+              renderIntro={props.renderBgIntro}
+            />
+          </table>
+        </div>
+        <div class="fc-content-skeleton">
+          <table>
+            {this.getIsNumbersVisible() &&
+              <thead>
+                {this.renderNumberTr(row)}
+              </thead>
+            }
+          </table>
+        </div>
+      </div>
+    )
+  }
+
+
+  getIsNumbersVisible() {
+    let { props } = this
+
+    return this.getIsDayNumbersVisible(props.cells.length) ||
+      props.cellWeekNumbersVisible ||
+      props.colWeekNumbersVisible
+  }
+
+
+  getIsDayNumbersVisible(rowCnt) {
+    return rowCnt > 1
+  }
+
+
+  renderNumberTr(row: number) {
+    let { props, context } = this
+    let intro = props.renderNumberIntro(row, props.cells)
+
+    return (
+      <tr>
+        {!context.isRtl && intro}
+        {this.renderNumberCells(row)}
+        {context.isRtl && intro}
+      </tr>
+    )
+  }
+
+
+  renderNumberCells(row) {
+    let { cells } = this.props
+    let colCnt = cells[row].length
+    let parts: VNode[] = []
+    let col
+    let date
+
+    for (col = 0; col < colCnt; col++) {
+      date = cells[row][col].date
+
+      parts.push(
+        this.renderNumberCell(date)
+      )
+    }
+
+    if (this.context.isRtl) {
+      parts.reverse()
+    }
+
+    return parts
+  }
+
+
+  // Generates the HTML for the <td>s of the "number" row in the DayGrid's content skeleton.
+  // The number row will only exist if either day numbers or week numbers are turned on.
+  renderNumberCell(date) {
+    let { props, context } = this
+    let { dateEnv, options } = context
+    let { dateProfile } = props
+    let isDateValid = rangeContainsMarker(dateProfile.activeRange, date) // TODO: called too frequently. cache somehow.
+    let isDayNumberVisible = this.getIsDayNumbersVisible(props.cells.length) && isDateValid
+    let weekCalcFirstDow
+
+    if (!isDayNumberVisible && !props.cellWeekNumbersVisible) {
+      // no numbers in day cell (week number must be along the side)
+      return (<td></td>) //  will create an empty space above events :(
+    }
+
+    let classNames = getDayClasses(date, dateProfile, context)
+    classNames.unshift('fc-day-top')
+
+    let attrs = {} as any
+    if (isDateValid) {
+      attrs['data-date'] = dateEnv.formatIso(date, { omitTime: true })
+    }
+
+    if (props.cellWeekNumbersVisible) {
+      weekCalcFirstDow = dateEnv.weekDow
+    }
+
+    return (
+      <td class={classNames.join(' ')} {...attrs}>
+        {(props.cellWeekNumbersVisible && (date.getUTCDay() === weekCalcFirstDow)) &&
+          <GotoAnchor
+            navLinks={options.navLinks}
+            gotoOptions={{ date, type: 'week' }}
+            extraAttrs={{ 'class': 'fc-week-number' }}
+          >{dateEnv.format(date, WEEK_NUM_FORMAT)}</GotoAnchor>
+        }
+        {isDayNumberVisible &&
+          <GotoAnchor
+            navLinks={options.navLinks}
+            gotoOptions={date}
+            extraAttrs={{ 'class': 'fc-day-number' }}
+          >{dateEnv.format(date, DAY_NUM_FORMAT)}</GotoAnchor>
+        }
+      </td>
+    )
+  }
+
+
+  handleRootEl = (rootEl: HTMLElement | null) => {
+    let { isRtl, calendar, view, dateEnv } = this.context
+    let { cells, handleDom } = this.props
+    let rowEls = null
+    let cellEls = null
+
+    if (rootEl) {
+      let rowCnt = cells.length
+      let colCnt = cells[0].length
+
+      rowEls = findElements(rootEl, '.fc-row')
+      cellEls = findElements(rootEl, '.fc-day, .fc-disabled-day')
+
+      if (isRtl) {
+        cellEls.reverse() // TODO: reverse before calling dayRender?
+      }
+
+      // trigger dayRender with each cell's element
+      for (let row = 0; row < rowCnt; row++) {
+        for (let col = 0; col < colCnt; col++) {
+          calendar.publiclyTrigger('dayRender', [
+            {
+              date: dateEnv.toDate(cells[row][col].date),
+              el: cellEls[row * colCnt + col],
+              view
+            }
+          ])
+        }
+      }
+    }
+
+    if (handleDom) {
+      handleDom(rootEl, rowEls, cellEls)
+    }
+  }
+
+}

+ 97 - 109
packages/daygrid/src/TableView.ts → packages/daygrid/src/TableView.tsx

@@ -1,5 +1,5 @@
 import {
-  htmlEscape, findElements,
+  findElements,
   matchCellWidths,
   uncompensateScroll,
   compensateScroll,
@@ -9,15 +9,14 @@ import {
   createFormatter,
   Scroller,
   View,
-  buildGotoAnchorHtml,
   Duration,
-  ComponentContext,
   memoize,
-  renderer,
-  renderViewEl
+  getViewClassNames,
+  GotoAnchor
 } from '@fullcalendar/core'
-import Table, { TableRenderProps } from './Table'
+import Table from './Table'
 import TableDateProfileGenerator from './TableDateProfileGenerator'
+import { VNode, h, createRef, ComponentChildren } from 'preact'
 
 const WEEK_NUM_FORMAT = createFormatter({ week: 'numeric' })
 
@@ -27,71 +26,55 @@ const WEEK_NUM_FORMAT = createFormatter({ week: 'numeric' })
 // It is a manager for a Table subcomponent, which does most of the heavy lifting.
 // It is responsible for managing width/height.
 
+
 export default abstract class TableView extends View {
 
   private processOptions = memoize(this._processOptions)
-  private renderSkeleton = renderer(this._renderSkeleton)
-  private renderScroller = renderer(Scroller)
-
-  private scroller: Scroller
+  private rootElRef = createRef<HTMLDivElement>()
+  private scrollerRef = createRef<Scroller>()
 
   // computed options
-  private colWeekNumbersVisible: boolean
-  private cellWeekNumbersVisible: boolean
-  private weekNumberWidth: number
-
-
-  renderLayout(options: { type: string }, context: ComponentContext) {
-    this.processOptions(context.options)
-
-    let res = this.renderSkeleton(options)
+  protected colWeekNumbersVisible: boolean
+  protected cellWeekNumbersVisible: boolean
+  protected weekNumberWidth: number
 
-    let scroller = this.renderScroller({
-      parentEl: res.contentWrapEl,
-      overflowX: 'hidden',
-      overflowY: 'auto'
-    })
-
-    let tableWrapEl = scroller.rootEl
-    tableWrapEl.classList.add('fc-day-grid-container') // TODO: avoid every time
-
-    this.scroller = scroller
-
-    return res
-  }
+  getRootEl() { return this.rootElRef.current }
 
 
-  _renderSkeleton({ type }: { type: string }, context: ComponentContext) {
-    let { theme, options } = context
-
-    let el = renderViewEl(type)
-    el.classList.add('fc-dayGrid-view')
-    el.innerHTML = '' +
-      '<table class="' + theme.getClass('tableGrid') + '">' +
-        (options.columnHeader ?
-          '<thead class="fc-head">' +
-            '<tr>' +
-              '<td class="fc-head-container ' + theme.getClass('widgetHeader') + '">&nbsp;</td>' +
-            '</tr>' +
-          '</thead>' :
-          ''
-          ) +
-        '<tbody class="fc-body">' +
-          '<tr>' +
-            '<td class="' + theme.getClass('widgetContent') + '"></td>' +
-          '</tr>' +
-        '</tbody>' +
-      '</table>'
-
-    return {
-      rootEl: el,
-      headerWrapEl: options.columnHeader ? (el.querySelector('.fc-head-container') as HTMLElement) : null,
-      contentWrapEl: el.querySelector('.fc-body > tr > td') as HTMLElement
-    }
+  renderLayout(headerContent: ComponentChildren, bodyContent: ComponentChildren) {
+    let { theme, options } = this.context
+    let classNames = getViewClassNames(this.props.viewSpec).concat('fc-dayGrid-view')
+
+    this.processOptions(options)
+
+    return (
+      <div ref={this.rootElRef} class={classNames.join(' ')}>
+        <table class={theme.getClass('tableGrid')}>
+          {options.columnHeader &&
+            <thead class='fc-head'>
+              <tr>
+                <td class={'fc-head-container ' + theme.getClass('widgetHeader')}>
+                  {headerContent}
+                </td>
+              </tr>
+            </thead>
+          }
+          <tbody class='fc-body'>
+            <tr>
+              <td class={theme.getClass('widgetContent')}>
+                <Scroller ref={this.scrollerRef} overflowX='hidden' overflowY='auto' extraClassName='fc-day-grid-container'>
+                  {bodyContent}
+                </Scroller>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
+    )
   }
 
 
-  _processOptions(options) {
+  private _processOptions(options) {
     if (options.weekNumbers) {
       if (options.weekNumbersWithinDays) {
         this.cellWeekNumbersVisible = true
@@ -108,11 +91,11 @@ export default abstract class TableView extends View {
 
 
   // Generates an HTML attribute string for setting the width of the week number column, if it is known
-  weekNumberStyleAttr() {
+  weekNumberStyles() {
     if (this.weekNumberWidth != null) {
-      return 'style="width:' + this.weekNumberWidth + 'px"'
+      return { width: this.weekNumberWidth }
     }
-    return ''
+    return {}
   }
 
 
@@ -122,6 +105,8 @@ export default abstract class TableView extends View {
 
   // Refreshes the horizontal dimensions of the view
   updateLayoutHeight(headRowEl: HTMLElement | null, table: Table, viewHeight: number, isAuto: boolean, options) {
+    let rootEl = this.rootElRef.current
+    let scroller = this.scrollerRef.current
     let eventLimit = options.eventLimit
     let scrollerHeight
     let scrollbarWidths
@@ -131,7 +116,7 @@ export default abstract class TableView extends View {
     if (!table.rowEls) {
       if (!isAuto) {
         scrollerHeight = this.computeScrollerHeight(viewHeight)
-        this.scroller.setHeight(scrollerHeight)
+        scroller.setHeight(scrollerHeight)
       }
       return
     }
@@ -139,12 +124,12 @@ export default abstract class TableView extends View {
     if (this.colWeekNumbersVisible) {
       // Make sure all week number cells running down the side have the same width.
       this.weekNumberWidth = matchCellWidths(
-        findElements(this.rootEl, '.fc-week-number')
+        findElements(rootEl, '.fc-week-number')
       )
     }
 
     // reset all heights to be natural
-    this.scroller.clear()
+    scroller.clear()
     if (headRowEl) {
       uncompensateScroll(headRowEl)
     }
@@ -166,8 +151,8 @@ export default abstract class TableView extends View {
 
     if (!isAuto) { // should we force dimensions of the scroll container?
 
-      this.scroller.setHeight(scrollerHeight)
-      scrollbarWidths = this.scroller.getScrollbarWidths()
+      scroller.setHeight(scrollerHeight)
+      scrollbarWidths = scroller.getScrollbarWidths()
 
       if (scrollbarWidths.left || scrollbarWidths.right) { // using scrollbars?
 
@@ -177,19 +162,21 @@ export default abstract class TableView extends View {
 
         // doing the scrollbar compensation might have created text overflow which created more height. redo
         scrollerHeight = this.computeScrollerHeight(viewHeight)
-        this.scroller.setHeight(scrollerHeight)
+        scroller.setHeight(scrollerHeight)
       }
 
       // guarantees the same scrollbar widths
-      this.scroller.lockOverflow(scrollbarWidths)
+      scroller.lockOverflow(scrollbarWidths)
     }
   }
 
 
   // given a desired total height of the view, returns what the height of the scroller should be
   computeScrollerHeight(viewHeight) {
-    return viewHeight -
-      subtractInnerElHeight(this.rootEl, this.scroller.el) // everything that's NOT the scroller
+    let rootEl = this.rootElRef.current
+    let scroller = this.scrollerRef.current
+
+    return viewHeight - subtractInnerElHeight(rootEl, scroller.rootEl) // everything that's NOT the scroller
   }
 
 
@@ -227,13 +214,17 @@ export default abstract class TableView extends View {
 
 
   queryDateScroll() {
-    return { top: this.scroller.controller.getScrollTop() }
+    let scroller = this.scrollerRef.current
+
+    return { top: scroller.controller.getScrollTop() }
   }
 
 
   applyDateScroll(scroll) {
+    let scroller = this.scrollerRef.current
+
     if (scroll.top !== undefined) {
-      this.scroller.controller.setScrollTop(scroll.top)
+      scroller.controller.setScrollTop(scroll.top)
     }
   }
 
@@ -243,19 +234,21 @@ export default abstract class TableView extends View {
 
 
   // Generates the HTML that will go before the day-of week header cells
-  renderHeadIntroHtml = () => {
+  renderHeadIntro = (): VNode[] => {
     let { theme, options } = this.context
 
     if (this.colWeekNumbersVisible) {
-      return '' +
-        '<th class="fc-week-number ' + theme.getClass('widgetHeader') + '" ' + this.weekNumberStyleAttr() + '>' +
-          '<span>' + // needed for matchCellWidths
-            htmlEscape(options.weekLabel) +
-          '</span>' +
-        '</th>'
+      // inner span needed for matchCellWidths
+      return [
+        <th class={'fc-week-number ' + theme.getClass('widgetHeader')} style={this.weekNumberStyles()}>
+          <span>
+            {options.weekLabel}
+          </span>
+        </th>
+      ]
     }
 
-    return ''
+    return []
   }
 
 
@@ -264,58 +257,53 @@ export default abstract class TableView extends View {
 
 
   // Generates the HTML that will go before content-skeleton cells that display the day/week numbers
-  renderNumberIntroHtml = (row: number, table: Table) => {
+  renderNumberIntro = (row: number, cells: any): VNode[] => {
     let { options, dateEnv } = this.context
-    let cells = table.props.cells
     let weekStart = cells[row][0].date
     let colCnt = cells[0].length
 
     if (this.colWeekNumbersVisible) {
-      return '' +
-        '<td class="fc-week-number" ' + this.weekNumberStyleAttr() + '>' +
-          buildGotoAnchorHtml( // aside from link, important for matchCellWidths
-            options,
-            dateEnv,
-            { date: weekStart, type: 'week', forceOff: colCnt === 1 },
-            dateEnv.format(weekStart, WEEK_NUM_FORMAT) // inner HTML
-          ) +
-        '</td>'
+
+      // aside from link, the GotoAnchor is important for matchCellWidths
+      return [
+        <td class='fc-week-number' style={this.weekNumberStyles()}>
+          <GotoAnchor
+            navLinks={options.navLinks}
+            gotoOptions={{ date: weekStart, type: 'week', forceOff: colCnt === 1 }}
+          >{dateEnv.format(weekStart, WEEK_NUM_FORMAT)}</GotoAnchor>
+        </td>
+      ]
     }
 
-    return ''
+    return []
   }
 
 
   // Generates the HTML that goes before the day bg cells for each day-row
-  renderBgIntroHtml = () => {
+  renderBgIntro = (): VNode[] => {
     let { theme } = this.context
 
     if (this.colWeekNumbersVisible) {
-      return '<td class="fc-week-number ' + theme.getClass('widgetContent') + '" ' + this.weekNumberStyleAttr() + '></td>'
+      return [
+        <td class={'fc-week-number ' + theme.getClass('widgetContent')} style={this.weekNumberStyles()}></td>
+      ]
     }
 
-    return ''
+    return []
   }
 
 
   // Generates the HTML that goes before every other type of row generated by Table.
   // Affects mirror-skeleton and highlight-skeleton rows.
-  renderIntroHtml = () => {
+  renderIntro = (): VNode[] => {
 
     if (this.colWeekNumbersVisible) {
-      return '<td class="fc-week-number" ' + this.weekNumberStyleAttr() + '></td>'
+      return [
+        <td class='fc-week-number' style={this.weekNumberStyles()}></td>
+      ]
     }
 
-    return ''
-  }
-
-
-  tableRenderProps: TableRenderProps = {
-    renderNumberIntroHtml: this.renderNumberIntroHtml,
-    renderBgIntroHtml: this.renderBgIntroHtml,
-    renderIntroHtml: this.renderIntroHtml,
-    colWeekNumbersVisible: this.colWeekNumbersVisible,
-    cellWeekNumbersVisible: this.cellWeekNumbersVisible
+    return []
   }
 
 }

+ 1 - 1
packages/daygrid/src/main.ts

@@ -5,7 +5,7 @@ export { default as DayTable, DayTableSlicer } from './DayTable'
 export { default as Table, TableSeg } from './Table'
 export { default as TableView, hasRigidRows } from './TableView'
 export { buildDayTableModel } from './DayTableView'
-export { renderDayBgRowHtml } from './DayBgRow'
+export { default as DayBgRow } from './DayBgRow'
 export { DayTableView as DayGridView } // export as old name!
 
 export default createPlugin({

+ 1 - 2
packages/interaction/src/interactions/DateClicking.ts

@@ -13,10 +13,9 @@ export default class DateClicking extends Interaction {
 
   constructor(settings: InteractionSettings) {
     super(settings)
-    let { component } = settings
 
     // we DO want to watch pointer moves because otherwise finalHit won't get populated
-    this.dragging = new FeaturefulElementDragging(component.rootEl)
+    this.dragging = new FeaturefulElementDragging(settings.el)
     this.dragging.autoScroller.isEnabled = false
 
     let hitDragging = this.hitDragging = new HitDragging(this.dragging, interactionSettingsToStore(settings))

+ 1 - 1
packages/interaction/src/interactions/DateSelecting.ts

@@ -22,7 +22,7 @@ export default class DateSelecting extends Interaction {
     let { component } = settings
     let { options } = component.context
 
-    let dragging = this.dragging = new FeaturefulElementDragging(component.rootEl)
+    let dragging = this.dragging = new FeaturefulElementDragging(settings.el)
     dragging.touchScrollAllowed = false
     dragging.minDistance = options.selectMinDistance || 0
     dragging.autoScroller.isEnabled = options.dragScroll

+ 1 - 1
packages/interaction/src/interactions/EventDragging.ts

@@ -40,7 +40,7 @@ export default class EventDragging extends Interaction { // TODO: rename to Even
     let { component } = this
     let { options } = component.context
 
-    let dragging = this.dragging = new FeaturefulElementDragging(component.rootEl)
+    let dragging = this.dragging = new FeaturefulElementDragging(settings.el)
     dragging.pointer.selector = EventDragging.SELECTOR
     dragging.touchScrollAllowed = false
     dragging.autoScroller.isEnabled = options.dragScroll

+ 1 - 1
packages/interaction/src/interactions/EventResizing.ts

@@ -34,7 +34,7 @@ export default class EventDragging extends Interaction {
     super(settings)
     let { component } = settings
 
-    let dragging = this.dragging = new FeaturefulElementDragging(component.rootEl)
+    let dragging = this.dragging = new FeaturefulElementDragging(settings.el)
     dragging.pointer.selector = '.fc-resizer'
     dragging.touchScrollAllowed = false
     dragging.autoScroller.isEnabled = component.context.options.dragScroll

+ 57 - 54
packages/list/src/ListView.ts → packages/list/src/ListView.tsx

@@ -16,11 +16,11 @@ import {
   EventStore,
   memoize,
   Seg,
-  ViewSpec,
-  renderer,
-  renderViewEl
+  subrenderer,
+  getViewClassNames
 } from '@fullcalendar/core'
 import ListViewEvents from './ListViewEvents'
+import { h, createRef } from 'preact'
 
 /*
 Responsible for the scroller, and forwarding event-related actions into the "grid".
@@ -29,52 +29,67 @@ export default class ListView extends View {
 
   private computeDateVars = memoize(computeDateVars)
   private eventStoreToSegs = memoize(this._eventStoreToSegs)
-  private renderSkeleton = renderer(renderSkeleton)
-  private renderScroller = renderer(Scroller)
-  private renderEvents = renderer(ListViewEvents)
-
-  // for sizing
+  private renderEvents = subrenderer(ListViewEvents)
+  private rootEl: HTMLDivElement
+  private scrollerRef = createRef<Scroller>()
   private eventRenderer: ListViewEvents
-  private scroller: Scroller
 
+  getRootEl() { return this.rootEl }
 
-  render(props: ViewProps) {
-    let { dayDates, dayRanges } = this.computeDateVars(props.dateProfile)
 
-    let rootEl = this.renderSkeleton({
-      viewSpec: props.viewSpec
-    })
+  render(props: ViewProps, state: {}, context: ComponentContext) {
+    let classNames = getViewClassNames(props.viewSpec).concat('fc-list-view')
+    let themeClassName = context.theme.getClass('listView')
 
-    this.scroller = this.renderScroller({
-      parentEl: rootEl,
-      overflowX: 'hidden',
-      overflowY: 'auto'
-    })
+    if (themeClassName) {
+      classNames.push(themeClassName)
+    }
 
-    this.eventRenderer = this.renderEvents({
-      segs: this.eventStoreToSegs(props.eventStore, props.eventUiBases, dayRanges),
-      dayDates,
-      contentEl: this.scroller.el,
-      selectedInstanceId: props.eventSelection, // TODO: rename
-      hiddenInstances: // TODO: more convenient
-        (props.eventDrag ? props.eventDrag.affectedEvents.instances : null) ||
-        (props.eventResize ? props.eventResize.affectedEvents.instances : null)
-    })
+    return (
+      <div ref={this.setRootEl} class={classNames.join(' ')}>
+        <Scroller ref={this.scrollerRef} overflowX='hidden' overflowY='auto' />
+      </div>
+    )
+  }
 
-    return rootEl
+
+  setRootEl = (rootEl: HTMLDivElement | null) => {
+    this.rootEl = rootEl
+
+    if (rootEl) {
+      this.context.calendar.registerInteractiveComponent(this, { // TODO: make aware that it doesn't do Hits
+        el: rootEl
+      })
+    } else {
+      this.context.calendar.unregisterInteractiveComponent(this)
+      this.subrenderDestroy()
+    }
   }
 
 
   componentDidMount() {
-    this.context.calendar.registerInteractiveComponent(this, {
-      el: this.rootEl
-      // TODO: make aware that it doesn't do Hits
-    })
+    this.subrender()
+  }
+
+
+  componentDidUpdate() {
+    this.subrender()
   }
 
 
-  componentWillUnmount() {
-    this.context.calendar.unregisterInteractiveComponent(this)
+  subrender() {
+    let { props } = this
+    let { dayDates, dayRanges } = this.computeDateVars(props.dateProfile)
+
+    this.eventRenderer = this.renderEvents({
+      segs: this.eventStoreToSegs(props.eventStore, props.eventUiBases, dayRanges),
+      dayDates,
+      contentEl: this.scrollerRef.current.rootEl,
+      selectedInstanceId: props.eventSelection, // TODO: rename
+      hiddenInstances: // TODO: more convenient
+        (props.eventDrag ? props.eventDrag.affectedEvents.instances : null) ||
+        (props.eventResize ? props.eventResize.affectedEvents.instances : null)
+    })
   }
 
 
@@ -84,17 +99,20 @@ export default class ListView extends View {
     this.eventRenderer.computeSizes(isResize, this)
     this.eventRenderer.assignSizes(isResize, this)
 
-    this.scroller.clear() // sets height to 'auto' and clears overflow
+    let scroller = this.scrollerRef.current
+    scroller.clear() // sets height to 'auto' and clears overflow
 
     if (!isAuto) {
-      this.scroller.setHeight(this.computeScrollerHeight(viewHeight))
+      scroller.setHeight(this.computeScrollerHeight(viewHeight))
     }
   }
 
 
   computeScrollerHeight(viewHeight) {
-    return viewHeight -
-      subtractInnerElHeight(this.rootEl, this.scroller.el) // everything that's NOT the scroller
+    let { rootEl } = this
+    let scrollerEl = this.scrollerRef.current.rootEl
+
+    return viewHeight - subtractInnerElHeight(rootEl, scrollerEl) // everything that's NOT the scroller
   }
 
 
@@ -173,21 +191,6 @@ export default class ListView extends View {
 ListView.prototype.fgSegSelector = '.fc-list-item' // which elements accept event actions
 
 
-function renderSkeleton(props: { viewSpec: ViewSpec }, context: ComponentContext) {
-  let rootEl = renderViewEl(props.viewSpec.type)
-  rootEl.classList.add('fc-list-view')
-
-  let listViewClassNames = (context.theme.getClass('listView') || '').split(' ') // wish we didn't have to do this
-  for (let listViewClassName of listViewClassNames) {
-    if (listViewClassName) { // in case input was empty string
-      rootEl.classList.add(listViewClassName)
-    }
-  }
-
-  return rootEl
-}
-
-
 function computeDateVars(dateProfile: DateProfile) {
   let dayStart = startOfDay(dateProfile.renderRange.start)
   let viewEnd = dateProfile.renderRange.end

+ 79 - 45
packages/list/src/ListViewEvents.ts → packages/list/src/ListViewEvents.tsx

@@ -3,16 +3,16 @@ import {
   FgEventRenderer,
   Seg,
   isMultiDayRange,
-  getAllDayHtml,
   BaseFgEventRendererProps,
   ComponentContext,
   createFormatter,
-  createElement,
-  buildGotoAnchorHtml,
   htmlToElement,
-  renderer,
-  sortEventSegs
+  subrenderer,
+  sortEventSegs,
+  renderVNodes,
+  GotoAnchor
 } from '@fullcalendar/core'
+import { h } from 'preact'
 
 
 export interface ListViewEventsProps extends BaseFgEventRendererProps {
@@ -23,7 +23,7 @@ export interface ListViewEventsProps extends BaseFgEventRendererProps {
 
 export default class ListViewEvents extends FgEventRenderer<ListViewEventsProps> {
 
-  attachSegs = renderer(attachSegs)
+  private attachSegs = subrenderer(attachSegs, detachSegs)
 
 
   render(props: ListViewEventsProps, context: ComponentContext) {
@@ -32,7 +32,7 @@ export default class ListViewEvents extends FgEventRenderer<ListViewEventsProps>
       mirrorInfo: props.mirrorInfo,
       selectedInstanceId: props.selectedInstanceId,
       hiddenInstances: props.hiddenInstances
-    }, context)
+    })
 
     this.attachSegs({
       segs,
@@ -52,35 +52,47 @@ export default class ListViewEvents extends FgEventRenderer<ListViewEventsProps>
     let url = eventDef.url
     let classes = [ 'fc-list-item' ].concat(eventUi.classNames)
     let bgColor = eventUi.backgroundColor
+    let timeText
     let timeHtml
 
     if (eventDef.allDay) {
-      timeHtml = getAllDayHtml(options)
+      timeHtml = options.allDayHtml
+      timeText = options.allDayText
+
     } else if (isMultiDayRange(eventRange.range)) {
+
       if (seg.isStart) {
-        timeHtml = htmlEscape(this._getTimeText(
+        timeText = this._getTimeText(
           eventInstance.range.start,
           seg.end,
           false // allDay
-        ))
+        )
+
       } else if (seg.isEnd) {
-        timeHtml = htmlEscape(this._getTimeText(
+        timeText = this._getTimeText(
           seg.start,
           eventInstance.range.end,
           false // allDay
-        ))
+        )
+
       } else { // inner segment that lasts the whole day
-        timeHtml = getAllDayHtml(options)
+        timeHtml = options.allDayHtml
+        timeText = options.allDayText
       }
+
     } else {
       // Display the normal time text for the *event's* times
-      timeHtml = htmlEscape(this.getTimeText(eventRange))
+      timeText = this.getTimeText(eventRange)
     }
 
     if (url) {
       classes.push('fc-has-url')
     }
 
+    if (timeText) {
+      timeHtml = htmlEscape(timeText)
+    }
+
     return '<tr class="' + classes.join(' ') + '">' +
       (this.displayEventTime ?
         '<td class="fc-list-item-time ' + theme.getClass('widgetContent') + '">' +
@@ -115,12 +127,19 @@ export default class ListViewEvents extends FgEventRenderer<ListViewEventsProps>
 }
 
 
-function attachSegs(props: { segs, dayDates: Date[], contentEl: HTMLElement }, context: ComponentContext) {
-  if (props.segs.length) {
-    renderSegList(props.segs, props.dayDates, props.contentEl, context)
+function attachSegs({ segs, dayDates, contentEl }: { segs, dayDates: Date[], contentEl: HTMLElement }, context: ComponentContext) {
+  if (segs.length) {
+    renderSegList(segs, dayDates, contentEl, context)
   } else {
-    renderEmptyMessage(props.contentEl, context)
+    renderEmptyMessage(contentEl, context)
   }
+
+  return contentEl
+}
+
+
+function detachSegs(contentEl: HTMLElement) {
+  contentEl.innerHTML = ''
 }
 
 
@@ -163,7 +182,6 @@ function renderSegList(allSegs, dayDates: Date[], contentEl: HTMLElement, contex
     }
   }
 
-  contentEl.innerHTML = '' // will unrender previous renders
   contentEl.appendChild(tableEl)
 }
 
@@ -174,32 +192,48 @@ function buildDayHeaderRow(dayDate, context: ComponentContext) {
   let mainFormat = createFormatter(options.listDayFormat) // TODO: cache
   let altFormat = createFormatter(options.listDayAltFormat) // TODO: cache
 
-  return createElement('tr', {
-    className: 'fc-list-heading',
-    'data-date': dateEnv.formatIso(dayDate, { omitTime: true })
-  }, '<td class="' + (
-    theme.getClass('tableListHeading') ||
-    theme.getClass('widgetHeader')
-  ) + '" colspan="3">' +
-    (mainFormat ?
-      buildGotoAnchorHtml(
-        options,
-        dateEnv,
-        dayDate,
-        { 'class': 'fc-list-heading-main' },
-        htmlEscape(dateEnv.format(dayDate, mainFormat)) // inner HTML
-      ) :
-      '') +
-    (altFormat ?
-      buildGotoAnchorHtml(
-        options,
-        dateEnv,
-        dayDate,
-        { 'class': 'fc-list-heading-alt' },
-        htmlEscape(dateEnv.format(dayDate, altFormat)) // inner HTML
-      ) :
-      '') +
-  '</td>') as HTMLTableRowElement
+  let tr = document.createElement('tr')
+  tr.className = 'fc-list-heading'
+  tr.setAttribute('data-date', dateEnv.formatIso(dayDate, { omitTime: true }))
+
+  let td = document.createElement('td')
+  td.className = theme.getClass('tableListHeading') + ' ' + theme.getClass('widgetHeader')
+  td.colSpan = 3
+  tr.appendChild(td)
+
+  let inners = []
+
+  if (mainFormat) {
+    inners.push(
+      ...renderVNodes(
+        <GotoAnchor
+          navLinks={options.navLinks}
+          gotoOptions={dayDate}
+          extraAttrs={{ 'class': 'fc-list-heading-main' }}
+        >{dateEnv.format(dayDate, mainFormat)}</GotoAnchor>,
+        context
+      )
+    )
+  }
+
+  if (altFormat) {
+    inners.push(
+      ...renderVNodes(
+        <GotoAnchor
+          navLinks={options.navLinks}
+          gotoOptions={dayDate}
+          extraAttrs={{ 'class': 'fc-list-heading-alt' }}
+        >{dateEnv.format(dayDate, altFormat)}</GotoAnchor>,
+        context
+      )
+    )
+  }
+
+  for (let inner of inners) {
+    td.appendChild(inner)
+  }
+
+  return tr
 }
 
 

+ 34 - 32
packages/timegrid/src/DayTimeCols.ts → packages/timegrid/src/DayTimeCols.tsx

@@ -12,13 +12,13 @@ import {
   DateMarker,
   Slicer,
   Hit,
-  ComponentContext,
-  renderer,
+  ComponentContext
 } from '@fullcalendar/core'
-import TimeCols, { TimeColsSeg, TimeColsRenderProps } from './TimeCols'
+import TimeCols, { TimeColsSeg } from './TimeCols'
+import { h, createRef, VNode } from 'preact'
+
 
 export interface DayTimeColsProps {
-  renderProps: TimeColsRenderProps
   dateProfile: DateProfile | null
   dayTableModel: DayTableModel
   businessHours: EventStore
@@ -28,48 +28,49 @@ export interface DayTimeColsProps {
   eventSelection: string
   eventDrag: EventInteractionState | null
   eventResize: EventInteractionState | null
+  renderBgIntro: () => VNode[]
+  renderIntro: () => VNode[]
 }
 
+
 export default class DayTimeCols extends DateComponent<DayTimeColsProps> {
 
   private buildDayRanges = memoize(buildDayRanges)
-  private renderTimeCols = renderer(TimeCols)
-  private registerInteractive = renderer(this._registerInteractive, this._unregisterInteractive)
-
   private dayRanges: DateRange[] // for now indicator
   private slicer = new DayTimeColsSlicer()
-  timeCols: TimeCols
+  private timeColsRef = createRef<TimeCols>()
+
+  get timeCols() { return this.timeColsRef.current }
 
 
-  render(props: DayTimeColsProps, context: ComponentContext) {
+  render(props: DayTimeColsProps, state: {}, context: ComponentContext) {
     let { dateEnv } = context
     let { dateProfile, dayTableModel } = props
-
-    let dayRanges = this.buildDayRanges(dayTableModel, dateProfile, dateEnv)
-
-    let timeCols = this.renderTimeCols({
-      ...this.slicer.sliceProps(props, dateProfile, null, context.calendar, dayRanges),
-      renderProps: props.renderProps,
-      dateProfile,
-      cells: dayTableModel.cells[0] // give the first row
-    })
-
-    this.registerInteractive({ el: timeCols.rootEl })
-
-    this.dayRanges = dayRanges
-    this.timeCols = timeCols
-
-    return timeCols
+    let dayRanges = this.dayRanges = this.buildDayRanges(dayTableModel, dateProfile, dateEnv)
+
+    // give it the first row of cells
+    return (
+      <TimeCols
+        ref={this.timeColsRef}
+        rootElRef={this.handleRootEl}
+        {...this.slicer.sliceProps(props, dateProfile, null, context.calendar, dayRanges)}
+        dateProfile={dateProfile}
+        cells={dayTableModel.cells[0]}
+        renderBgIntro={props.renderBgIntro}
+        renderIntro={props.renderIntro}
+      />
+    )
   }
 
 
-  _registerInteractive({ el }: { el: HTMLElement }, context: ComponentContext) {
-    context.calendar.registerInteractiveComponent(this, { el })
-  }
-
+  handleRootEl = (rootEl: HTMLDivElement | null) => {
+    let { calendar } = this.context
 
-  _unregisterInteractive(funcState: void, context: ComponentContext) {
-    context.calendar.unregisterInteractiveComponent(this)
+    if (rootEl) {
+      calendar.registerInteractiveComponent(this, { el: rootEl })
+    } else {
+      calendar.unregisterInteractiveComponent(this)
+    }
   }
 
 
@@ -102,7 +103,8 @@ export default class DayTimeCols extends DateComponent<DayTimeColsProps> {
 
 
   queryHit(positionLeft: number, positionTop: number): Hit {
-    let rawHit = this.timeCols.positionToHit(positionLeft, positionTop)
+    let timeCols = this.timeColsRef.current
+    let rawHit = timeCols.positionToHit(positionLeft, positionTop)
 
     if (rawHit) {
       return {

+ 0 - 110
packages/timegrid/src/DayTimeColsView.ts

@@ -1,110 +0,0 @@
-import {
-  DateProfileGenerator, DateProfile,
-  ComponentContext,
-  DayHeader,
-  DaySeries,
-  DayTableModel,
-  memoize,
-  ViewProps,
-  renderer
-} from '@fullcalendar/core'
-import { DayTable } from '@fullcalendar/daygrid'
-import TimeColsView from './TimeColsView'
-import DayTimeCols from './DayTimeCols'
-
-
-export default class DayTimeColsView extends TimeColsView {
-
-  private buildDayTableModel = memoize(buildDayTableModel)
-  private renderDayHeader = renderer(DayHeader)
-  private renderDayTable = renderer(DayTable)
-  private renderDayTimeCols = renderer(DayTimeCols)
-
-  private allDayTable: DayTable
-  private timeCols: DayTimeCols
-
-
-  render(props: ViewProps, context: ComponentContext) {
-    let { dateProfile, dateProfileGenerator } = props
-    let { nextDayThreshold } = context
-    let dayTableModel = this.buildDayTableModel(dateProfile, dateProfileGenerator)
-    let splitProps = this.allDaySplitter.splitProps(props)
-
-    let {
-      rootEl,
-      headerWrapEl,
-      contentWrapEl
-    } = this.renderLayout({ type: props.viewSpec.type })
-
-    this.renderDayHeader({
-      parentEl: headerWrapEl, // might be null
-      dateProfile,
-      dates: dayTableModel.headerDates,
-      datesRepDistinctDays: true,
-      renderIntroHtml: this.renderHeadIntroHtml
-    })
-
-    let allDayTable = this.renderDayTable({
-      parentEl: contentWrapEl, // might be null
-      prepend: true,
-      ...splitProps['allDay'],
-      dateProfile,
-      dayTableModel,
-      nextDayThreshold,
-      isRigid: false,
-      renderProps: this.tableRenderProps
-    })
-
-    let timeCols = this.renderDayTimeCols({
-      parentEl: contentWrapEl,
-      ...splitProps['timed'],
-      dateProfile,
-      dayTableModel,
-      renderProps: this.timeColsRenderProps
-    })
-
-    this.startNowIndicator()
-
-    this.allDayTable = allDayTable
-    this.timeCols = timeCols
-
-    return rootEl
-  }
-
-
-  updateSize(isResize: boolean, viewHeight: number, isAuto: boolean) {
-
-    if (isResize || this.isLayoutSizeDirty()) {
-      this.updateLayoutSize(
-        this.timeCols.timeCols,
-        this.allDayTable ? this.allDayTable.table : null,
-        viewHeight,
-        isAuto
-      )
-    }
-
-    if (this.allDayTable) {
-      this.allDayTable.updateSize(isResize)
-    }
-
-    this.timeCols.updateSize(isResize)
-  }
-
-
-  getAllDayTableObj() {
-    return this.allDayTable
-  }
-
-
-  getTimeColsObj() {
-    return this.timeCols
-  }
-
-}
-
-
-export function buildDayTableModel(dateProfile: DateProfile, dateProfileGenerator: DateProfileGenerator) {
-  let daySeries = new DaySeries(dateProfile.renderRange, dateProfileGenerator)
-
-  return new DayTableModel(daySeries, false)
-}

+ 100 - 0
packages/timegrid/src/DayTimeColsView.tsx

@@ -0,0 +1,100 @@
+import {
+  DateProfileGenerator, DateProfile,
+  ComponentContext,
+  DayHeader,
+  DaySeries,
+  DayTableModel,
+  memoize,
+  ViewProps
+} from '@fullcalendar/core'
+import { DayTable } from '@fullcalendar/daygrid'
+import TimeColsView from './TimeColsView'
+import DayTimeCols from './DayTimeCols'
+import { h, createRef } from 'preact'
+
+
+export default class DayTimeColsView extends TimeColsView {
+
+  private buildDayTableModel = memoize(buildDayTableModel)
+  private dayTableRef = createRef<DayTable>()
+  private timeColsRef = createRef<DayTimeCols>()
+
+
+  render(props: ViewProps, state: {}, context: ComponentContext) {
+    let { dateProfile, dateProfileGenerator } = props
+    let { nextDayThreshold, options } = context
+    let dayTableModel = this.buildDayTableModel(dateProfile, dateProfileGenerator)
+    let splitProps = this.allDaySplitter.splitProps(props)
+
+    return this.renderLayout(
+      options.columnHeader &&
+        <DayHeader
+          dateProfile={dateProfile}
+          dates={dayTableModel.headerDates}
+          datesRepDistinctDays={true}
+          renderIntro={this.renderHeadIntro}
+        />,
+      options.allDaySlot &&
+        <DayTable
+          ref={this.dayTableRef}
+          {...splitProps['allDay']}
+          dateProfile={dateProfile}
+          dayTableModel={dayTableModel}
+          nextDayThreshold={nextDayThreshold}
+          isRigid={false}
+          renderNumberIntro={this.renderTableIntro}
+          renderBgIntro={this.renderTableBgIntro}
+          renderIntro={this.renderTableIntro}
+          colWeekNumbersVisible={false}
+          cellWeekNumbersVisible={false}
+        />,
+      <DayTimeCols
+        ref={this.timeColsRef}
+        {...splitProps['timed']}
+        dateProfile={dateProfile}
+        dayTableModel={dayTableModel}
+        renderBgIntro={this.renderTimeColsBgIntro}
+        renderIntro={this.renderTimeColsIntro}
+      />
+    )
+  }
+
+
+  updateSize(isResize: boolean, viewHeight: number, isAuto: boolean) {
+    let timeCols = this.timeColsRef.current
+    let dayTableRef = this.dayTableRef.current
+
+    if (isResize || this.isLayoutSizeDirty()) {
+      this.updateLayoutSize(
+        timeCols.timeCols,
+        dayTableRef ? dayTableRef.table : null,
+        viewHeight,
+        isAuto
+      )
+    }
+
+    if (dayTableRef) {
+      dayTableRef.updateSize(isResize)
+    }
+
+    timeCols.updateSize(isResize)
+  }
+
+
+  getAllDayTableObj() {
+    return this.dayTableRef.current
+  }
+
+
+  getTimeColsObj() {
+    return this.timeColsRef.current
+  }
+
+}
+
+
+export function buildDayTableModel(dateProfile: DateProfile, dateProfileGenerator: DateProfileGenerator) {
+  let daySeries = new DaySeries(dateProfile.renderRange, dateProfileGenerator)
+
+  return new DayTableModel(daySeries, false)
+}

+ 0 - 759
packages/timegrid/src/TimeCols.ts

@@ -1,759 +0,0 @@
-import {
-  htmlEscape,
-  htmlToElement,
-  findElements,
-  removeElement,
-  applyStyle,
-  createElement,
-  PositionCache,
-  Duration,
-  createDuration,
-  addDurations,
-  multiplyDuration,
-  wholeDivideDurations,
-  asRoughMs,
-  startOfDay,
-  DateMarker,
-  DateFormatter,
-  createFormatter,
-  formatIsoTimeString,
-  ComponentContext,
-  Component,
-  Seg,
-  EventSegUiInteractionState,
-  DateProfile,
-  sortEventSegs,
-  memoize,
-  renderer
-} from '@fullcalendar/core'
-import { renderDayBgRowHtml } from '@fullcalendar/daygrid'
-import TimeColsEvents from './TimeColsEvents'
-import TimeColsMirrorEvents from './TimeColsMirrorEvents'
-import TimeColsFills from './TimeColsFills'
-
-
-/* A component that renders one or more columns of vertical time slots
-----------------------------------------------------------------------------------------------------------------------*/
-
-// potential nice values for the slot-duration and interval-duration
-// from largest to smallest
-const STOCK_SUB_DURATIONS = [
-  { hours: 1 },
-  { minutes: 30 },
-  { minutes: 15 },
-  { seconds: 30 },
-  { seconds: 15 }
-]
-
-export interface TimeColsRenderProps {
-  renderBgIntroHtml: () => string
-  renderIntroHtml: () => string
-}
-
-export interface TimeColsSeg extends Seg {
-  col: number
-  start: DateMarker
-  end: DateMarker
-}
-
-export interface TimeColsCell {
-  date: DateMarker
-  htmlAttrs?: string
-}
-
-export interface TimeColsProps {
-  renderProps: TimeColsRenderProps
-  dateProfile: DateProfile
-  cells: TimeColsCell[]
-  businessHourSegs: TimeColsSeg[]
-  bgEventSegs: TimeColsSeg[]
-  fgEventSegs: TimeColsSeg[]
-  dateSelectionSegs: TimeColsSeg[]
-  eventSelection: string
-  eventDrag: EventSegUiInteractionState | null
-  eventResize: EventSegUiInteractionState | null
-}
-
-export default class TimeCols extends Component<TimeColsProps, ComponentContext> {
-
-  processOptions = memoize(this._processOptions)
-  renderSkeleton = renderer(renderSkeleton)
-  renderSlats = renderer(this._renderSlats)
-  renderBgColumns = renderer(this._renderBgColumns)
-  renderContentSkeleton = renderer(renderContentSkeleton)
-  renderMirrorEvents = renderer(TimeColsMirrorEvents)
-  renderFgEvents = renderer(TimeColsEvents)
-  renderBgEvents = renderer(TimeColsFills)
-  renderBusinessHours = renderer(TimeColsFills)
-  renderDateSelection = renderer(TimeColsFills)
-
-  // computed options
-  slotDuration: Duration // duration of a "slot", a distinct time segment on given day, visualized by lines
-  snapDuration: Duration // granularity of time for dragging and selecting
-  snapsPerSlot: any
-  labelFormat: DateFormatter // formatting string for times running along vertical axis
-  labelInterval: Duration // duration of how often a label should be displayed for a slot
-
-  colEls: HTMLElement[] // cells elements in the day-row background
-  slatContainerEl: HTMLElement // div that wraps all the slat rows
-  slatEls: HTMLElement[] // elements running horizontally across all columns
-  nowIndicatorEls: HTMLElement[]
-
-  colPositions: PositionCache
-  slatPositions: PositionCache
-  isSlatSizesDirty: boolean = false
-  isColSizesDirty: boolean = false
-
-  bottomRuleEl: HTMLElement // hidden by default. controlled by parent components!?
-  contentSkeletonEl: HTMLElement
-  colContainerEls: HTMLElement[] // containers for each column
-
-  segRenderers: (TimeColsEvents | TimeColsFills | null)[]
-
-
-  /* Options
-  ------------------------------------------------------------------------------------------------------------------*/
-
-
-  // Parses various options into properties of this object
-  // MUST have context already set
-  _processOptions(options) {
-    let { slotDuration, snapDuration } = options
-    let snapsPerSlot
-    let input
-
-    slotDuration = createDuration(slotDuration)
-    snapDuration = snapDuration ? createDuration(snapDuration) : slotDuration
-    snapsPerSlot = wholeDivideDurations(slotDuration, snapDuration)
-
-    if (snapsPerSlot === null) {
-      snapDuration = slotDuration
-      snapsPerSlot = 1
-      // TODO: say warning?
-    }
-
-    this.slotDuration = slotDuration
-    this.snapDuration = snapDuration
-    this.snapsPerSlot = snapsPerSlot
-
-    // might be an array value (for TimelineView).
-    // if so, getting the most granular entry (the last one probably).
-    input = options.slotLabelFormat
-    if (Array.isArray(input)) {
-      input = input[input.length - 1]
-    }
-
-    this.labelFormat = createFormatter(input || {
-      hour: 'numeric',
-      minute: '2-digit',
-      omitZeroMinute: true,
-      meridiem: 'short'
-    })
-
-    input = options.slotLabelInterval
-    this.labelInterval = input ?
-      createDuration(input) :
-      computeLabelInterval(slotDuration)
-  }
-
-
-  /* Rendering
-  ------------------------------------------------------------------------------------------------------------------*/
-
-
-  render(props: TimeColsProps, context: ComponentContext) {
-    let { options } = context
-    this.processOptions(options)
-
-    let {
-      rootEl,
-      rootBgContainerEl,
-      contentSkeletonEl,
-      bottomRuleEl,
-      slatContainerEl
-    } = this.renderSkeleton({})
-
-    this.renderBgColumns({
-      parentEl: rootBgContainerEl,
-      rootEl,
-      cells: props.cells,
-      dateProfile: props.dateProfile,
-      renderProps: props.renderProps
-    })
-
-    this.renderSlats({
-      parentEl: slatContainerEl,
-      rootEl,
-      dateProfile: props.dateProfile
-    })
-
-    let {
-      colContainerEls,
-      businessContainerEls,
-      bgContainerEls,
-      fgContainerEls,
-      highlightContainerEls,
-      mirrorContainerEls
-    } = this.renderContentSkeleton({
-      parentEl: contentSkeletonEl,
-      colCnt: props.cells.length,
-      renderProps: props.renderProps
-    })
-
-    let segRenderers = [
-      this.renderBusinessHours({
-        type: 'businessHours',
-        containerEls: businessContainerEls,
-        segs: props.businessHourSegs,
-      }),
-
-      this.renderDateSelection({
-        type: 'highlight',
-        containerEls: highlightContainerEls,
-        segs: options.selectMirror ? null : props.dateSelectionSegs // do highlight if NO mirror
-      }),
-
-      this.renderBgEvents({
-        type: 'bgEvent',
-        containerEls: bgContainerEls,
-        segs: props.bgEventSegs
-      }),
-
-      this.renderFgEvents({
-        containerEls: fgContainerEls,
-        segs: props.fgEventSegs,
-        selectedInstanceId: props.eventSelection,
-        hiddenInstances: // TODO: more convenient
-          (props.eventDrag ? props.eventDrag.affectedInstances : null) ||
-          (props.eventResize ? props.eventResize.affectedInstances : null)
-      }),
-
-      this.handleMirror(props, mirrorContainerEls, options)
-    ]
-
-    this.segRenderers = segRenderers
-    this.contentSkeletonEl = contentSkeletonEl
-    this.bottomRuleEl = bottomRuleEl
-    this.colContainerEls = colContainerEls
-
-    return rootEl
-  }
-
-
-  handleMirror(props: TimeColsProps, mirrorContainerEls: HTMLElement[], options): TimeColsEvents | null {
-
-    if (props.eventDrag) {
-      return this.renderMirrorEvents({
-        containerEls: mirrorContainerEls,
-        segs: props.eventDrag.segs,
-        mirrorInfo: { isDragging: true, sourceSeg: props.eventDrag.sourceSeg }
-      })
-
-    } else if (props.eventResize) {
-      return this.renderMirrorEvents({
-        containerEls: mirrorContainerEls,
-        segs: props.eventResize.segs,
-        mirrorInfo: { isDragging: true, sourceSeg: props.eventResize.sourceSeg }
-      })
-
-    } else if (options.selectMirror) {
-      return this.renderMirrorEvents({
-        containerEls: mirrorContainerEls,
-        segs: props.dateSelectionSegs,
-        mirrorInfo: { isSelecting: true }
-      })
-
-    } else {
-      return this.renderMirrorEvents(false)
-    }
-  }
-
-
-  updateSize(isResize: boolean) {
-    let { segRenderers } = this
-
-    if (isResize || this.isSlatSizesDirty) {
-      this.buildSlatPositions()
-      this.isSlatSizesDirty = false
-    }
-
-    if (isResize || this.isColSizesDirty) {
-      this.buildColPositions()
-      this.isColSizesDirty = false
-    }
-
-    for (let segRenderer of segRenderers) {
-      if (segRenderer) {
-        segRenderer.computeSizes(isResize, this)
-      }
-    }
-
-    for (let segRenderer of segRenderers) {
-      if (segRenderer) {
-        segRenderer.assignSizes(isResize, this)
-      }
-    }
-  }
-
-
-  _renderSlats(
-    { rootEl, dateProfile }: { rootEl: HTMLElement, dateProfile: DateProfile },
-    context: ComponentContext
-  ) {
-    let tableEl = createElement(
-      'table',
-      { className: context.theme.getClass('tableGrid') },
-      this.renderSlatRowHtml(dateProfile)
-    )
-
-    let slatEls = this.slatEls = findElements(tableEl, 'tr')
-
-    this.slatPositions = new PositionCache(
-      rootEl,
-      slatEls,
-      false,
-      true // vertical
-    )
-    this.isSlatSizesDirty = true
-
-    return [ tableEl ]
-  }
-
-
-  // Generates the HTML for the horizontal "slats" that run width-wise. Has a time axis on a side. Depends on RTL.
-  renderSlatRowHtml(dateProfile: DateProfile) {
-    let { dateEnv, theme, isRtl } = this.context
-    let html = ''
-    let dayStart = startOfDay(dateProfile.renderRange.start)
-    let slotTime = dateProfile.minTime
-    let slotIterator = createDuration(0)
-    let slotDate // will be on the view's first day, but we only care about its time
-    let isLabeled
-    let axisHtml
-
-    // Calculate the time for each slot
-    while (asRoughMs(slotTime) < asRoughMs(dateProfile.maxTime)) {
-      slotDate = dateEnv.add(dayStart, slotTime)
-      isLabeled = wholeDivideDurations(slotIterator, this.labelInterval) !== null
-
-      axisHtml =
-        '<td class="fc-axis fc-time ' + theme.getClass('widgetContent') + '">' +
-          (isLabeled ?
-            '<span>' + // for matchCellWidths
-              htmlEscape(dateEnv.format(slotDate, this.labelFormat)) +
-            '</span>' :
-            ''
-            ) +
-        '</td>'
-
-      html +=
-        '<tr data-time="' + formatIsoTimeString(slotDate) + '"' +
-          (isLabeled ? '' : ' class="fc-minor"') +
-          '>' +
-          (!isRtl ? axisHtml : '') +
-          '<td class="' + theme.getClass('widgetContent') + '"></td>' +
-          (isRtl ? axisHtml : '') +
-        '</tr>'
-
-      slotTime = addDurations(slotTime, this.slotDuration)
-      slotIterator = addDurations(slotIterator, this.slotDuration)
-    }
-
-    return html
-  }
-
-
-  // goes behind the slats
-  _renderBgColumns(
-    { rootEl, cells, dateProfile, renderProps }: { rootEl: HTMLElement, cells: TimeColsCell[], dateProfile: DateProfile, renderProps: any },
-    context: ComponentContext
-  ) {
-    let { calendar, view, isRtl, theme, dateEnv } = context
-
-    let tableEl = createElement(
-      'table',
-      { className: theme.getClass('tableGrid') },
-      renderDayBgRowHtml({
-        cells,
-        dateProfile,
-        renderIntroHtml: renderProps.renderBgIntroHtml
-      }, context)
-    )
-
-    let colEls = this.colEls = findElements(tableEl, '.fc-day, .fc-disabled-day')
-
-    for (let col = 0; col < cells.length; col++) {
-      calendar.publiclyTrigger('dayRender', [
-        {
-          date: dateEnv.toDate(cells[col].date),
-          el: colEls[col],
-          view
-        }
-      ])
-    }
-
-    if (isRtl) {
-      this.colEls.reverse()
-    }
-
-    this.colPositions = new PositionCache(
-      rootEl,
-      colEls,
-      true, // horizontal
-      false
-    )
-    this.isColSizesDirty = true
-
-    return [ tableEl ]
-  }
-
-
-  /* Now Indicator
-  ------------------------------------------------------------------------------------------------------------------*/
-
-
-  getNowIndicatorUnit() {
-    return 'minute' // will refresh on the minute
-  }
-
-
-  renderNowIndicator(segs: TimeColsSeg[], date) {
-
-    // HACK: if date columns not ready for some reason (scheduler)
-    if (!this.colContainerEls) {
-      return
-    }
-
-    let top = this.computeDateTop(date)
-    let nodes = []
-    let i
-
-    // render lines within the columns
-    for (i = 0; i < segs.length; i++) {
-      let lineEl = createElement('div', { className: 'fc-now-indicator fc-now-indicator-line' })
-      lineEl.style.top = top + 'px'
-      this.colContainerEls[segs[i].col].appendChild(lineEl)
-      nodes.push(lineEl)
-    }
-
-    // render an arrow over the axis
-    if (segs.length > 0) { // is the current time in view?
-      let arrowEl = createElement('div', { className: 'fc-now-indicator fc-now-indicator-arrow' })
-      arrowEl.style.top = top + 'px'
-      this.contentSkeletonEl.appendChild(arrowEl)
-      nodes.push(arrowEl)
-    }
-
-    this.nowIndicatorEls = nodes
-  }
-
-
-  unrenderNowIndicator() {
-    if (this.nowIndicatorEls) {
-      this.nowIndicatorEls.forEach(removeElement)
-      this.nowIndicatorEls = null
-    }
-  }
-
-
-  /* Coordinates
-  ------------------------------------------------------------------------------------------------------------------*/
-
-
-  getTotalSlatHeight() {
-    return this.slatContainerEl.getBoundingClientRect().height
-  }
-
-
-  // Computes the top coordinate, relative to the bounds of the grid, of the given date.
-  // A `startOfDayDate` must be given for avoiding ambiguity over how to treat midnight.
-  computeDateTop(when: DateMarker, startOfDayDate?: DateMarker) {
-    if (!startOfDayDate) {
-      startOfDayDate = startOfDay(when)
-    }
-    return this.computeTimeTop(createDuration(when.valueOf() - startOfDayDate.valueOf()))
-  }
-
-
-  // Computes the top coordinate, relative to the bounds of the grid, of the given time (a Duration).
-  computeTimeTop(duration: Duration) {
-    let len = this.slatEls.length
-    let dateProfile = this.props.dateProfile
-    let slatCoverage = (duration.milliseconds - asRoughMs(dateProfile.minTime)) / asRoughMs(this.slotDuration) // floating-point value of # of slots covered
-    let slatIndex
-    let slatRemainder
-
-    // compute a floating-point number for how many slats should be progressed through.
-    // from 0 to number of slats (inclusive)
-    // constrained because minTime/maxTime might be customized.
-    slatCoverage = Math.max(0, slatCoverage)
-    slatCoverage = Math.min(len, slatCoverage)
-
-    // an integer index of the furthest whole slat
-    // from 0 to number slats (*exclusive*, so len-1)
-    slatIndex = Math.floor(slatCoverage)
-    slatIndex = Math.min(slatIndex, len - 1)
-
-    // how much further through the slatIndex slat (from 0.0-1.0) must be covered in addition.
-    // could be 1.0 if slatCoverage is covering *all* the slots
-    slatRemainder = slatCoverage - slatIndex
-
-    return this.slatPositions.tops[slatIndex] +
-      this.slatPositions.getHeight(slatIndex) * slatRemainder
-  }
-
-
-  // For each segment in an array, computes and assigns its top and bottom properties
-  computeSegVerticals(segs: Seg[]) {
-    let { options } = this.context
-    let eventMinHeight = options.timeGridEventMinHeight
-    let i
-    let seg
-    let dayDate
-
-    for (i = 0; i < segs.length; i++) {
-      seg = segs[i]
-      dayDate = this.props.cells[seg.col].date
-
-      seg.top = this.computeDateTop(seg.start, dayDate)
-      seg.bottom = Math.max(
-        seg.top + eventMinHeight,
-        this.computeDateTop(seg.end, dayDate)
-      )
-    }
-  }
-
-
-  // Given segments that already have their top/bottom properties computed, applies those values to
-  // the segments' elements.
-  assignSegVerticals(segs) {
-    let i
-    let seg
-
-    for (i = 0; i < segs.length; i++) {
-      seg = segs[i]
-      applyStyle(seg.el, this.generateSegVerticalCss(seg))
-    }
-  }
-
-
-  // Generates an object with CSS properties for the top/bottom coordinates of a segment element
-  generateSegVerticalCss(seg) {
-    return {
-      top: seg.top,
-      bottom: -seg.bottom // flipped because needs to be space beyond bottom edge of event container
-    }
-  }
-
-
-  /* Sizing
-  ------------------------------------------------------------------------------------------------------------------*/
-
-
-  buildPositionCaches() {
-    this.buildColPositions()
-    this.buildSlatPositions()
-  }
-
-
-  buildColPositions() {
-    this.colPositions.build()
-  }
-
-
-  buildSlatPositions() {
-    this.slatPositions.build()
-  }
-
-
-  /* Hit System
-  ------------------------------------------------------------------------------------------------------------------*/
-
-  positionToHit(positionLeft, positionTop) {
-    let { dateEnv } = this.context
-    let { snapsPerSlot, slatPositions, colPositions } = this
-
-    let colIndex = colPositions.leftToIndex(positionLeft)
-    let slatIndex = slatPositions.topToIndex(positionTop)
-
-    if (colIndex != null && slatIndex != null) {
-      let slatTop = slatPositions.tops[slatIndex]
-      let slatHeight = slatPositions.getHeight(slatIndex)
-      let partial = (positionTop - slatTop) / slatHeight // floating point number between 0 and 1
-      let localSnapIndex = Math.floor(partial * snapsPerSlot) // the snap # relative to start of slat
-      let snapIndex = slatIndex * snapsPerSlot + localSnapIndex
-
-      let dayDate = this.props.cells[colIndex].date
-      let time = addDurations(
-        this.props.dateProfile.minTime,
-        multiplyDuration(this.snapDuration, snapIndex)
-      )
-
-      let start = dateEnv.add(dayDate, time)
-      let end = dateEnv.add(start, this.snapDuration)
-
-      return {
-        col: colIndex,
-        dateSpan: {
-          range: { start, end },
-          allDay: false
-        },
-        dayEl: this.colEls[colIndex],
-        relativeRect: {
-          left: colPositions.lefts[colIndex],
-          right: colPositions.rights[colIndex],
-          top: slatTop,
-          bottom: slatTop + slatHeight
-        }
-      }
-    }
-  }
-
-}
-
-
-// Computes an automatic value for slotLabelInterval
-function computeLabelInterval(slotDuration) {
-  let i
-  let labelInterval
-  let slotsPerLabel
-
-  // find the smallest stock label interval that results in more than one slots-per-label
-  for (i = STOCK_SUB_DURATIONS.length - 1; i >= 0; i--) {
-    labelInterval = createDuration(STOCK_SUB_DURATIONS[i])
-    slotsPerLabel = wholeDivideDurations(labelInterval, slotDuration)
-    if (slotsPerLabel !== null && slotsPerLabel > 1) {
-      return labelInterval
-    }
-  }
-
-  return slotDuration // fall back
-}
-
-
-function renderSkeleton(props: {}, context: ComponentContext) {
-  let rootEl = createElement(
-    'div',
-    { className: 'fc-time-grid' },
-    '<div class="fc-bg"></div>' +
-    '<div class="fc-slats"></div>' +
-    '<div class="fc-content-skeleton">' +
-    '<hr class="fc-divider ' + context.theme.getClass('widgetHeader') + '" style="display:none" />'
-  )
-
-  return {
-    rootEl,
-    rootBgContainerEl: rootEl.querySelector('.fc-bg') as HTMLElement,
-    slatContainerEl: rootEl.querySelector('.fc-slats') as HTMLElement,
-    contentSkeletonEl: rootEl.querySelector('.fc-content-skeleton') as HTMLElement,
-    bottomRuleEl: rootEl.querySelector('.fc-divider') as HTMLElement
-  }
-}
-
-
-// Renders the DOM that the view's content will live in
-// goes in front of the slats
-function renderContentSkeleton({ colCnt, renderProps }: { colCnt: number, renderProps: any  }, context: ComponentContext) {
-  let { isRtl } = context
-  let parts = []
-
-  parts.push(
-    renderProps.renderIntroHtml()
-  )
-
-  for (let i = 0; i < colCnt; i++) {
-    parts.push(
-      '<td>' +
-        '<div class="fc-content-col">' +
-          '<div class="fc-event-container fc-mirror-container"></div>' +
-          '<div class="fc-event-container"></div>' +
-          '<div class="fc-highlight-container"></div>' +
-          '<div class="fc-bgevent-container"></div>' +
-          '<div class="fc-business-container"></div>' +
-        '</div>' +
-      '</td>'
-    )
-  }
-
-  if (isRtl) {
-    parts.reverse()
-  }
-
-  let tableEl = htmlToElement(
-    '<div class="fc-content-skeleton">' +
-      '<table>' +
-        '<tr>' + parts.join('') + '</tr>' +
-      '</table>' +
-    '</div>'
-  )
-
-  let colContainerEls = findElements(tableEl, '.fc-content-col')
-  let mirrorContainerEls = findElements(tableEl, '.fc-mirror-container')
-  let fgContainerEls = findElements(tableEl, '.fc-event-container:not(.fc-mirror-container)')
-  let bgContainerEls = findElements(tableEl, '.fc-bgevent-container')
-  let highlightContainerEls = findElements(tableEl, '.fc-highlight-container')
-  let businessContainerEls = findElements(tableEl, '.fc-business-container')
-
-  if (isRtl) {
-    colContainerEls.reverse()
-    mirrorContainerEls.reverse()
-    fgContainerEls.reverse()
-    bgContainerEls.reverse()
-    highlightContainerEls.reverse()
-    businessContainerEls.reverse()
-  }
-
-  return {
-    rootEl: tableEl,
-    colContainerEls,
-    businessContainerEls,
-    bgContainerEls,
-    fgContainerEls,
-    highlightContainerEls,
-    mirrorContainerEls
-  }
-}
-
-
-// Given segments grouped by column, insert the segments' elements into a parallel array of container
-// elements, each living within a column.
-export function attachSegs({ segs, containerEls }: { segs: Seg[], containerEls: HTMLElement[] }, context: ComponentContext) {
-  let segsByCol = groupSegsByCol(segs, containerEls.length)
-
-  for (let col = 0; col < segsByCol.length; col++) {
-    segsByCol[col] = sortEventSegs(segsByCol[col], context.eventOrderSpecs)
-  }
-
-  for (let col = 0; col < containerEls.length; col++) { // iterate each column grouping
-    let segs = segsByCol[col]
-
-    for (let seg of segs) {
-      containerEls[col].appendChild(seg.el)
-    }
-  }
-
-  return segs
-}
-
-
-export function detachSegs(segs: Seg[]) {
-  segs.forEach(function(seg) {
-    removeElement(seg.el)
-  })
-}
-
-
-function groupSegsByCol(segs, colCnt) {
-  let segsByCol = []
-  let i
-
-  for (i = 0; i < colCnt; i++) {
-    segsByCol.push([])
-  }
-
-  for (i = 0; i < segs.length; i++) {
-    segsByCol[segs[i].col].push(segs[i])
-  }
-
-  return segsByCol
-}

+ 534 - 0
packages/timegrid/src/TimeCols.tsx

@@ -0,0 +1,534 @@
+import {
+  removeElement,
+  applyStyle,
+  PositionCache,
+  Duration,
+  createDuration,
+  addDurations,
+  multiplyDuration,
+  wholeDivideDurations,
+  asRoughMs,
+  startOfDay,
+  DateMarker,
+  ComponentContext,
+  BaseComponent,
+  Seg,
+  EventSegUiInteractionState,
+  DateProfile,
+  sortEventSegs,
+  memoize,
+  subrenderer
+} from '@fullcalendar/core'
+import TimeColsEvents from './TimeColsEvents'
+import TimeColsMirrorEvents from './TimeColsMirrorEvents'
+import TimeColsFills from './TimeColsFills'
+import TimeColsSlats from './TimeColsSlats'
+import TimeColsBg, { TimeColsCell } from './TimeColsBg'
+import TimeColsContentSkeleton, { TimeColsContentSkeletonContainers } from './TimeColsContentSkeleton'
+import { h, VNode, createRef, Ref } from 'preact'
+import { __assign } from 'tslib'
+
+
+export interface TimeColsSeg extends Seg {
+  col: number
+  start: DateMarker
+  end: DateMarker
+}
+
+export interface TimeColsProps {
+  dateProfile: DateProfile
+  cells: TimeColsCell[]
+  businessHourSegs: TimeColsSeg[]
+  bgEventSegs: TimeColsSeg[]
+  fgEventSegs: TimeColsSeg[]
+  dateSelectionSegs: TimeColsSeg[]
+  eventSelection: string
+  eventDrag: EventSegUiInteractionState | null
+  eventResize: EventSegUiInteractionState | null
+  rootElRef?: Ref<HTMLDivElement>
+  renderBgIntro: () => VNode[]
+  renderIntro: () => VNode[]
+}
+
+
+/* A component that renders one or more columns of vertical time slots
+----------------------------------------------------------------------------------------------------------------------*/
+
+export default class TimeCols extends BaseComponent<TimeColsProps> {
+
+  private processOptions = memoize(this._processOptions)
+  private renderMirrorEvents = subrenderer(TimeColsMirrorEvents)
+  private renderFgEvents = subrenderer(TimeColsEvents)
+  private renderBgEvents = subrenderer(TimeColsFills)
+  private renderBusinessHours = subrenderer(TimeColsFills)
+  private renderDateSelection = subrenderer(TimeColsFills)
+
+  // computed options
+  private slotDuration: Duration // duration of a "slot", a distinct time segment on given day, visualized by lines
+  private snapDuration: Duration // granularity of time for dragging and selecting
+  private snapsPerSlot: any
+
+  private bottomRuleElRef = createRef<HTMLHRElement>()
+  private contentSkeletonEl: HTMLElement
+  private colContainerEls: HTMLElement[] // containers for each column
+  private businessContainerEls: HTMLElement[]
+  private highlightContainerEls: HTMLElement[]
+  private bgContainerEls: HTMLElement[]
+  private fgContainerEls: HTMLElement[]
+  private mirrorContainerEls: HTMLElement[]
+  public colEls: HTMLElement[] // cells elements in the day-row background
+  private rootSlatEl: HTMLElement // div that wraps all the slat rows
+  private slatEls: HTMLElement[] // elements running horizontally across all columns
+  private nowIndicatorEls: HTMLElement[]
+
+  private colPositions: PositionCache
+  private slatPositions: PositionCache
+  private isSlatSizesDirty: boolean = false
+  private isColSizesDirty: boolean = false
+  private segRenderers: (TimeColsEvents | TimeColsFills | null)[]
+
+  get bottomRuleEl() { return this.bottomRuleElRef.current }
+
+
+  /* Rendering
+  ------------------------------------------------------------------------------------------------------------------*/
+
+
+  render(props: TimeColsProps, state: {}, context: ComponentContext) {
+
+    this.processOptions(context.options)
+
+    return (
+      <div class='fc-time-grid' ref={props.rootElRef}>
+        <TimeColsBg
+          dateProfile={props.dateProfile}
+          cells={props.cells}
+          renderIntro={props.renderBgIntro}
+          handleDom={this.handleBgDom}
+        />
+        <TimeColsSlats
+          dateProfile={props.dateProfile}
+          slotDuration={this.slotDuration}
+          handleDom={this.handleSlatDom}
+        />
+        <TimeColsContentSkeleton
+          colCnt={props.cells.length}
+          renderIntro={props.renderIntro}
+          handleDom={this.handleContentSkeletonDom}
+        />
+        <hr class={'fc-divider ' + context.theme.getClass('widgetHeader')} ref={this.bottomRuleElRef} />
+      </div>
+    )
+  }
+
+
+  // Parses various options into properties of this object
+  // MUST have context already set
+  _processOptions(options) {
+    let { slotDuration, snapDuration } = options
+    let snapsPerSlot
+
+    slotDuration = createDuration(slotDuration)
+    snapDuration = snapDuration ? createDuration(snapDuration) : slotDuration
+    snapsPerSlot = wholeDivideDurations(slotDuration, snapDuration)
+
+    if (snapsPerSlot === null) {
+      snapDuration = slotDuration
+      snapsPerSlot = 1
+      // TODO: say warning?
+    }
+
+    this.slotDuration = slotDuration
+    this.snapDuration = snapDuration
+    this.snapsPerSlot = snapsPerSlot
+  }
+
+
+  handleBgDom = (bgEl: HTMLElement | null, colEls: HTMLElement[] | null) => {
+    if (bgEl) {
+      this.colEls = colEls
+      this.isColSizesDirty = true
+      this.colPositions = new PositionCache(
+        bgEl,
+        colEls,
+        true, // horizontal
+        false
+      )
+    }
+  }
+
+
+  handleSlatDom = (rootSlatEl: HTMLElement | null, slatEls: HTMLElement[] | null) => {
+    if (rootSlatEl) {
+      this.rootSlatEl = rootSlatEl
+      this.slatEls = slatEls
+      this.isSlatSizesDirty = true
+      this.slatPositions = new PositionCache(
+        rootSlatEl,
+        slatEls,
+        false,
+        true // vertical
+      )
+    }
+  }
+
+
+  handleContentSkeletonDom = (contentSkeletonEl: HTMLElement | null, containers: TimeColsContentSkeletonContainers | null) => {
+    if (!contentSkeletonEl) {
+      this.subrenderDestroy()
+
+    } else {
+      this.contentSkeletonEl = contentSkeletonEl
+      __assign(this, containers)
+    }
+  }
+
+
+  componentDidMount() {
+    this.subrender()
+  }
+
+
+  componentDidUpdate() {
+    this.subrender()
+  }
+
+
+  subrender() {
+    let { props } = this
+    let { options } = this.context
+
+    this.segRenderers = [
+      this.renderBusinessHours({
+        type: 'businessHours',
+        containerEls: this.businessContainerEls,
+        segs: props.businessHourSegs,
+      }),
+      this.renderDateSelection({
+        type: 'highlight',
+        containerEls: this.highlightContainerEls,
+        segs: options.selectMirror ? null : props.dateSelectionSegs // do highlight if NO mirror
+      }),
+      this.renderBgEvents({
+        type: 'bgEvent',
+        containerEls: this.bgContainerEls,
+        segs: props.bgEventSegs
+      }),
+      this.renderFgEvents({
+        containerEls: this.fgContainerEls,
+        segs: props.fgEventSegs,
+        selectedInstanceId: props.eventSelection,
+        hiddenInstances: // TODO: more convenient
+          (props.eventDrag ? props.eventDrag.affectedInstances : null) ||
+          (props.eventResize ? props.eventResize.affectedInstances : null)
+      }),
+      this.subrenderMirror(props, this.mirrorContainerEls, options)
+    ]
+  }
+
+
+  subrenderMirror(props: TimeColsProps, mirrorContainerEls: HTMLElement[], options): TimeColsEvents | null {
+    if (props.eventDrag) {
+      return this.renderMirrorEvents({
+        containerEls: mirrorContainerEls,
+        segs: props.eventDrag.segs,
+        mirrorInfo: { isDragging: true, sourceSeg: props.eventDrag.sourceSeg }
+      })
+
+    } else if (props.eventResize) {
+      return this.renderMirrorEvents({
+        containerEls: mirrorContainerEls,
+        segs: props.eventResize.segs,
+        mirrorInfo: { isDragging: true, sourceSeg: props.eventResize.sourceSeg }
+      })
+
+    } else if (options.selectMirror) {
+      return this.renderMirrorEvents({
+        containerEls: mirrorContainerEls,
+        segs: props.dateSelectionSegs,
+        mirrorInfo: { isSelecting: true }
+      })
+
+    } else {
+      return this.renderMirrorEvents(false)
+    }
+  }
+
+
+  updateSize(isResize: boolean) {
+    let { segRenderers } = this
+
+    if (isResize || this.isSlatSizesDirty) {
+      this.buildSlatPositions()
+      this.isSlatSizesDirty = false
+    }
+
+    if (isResize || this.isColSizesDirty) {
+      this.buildColPositions()
+      this.isColSizesDirty = false
+    }
+
+    for (let segRenderer of segRenderers) {
+      if (segRenderer) {
+        segRenderer.computeSizes(isResize, this)
+      }
+    }
+
+    for (let segRenderer of segRenderers) {
+      if (segRenderer) {
+        segRenderer.assignSizes(isResize, this)
+      }
+    }
+  }
+
+
+  /* Now Indicator
+  ------------------------------------------------------------------------------------------------------------------*/
+
+
+  getNowIndicatorUnit() {
+    return 'minute' // will refresh on the minute
+  }
+
+
+  renderNowIndicator(segs: TimeColsSeg[], date) {
+
+    // HACK: if date columns not ready for some reason (scheduler)
+    if (!this.colContainerEls) {
+      return
+    }
+
+    let top = this.computeDateTop(date)
+    let nodes = []
+    let i
+
+    // render lines within the columns
+    for (i = 0; i < segs.length; i++) {
+      let lineEl = document.createElement('div')
+      lineEl.className = 'fc-now-indicator fc-now-indicator-line'
+      lineEl.style.top = top + 'px'
+      this.colContainerEls[segs[i].col].appendChild(lineEl)
+      nodes.push(lineEl)
+    }
+
+    // render an arrow over the axis
+    if (segs.length > 0) { // is the current time in view?
+      let arrowEl = document.createElement('div')
+      arrowEl.className = 'fc-now-indicator fc-now-indicator-arrow'
+      arrowEl.style.top = top + 'px'
+      this.contentSkeletonEl.appendChild(arrowEl)
+      nodes.push(arrowEl)
+    }
+
+    this.nowIndicatorEls = nodes
+  }
+
+
+  unrenderNowIndicator() {
+    if (this.nowIndicatorEls) {
+      this.nowIndicatorEls.forEach(removeElement)
+      this.nowIndicatorEls = null
+    }
+  }
+
+
+  /* Coordinates
+  ------------------------------------------------------------------------------------------------------------------*/
+
+
+  getTotalSlatHeight() {
+    return this.rootSlatEl.getBoundingClientRect().height
+  }
+
+
+  // Computes the top coordinate, relative to the bounds of the grid, of the given date.
+  // A `startOfDayDate` must be given for avoiding ambiguity over how to treat midnight.
+  computeDateTop(when: DateMarker, startOfDayDate?: DateMarker) {
+    if (!startOfDayDate) {
+      startOfDayDate = startOfDay(when)
+    }
+    return this.computeTimeTop(createDuration(when.valueOf() - startOfDayDate.valueOf()))
+  }
+
+
+  // Computes the top coordinate, relative to the bounds of the grid, of the given time (a Duration).
+  computeTimeTop(duration: Duration) {
+    let len = this.slatEls.length
+    let dateProfile = this.props.dateProfile
+    let slatCoverage = (duration.milliseconds - asRoughMs(dateProfile.minTime)) / asRoughMs(this.slotDuration) // floating-point value of # of slots covered
+    let slatIndex
+    let slatRemainder
+
+    // compute a floating-point number for how many slats should be progressed through.
+    // from 0 to number of slats (inclusive)
+    // constrained because minTime/maxTime might be customized.
+    slatCoverage = Math.max(0, slatCoverage)
+    slatCoverage = Math.min(len, slatCoverage)
+
+    // an integer index of the furthest whole slat
+    // from 0 to number slats (*exclusive*, so len-1)
+    slatIndex = Math.floor(slatCoverage)
+    slatIndex = Math.min(slatIndex, len - 1)
+
+    // how much further through the slatIndex slat (from 0.0-1.0) must be covered in addition.
+    // could be 1.0 if slatCoverage is covering *all* the slots
+    slatRemainder = slatCoverage - slatIndex
+
+    return this.slatPositions.tops[slatIndex] +
+      this.slatPositions.getHeight(slatIndex) * slatRemainder
+  }
+
+
+  // For each segment in an array, computes and assigns its top and bottom properties
+  computeSegVerticals(segs: Seg[]) {
+    let { options } = this.context
+    let eventMinHeight = options.timeGridEventMinHeight
+    let i
+    let seg
+    let dayDate
+
+    for (i = 0; i < segs.length; i++) {
+      seg = segs[i]
+      dayDate = this.props.cells[seg.col].date
+
+      seg.top = this.computeDateTop(seg.start, dayDate)
+      seg.bottom = Math.max(
+        seg.top + eventMinHeight,
+        this.computeDateTop(seg.end, dayDate)
+      )
+    }
+  }
+
+
+  // Given segments that already have their top/bottom properties computed, applies those values to
+  // the segments' elements.
+  assignSegVerticals(segs) {
+    let i
+    let seg
+
+    for (i = 0; i < segs.length; i++) {
+      seg = segs[i]
+      applyStyle(seg.el, this.generateSegVerticalCss(seg))
+    }
+  }
+
+
+  // Generates an object with CSS properties for the top/bottom coordinates of a segment element
+  generateSegVerticalCss(seg) {
+    return {
+      top: seg.top,
+      bottom: -seg.bottom // flipped because needs to be space beyond bottom edge of event container
+    }
+  }
+
+
+  /* Sizing
+  ------------------------------------------------------------------------------------------------------------------*/
+
+
+  buildPositionCaches() {
+    this.buildColPositions()
+    this.buildSlatPositions()
+  }
+
+
+  buildColPositions() {
+    this.colPositions.build()
+  }
+
+
+  buildSlatPositions() {
+    this.slatPositions.build()
+  }
+
+
+  /* Hit System
+  ------------------------------------------------------------------------------------------------------------------*/
+
+  positionToHit(positionLeft, positionTop) {
+    let { dateEnv } = this.context
+    let { snapsPerSlot, slatPositions, colPositions } = this
+
+    let colIndex = colPositions.leftToIndex(positionLeft)
+    let slatIndex = slatPositions.topToIndex(positionTop)
+
+    if (colIndex != null && slatIndex != null) {
+      let slatTop = slatPositions.tops[slatIndex]
+      let slatHeight = slatPositions.getHeight(slatIndex)
+      let partial = (positionTop - slatTop) / slatHeight // floating point number between 0 and 1
+      let localSnapIndex = Math.floor(partial * snapsPerSlot) // the snap # relative to start of slat
+      let snapIndex = slatIndex * snapsPerSlot + localSnapIndex
+
+      let dayDate = this.props.cells[colIndex].date
+      let time = addDurations(
+        this.props.dateProfile.minTime,
+        multiplyDuration(this.snapDuration, snapIndex)
+      )
+
+      let start = dateEnv.add(dayDate, time)
+      let end = dateEnv.add(start, this.snapDuration)
+
+      return {
+        col: colIndex,
+        dateSpan: {
+          range: { start, end },
+          allDay: false
+        },
+        dayEl: this.colEls[colIndex],
+        relativeRect: {
+          left: colPositions.lefts[colIndex],
+          right: colPositions.rights[colIndex],
+          top: slatTop,
+          bottom: slatTop + slatHeight
+        }
+      }
+    }
+  }
+
+}
+
+
+// Given segments grouped by column, insert the segments' elements into a parallel array of container
+// elements, each living within a column.
+export function attachSegs({ segs, containerEls }: { segs: Seg[], containerEls: HTMLElement[] }, context: ComponentContext) {
+  let segsByCol = groupSegsByCol(segs, containerEls.length)
+
+  for (let col = 0; col < segsByCol.length; col++) {
+    segsByCol[col] = sortEventSegs(segsByCol[col], context.eventOrderSpecs)
+  }
+
+  for (let col = 0; col < containerEls.length; col++) { // iterate each column grouping
+    let segs = segsByCol[col]
+
+    for (let seg of segs) {
+      containerEls[col].appendChild(seg.el)
+    }
+  }
+
+  return segsByCol
+}
+
+
+export function detachSegs(segsByCol: Seg[][]) {
+  for (let segGroup of segsByCol) {
+    for (let seg of segGroup) {
+      removeElement(seg.el)
+    }
+  }
+}
+
+
+function groupSegsByCol(segs, colCnt) {
+  let segsByCol = []
+  let i
+
+  for (i = 0; i < colCnt; i++) {
+    segsByCol.push([])
+  }
+
+  for (i = 0; i < segs.length; i++) {
+    segsByCol[segs[i].col].push(segs[i])
+  }
+
+  return segsByCol
+}

+ 74 - 0
packages/timegrid/src/TimeColsBg.tsx

@@ -0,0 +1,74 @@
+import {
+  BaseComponent,
+  DateProfile,
+  ComponentContext,
+  DateMarker,
+  findElements,
+  guid
+} from '@fullcalendar/core'
+import { DayBgRow } from '@fullcalendar/daygrid'
+import { h, VNode } from 'preact'
+
+
+export interface TimeColsBgProps {
+  dateProfile: DateProfile
+  cells: TimeColsCell[]
+  renderIntro: () => VNode[]
+  handleDom?: (rootEl: HTMLElement | null, colEls: HTMLElement[] | null) => void
+}
+
+export interface TimeColsCell {
+  date: DateMarker
+  htmlAttrs?: object
+}
+
+
+export default class TimeColsBg extends BaseComponent<TimeColsBgProps> {
+
+
+  render(props: TimeColsBgProps, state: {}, context: ComponentContext) {
+    let { theme } = context
+
+    return ( // guid rerenders whole DOM every time
+      <div class='fc-bg' ref={this.handleRootEl} key={guid()}>
+        <table class={theme.getClass('tableGrid')}>
+          <DayBgRow
+            cells={props.cells}
+            dateProfile={props.dateProfile}
+            renderIntro={props.renderIntro}
+          />
+        </table>
+      </div>
+    )
+  }
+
+
+  handleRootEl = (rootEl: HTMLDivElement | null) => {
+    let { calendar, view, dateEnv, isRtl } = this.context
+    let { cells, handleDom } = this.props
+    let colEls = null
+
+    if (rootEl) {
+      colEls = findElements(rootEl, '.fc-day, .fc-disabled-day')
+
+      for (let col = 0; col < cells.length; col++) {
+        calendar.publiclyTrigger('dayRender', [
+          {
+            date: dateEnv.toDate(cells[col].date),
+            el: colEls[col],
+            view
+          }
+        ])
+      }
+
+      if (isRtl) {
+        colEls.reverse()
+      }
+    }
+
+    if (handleDom) {
+      handleDom(rootEl, colEls)
+    }
+  }
+
+}

+ 97 - 0
packages/timegrid/src/TimeColsContentSkeleton.tsx

@@ -0,0 +1,97 @@
+import {
+  BaseComponent,
+  ComponentContext,
+  findElements,
+  guid,
+} from '@fullcalendar/core'
+import { h, VNode } from 'preact'
+
+
+export interface TimeColsContentSkeletonProps {
+  colCnt: number
+  renderIntro: () => VNode[]
+  handleDom?: (rootEl: HTMLElement | null, containers: TimeColsContentSkeletonContainers | null) => void
+}
+
+export interface TimeColsContentSkeletonContainers {
+  colContainerEls: HTMLElement[]
+  mirrorContainerEls: HTMLElement[]
+  fgContainerEls: HTMLElement[]
+  bgContainerEls: HTMLElement[]
+  highlightContainerEls: HTMLElement[]
+  businessContainerEls: HTMLElement[]
+}
+
+
+export default class TimeColsContentSkeleton extends BaseComponent<TimeColsContentSkeletonProps> {
+
+
+  render(props: TimeColsContentSkeletonProps, state: {}, context: ComponentContext) {
+    let { isRtl } = context
+    let cellNodes: VNode[] = props.renderIntro()
+
+    for (let i = 0; i < props.colCnt; i++) {
+      cellNodes.push(
+        <td>
+          <div class='fc-content-col'>
+            <div class='fc-event-container fc-mirror-container'></div>
+            <div class='fc-event-container'></div>
+            <div class='fc-highlight-container'></div>
+            <div class='fc-bgevent-container'></div>
+            <div class='fc-business-container'></div>
+          </div>
+        </td>
+      )
+    }
+
+    if (isRtl) {
+      cellNodes.reverse()
+    }
+
+    return ( // guid rerenders whole DOM every time
+      <div class='fc-content-skeleton' ref={this.handleRootEl} key={guid()}>
+        <table>
+          <tr>{cellNodes}</tr>
+        </table>
+      </div>
+    )
+  }
+
+
+  handleRootEl = (rootEl: HTMLElement | null) => {
+    let { handleDom } = this.props
+    let containers: TimeColsContentSkeletonContainers | null = null
+
+    if (rootEl) {
+      let colContainerEls = findElements(rootEl, '.fc-content-col')
+      let mirrorContainerEls = findElements(rootEl, '.fc-mirror-container')
+      let fgContainerEls = findElements(rootEl, '.fc-event-container:not(.fc-mirror-container)')
+      let bgContainerEls = findElements(rootEl, '.fc-bgevent-container')
+      let highlightContainerEls = findElements(rootEl, '.fc-highlight-container')
+      let businessContainerEls = findElements(rootEl, '.fc-business-container')
+
+      if (this.context.isRtl) {
+        colContainerEls.reverse()
+        mirrorContainerEls.reverse()
+        fgContainerEls.reverse()
+        bgContainerEls.reverse()
+        highlightContainerEls.reverse()
+        businessContainerEls.reverse()
+      }
+
+      containers = {
+        colContainerEls,
+        mirrorContainerEls,
+        fgContainerEls,
+        bgContainerEls,
+        highlightContainerEls,
+        businessContainerEls
+      }
+    }
+
+    if (handleDom) {
+      handleDom(rootEl, containers)
+    }
+  }
+
+}

+ 29 - 31
packages/timegrid/src/TimeColsEvents.ts

@@ -4,7 +4,7 @@ import {
   createFormatter, DateFormatter,
   FgEventRenderer, buildSegCompareObj,
   Seg, isMultiDayRange, compareByFieldSpecs,
-  computeEventDraggable, computeEventStartResizable, computeEventEndResizable, ComponentContext, BaseFgEventRendererProps, renderer
+  computeEventDraggable, computeEventStartResizable, computeEventEndResizable, ComponentContext, BaseFgEventRendererProps, subrenderer
 } from '@fullcalendar/core'
 import TimeCols, { attachSegs, detachSegs } from './TimeCols'
 
@@ -18,33 +18,22 @@ Does not own rendering. Use for low-level util methods by TimeCols.
 */
 export default class TimeColsEvents extends FgEventRenderer<TimeColsEventsProps> {
 
-  attachSegs = renderer(attachSegs, detachSegs)
+  private updateFormatter = subrenderer(this._updateFormatter)
+  private attachSegs = subrenderer(attachSegs, detachSegs)
 
-  // computed options
-  private fullTimeFormat: DateFormatter
-
-  // for sizing
-  private segsByCol: any
-
-
-  _updateComputedOptions(options) {
-    super._updateComputedOptions(options)
-
-    this.fullTimeFormat = createFormatter({
-      hour: 'numeric',
-      minute: '2-digit',
-      separator: options.defaultRangeSeparator
-    })
-  }
+  private fullTimeFormat: DateFormatter // computed options
+  private segsByCol: any // for sizing
 
 
   render(props: TimeColsEventsProps, context: ComponentContext) {
+    this.updateFormatter(context.options)
+
     let segs = this.renderSegs({
       segs: props.segs,
       mirrorInfo: props.mirrorInfo,
       selectedInstanceId: props.selectedInstanceId,
       hiddenInstances: props.hiddenInstances
-    }, context)
+    })
 
     this.segsByCol = this.attachSegs({
       segs,
@@ -53,11 +42,20 @@ export default class TimeColsEvents extends FgEventRenderer<TimeColsEventsProps>
   }
 
 
-  computeSegSizes(allSegs: Seg[], timeGrid: TimeCols) {
+  _updateFormatter(allOptions) {
+    this.fullTimeFormat = createFormatter({
+      hour: 'numeric',
+      minute: '2-digit',
+      separator: allOptions.defaultRangeSeparator
+    })
+  }
+
+
+  computeSegSizes(allSegs: Seg[], timeCols: TimeCols) {
     let { segsByCol } = this
-    let colCnt = timeGrid.props.cells.length
+    let colCnt = timeCols.props.cells.length
 
-    timeGrid.computeSegVerticals(allSegs) // horizontals relies on this
+    timeCols.computeSegVerticals(allSegs) // horizontals relies on this
 
     for (let col = 0; col < colCnt; col++) {
       computeSegHorizontals(segsByCol[col], this.context) // compute horizontal coordinates, z-index's, and reorder the array
@@ -65,24 +63,24 @@ export default class TimeColsEvents extends FgEventRenderer<TimeColsEventsProps>
   }
 
 
-  assignSegSizes(allSegs: Seg[], timeGrid: TimeCols) {
+  assignSegSizes(allSegs: Seg[], timeCols: TimeCols) {
     let { segsByCol } = this
-    let colCnt = timeGrid.props.cells.length
+    let colCnt = timeCols.props.cells.length
 
-    timeGrid.assignSegVerticals(allSegs) // horizontals relies on this
+    timeCols.assignSegVerticals(allSegs) // horizontals relies on this
 
     for (let col = 0; col < colCnt; col++) {
-      this.assignSegCss(segsByCol[col], timeGrid)
+      this.assignSegCss(segsByCol[col], timeCols)
     }
   }
 
 
   // Given foreground event segments that have already had their position coordinates computed,
   // assigns position-related CSS values to their elements.
-  assignSegCss(segs: Seg[], timeGrid: TimeCols) {
+  assignSegCss(segs: Seg[], timeCols: TimeCols) {
 
     for (let seg of segs) {
-      applyStyle(seg.el, this.generateSegCss(seg, timeGrid))
+      applyStyle(seg.el, this.generateSegCss(seg, timeCols))
 
       if (seg.level > 0) {
         seg.el.classList.add('fc-time-grid-event-inset')
@@ -99,12 +97,12 @@ export default class TimeColsEvents extends FgEventRenderer<TimeColsEventsProps>
 
   // Generates an object with CSS properties/values that should be applied to an event segment element.
   // Contains important positioning-related properties that should be applied to any event element, customized or not.
-  generateSegCss(seg: Seg, timeGrid: TimeCols) {
+  generateSegCss(seg: Seg, timeCols: TimeCols) {
     let { isRtl, options } = this.context
     let shouldOverlap = options.slotEventOverlap
     let backwardCoord = seg.backwardCoord // the left side if LTR. the right side if RTL. floating-point
     let forwardCoord = seg.forwardCoord // the right side if LTR. the left side if RTL. floating-point
-    let props = timeGrid.generateSegVerticalCss(seg) as any // get top/bottom first
+    let props = timeCols.generateSegVerticalCss(seg) as any // get top/bottom first
     let left // amount of space from left edge, a fraction of the total width
     let right // amount of space from right edge, a fraction of the total width
 
@@ -290,7 +288,7 @@ function computeSegForwardBack(seg: Seg, seriesBackwardPressure, seriesBackwardC
     // use this segment's coordinates to computed the coordinates of the less-pressurized
     // forward segments
     for (i = 0; i < forwardSegs.length; i++) {
-      this.computeSegForwardBack(forwardSegs[i], 0, seg.forwardCoord)
+      computeSegForwardBack(forwardSegs[i], 0, seg.forwardCoord, context)
     }
   }
 }

+ 2 - 2
packages/timegrid/src/TimeColsFills.ts

@@ -1,5 +1,5 @@
 import {
-  FillRenderer, Seg, renderer, BaseFillRendererProps
+  FillRenderer, Seg, subrenderer, BaseFillRendererProps
 } from '@fullcalendar/core'
 import TimeCols, { attachSegs, detachSegs } from './TimeCols'
 
@@ -9,7 +9,7 @@ export interface TimeColsFillsProps extends BaseFillRendererProps {
 
 export default class TimeColsFills extends FillRenderer<TimeColsFillsProps> {
 
-  private attachSegs = renderer(attachSegs, detachSegs)
+  private attachSegs = subrenderer(attachSegs, detachSegs)
 
 
   render(props: TimeColsFillsProps) {

+ 150 - 0
packages/timegrid/src/TimeColsSlats.tsx

@@ -0,0 +1,150 @@
+import {
+  BaseComponent,
+  DateProfile,
+  ComponentContext,
+  createDuration,
+  startOfDay,
+  asRoughMs,
+  formatIsoTimeString,
+  addDurations,
+  wholeDivideDurations,
+  Duration,
+  createFormatter,
+  memoize,
+  findElements,
+  guid
+} from '@fullcalendar/core'
+import { h, VNode } from 'preact'
+
+export interface TimeColsSlatsProps {
+  dateProfile: DateProfile
+  slotDuration: Duration
+  handleDom?: (rootEl: HTMLElement | null, slatEls: HTMLElement[] | null) => void
+}
+
+// potential nice values for the slot-duration and interval-duration
+// from largest to smallest
+const STOCK_SUB_DURATIONS = [
+  { hours: 1 },
+  { minutes: 30 },
+  { minutes: 15 },
+  { seconds: 30 },
+  { seconds: 15 }
+]
+
+/*
+for the horizontal "slats" that run width-wise. Has a time axis on a side. Depends on RTL.
+*/
+export default class TimeColsSlats extends BaseComponent<TimeColsSlatsProps> {
+
+  private getLabelInterval = memoize(getLabelInterval)
+  private getLabelFormat = memoize(getLabelFormat)
+
+
+  render(props: TimeColsSlatsProps, state: {}, context: ComponentContext) {
+    let { dateEnv, theme, isRtl, options } = context
+    let { dateProfile, slotDuration } = props
+
+    let labelInterval = this.getLabelInterval(options.slotLabelInterval, slotDuration)
+    let labelFormat = this.getLabelFormat(options.slotLabelFormat)
+
+    let dayStart = startOfDay(dateProfile.renderRange.start)
+    let slotTime = dateProfile.minTime
+    let slotIterator = createDuration(0)
+    let slotDate // will be on the view's first day, but we only care about its time
+    let isLabeled
+    let rowsNodes: VNode[] = []
+
+    // Calculate the time for each slot
+    while (asRoughMs(slotTime) < asRoughMs(dateProfile.maxTime)) {
+      slotDate = dateEnv.add(dayStart, slotTime)
+      isLabeled = wholeDivideDurations(slotIterator, labelInterval) !== null
+
+      let axisNode =
+        <td class={'fc-axis fc-time ' + theme.getClass('widgetContent')}>
+          {isLabeled &&
+            <span>
+              {dateEnv.format(slotDate, labelFormat)}
+            </span>
+          }
+        </td>
+
+      rowsNodes.push(
+        <tr data-time={formatIsoTimeString(slotDate)} class={isLabeled ? 'fc-minor' : ''}>
+          {!isRtl && axisNode}
+          <td class={theme.getClass('widgetContent')}></td>
+          {isRtl && axisNode}
+        </tr>
+      )
+
+      slotTime = addDurations(slotTime, slotDuration)
+      slotIterator = addDurations(slotIterator, slotDuration)
+    }
+
+    return ( // guid rerenders whole DOM every time
+      <div class='fc-slats' ref={this.handleRootEl} key={guid()}>
+        <table class={theme.getClass('tableGrid')}>
+          {rowsNodes}
+        </table>
+      </div>
+    )
+  }
+
+
+  handleRootEl = (rootEl: HTMLElement | null) => {
+    let { handleDom } = this.props
+    let slatEls = null
+
+    if (rootEl) {
+      slatEls = findElements(rootEl, 'tr')
+    }
+
+    if (handleDom) {
+      handleDom(rootEl, slatEls)
+    }
+  }
+
+}
+
+
+function getLabelInterval(optionInput, slotDuration: Duration) {
+
+  // might be an array value (for TimelineView).
+  // if so, getting the most granular entry (the last one probably).
+  if (Array.isArray(optionInput)) {
+    optionInput = optionInput[optionInput.length - 1]
+  }
+
+  return optionInput ?
+    createDuration(optionInput) :
+    computeLabelInterval(slotDuration)
+}
+
+
+function getLabelFormat(optionInput) {
+  return createFormatter(optionInput || {
+    hour: 'numeric',
+    minute: '2-digit',
+    omitZeroMinute: true,
+    meridiem: 'short'
+  })
+}
+
+
+// Computes an automatic value for slotLabelInterval
+function computeLabelInterval(slotDuration) {
+  let i
+  let labelInterval
+  let slotsPerLabel
+
+  // find the smallest stock label interval that results in more than one slots-per-label
+  for (i = STOCK_SUB_DURATIONS.length - 1; i >= 0; i--) {
+    labelInterval = createDuration(STOCK_SUB_DURATIONS[i])
+    slotsPerLabel = wholeDivideDurations(labelInterval, slotDuration)
+    if (slotsPerLabel !== null && slotsPerLabel > 1) {
+      return labelInterval
+    }
+  }
+
+  return slotDuration // fall back
+}

+ 127 - 115
packages/timegrid/src/TimeColsView.ts → packages/timegrid/src/TimeColsView.tsx

@@ -1,19 +1,18 @@
 import {
-  findElements, htmlEscape,
+  findElements,
   matchCellWidths, uncompensateScroll, compensateScroll, subtractInnerElHeight,
   Scroller,
   View,
-  ComponentContext,
   createFormatter, diffDays,
-  buildGotoAnchorHtml, getAllDayHtml, Duration,
+  Duration,
   DateMarker,
-  renderViewEl,
-  renderer
+  getViewClassNames,
+  GotoAnchor,
 } from '@fullcalendar/core'
-import { TimeColsRenderProps } from './TimeCols'
-import Table, { TableRenderProps } from 'packages/daygrid/src/Table'
+import { Table } from '@fullcalendar/daygrid'
 import { TimeCols } from './main'
 import AllDaySplitter from './AllDaySplitter'
+import { h, ComponentChildren, createRef } from 'preact'
 
 const ALL_DAY_EVENT_LIMIT = 5
 const WEEK_HEADER_FORMAT = createFormatter({ week: 'short' })
@@ -26,15 +25,17 @@ const WEEK_HEADER_FORMAT = createFormatter({ week: 'short' })
 
 export default abstract class TimeColsView extends View {
 
-  private renderSkeleton = renderer(this._renderSkeleton)
-  private renderScroller = renderer(Scroller)
-
   protected allDaySplitter = new AllDaySplitter() // for use by subclasses
-  private scroller: Scroller
+
+  private rootElRef = createRef<HTMLDivElement>()
+  private dividerElRef = createRef<HTMLHRElement>()
+  private scrollerRef = createRef<Scroller>()
   private axisWidth: any // the width of the time axis running down the side
-  private dividerEl: HTMLElement
 
 
+  // abstract requirements
+  // ----------------------------------------------------------------------------------------------------
+
   abstract getAllDayTableObj(): { table: Table } | null
 
   abstract getTimeColsObj(): {
@@ -44,65 +45,70 @@ export default abstract class TimeColsView extends View {
     unrenderNowIndicator: () => void
   }
 
+  getRootEl() { return this.rootElRef.current }
 
-  renderLayout(props: { type: string }) {
-    let res = this.renderSkeleton({ type: props.type })
 
-    let scroller = this.renderScroller({
-      parentEl: res.contentWrapEl,
-      overflowX: 'hidden',
-      overflowY: 'auto'
-    })
-
-    this.scroller = scroller
-
-    return res
-  }
+  // rendering
+  // ----------------------------------------------------------------------------------------------------
 
 
-  _renderSkeleton(props: { type: string }, context: ComponentContext) {
-    let { theme, options } = context
-
-    let el = renderViewEl(props.type)
-    el.classList.add('fc-timeGrid-view')
-    el.innerHTML = '' +
-      '<table class="' + theme.getClass('tableGrid') + '">' +
-        (options.columnHeader ?
-          '<thead class="fc-head">' +
-            '<tr>' +
-              '<td class="fc-head-container ' + theme.getClass('widgetHeader') + '">&nbsp;</td>' +
-            '</tr>' +
-          '</thead>' :
-          ''
-          ) +
-        '<tbody class="fc-body">' +
-          '<tr>' +
-            '<td class="' + theme.getClass('widgetContent') + '">' +
-              (options.allDaySlot ?
-                '<hr class="fc-divider ' + theme.getClass('widgetHeader') + '" />' :
-                ''
-                ) +
-            '</td>' +
-          '</tr>' +
-        '</tbody>' +
-      '</table>'
-
-    this.dividerEl = options.allDaySlot ? (el.querySelector('.fc-divider') as HTMLElement) : null
-
-    return {
-      rootEl: el,
-      headerWrapEl: options.columnHeader ? (el.querySelector('.fc-head-container') as HTMLElement) : null,
-      contentWrapEl: el.querySelector('.fc-body > tr > td') as HTMLElement
-    }
+  renderLayout(
+    headerChildren: ComponentChildren | null,
+    allDayChildren: ComponentChildren | null,
+    timeChildren: ComponentChildren
+  ) {
+    let { theme } = this.context
+    let classNames = getViewClassNames(this.props.viewSpec).concat('fc-timeGrid-view')
+
+    return (
+      <div class={classNames.join(' ')} ref={this.rootElRef}>
+        <table class={theme.getClass('tableGrid')}>
+          {headerChildren &&
+            <thead class='fc-head'>
+              <tr>
+                <td class={'fc-head-container ' + theme.getClass('widgetHeader')}>
+                  {headerChildren}
+                </td>
+              </tr>
+            </thead>
+          }
+          <tbody class='fc-body'>
+            <tr>
+              <td class={theme.getClass('widgetContent')}>
+                {allDayChildren}
+                {allDayChildren &&
+                  <hr class={'fc-divider ' + theme.getClass('widgetHeader')} ref={this.dividerElRef}/>
+                }
+                <Scroller
+                  ref={this.scrollerRef}
+                  overflowX='hidden'
+                  overflowY='auto'
+                >
+                  {timeChildren}
+                </Scroller>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
+    )
   }
 
 
   componentDidMount() {
     let allDayTable = this.getAllDayTableObj()
+    let dividerEl = this.dividerElRef.current
 
     if (allDayTable) {
-      allDayTable.table.bottomCoordPadding = this.dividerEl.getBoundingClientRect().height
+      allDayTable.table.bottomCoordPadding = dividerEl.getBoundingClientRect().height
     }
+
+    this.startNowIndicator()
+  }
+
+
+  componentWillUnmount() {
+    this.stopNowIndicator()
   }
 
 
@@ -134,31 +140,33 @@ export default abstract class TimeColsView extends View {
 
   // Adjusts the vertical dimensions of the view to the specified values
   updateLayoutSize(timeCols: TimeCols, table: Table | null, viewHeight, isAuto) {
+    let rootEl = this.rootElRef.current
+    let scroller = this.scrollerRef.current
     let eventLimit
     let scrollerHeight
     let scrollbarWidths
 
     // make all axis cells line up
-    this.axisWidth = matchCellWidths(findElements(this.rootEl, '.fc-axis'))
+    this.axisWidth = matchCellWidths(findElements(rootEl, '.fc-axis'))
 
     // hack to give the view some height prior to timeGrid's columns being rendered
     // TODO: separate setting height from scroller VS timeGrid.
     if (!timeCols.colEls) {
       if (!isAuto) {
         scrollerHeight = this.computeScrollerHeight(viewHeight)
-        this.scroller.setHeight(scrollerHeight)
+        scroller.setHeight(scrollerHeight)
       }
       return
     }
 
     // set of fake row elements that must compensate when scroller has scrollbars
-    let noScrollRowEls: HTMLElement[] = findElements(this.rootEl, '.fc-row').filter((node) => {
-      return !this.scroller.el.contains(node)
+    let noScrollRowEls: HTMLElement[] = findElements(rootEl, '.fc-row').filter((node) => {
+      return !scroller.rootEl.contains(node)
     })
 
     // reset all dimensions back to the original state
     timeCols.bottomRuleEl.style.display = 'none' // will be shown later if this <hr> is necessary
-    this.scroller.clear() // sets height to 'auto' and clears overflow
+    scroller.clear() // sets height to 'auto' and clears overflow
     noScrollRowEls.forEach(uncompensateScroll)
 
     // limit number of events in the all-day area
@@ -176,8 +184,8 @@ export default abstract class TimeColsView extends View {
     if (!isAuto) { // should we force dimensions of the scroll container?
 
       scrollerHeight = this.computeScrollerHeight(viewHeight)
-      this.scroller.setHeight(scrollerHeight)
-      scrollbarWidths = this.scroller.getScrollbarWidths()
+      scroller.setHeight(scrollerHeight)
+      scrollbarWidths = scroller.getScrollbarWidths()
 
       if (scrollbarWidths.left || scrollbarWidths.right) { // using scrollbars?
 
@@ -189,11 +197,11 @@ export default abstract class TimeColsView extends View {
         // the scrollbar compensation might have changed text flow, which might affect height, so recalculate
         // and reapply the desired height to the scroller.
         scrollerHeight = this.computeScrollerHeight(viewHeight)
-        this.scroller.setHeight(scrollerHeight)
+        scroller.setHeight(scrollerHeight)
       }
 
       // guarantees the same scrollbar widths
-      this.scroller.lockOverflow(scrollbarWidths)
+      scroller.lockOverflow(scrollbarWidths)
 
       // if there's any space below the slats, show the horizontal rule.
       // this won't cause any new overflow, because lockOverflow already called.
@@ -206,8 +214,10 @@ export default abstract class TimeColsView extends View {
 
   // given a desired total height of the view, returns what the height of the scroller should be
   computeScrollerHeight(viewHeight) {
-    return viewHeight -
-      subtractInnerElHeight(this.rootEl, this.scroller.el) // everything that's NOT the scroller
+    let rootEl = this.rootElRef.current
+    let scroller = this.scrollerRef.current
+
+    return viewHeight - subtractInnerElHeight(rootEl, scroller.rootEl) // everything that's NOT the scroller
   }
 
 
@@ -231,13 +241,17 @@ export default abstract class TimeColsView extends View {
 
 
   queryDateScroll() {
-    return { top: this.scroller.controller.getScrollTop() }
+    let scroller = this.scrollerRef.current
+
+    return { top: scroller.controller.getScrollTop() }
   }
 
 
   applyDateScroll(scroll) {
+    let scroller = this.scrollerRef.current
+
     if (scroll.top !== undefined) {
-      this.scroller.controller.setScrollTop(scroll.top)
+      scroller.controller.setScrollTop(scroll.top)
     }
   }
 
@@ -247,7 +261,7 @@ export default abstract class TimeColsView extends View {
 
 
   // Generates the HTML that will go before the day-of week header cells
-  renderHeadIntroHtml = () => {
+  renderHeadIntro = () => {
     let { theme, dateEnv, options } = this.context
     let range = this.props.dateProfile.renderRange
     let dayCnt = diffDays(range.start, range.end)
@@ -256,27 +270,28 @@ export default abstract class TimeColsView extends View {
     if (options.weekNumbers) {
       weekText = dateEnv.format(range.start, WEEK_HEADER_FORMAT)
 
-      return '' +
-        '<th class="fc-axis fc-week-number ' + theme.getClass('widgetHeader') + '" ' + this.axisStyleAttr() + '>' +
-          buildGotoAnchorHtml( // aside from link, important for matchCellWidths
-            options,
-            dateEnv,
-            { date: range.start, type: 'week', forceOff: dayCnt > 1 },
-            htmlEscape(weekText) // inner HTML
-          ) +
-        '</th>'
-    } else {
-      return '<th class="fc-axis ' + theme.getClass('widgetHeader') + '" ' + this.axisStyleAttr() + '></th>'
+      return [
+        <th class={'fc-axis fc-week-number ' + theme.getClass('widgetHeader')} style={this.getAxisStyles()}>
+          <GotoAnchor
+            navLinks={options.navLinks}
+            gotoOptions={{ date: range.start, type: 'week', forceOff: dayCnt > 1 }}
+          >{weekText}</GotoAnchor>
+        </th>
+      ]
     }
+
+    return [
+      <th class={'fc-axis ' + theme.getClass('widgetHeader')} style={this.getAxisStyles()}></th>
+    ]
   }
 
 
   // Generates an HTML attribute string for setting the width of the axis, if it is known
-  axisStyleAttr() {
+  getAxisStyles() {
     if (this.axisWidth != null) {
-      return 'style="width:' + this.axisWidth + 'px"'
+      return { width: this.axisWidth }
     }
-    return ''
+    return {}
   }
 
 
@@ -285,23 +300,21 @@ export default abstract class TimeColsView extends View {
 
 
   // Generates the HTML that goes before the bg of the TimeCols slot area. Long vertical column.
-  renderTimeColsBgIntroHtml = () => {
+  renderTimeColsBgIntro = () => {
     let { theme } = this.context
 
-    return '<td class="fc-axis ' + theme.getClass('widgetContent') + '" ' + this.axisStyleAttr() + '></td>'
+    return [
+      <td class={'fc-axis ' + theme.getClass('widgetContent')} style={this.getAxisStyles()}></td>
+    ]
   }
 
 
   // Generates the HTML that goes before all other types of cells.
   // Affects content-skeleton, mirror-skeleton, highlight-skeleton for both the time-grid and day-grid.
-  renderTimeColsIntroHtml = () => {
-    return '<td class="fc-axis" ' + this.axisStyleAttr() + '></td>'
-  }
-
-
-  timeColsRenderProps: TimeColsRenderProps = {
-    renderBgIntroHtml: this.renderTimeColsBgIntroHtml,
-    renderIntroHtml: this.renderTimeColsIntroHtml
+  renderTimeColsIntro = () => {
+    return [
+      <td class='fc-axis' style={this.getAxisStyles()}></td>
+    ]
   }
 
 
@@ -310,31 +323,30 @@ export default abstract class TimeColsView extends View {
 
 
   // Generates the HTML that goes before the all-day cells
-  renderTableBgIntroHtml = () => {
+  renderTableBgIntro = () => {
     let { theme, options } = this.context
+    let spanAttrs = {} as any
+
+    if (typeof options.allDayHtml === 'string') {
+      spanAttrs.dangerouslySetInnerHTML = { __html: options.allDayHtml }
+    }
 
-    return '' +
-      '<td class="fc-axis ' + theme.getClass('widgetContent') + '" ' + this.axisStyleAttr() + '>' +
-        '<span>' + // needed for matchCellWidths
-          getAllDayHtml(options) +
-        '</span>' +
-      '</td>'
+    return [
+      <td class={'fc-axis ' + theme.getClass('widgetContent')} style={this.getAxisStyles()}>
+        <span {...spanAttrs}>
+          {options.allDayText}
+        </span>
+      </td>
+    ]
   }
 
 
   // Generates the HTML that goes before all other types of cells.
   // Affects content-skeleton, mirror-skeleton, highlight-skeleton for both the time-grid and day-grid.
-  renderTableIntroHtml = () => {
-    return '<td class="fc-axis" ' + this.axisStyleAttr() + '></td>'
-  }
-
-
-  tableRenderProps: TableRenderProps = {
-    renderNumberIntroHtml: this.renderTableIntroHtml, // don't want numbers
-    renderBgIntroHtml: this.renderTableBgIntroHtml,
-    renderIntroHtml: this.renderTableIntroHtml,
-    colWeekNumbersVisible: false,
-    cellWeekNumbersVisible: false
+  renderTableIntro = () => {
+    return [
+      <td class='fc-axis' style={this.getAxisStyles()}></td>
+    ]
   }
 
 }

+ 2 - 2
packages/timegrid/src/main.ts

@@ -1,10 +1,10 @@
 import { createPlugin } from '@fullcalendar/core'
 import TimeColsView from './TimeColsView'
 import DayTimeColsView, { buildDayTableModel } from './DayTimeColsView'
-import { TimeColsSeg, TimeColsRenderProps } from './TimeCols'
+import { TimeColsSeg } from './TimeCols'
 import { default as DayTimeCols, DayTimeColsSlicer, buildDayRanges } from './DayTimeCols'
 
-export { DayTimeCols, DayTimeColsView, TimeColsView, buildDayTableModel, buildDayRanges, DayTimeColsSlicer, TimeColsSeg, TimeColsRenderProps }
+export { DayTimeCols, DayTimeColsView, TimeColsView, buildDayTableModel, buildDayRanges, DayTimeColsSlicer, TimeColsSeg }
 export { default as TimeCols } from './TimeCols'
 
 export default createPlugin({