From eec8414a0279dc8e88e84f4e2317e41218a19173 Mon Sep 17 00:00:00 2001 From: erykciepiela Date: Sun, 26 Jul 2026 18:00:16 +0200 Subject: [PATCH] experiment: static vs dynamic parts, and the Slot that lets the caller choose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scan of all ~45 MDC members recording which parts are static-only, which are dynamic-only, and which want to be both (doc/experiment-static-dynamic-parts.md). Sixteen members have a part a real app would drive from data; the codebase already pays for two of them — iconToggle exists because iconButton's icon can't follow data, and photo-gallery rebuilds its whole image list per album because imageListItem's src/label are static. Prototype: `Slot i a = Absent | Pinned a | Fed (i -> a)` — the three times a part's value can be known. `card` and `topAppBar` converted; every demo compiles unchanged, no new warnings. Pinned stays distinct from Fed (const a) on purpose: only Pinned renders at registration, so the static half of a widget stays viewable before any model exists. Findings recorded in the note: the dynamic case must be named (`fed _.album`) because an instance chain can't commit on an unsolved projection type; Absent can't fold into the slot because node presence is construction-time knowledge; and today's static/dynamic boundary coincides with the `mvu` seam, so a dynamic caption pulls chrome inside it. photo-gallery's app bar now names the open album, which retires its separate headline stage; smoke-tested for in-place text-node updates. Co-Authored-By: Claude Opus 4.8 (1M context) --- demo/nguis/photo-gallery/PhotoGallery.purs | 17 +- doc/experiment-static-dynamic-parts.md | 227 +++++++++++++++++++++ scripts/smoke/tests/photo-gallery.mjs | 25 +++ src/PUI/MDC.purs | 165 +++++++++++++-- 4 files changed, 403 insertions(+), 31 deletions(-) create mode 100644 doc/experiment-static-dynamic-parts.md create mode 100644 scripts/smoke/tests/photo-gallery.mjs diff --git a/demo/nguis/photo-gallery/PhotoGallery.purs b/demo/nguis/photo-gallery/PhotoGallery.purs index d492abe5..3b07661c 100644 --- a/demo/nguis/photo-gallery/PhotoGallery.purs +++ b/demo/nguis/photo-gallery/PhotoGallery.purs @@ -12,16 +12,15 @@ import Data.String (joinWith) import Data.String.CodeUnits (toCharArray) import Data.Variant (match) import Effect (Effect) -import PUI (displayed, forField, forValue, mvu, projection, tapped, toCase, updates) +import PUI (displayed, mvu, projection, toCase, updates) import PUI.HTML (body, dynamic, each, span, staticText, text) -import PUI.MDC (divider, drawer, headline2, imageList, imageListItem, list, listItem, listOf, overline, topAppBar) -import QualifiedDo.Semigroupoid as Semigroupoid +import PUI.MDC (divider, drawer, fed, imageList, imageListItem, list, listItem, listOf, overline, topAppBar) photoGallery :: Effect Unit photoGallery = body $ - topAppBar { title: "Photo Gallery" } $ - ( drawer { title: "Darkroom", subtitle: "photos drawn on the spot" } + ( topAppBar { title: fed _.album } $ + drawer { title: "Darkroom", subtitle: "photos drawn on the spot" } ( RecordToRecord.do listOf { selected: _.current } (span text # projection _.name) # rmap _.name # toCase @"albumPicked" # lcmap albumChoices # updates (match { albumPicked: openAlbum }) divider @@ -35,11 +34,9 @@ photoGallery = imageListItem { src: developedPhoto "Half Smile", label: "Half Smile" } imageListItem { src: developedPhoto "Orbit Study", label: "Orbit Study" } imageListItem { src: developedPhoto "Quiet Lake", label: "Quiet Lake" }) - ( Semigroupoid.do - headline2 text # forValue # forField @"album" # tapped - imageList { columns: 3 } $ displayed $ dynamic \m -> - each (albumPhotos m) \p -> imageListItem { src: p.src, label: p.caption }) - ) # mvu landscapesOpen + ( imageList { columns: 3 } $ displayed $ dynamic \m -> + each (albumPhotos m) \p -> imageListItem { src: p.src, label: p.caption } ) + ) # mvu landscapesOpen landscapesOpen :: { album :: String } landscapesOpen = { album: "Landscapes" } diff --git a/doc/experiment-static-dynamic-parts.md b/doc/experiment-static-dynamic-parts.md new file mode 100644 index 00000000..c135b1bd --- /dev/null +++ b/doc/experiment-static-dynamic-parts.md @@ -0,0 +1,227 @@ +# Experiment: static parts, dynamic parts, and when each is known + +A widget is made of **parts** — a caption, a label, an icon, an option list, a +value, a column header. Each part's content becomes known at one of two times: +at **construction**, from a PureScript value the author writes down; or at +**feed**, from the model flowing through the channel. Today the MDC vocabulary +decides that time *for the caller*: a part is either a field of the config +record (always static) or the `i` of `PUI Web i o` (always dynamic), and no +part can be both. + +This note scans every MDC member, records which parts are which, and works out +what it costs to let the caller choose — including a prototype (`Slot`) that is +on this branch and passing. + +## The scan + +`S` = static only (a config field or hardcoded markup; cannot follow data). +`D` = dynamic only (arrives through the channel; has no static form). +`B` = **wanted both ways**, only one available. The last column names the part +that is stuck. + +### Components — `×→×` editors + +| Component | Static parts | Dynamic parts | Stuck (`B`) | +|---|---|---|---| +| `filledTextField` | `floatingLabel`; helper-line text (hardcoded empty) | `value`; the label's float-above class (`clDyn … isJust`) | helper/error text — no path at all | +| `debouncedTextField` | `floatingLabel`, `millis` | `value` | as above | +| `filledTextArea` | `columns`, `rows` | `value` | — | +| `checkbox` | label content — a **widget slot** (`PUI Web {} {}`), so structured but pinned at `{}` | `value` | label content: cannot read the model (`{}`, not `i`) | +| `radioButton` | `options` | selection | `options` | +| `toggleSwitch` | `label` | `value` | `label` | +| `slider` / `sliderLive` | `label`, `min`, `max`, `step` | `value` | `min`/`max` (a bound derived from the model) | +| `select` | `floatingLabel`, `options` | selection | `options` | +| `segmentedButton` | `options` | selection | `options` | +| `tabBar` | `options` (`value`/`label`/`icon`) | selection | `options` | +| `filterChip` | `label` | `value` | `label` | +| `iconToggle` | `onIcon`, `offIcon`, `label` | `value` | — **but see below**: this component *is* a dynamic icon, hand-encoded as two static ones | + +### Components — `×→×` displays, `×→+` events, `+→×` statuses + +| Component | Static parts | Dynamic parts | Stuck (`B`) | +|---|---|---|---| +| `indeterminateLinearProgress` | aria label | `busy` | — | +| `indeterminateCircularProgress` | aria label, 48px inline size | `busy` | — | +| `linearProgress` | aria label | `value` | — | +| `button` | `label`, `icon` | the fed record (replayed on click) | `label` (Save / Saving…), `icon` | +| `fab` | `icon`, `label` | fed record | `label` | +| `iconButton` | `icon`, `label` | fed record | `icon` | +| `menuItem` | `label` | fed record | `label` (data-driven menus) | +| `snackbar` | auto-dismiss timing | **the message** (the `event` case payload) | — the one component whose text is dynamic by construction | +| `banner` | the `"Dismiss"` action label (hardcoded) | the message | `"Dismiss"` — not even reachable for translation | + +### Collection component + +| Component | Static parts | Dynamic parts | Stuck (`B`) | +|---|---|---|---| +| `listOf` | `selected` — **a function of the element**, i.e. already a fed slot | the array; each item widget re-fed by key | — | + +### Oculars + +| Ocular | Static parts | Dynamic parts | Stuck (`B`) | +|---|---|---|---| +| `card` | `caption` | content | `caption` → **prototyped as a `Slot`** | +| `topAppBar` | `title` | content | `title` → **prototyped as a `Slot`** | +| `dialog` | `title` | open/close (opens on feed, closes on emission) | `title` (`Delete "Lunch on Thursday?"`) | +| `simpleDialog` | `title`, `confirm` | open/close | both | +| `menu` | `label` | items | `label` | +| `dataTable` | `label`, `columns` | rows (content) | `columns` (pivots, chosen columns) | +| `imageList` | `columns` | content | `columns` (responsive) | +| `layoutCell` | `span` | content | `span` (responsive) | +| `drawer` | `title`, `subtitle` | nav and content (both fed) | `title`/`subtitle` | +| `tooltip` | `text` | — | `text` | +| `cardActions`, `chipSet`, `list`, `listItem`, `dataRow`, `dataCell`, `layoutGrid` | all (pure chrome) | content | — genuinely static | +| typography (`headline1`–`6`, `subtitle1/2`, `body1/2`, `caption`, `overline`), `elevation1/10/20` | all | content | — genuinely static | + +### Announcing statics + +| Static | Static parts | Stuck (`B`) | +|---|---|---| +| `divider` | fixed markup | — genuinely static | +| `imageListItem` | `src`, `label` | **both** — the sharpest case, see below | + +## What the scan shows + +**1. The vocabulary is lopsided.** Of ~45 members, three carry a dynamic +*text* part (`snackbar`, `banner`, and `listOf`'s items); everything +presentational is static-only. Sixteen members have at least one part a real +app would want to drive from data. + +**2. The escape hatches exist, one level down.** `PUI.HTML` already has the +static/dynamic pairing MDC lacks: `staticText`/`text`, `attr`/`attrWith`, +`cl`/`clWhen`/`clDyn`. Six demos use them (tic-tac-toe, cells, calculator, +circle-drawer, color-mixer, restaurant-menu). So the distinction is not new — +it is just missing from the *component* layer, where it is expressed by +*leaving the vocabulary* instead. + +**3. Two workarounds are visible in the codebase.** + +- `iconToggle { onIcon, offIcon, label }` is a whole component whose reason to + exist is that `iconButton`'s icon can't follow data. A dynamic icon slot + subsumes it. +- photo-gallery cannot build an image list from data, because + `imageListItem { src, label }` is a static. It falls back to + `displayed $ dynamic \m -> each (albumPhotos m) …`, which **tears down and + rebuilds the whole list on every album change** — precisely the wholesale + rebuild the keyed `foreach` regime exists to avoid. Two static parts cost a + reconciled collection. + +**4. Chrome sits outside the fed region.** Every demo reads +`card { caption: … } $ (pipeline # mvu seed)` — the ocular wraps the `mvu` +stage, so nothing ever feeds it. The static/dynamic boundary is not primarily a +vocabulary fact; it **coincides with the `mvu` seam**. Making chrome dynamic +means pulling it inside: `(card { caption: fed … } $ pipeline) # mvu seed`. +This is the one structural change a slot forces on a call site, and the +photo-gallery diff on this branch shows it. + +## The staging question + +> pass static parameters and dynamic parameters separately, and pass dynamic +> parameters later, so the static view can be viewed/tested/peeked sooner + +Three times already exist, in order, and the API already separates them: + +| Stage | When | What is fixed | What you can see | +|---|---|---|---| +| **construction** | PureScript application (`card { caption: "Quiz" }`) | config record | nothing yet — a value | +| **registration** | `Web`, at `body` | all chrome DOM built, MDC components created | **the static view** | +| **feed** | `with seed`, `mvu`, an upstream emission | dynamic parts filled | the live view | + +So "pass the dynamic parameters later" is already the shape of the library: +statics are function arguments, dynamics arrive through the channel, and the +channel can be supplied later or not at all. Two peeks work *today*: + +- `body $ widget` — registration only, no feed: the static skeleton. +- `body $ with sampleModel $ widget` — the same skeleton with sample data, + using the ordinary `with`. No test-only machinery needed. + +What was missing is not staging but **per-part choice of stage**. That is what +this experiment adds. + +## The design: `Slot` + +```purescript +data Slot i a + = Absent -- the part is not there, and neither is its DOM node + | Pinned a -- known at construction; renders at registration, no channel + | Fed (i -> a) -- known at feed; renders on the first feed +``` + +`Pinned a` and `Fed (const a)` display the same thing but are not the same +widget: only `Pinned` can be seen without data. Keeping them distinct is +exactly what makes a widget's static half a peekable artifact — the type +records which parts are visible before the model exists. + +Call sites: + +```purescript +card { caption: "Quiz" } -- Pinned (bare constant, lifted by the OptSlot tag) +card { caption: fed _.album } -- Fed (named) +card {} -- Absent (default) +``` + +### Results (measured on this branch) + +- **`card` and `topAppBar` converted; all 31 demos compile unchanged.** Static + call sites keep their exact syntax and their exact behaviour, and the build + produces no new warnings. The lifting rides the same `ConvertOption` + mechanism already used for optional fields. +- **A widget stays polarity-agnostic until a call site asks for a dynamic + part.** `Absent`/`Pinned` leave `i` free, so `card {…}` is still usable at + any polarity; only `fed` constrains `i`. The cost is that `card` and + `topAppBar` are no longer `Ocular`s (they read their input) — the precedent + is `drawer`, `clWhen` and `attrWith`, which already gave up `Ocular` for the + same reason. +- **The dynamic case must be *named*.** Lifting a bare projection + (`{ caption: _.album }`) does not typecheck: an instance chain can only + commit on a concrete `from`, and `_.album` is still + `{ album :: t | r } -> t` at conversion time — "the instance head contains + unknown type variables" (doc/type-errors.md's family). Hence `fed`. This + turns out to be worth keeping: the config record then says out loud which + parts wait for data. +- **`Absent` has to be a separate case, not `Fed (const Nothing)`.** Whether a + part's *node exists* is construction-time knowledge; a function of `i` can't + be consulted before the first feed, so folding absence into the slot would + force every optional part's DOM node to exist. The three-way split preserves + today's behaviour exactly. +- **Verified in the browser** (scripts/smoke/tests/photo-gallery.mjs): the fed + title renders on the first feed, follows the model, and updates *its own text + node in place* — the chrome is built once, so a slot is a channel, not a + rebuild. The pinned drawer title is unaffected. +- **One dynamic slot deleted a pipeline stage.** photo-gallery's separate + `headline2 text # forValue # forField @"album" # tapped` album heading is + gone; the app bar names the open album instead. + +### Alternatives considered + +- **Twin words** (`card`/`cardWith`, the `staticText`/`text` pattern lifted to + components). Rejected: it doubles a ~45-word vocabulary and the *combination* + cases (a pinned caption on a dynamic-titled dialog) multiply. +- **Widget slots** (`checkbox`'s `labelContent` generalized: pass a + `PUI Web i {}` for the part). Strictly more expressive — the part can be any + widget, styled and conditional — and it is the right answer where a part + needs *structure* rather than a string. The obstacle is implementation, not + design: most MDC innards are `staticHTML` strings with nowhere to attach a + child. `topAppBar` had to be rebuilt from element oculars for this + experiment, and that is the per-component cost of the general version. Good + follow-up; `Slot` is the cheap 80%. +- **Row promotion** (a config field omitted statically reappears in the input + row, so dynamic parts become model fields and the merges carry them). Most + bambik-native, and it needs no new data type. Rejected for now: it puts view + concerns (a caption) into the model row, the type-level machinery is heavier + than the `ConvertOption` lifting, and the error messages land in the worst + place. A `fed` projection gets the same reach without touching the model. + +### Follow-ups, in value order + +1. `imageListItem` as a component (`PUI Web { src, label } {}`), retiring + photo-gallery's `dynamic` rebuild — the measured win. +2. Slots for `dialog`/`simpleDialog` `title` (naming the thing being confirmed + is the most common real-world dynamic caption) and `tooltip` `text`. +3. Dynamic `options` for `select`/`radioButton`/`segmentedButton`/`tabBar`. + Distinct problem: the option *set* is a collection, so this is the + collection-action question (keyed reconciliation of options), not a slot. +4. A fourth slot case — `FedOr a (i -> a)`, a placeholder shown until the first + feed — which would make the registration-time peek complete rather than + holey. Cheap on top of `slotted`. +5. Slots for `iconButton`'s `icon`, retiring `iconToggle`. diff --git a/scripts/smoke/tests/photo-gallery.mjs b/scripts/smoke/tests/photo-gallery.mjs new file mode 100644 index 00000000..2bd24a11 --- /dev/null +++ b/scripts/smoke/tests/photo-gallery.mjs @@ -0,0 +1,25 @@ +// Slot parts (MDC `Slot`): the top app bar's title is `fed _.album`, so it +// renders on the first feed and follows the model in place; the drawer's own +// title is pinned at construction and never moves. +export const demos = ['demo/nguis/photo-gallery'] +export const url = '/demo/nguis/photo-gallery/' + +const title = `document.querySelector('.mdc-top-app-bar__title').textContent` + +export const run = async ({ ev, assertEq, sleep }) => { + await sleep(400) + assertEq(await ev(title), 'Landscapes', 'a fed slot renders on the first feed (the seeded album names the bar)') + assertEq(await ev(`document.querySelector('.mdc-drawer__title').textContent`), 'Darkroom', 'a pinned slot renders its constant') + + await ev(`(() => { window.__bar = document.querySelector('.mdc-top-app-bar__title'); return true })()`) + await ev(`[...document.querySelectorAll('.mdc-drawer .mdc-deprecated-list-item')].find(li => li.textContent.includes('Abstract')).click()`) + await sleep(300) + + assertEq(await ev(title), 'Abstract', 'the fed slot follows the model') + assertEq( + await ev(`window.__bar === document.querySelector('.mdc-top-app-bar__title')`), + true, + 'the slot updates its text node in place — the chrome is built once' + ) + assertEq(await ev(`document.querySelector('.mdc-drawer__title').textContent`), 'Darkroom', 'the pinned slot is unaffected') +} diff --git a/src/PUI/MDC.purs b/src/PUI/MDC.purs index eedf9247..dad0c3b3 100644 --- a/src/PUI/MDC.purs +++ b/src/PUI/MDC.purs @@ -21,14 +21,31 @@ -- followed by payload-typed panes, each `# provided # lcmap ` -- shown by the presence of its `Maybe` payload — see the demos); `+→+` -- remains the dispatch direction (`VariantToVariant.do` of action stages). --- * **oculars** — shape-preserving decorators (`card`, `dialog`, `menu`, --- `chipSet`, `list`/`listItem`, `dataTable`/`dataRow`/ --- `dataCell`, `imageList`, `layoutGrid`/`layoutCell`, `topAppBar`, --- `drawer`, `tooltip`, typography, elevations): they have no model of --- their own, so they wrap any polarity and impose none. +-- * **oculars** — shape-preserving decorators (`dialog`, `menu`, `chipSet`, +-- `list`/`listItem`, `dataTable`/`dataRow`/`dataCell`, `imageList`, +-- `layoutGrid`/`layoutCell`, `tooltip`, typography, elevations): they have +-- no model of their own, so they wrap any polarity and impose none. The +-- **slot-taking** decorators (`card`, `topAppBar`, `drawer`) are the same +-- kind of thing at a narrower type — see below. -- * plus **announcing statics** (`{} → {}` chrome with a face, like -- `Web.staticText`): `divider`, `imageListItem`. -- +-- A component's **parts** (a caption, a label, an option list, a value) each +-- become known at one of three times, and `Slot` is where the caller says +-- which: `Absent` (no part, no node — construction), `Pinned a` (construction, +-- so it renders at *registration*, before any model exists) or `Fed f` (read +-- from the value flowing through, so it renders on the first feed). A bare +-- constant lifts to `Pinned`, `fed _.field` names the dynamic case: +-- `card { caption: "Quiz" }` / `card { caption: fed _.album }` / `card {}`. +-- Only a `Fed` slot mentions the host's input, so a widget stays as +-- polarity-agnostic as it ever was until a call site asks for a dynamic part +-- (`card` and `topAppBar` therefore read `PUI Web i o -> PUI Web i o` rather +-- than `Ocular`, like `drawer` before them). The point of keeping `Pinned a` +-- distinct from `Fed (const a)` is that the static half of a widget stays a +-- viewable artifact: `body $ w` shows every pinned part with no data at all, +-- and `body $ with sample $ w` shows the rest. Scan of which MDC parts are +-- static, dynamic, or want to be both: doc/experiment-static-dynamic-parts.md. +-- -- MD2 catalog entries with no MDC Web implementation (backdrop, bottom app -- bar, bottom navigation, date pickers, navigation rail, sheets) are -- absent here too. @@ -57,7 +74,10 @@ module PUI.MDC , OptLabel(..) , OptIcon(..) , OptSelected(..) + , OptSlot(..) , OptStep(..) + , Slot(..) + , fed , banner , body1 , body2 @@ -147,7 +167,7 @@ import Prim.Row (class Cons) import ConvertableOptions (class ConvertOption, class ConvertOptionsWithDefaults, convertOption, convertOptionsWithDefaults) import QualifiedDo.Semigroupoid as Semigroupoid import PUI (PUI, constantly, effAdapter, foreach) -import PUI.HTML (aside, attr, checkboxInput, cl, clDyn, clWhen, clicked, div, h1, h2, h3, h4, h5, h6, i, init, input, inputDebounced, label, li, p, span, staticHTML, staticText, table, tbody, td, text, textArea, th, thead, tr, ul, (:=)) +import PUI.HTML (aside, attr, checkboxInput, cl, clDyn, clWhen, clicked, div, h1, h2, h3, h4, h5, h6, header, i, init, input, inputDebounced, label, li, p, section, span, staticHTML, staticText, table, tbody, td, text, textArea, th, thead, tr, ul, (:=)) import PUI.HTML (button) as HTML import PUI.Web (Node, Web, setAttribute, uniqueId) @@ -201,6 +221,78 @@ instance ConvertOption OptStep "step" Number (Maybe Number) where else instance ConvertOption OptStep sym a a where convertOption _ _ = identity +-- | A **slot** — a presentational part together with *when* its value is +-- | known. The three cases are the three times available to a widget, in +-- | order: +-- | +-- | * `Absent` — the part is not there, and neither is its DOM node. Known +-- | at construction. +-- | * `Pinned a` — the value is known at construction, so the part renders +-- | at **registration** and never needs the channel. This is the static +-- | view: it is on screen before any model exists. +-- | * `Fed f` — the value is read from the value flowing through, so the +-- | part renders on the **first feed**. This is the dynamic view. +-- | +-- | `Pinned a` and `Fed (const a)` show the same thing, but they are not the +-- | same widget: only `Pinned` can be seen without data. Keeping them +-- | distinct is what makes a widget's static half a peekable artifact. +-- | +-- | A slot-valued config field reads +-- | +-- | ```purescript +-- | card { caption: "Quiz" } -- Pinned: static chrome, no channel +-- | card { caption: fed _.album } -- Fed: chrome that follows the model +-- | card {} -- Absent: no caption node +-- | ``` +-- | +-- | The static case is the bare constant (the `OptSlot` tag lifts it, as +-- | `OptLabelIcon` lifts an optional `String` to `Just`); the dynamic case is +-- | named with `fed`. The asymmetry is forced, not stylistic: an instance +-- | chain can only commit on a *concrete* `from` type, and a projection like +-- | `_.album` is still an unsolved `{ album :: t | r } -> t` at the moment the +-- | field is converted — so lifting it implicitly fails with "the instance +-- | head contains unknown type variables". Naming the dynamic case earns its +-- | keep anyway: the config record then says out loud which parts wait for +-- | data. +data Slot i a + = Absent + | Pinned a + | Fed (i -> a) + +-- | Read a part from the value flowing through instead of pinning it: +-- | `card { caption: fed _.album }`. The dynamic half of a slot. +fed :: forall i a. (i -> a) -> Slot i a +fed = Fed + +-- | The lifting tag for slot-valued config fields: a bare constant becomes +-- | `Pinned`, anything already a `Slot` (i.e. `fed …`) passes through. Only a +-- | `Fed` slot mentions the host's input type, so a widget stays as +-- | polarity-agnostic as it was until a call site actually asks for a dynamic +-- | part. +data OptSlot = OptSlot + +instance ConvertOption OptSlot sym String (Slot i String) where + convertOption _ _ = Pinned +else instance ConvertOption OptSlot sym a a where + convertOption _ _ = identity + +-- | The chrome that shows a slot, at the host's own input type: nothing at +-- | all for `Absent`, a registration-time text node for `Pinned`, a +-- | channel-fed text leaf for `Fed`. The caller wraps the result in whatever +-- | ocular the part wears. +slotted :: forall i. Slot i String -> Maybe (PUI Web i {}) +slotted Absent = Nothing +slotted (Pinned a) = Just (constantly {} (staticText a)) +slotted (Fed f) = Just (lcmap (\i -> { value: f i }) text) + +-- Register a slot's chrome and return its feeder (chrome swallows its own +-- echo, like the text field's floating label). +slotFeeder :: forall i. PUI Web i {} -> Web (i -> Effect Unit) +slotFeeder s = do + w <- unwrap s + liftEffect $ w.fromUser mempty + pure w.toUser + -- | The `×→+` event button: reads the whole record it is shown and fires it -- | as event case `l` on click (`recordToCase` over the raw button). Both -- | fields are optional and default to `Nothing`: `button {}` is bare, @@ -883,22 +975,30 @@ elevation10 w = div w # cl "mdc-elevation--z10" # "style" := "padding: 25px" elevation20 :: Ocular (PUI Web) elevation20 w = div w # cl "mdc-elevation--z20" # "style" := "padding: 25px" --- | A card with an optional caption — the caption is design-system config --- | (like `filledTextField`'s `floatingLabel`). The card is content-agnostic --- | (any polarity), so its caption chrome is hand-fused, not merged. The --- | caption defaults to none: `card {}` is captionless, `card { caption: --- | "Title" }` labels it. +-- | A card with an optional caption — a `Slot`, so the caption is either +-- | pinned at construction (`card { caption: "Quiz" }`, on screen with no +-- | data) or read from the model (`card { caption: _.album }`, on screen +-- | after the first feed), and `card {}` has no caption node at all. The card +-- | stays content-agnostic (any polarity) for the first two; a `Fed` caption +-- | makes the card read its input, so it is no longer an `Ocular`. card - :: forall provided - . ConvertOptionsWithDefaults OptLabel { caption :: Maybe String } { | provided } { caption :: Maybe String } + :: forall provided i o + . ConvertOptionsWithDefaults OptSlot { caption :: Slot i String } { | provided } { caption :: Slot i String } => { | provided } - -> Ocular (PUI Web) + -> PUI Web i o + -> PUI Web i o card provided content = div >>> cl "mdc-card" >>> "style" := "padding: 10px; margin: 15px 0 15px 0; text-align: justify;" $ wrap do - for_ mCaption \c -> void $ unwrap (caption $ staticText c) - unwrap content + cap <- for (slotted config.caption) (slotFeeder <<< caption) + w <- unwrap content + pure + { toUser: \i -> do + for_ cap \feed -> feed i + w.toUser i + , fromUser: w.fromUser + } where - { caption: mCaption } = convertOptionsWithDefaults OptLabel { caption: Nothing } provided :: { caption :: Maybe String } + config = convertOptionsWithDefaults OptSlot { caption: Absent } provided -- | The MD2 card button-row area: a flex row for a group of buttons, so they -- | sit inline at their natural width instead of stretching down the card's @@ -1091,12 +1191,35 @@ layoutGrid content = div >>> cl "mdc-layout-grid" $ div >>> cl "mdc-layout-grid_ layoutCell :: { span :: Int } -> Ocular (PUI Web) layoutCell config content = div >>> cl "mdc-layout-grid__cell" >>> cl ("mdc-layout-grid__cell--span-" <> show config.span) $ content -topAppBar :: { title :: String } -> Ocular (PUI Web) -topAppBar config content = wrap do - _ <- unwrap (staticHTML ("
" <> config.title <> "
")) +-- | The title is a `Slot`: `topAppBar { title: "Photo Gallery" }` pins it, +-- | `topAppBar { title: _.album }` lets the bar name what is open. The header +-- | is built from element oculars rather than raw markup precisely so the +-- | title has a place to attach a channel-fed leaf. +topAppBar + :: forall provided i o + . ConvertOptionsWithDefaults OptSlot { title :: Slot i String } { | provided } { title :: Slot i String } + => { | provided } + -> PUI Web i o + -> PUI Web i o +topAppBar provided content = wrap do + ttl <- slotFeeder $ + header >>> cl "mdc-top-app-bar" $ + div >>> cl "mdc-top-app-bar__row" $ + section >>> cl "mdc-top-app-bar__section" >>> cl "mdc-top-app-bar__section--align-start" $ + case slotted config.title of + Nothing -> constantly {} pempty + Just s -> span >>> cl "mdc-top-app-bar__title" $ s headerNode <- gets _.sibling _ <- liftEffect $ newComponent material.topAppBar."MDCTopAppBar" headerNode - unwrap (div >>> cl "mdc-top-app-bar--fixed-adjust" $ content) + content' <- unwrap (div >>> cl "mdc-top-app-bar--fixed-adjust" $ content) + pure + { toUser: \i -> do + ttl i + content'.toUser i + , fromUser: content'.fromUser + } + where + config = convertOptionsWithDefaults OptSlot { title: Absent } provided -- | Permanent navigation drawer beside the content; the drawer's own nav -- | is chrome (`{} → {}`, e.g. a `list` of `listItem`s).