@miso.ai/client-sdk-ui 1.14.0-beta.2 → 1.14.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/cjs/asset/svgs.js +4 -2
  2. package/dist/cjs/constants.js +2 -0
  3. package/dist/cjs/defaults.js +100 -10
  4. package/dist/cjs/element/container/index.js +14 -0
  5. package/dist/cjs/element/container/miso-message-item.js +91 -0
  6. package/dist/cjs/element/container/miso-thread-item.js +91 -0
  7. package/dist/cjs/element/role/index.js +7 -0
  8. package/dist/cjs/element/role/miso-delete.js +20 -0
  9. package/dist/cjs/layout/button/button.js +45 -5
  10. package/dist/cjs/layout/button/checkbox.js +2 -1
  11. package/dist/cjs/layout/container/item-container.js +99 -0
  12. package/dist/cjs/layout/index.js +14 -0
  13. package/dist/cjs/layout/input/search-box.js +9 -4
  14. package/dist/cjs/layout/list/collection.js +10 -3
  15. package/dist/cjs/layout/list/messages.js +239 -226
  16. package/dist/cjs/layout/list/threads.js +97 -155
  17. package/dist/cjs/layout/templates.js +65 -38
  18. package/dist/cjs/layout/text/question.js +71 -0
  19. package/dist/cjs/layout/text/typewriter/index.js +14 -21
  20. package/dist/cjs/util/dom.js +21 -0
  21. package/dist/cjs/version.js +1 -1
  22. package/package.json +3 -3
  23. package/src/CLAUDE.md +6 -2
  24. package/src/asset/svgs.js +3 -1
  25. package/src/constants.js +2 -0
  26. package/src/defaults.js +71 -4
  27. package/src/element/container/index.js +2 -0
  28. package/src/element/container/miso-message-item.js +94 -0
  29. package/src/element/container/miso-thread-item.js +94 -0
  30. package/src/element/role/index.js +1 -0
  31. package/src/element/role/miso-delete.js +16 -0
  32. package/src/layout/button/button.js +40 -4
  33. package/src/layout/button/checkbox.js +2 -1
  34. package/src/layout/container/item-container.js +99 -0
  35. package/src/layout/index.js +2 -0
  36. package/src/layout/input/search-box.js +4 -2
  37. package/src/layout/list/collection.js +12 -4
  38. package/src/layout/list/messages.js +202 -204
  39. package/src/layout/list/threads.js +90 -130
  40. package/src/layout/templates.js +74 -39
  41. package/src/layout/text/question.js +58 -0
  42. package/src/layout/text/typewriter/index.js +10 -15
  43. package/src/util/dom.js +16 -0
  44. package/src/version.js +1 -1
package/src/CLAUDE.md CHANGED
@@ -44,7 +44,9 @@ Web Components (`<miso-*>`). They contain **no rendering or data logic** — the
44
44
  - `<miso-ask>` — binds by `parent-question-id` attribute (absent = root ask), or `workflow="active"` to follow `asks.active`
45
45
  - `<miso-search>`, `<miso-hybrid-search>` — bind to `client.ui.search` / `.hybridSearch`
46
46
  - `<miso-explore>`, `<miso-recommendation>` — bind by `unit-id` attribute
47
- - `<miso-history>`, `<miso-conversation>` — bind to `client.workflows.history` / `.conversation`, forming the chat history interface (thread list + conversation panel); their role elements are `<miso-threads>`, `<miso-new-thread>`, `<miso-messages>`, `<miso-title>`, `<miso-rename>` and `<miso-subscription>`. The new-thread button is its own component (`<miso-new-thread>`, role `new_thread`), *not* part of the thread list, so it renders regardless of the list state (loading, empty, erroneous); it uses the generic `button` layout — an icon and a label, both configurable (`icon`, `text`), emitting a `submit` view event when clicked — which History answers by clearing the selection and resetting the conversation panel to new-thread mode. The thread list uses the `threads` layout (a list of `thread` items — the `<li>` itself is the item, carrying `data-role="item"`, the thread id and the state attributes; the thread template renders it directly, skipping the stock li-wrapper + role-body split that exists for anchor-bodied product items; selection is part of the data — `selectedThreadId` lives in the workflow's committed value and each record carries its `selected` flag right in the data layer (stamped by the workflow's default data pass; the role mapping is a plain slice) — and the list renders incrementally: appends render new items, while in-place state changes on existing items (selected/unread) are applied by an attribute-sync pass after each render, reading fresh values off the item bindings); the conversation uses the `messages` layout with `message` items (question bubble + answer body, with a per-message loading placeholder while answer contents are fetched), rendered incrementally; each question bubble carries its authorship — `data-author` ('Written by Miso' for questions whose `metadata.miso_generated_by` is `answer_update_monitor`, read via `getGeneratedBy`; the user's own questions carry no label; the label renders as a CSS `attr()` badge overlapping the bubble's top-left border, and monitor-generated bubbles take a distinct background) — stamped by the item template and re-applied by the sync pass, since the metadata arrives with the answers response after the stub render; the item template leaves question/answer contents blank, and a post-render pass (`_syncMessages`) fills them in place — finished answers via the `std:ui-markdown` plugin's pure `transform()`, and `live`/streaming answers (posted follow-ups) via a per-message typewriter (progressive markdown renderer + paced cursor). The conversation's header roles come from the open thread's record: `<miso-title>` (role `title`) renders its title with the generic `text` layout, `<miso-rename>` (role `rename`) renders a pencil button with the generic `button` layout whose **`prompt` option** turns the click into ask-then-submit: the shared prompt dialog opens pre-filled with the control's current value — the role's mapped data value, read off the latest rendered state (`_currentValue()`; `RafLayout` retains it per element) — and the `submit` view event carries the confirmed text as `value`, which Conversation answers with `rename(value)` — and `<miso-subscription>` (role `subscription`) renders the answer-updates toggle with the **`checkbox` layout** — a button-layout subclass; a checkbox that looks like a button (`<button role="switch" aria-checked>` with `data-checked`, an icon and label per state via `icon`/`text` and `checkedIcon`/`checkedText`). Its checked state comes from the data, so a click emits a `change` view event carrying only the requested state (`{ checked }`), read off the rendered element; the Conversation workflow answers it with `subscribe()`/`unsubscribe()` and the new state arrives back down the data path. Note the `visible-when` mechanism forces `display: block !important` on the element it toggles, so a page that lays such an element out as a flex row needs an inner wrapper (as the history demo pages do for the header). A `<miso-query>` (search-box layout) inside `<miso-conversation>` sends questions through the hub `query` field — follow-ups, or the first question of a new thread (the conversation starts in new-thread mode). With `messages`/`threads` registered as data roles, `<miso-conversation>` carries the `empty`/`nonempty` content status, which pages use to center the composer in the empty (new-thread) state. The `threads` layout's post-render sync also applies in-place record changes (title, `data-thread-id`) so renames and placeholder→real-thread resolutions reflect without item re-renders. A click on a thread item is a navigation action, not a content-engagement click: the layout overrides `_onClick` to emit a `select` view event (no click tracking), which the History workflow handles with `select()`. Each item also carries a context menu (vertical dots, revealed on hover) with thread actions, each going through a shared modal dialog util (promise-returning native `<dialog>`s on a common core, `util/modal.js`): rename asks via `prompt()` (`util/prompt.js`, a modal text input pre-filled with the current title — Enter confirms, resolving to the trimmed text, or undefined on cancel; exposed as `MisoClient.ui.prompt`) and emits a `rename` view event, handled by the History workflow with `rename()`; delete asks via `confirm()` (`util/confirm.js`, exposed as `MisoClient.ui.confirm`) and emits a `delete` view event, handled with `delete()`.
47
+ - `<miso-history>`, `<miso-conversation>` — bind to `client.workflows.history` / `.conversation`, forming the chat history interface (thread list + conversation panel); their role elements are `<miso-threads>`, `<miso-new-thread>`, `<miso-messages>`, `<miso-title>`, `<miso-rename>`, `<miso-delete>` and `<miso-subscription>`. The new-thread button is its own component (`<miso-new-thread>`, role `new_thread`), *not* part of the thread list, so it renders regardless of the list state (loading, empty, erroneous); it uses the generic `button` layout — an icon and a label, both configurable (`icon`, `text`), emitting a `submit` view event when clicked — which History answers by clearing the selection and resetting the conversation panel to new-thread mode. The thread list uses the `threads` layout as a **shell**, like the conversation's `messages` layout: each `thread` item is a `<miso-thread-item>` container element (`data-role="item"`, carrying the thread id and the state attributes) hosting the thread item subworkflow, with the title and the context menu's rename/subscription/delete controls as role elements inside rendering through that workflow's own generic layouts; selection is part of the data — `selectedThreadId` lives in the workflow's committed value and each record carries its `selected` flag right in the data layer (stamped by the workflow's default data pass, preserving the identity of records whose flag doesn't flip, so only the affected items' subworkflows see a commit; the role mapping is a plain slice) — and the list renders incrementally: fresh items land at either end — a newly created thread renders as a prepend (the list is newest-first), an older page fetched by the more flow as appends — told apart by which boundary the already-rendered records line up with (anything else re-renders in full), while in-place state changes on existing items (selected/unread, the thread id of a settled placeholder) are applied by an attribute-sync pass after each render, reading fresh values off the item bindings, and the item contents follow the record down each thread workflow's own data path — no item re-render either way. The list **pages by infinite scroll**, through the collection layout's stock machinery (the `data-role="trigger"` div after the list + the triggerable mixin, enabled by the threads layout's `infiniteScroll` option and the workflow's `'history'` pagination defaults): the trigger scrolling into view fires the `more` hub field, History appends the next page (`start`/`rows` against the user history list API, exhaustion from the response's own `has_more`), and the page size is set via `history.useApi('threads/list', { rows: N })`; the conversation uses the `messages` layout as a **shell**: each `message` item is a `<miso-message-item>` container element (question/answer/feedback/sources role elements inside, in that order — the feedback buttons right below the answer via the standard feedback layout, the sources as the hybrid-search-style compact horizontal cards under an h3 "Sources" heading (`messageSourcesBlock`, overridable — the block is `visible-when="nonempty"`, so the heading appears only once the item carries content, and a `:has()` rule hides the whole block again when there are zero source cards; a browser without `:has()` degrades to showing the heading, hybrid-search-parity); the item element carries `miso-circled-citation-index`, covering the answer's citation links and the source cards' indexes alike, as the hybrid-search UI scopes it; a message record without its answer body presents as workflow status `loading`, stamped on the element by its container layout as the standard `status` attribute — there is deliberately no per-message loading icon: the answer typewriter's blinking caret is the loading indication, as in ask (hidden at `initial` and at `done` by the stock typewriter CSS); a message whose content fetch failed for good presents as status `erroneous` and swaps its content out wholesale for the item template's muted notice (`messageErrorBlock`, `visible-when="erroneous"` — `miso-message-item` is in the visible-when container list, so the notice keys off the item's own status attribute) — the regular role elements are packed in a `__content` wrapper hidden by a class rule at erroneous status (a wrapper cannot be visible-when-driven itself: the mechanism forces `display: block !important` and only negates combined with positive terms) — and the messages layout's `ongoing` computation skips an errored last message so the composer is not held), rendered incrementally — fresh items land at either end: a posted follow-up appends at the bottom, an older page fetched by the more flow prepends at the top (told apart by which boundary the already-rendered records line up with; anything else re-renders in full) — the content itself renders through each message's own item subworkflow. The panel **pages by infinite scroll, chat-style from the bottom**: the messages layout's own root template places the stock `data-role="trigger"` div **above** the list, so scrolling up fires the `more` hub field (enabled by the layout's `infiniteScroll` option + the workflow's `'conversation'` pagination defaults) and the Conversation workflow prepends the previous page of messages. The post-render sync pass (`_syncWorkflows`) does one thing: assign each `<miso-message-item>` its workflow off the item binding (`element.workflow = conversation._getMessageWorkflow(record)` — implicit binding, item identity keyed by question id falling back to the posted record's local `placeholder_id`, so the element and workflow survive the settle). Item elements of a nested collection do not leak into the outer one: a message hosts its own source-card items (`data-role="item"` too), so `CollectionLayout._getItemElements` keeps only elements whose closest `[data-role="list"]` is the layout's own list — bindings zip values to elements by index, so a stray nested item would shift every binding after it. The question bubble is the **`question` layout** (the message workflow's default for the question role, whose mapping passes the whole record): it renders the question text and stamps the authorship as data attributes on its host element — `data-author` (the label text, configurable via the layout's `author` option; by default 'Written by Miso' for questions whose `metadata.miso_generated_by` is `answer_update_monitor`, per `isUpdateMessage`; it renders as a CSS `attr()` badge overlapping the bubble's top-left border, and monitor-generated bubbles take a distinct background) and `data-generated-by` — and hides its host while there is no question text. The shell also owns the panel-level behaviors: scroll pinning (following content growth via a ResizeObserver on the list, since answers type out through their own workflows outside the shell's render cycle), scroll anchoring while scrolled up — a prepend keeps the reading position by anchoring on the previously-first item: everything prepended, including its contents rendering in later through the item workflows, sits above the anchor, so holding the anchor stationary (at insert time and on every growth event while unpinned, the anchor's offset re-recorded as the user scrolls) holds the view, while growth below it (a streaming answer) needs no compensation; done by hand, since not every engine provides native scroll anchoring — and the `ongoing` view state read off the data — the last message's answer absent or unfinished — which e.g. disables the search box. The conversation's header roles come from the open thread's record: `<miso-title>` (role `title`) renders its title with the generic `text` layout, `<miso-rename>` (role `rename`) renders a pencil button with the generic `button` layout whose **`prompt` option** turns the click into ask-then-submit: the shared prompt dialog opens pre-filled with the control's current value — the role's mapped data value, read off the latest rendered state (`_currentValue()`; `RafLayout` retains it per element) — and the `submit` view event carries the confirmed text as `value`, which Conversation answers with `rename(value)` — `<miso-delete>` (role `delete`, placed right of the rename button) renders a red bin button with the button layout's `confirm` option (the dialog's message interpolates the mapped title), whose confirmed `submit` Conversation answers with `delete()` (the deletion fact then resets the panel to new-thread mode) — and `<miso-subscription>` (role `subscription`) renders the answer-updates toggle with the **`checkbox` layout** — a button-layout subclass; a checkbox that looks like a button (`<button role="switch" aria-checked>` with `data-checked`, an icon and label per state via `icon`/`text` and `checkedIcon`/`checkedText`). Its checked state comes from the data, so a click emits a `change` view event carrying only the requested state (`{ checked }`), read off the rendered element; the Conversation workflow answers it with `subscribe()`/`unsubscribe()` and the new state arrives back down the data path. Note the `visible-when` mechanism forces `display: block !important` on the element it toggles, so a page that lays such an element out as a flex row needs an inner wrapper (as the history demo pages do for the header). A `<miso-query>` (search-box layout) inside `<miso-conversation>` submits questions as the `submit` view event, which Conversation routes to `send()` — follow-ups, or the first question of a new thread (the conversation starts in new-thread mode). With `messages`/`threads` registered as data roles, `<miso-conversation>` carries the `empty`/`nonempty` content status, which pages use to center the composer in the empty (new-thread) state. A click on a thread item is a navigation action, not a content-engagement click: the `threads` layout overrides `_onClick` to emit a `select` view event (no click tracking), which the History workflow handles with `select()` — item selection stays list-level, while everything inside the item belongs to its thread workflow. Each item also carries a context menu (vertical dots, revealed on hover): its markup comes from the item template (`threadMenuBlock`), and its open/close behavior is driven by the thread workflow's **`item-container` layout** (`ItemContainerLayout`, the container-layout subclass for collection item subworkflows) **by contract** — the template marks the toggle button `data-role="item-menu-button"` and the popup `data-role="item-menu"`, and the layout toggles on the button and closes on any other click on the item or outside it (per-item document listeners, which is also how opening one item's menu closes another's); the shell only keeps clicks carrying those data-roles from selecting. The menu items are the thread workflow's own controls, each a generic `button` layout going through a shared modal dialog util (promise-returning native `<dialog>`s on a common core, `util/modal.js`): `<miso-rename>` (role `rename`) uses the button layout's `prompt` option (`util/prompt.js`, a modal text input pre-filled with the current title — Enter confirms, resolving to the trimmed text, or undefined on cancel; exposed as `MisoClient.ui.prompt`), and `<miso-delete>` (role `delete`) its **`confirm` option** (`util/confirm.js`, exposed as `MisoClient.ui.confirm`; the option's `message` may be a function of the control's current value — the mapped title); the confirmed `submit` view events fire the operation requests on the thread workflow's own hub, their facts riding onto the shared thread events emitter. Between the two sits `<miso-subscription>` (role `subscription`), the `checkbox` layout as a plain menu item: text only, labeled by the action a click takes ('Subscribe' / 'Unsubscribe' via `text`/`checkedText` in the `'history'` defaults — unlike the conversation header's state-labeled bell pill), its `change` view event answered by the thread workflow with `history.subscribe()`/`unsubscribe()`.
48
+ - `<miso-message-item>` — a **container element inside another container element** (`<miso-conversation>`), hosting the `message-item` workflow — an answer-based workflow that posts and streams its own content when live (ask-style), and is otherwise fed by the conversation via `updateData()` with its data actor off. Normally **bound implicitly**: the messages layout renders one per item and assigns `element.workflow` off the item binding (which also covers a just-posted message with no question id yet); a `question-id` attribute binds explicitly through the parent container's workflow instead (`conversation._getMessageWorkflow`). Its role children (`<miso-question>`, `<miso-answer>`, `<miso-sources>`, `<miso-feedback>`, ...) bind to the message workflow as usual (`getContainer` resolves to the closest container ancestor) and render one message via the standard answer-based layouts (the message workflow shares the conversation's options, whose `'conversation'` defaults hold the message roles too — omitting the query role, turning the container logo off, and giving the answer typewriter `instant: true` — already-finished answers render in one shot); citation/link clicks and feedback go out as message-level interactions, deduplicated per message.
49
+ - `<miso-thread-item>` — the thread list's analogue of `<miso-message-item>`: a container element inside `<miso-history>`, hosting the `thread-item` workflow — data actor off, fed by History via `updateData()` with only the records that actually changed (record identity is the change signal). Normally **bound implicitly**: the threads layout renders one per item and assigns `element.workflow` off the item binding; a `thread-id` attribute binds explicitly through the parent container's workflow instead (`history._getThreadWorkflow`). Its role children — `<miso-title>` (generic `text` layout, with an 'Untitled' fallback via a `content` template), `<miso-rename>`/`<miso-delete>` (generic `button` layout with the `prompt`/`confirm` dialog options), and `<miso-subscription>` (the `checkbox` layout, the context menu's toggle) — bind to the thread workflow as usual; title and subscription map dot-paths into the record (`'title'`, `'subscribed'`), while rename and delete map the whole record — their button layouts derive the dialog text via the `value` option and render disabled (the `disabled` option) until the record carries a thread id, so a thread being created is inert in the UI too. Its container role uses the `item-container` layout, which drives the context menu found in the item's markup via the `item-menu` data-role contract (a custom body without menu markup simply has nothing to drive).
48
50
  - **Role components** (`element/role/`): register with their ancestor container; the role determines which slice of data they display. Either dedicated tags (`<miso-answer>`, `<miso-sources>`, `<miso-products>`, `<miso-query>`, ...) or generic `<miso-component role="...">`. `<miso-results>` is deprecated in favor of `<miso-products>`.
49
51
  - **Combos** (`element/combo/`): pre-configured composite `<miso-ask-combo>` (deprecated).
50
52
 
@@ -54,7 +56,9 @@ Connecting an element to the DOM is what triggers view creation: container `conn
54
56
 
55
57
  Rendering strategies. A layout class has a static `type` (e.g. `'list'`, `'typewriter'`, `'search-box'`) and is registered in the plugin registry; workflows pick layouts per role via options (`useLayouts({ answer: ['typewriter', {...}] })`). `ViewsActor` instantiates one layout object per view.
56
58
 
57
- Contract: `render(element, data, { notifyUpdate })` is mandatory; `initialize(view)`, `unrender(element)`, `syncSize(element)`, `destroy()` are optional. `notifyUpdate(state)` is how a layout reports its view state back (published to `view:<role>` on the hub) — this drives workflow lifecycle events. A layout can also write to the hub (e.g. `SearchBoxLayout` submits `fields.query()`).
59
+ Contract: `render(element, data, { notifyUpdate })` is mandatory; `initialize(view)`, `unrender(element)`, `syncSize(element)`, `destroy()` are optional. `notifyUpdate(state)` is how a layout reports its view state back (published to `view:<role>` on the hub) — this drives workflow lifecycle events. A layout submits user actions as **view events**, like the buttons do: `SearchBoxLayout` emits `submit` (`{ session, value }`) on the query role, which each query-hosting workflow subscribes to and routes itself (`_onQuerySubmit` → `query()` in the answer-based, search-based and explore workflows; Conversation routes to `send()`) — layouts never write the query field or call workflow methods directly.
60
+
61
+ A layout root stamps its view role as a `data-role` attribute — the role name verbatim, snake_case included (`data-role="new_thread"`) — and CSS binds to layout className + role (`.miso-text[data-role="question"]`), never to the host element tag: `<miso-rename>` and `<miso-component role="rename">` style alike; element tags appear only in `_container.scss` for structural rules (display, box-sizing, `visible-when`).
58
62
 
59
63
  ## Entry Point
60
64
 
package/src/asset/svgs.js CHANGED
@@ -24,6 +24,7 @@ const ICON_NAME = {
24
24
  PENCIL: 'pencil',
25
25
  BELL: 'bell',
26
26
  BELL_FILL: 'bell-fill',
27
+ TRASH: 'trash',
27
28
  };
28
29
 
29
30
  // CC0 SVGs
@@ -43,8 +44,9 @@ const ICON_PLUS = `<symbol id="${PREFIX}-${ICON_NAME.PLUS}" viewBox="0 0 16 16">
43
44
  const ICON_PENCIL = `<symbol id="${PREFIX}-${ICON_NAME.PENCIL}" viewBox="0 0 16 16"><path fill="currentColor" d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.325-.175"/></symbol>`;
44
45
  const ICON_BELL = `<symbol id="${PREFIX}-${ICON_NAME.BELL}" viewBox="0 0 16 16"><path fill="currentColor" d="M8 16a2 2 0 0 0 2-2H6a2 2 0 0 0 2 2M8 1.918l-.797.161A4 4 0 0 0 4 6c0 .628-.134 2.197-.459 3.742-.16.767-.376 1.566-.663 2.258h10.244c-.287-.692-.502-1.49-.663-2.258C12.134 8.197 12 6.628 12 6a4 4 0 0 0-3.203-3.92zM14.22 12c.223.447.481.801.78 1H1c.299-.199.557-.553.78-1C2.68 10.2 3 6.88 3 6c0-2.42 1.72-4.44 4.005-4.901a1 1 0 1 1 1.99 0A5 5 0 0 1 13 6c0 .88.32 4.2 1.22 6"/></symbol>`;
45
46
  const ICON_BELL_FILL = `<symbol id="${PREFIX}-${ICON_NAME.BELL_FILL}" viewBox="0 0 16 16"><path fill="currentColor" d="M8 16a2 2 0 0 0 2-2H6a2 2 0 0 0 2 2m.995-14.901a1 1 0 1 0-1.99 0A5 5 0 0 0 3 6c0 1.098-.5 6-2 7h14c-1.5-1-2-5.902-2-7 0-2.42-1.72-4.44-4.005-4.901"/></symbol>`;
47
+ const ICON_TRASH = `<symbol id="${PREFIX}-${ICON_NAME.TRASH}" viewBox="0 0 16 16"><path fill="currentColor" d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5m2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5m3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0z"/><path fill="currentColor" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4zM2.5 3h11V2h-11z"/></symbol>`;
46
48
 
47
- const ICONS = [ICON_SEND, ICON_SEARCH, ICON_TRIANGLE, ICON_TRIANGLE_EQ, ICON_CHEVRON, ICON_CHEVRON_NEGATIVE, ICON_BULB, ICON_DOTS_VERTICAL, ICON_PLUS, ICON_PENCIL, ICON_BELL, ICON_BELL_FILL];
49
+ const ICONS = [ICON_SEND, ICON_SEARCH, ICON_TRIANGLE, ICON_TRIANGLE_EQ, ICON_CHEVRON, ICON_CHEVRON_NEGATIVE, ICON_BULB, ICON_DOTS_VERTICAL, ICON_PLUS, ICON_PENCIL, ICON_BELL, ICON_BELL_FILL, ICON_TRASH];
48
50
 
49
51
  const SPRITES_SVG = `<svg id="${ID}" style="display:none;">${ICONS.join('')}</svg>`;
50
52
 
package/src/constants.js CHANGED
@@ -23,6 +23,7 @@ export const ATTR_DATA_MISO_PRODUCT_ID = `data-miso-product-id`;
23
23
 
24
24
  export const LAYOUT_TYPE = Object.freeze({
25
25
  CONTAINER: 'container',
26
+ ITEM_CONTAINER: 'item-container',
26
27
  ERROR: 'error',
27
28
  BANNER: 'banner',
28
29
  AFFILIATION: 'affiliation',
@@ -42,5 +43,6 @@ export const LAYOUT_TYPE = Object.freeze({
42
43
  CHECKBOX: 'checkbox',
43
44
  FEEDBACK: 'feedback',
44
45
  TEXT: 'text',
46
+ QUESTION: 'question',
45
47
  TYPEWRITER: 'typewriter',
46
48
  });
package/src/defaults.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { escapeHtml } from '@miso.ai/commons';
1
2
  import { ROLE, LAYOUT_TYPE } from './constants.js';
2
3
  import { compactArticle, compactArticleInfoBlock } from './layout/templates.js';
3
4
 
@@ -33,6 +34,29 @@ function totalContent(layout, { value }) {
33
34
  return `${formatted} ${label}`;
34
35
  }
35
36
 
37
+ const { [ROLE.QUERY]: _answerBasedQueryLayout, ...MESSAGE_LAYOUTS } = ANSWER_BASED_LAYOUTS;
38
+
39
+ // the rename/delete button controls of a thread (the conversation header's,
40
+ // and a thread list item's context menu's): mapped to the thread record —
41
+ // the dialogs work with its title, and the buttons disable while the record
42
+ // has no thread id yet (a thread being created is not addressable until
43
+ // resolved; the workflows guard all the same)
44
+ const THREAD_CONTROL_OPTIONS = Object.freeze({
45
+ value: thread => thread && thread.title,
46
+ disabled: thread => !(thread && thread.thread_id),
47
+ });
48
+ const THREAD_RENAME_PROMPT = Object.freeze({
49
+ title: 'Rename thread',
50
+ placeholder: 'Thread name',
51
+ confirmText: 'Rename',
52
+ });
53
+ const THREAD_DELETE_CONFIRM = Object.freeze({
54
+ title: 'Delete thread',
55
+ message: title => `Are you sure you want to delete "${title || 'this thread'}"? This cannot be undone.`,
56
+ confirmText: 'Delete',
57
+ danger: true,
58
+ });
59
+
36
60
  const SEARCH_BASED_LAYOUTS = {
37
61
  ...BASE_LAYOUTS,
38
62
  [ROLE.QUERY]: [LAYOUT_TYPE.SEARCH_BOX],
@@ -89,18 +113,61 @@ export const defaultLayouts = Object.freeze({
89
113
  [ROLE.QUERY]: [LAYOUT_TYPE.SEARCH_BOX, { placeholder: 'Ask a question' }],
90
114
  }),
91
115
 
116
+ // the thread list's own roles plus its thread item subworkflows' — the
117
+ // items share the history workflow's options, so one bag serves both.
118
+ // The item roles: the item-container layout (the standard container
119
+ // duties plus the items' context menus, driven by the item-menu
120
+ // data-role contract — it only ever drives its own item's menu, so
121
+ // hosting it on the panel is inert, and the panel never triggers the
122
+ // auto logo either, having no main-role component), the title text
123
+ // (with a presentation fallback for untitled threads), the context
124
+ // menu's rename (ask-then-submit through the prompt dialog) and delete
125
+ // (confirm-then-submit) buttons, and its subscription toggle — a menu
126
+ // item like the other two, so text only, labeled by the action a click
127
+ // takes rather than by the state
92
128
  'history': Object.freeze({
93
129
  ...BASE_LAYOUTS,
94
- [ROLE.THREADS]: [LAYOUT_TYPE.THREADS, { itemType: 'thread', incremental: true }],
130
+ [ROLE.CONTAINER]: [LAYOUT_TYPE.ITEM_CONTAINER, { logo: false }],
131
+ [ROLE.THREADS]: [LAYOUT_TYPE.THREADS, { itemType: 'thread', incremental: true, infiniteScroll: true }],
95
132
  [ROLE.NEW_THREAD]: [LAYOUT_TYPE.BUTTON, { icon: 'plus', text: 'New chat' }],
133
+ [ROLE.TITLE]: [LAYOUT_TYPE.TEXT, { tag: 'div', templates: { content: (layout, { value }) => escapeHtml(value || 'Untitled') } }],
134
+ [ROLE.RENAME]: [LAYOUT_TYPE.BUTTON, { ...THREAD_CONTROL_OPTIONS, text: 'Rename', prompt: THREAD_RENAME_PROMPT }],
135
+ [ROLE.DELETE]: [LAYOUT_TYPE.BUTTON, { ...THREAD_CONTROL_OPTIONS, text: 'Delete', confirm: THREAD_DELETE_CONFIRM }],
136
+ [ROLE.SUBSCRIPTION]: [LAYOUT_TYPE.CHECKBOX, { text: 'Subscribe', checkedText: 'Unsubscribe' }],
96
137
  }),
97
138
 
139
+ // the conversation panel's own roles plus its message item subworkflows'
140
+ // — the items share the conversation workflow's options, so one bag
141
+ // serves both. The message roles: the answer-based layouts minus the
142
+ // query role (a message has no query flow of its own; the query entry
143
+ // below is the panel's composer), with the question layout (text plus
144
+ // authorship attributes, off the whole record), the answer typewriter
145
+ // with `instant: true` (an already-finished answer renders in one shot),
146
+ // and the sources as the compact horizontal cards of the hybrid-search
147
+ // UI. logo: false turns the per-message banner off; the panel never
148
+ // triggers the auto logo anyway, having no main-role component
98
149
  'conversation': Object.freeze({
99
- ...BASE_LAYOUTS,
100
- [ROLE.MESSAGES]: [LAYOUT_TYPE.MESSAGES, { itemType: 'message', incremental: true }],
150
+ ...MESSAGE_LAYOUTS,
151
+ [ROLE.CONTAINER]: [LAYOUT_TYPE.CONTAINER, { logo: false }],
152
+ [ROLE.QUESTION]: LAYOUT_TYPE.QUESTION,
153
+ [ROLE.ANSWER]: [LAYOUT_TYPE.TYPEWRITER, { instant: true }],
154
+ [ROLE.SOURCES]: [
155
+ LAYOUT_TYPE.HORIZONTAL,
156
+ {
157
+ incremental: true,
158
+ itemType: 'article',
159
+ templates: {
160
+ ordered: true,
161
+ article: compactArticle,
162
+ articleInfoBlock: compactArticleInfoBlock
163
+ }
164
+ }
165
+ ],
166
+ [ROLE.MESSAGES]: [LAYOUT_TYPE.MESSAGES, { itemType: 'message', incremental: true, infiniteScroll: true }],
101
167
  [ROLE.QUERY]: [LAYOUT_TYPE.SEARCH_BOX, { placeholder: 'Ask a follow-up question', clearOnSubmit: true, blurOnSubmit: false, disableWhenOngoing: true, templates: { buttonIcon: 'send' } }],
102
168
  [ROLE.TITLE]: [LAYOUT_TYPE.TEXT, { tag: 'div' }],
103
- [ROLE.RENAME]: [LAYOUT_TYPE.BUTTON, { icon: 'pencil', prompt: { title: 'Rename thread', placeholder: 'Thread name', confirmText: 'Rename' } }],
169
+ [ROLE.RENAME]: [LAYOUT_TYPE.BUTTON, { ...THREAD_CONTROL_OPTIONS, icon: 'pencil', prompt: THREAD_RENAME_PROMPT }],
170
+ [ROLE.DELETE]: [LAYOUT_TYPE.BUTTON, { ...THREAD_CONTROL_OPTIONS, icon: 'trash', confirm: THREAD_DELETE_CONFIRM }],
104
171
  [ROLE.SUBSCRIPTION]: [LAYOUT_TYPE.CHECKBOX, { icon: 'bell', text: 'Subscribe', checkedIcon: 'bell-fill', checkedText: 'Subscribed' }],
105
172
  }),
106
173
 
@@ -2,6 +2,8 @@ export { default as MisoAskElement } from './miso-ask.js';
2
2
  export { default as MisoExploreElement } from './miso-explore.js';
3
3
  export { default as MisoHistoryElement } from './miso-history.js';
4
4
  export { default as MisoConversationElement } from './miso-conversation.js';
5
+ export { default as MisoMessageItemElement } from './miso-message-item.js';
6
+ export { default as MisoThreadItemElement } from './miso-thread-item.js';
5
7
  export { default as MisoHybridSearchElement } from './miso-hybrid-search.js';
6
8
  export { default as MisoSearchElement } from './miso-search.js';
7
9
  export { default as MisoRecommendationElement } from './miso-recommendation.js';
@@ -0,0 +1,94 @@
1
+ import MisoContainerElement from './miso-container.js';
2
+ import { getContainer } from '../utils.js';
3
+
4
+ const TAG_NAME = 'miso-message-item';
5
+
6
+ const ATTR_QUESTION_ID = 'question-id';
7
+ const OBSERVED_ATTRIBUTES = Object.freeze([
8
+ ...MisoContainerElement.observedAttributes,
9
+ ATTR_QUESTION_ID,
10
+ ]);
11
+
12
+ /**
13
+ * A container element for one message (question-answer pair) of the
14
+ * conversation panel, placed inside <miso-conversation> — a container
15
+ * element inside another container element hosting an item subworkflow (the
16
+ * `message` workflow). Normally bound implicitly: the messages layout
17
+ * renders one <miso-message-item> per item and assigns the workflow off the item
18
+ * binding (element.workflow = parent workflow's _getMessageWorkflow(record)),
19
+ * which also covers a just-posted message that has no question id yet. A
20
+ * `question-id` attribute binds explicitly instead, through the parent
21
+ * container's workflow. Role elements inside (question, answer, sources,
22
+ * ...) bind to the message workflow as usual — getContainer() resolves to
23
+ * the closest container ancestor.
24
+ */
25
+ export default class MisoMessageItemElement extends MisoContainerElement {
26
+
27
+ static get tagName() {
28
+ return TAG_NAME;
29
+ }
30
+
31
+ static get observedAttributes() {
32
+ return OBSERVED_ATTRIBUTES;
33
+ }
34
+
35
+ // properties //
36
+ get questionId() {
37
+ return this.getAttribute(ATTR_QUESTION_ID) || undefined;
38
+ }
39
+
40
+ set questionId(value) {
41
+ value = value !== undefined ? `${value}` : undefined;
42
+ if (value === this.questionId) {
43
+ return;
44
+ }
45
+ if (value) {
46
+ this.setAttribute(ATTR_QUESTION_ID, value);
47
+ } else {
48
+ this.removeAttribute(ATTR_QUESTION_ID);
49
+ }
50
+ }
51
+
52
+ _getWorkflow(client) {
53
+ const { questionId } = this;
54
+ if (!questionId) {
55
+ // implicitly bound: the messages layout assigns element.workflow off
56
+ // the item binding — keep whatever is (or will be) assigned
57
+ return this._workflow;
58
+ }
59
+ const container = this._getParentContainer();
60
+ const workflow = container && container.workflow;
61
+ // the parent workflow hands out the item subworkflow; the parent binds
62
+ // its own workflow first (its connectedCallback resolves earlier), so
63
+ // it is normally present by now
64
+ return workflow && typeof workflow._getMessageWorkflow === 'function' ? workflow._getMessageWorkflow(questionId) : undefined;
65
+ }
66
+
67
+ _getParentContainer() {
68
+ try {
69
+ return this.parentNode ? getContainer(this.parentNode) : undefined;
70
+ } catch (_) {
71
+ return undefined; // not inside a container element
72
+ }
73
+ }
74
+
75
+ attributeChangedCallback(attr, oldValue, newValue) {
76
+ switch (attr) {
77
+ case ATTR_QUESTION_ID:
78
+ this._handleQuestionIdUpdate(oldValue, newValue);
79
+ break;
80
+ default:
81
+ super.attributeChangedCallback(attr, oldValue, newValue);
82
+ }
83
+ }
84
+
85
+ _handleQuestionIdUpdate(oldValue, newValue) {
86
+ oldValue = oldValue || undefined; // null -> undefined
87
+ newValue = newValue || undefined;
88
+ if (oldValue === newValue || !this._client) {
89
+ return;
90
+ }
91
+ this._setWorkflow(this._getWorkflow(this._client));
92
+ }
93
+
94
+ }
@@ -0,0 +1,94 @@
1
+ import MisoContainerElement from './miso-container.js';
2
+ import { getContainer } from '../utils.js';
3
+
4
+ const TAG_NAME = 'miso-thread-item';
5
+
6
+ const ATTR_THREAD_ID = 'thread-id';
7
+ const OBSERVED_ATTRIBUTES = Object.freeze([
8
+ ...MisoContainerElement.observedAttributes,
9
+ ATTR_THREAD_ID,
10
+ ]);
11
+
12
+ /**
13
+ * A container element for one thread item of the thread list, placed inside
14
+ * <miso-history> — a container element inside another container element
15
+ * hosting an item subworkflow (the `thread` workflow). Normally bound
16
+ * implicitly: the threads layout renders one <miso-thread-item> per item and
17
+ * assigns the workflow off the item binding (element.workflow = parent
18
+ * workflow's _getThreadWorkflow(record)), which also covers a thread being
19
+ * created that has no thread id yet. A `thread-id` attribute binds
20
+ * explicitly instead, through the parent container's workflow. Role
21
+ * elements inside (title, rename, delete, subscription) bind to the thread
22
+ * workflow as usual — getContainer() resolves to the closest container
23
+ * ancestor.
24
+ */
25
+ export default class MisoThreadItemElement extends MisoContainerElement {
26
+
27
+ static get tagName() {
28
+ return TAG_NAME;
29
+ }
30
+
31
+ static get observedAttributes() {
32
+ return OBSERVED_ATTRIBUTES;
33
+ }
34
+
35
+ // properties //
36
+ get threadId() {
37
+ return this.getAttribute(ATTR_THREAD_ID) || undefined;
38
+ }
39
+
40
+ set threadId(value) {
41
+ value = value !== undefined ? `${value}` : undefined;
42
+ if (value === this.threadId) {
43
+ return;
44
+ }
45
+ if (value) {
46
+ this.setAttribute(ATTR_THREAD_ID, value);
47
+ } else {
48
+ this.removeAttribute(ATTR_THREAD_ID);
49
+ }
50
+ }
51
+
52
+ _getWorkflow(client) {
53
+ const { threadId } = this;
54
+ if (!threadId) {
55
+ // implicitly bound: the threads layout assigns element.workflow off
56
+ // the item binding — keep whatever is (or will be) assigned
57
+ return this._workflow;
58
+ }
59
+ const container = this._getParentContainer();
60
+ const workflow = container && container.workflow;
61
+ // the parent workflow hands out the item subworkflow; the parent binds
62
+ // its own workflow first (its connectedCallback resolves earlier), so
63
+ // it is normally present by now
64
+ return workflow && typeof workflow._getThreadWorkflow === 'function' ? workflow._getThreadWorkflow(threadId) : undefined;
65
+ }
66
+
67
+ _getParentContainer() {
68
+ try {
69
+ return this.parentNode ? getContainer(this.parentNode) : undefined;
70
+ } catch (_) {
71
+ return undefined; // not inside a container element
72
+ }
73
+ }
74
+
75
+ attributeChangedCallback(attr, oldValue, newValue) {
76
+ switch (attr) {
77
+ case ATTR_THREAD_ID:
78
+ this._handleThreadIdUpdate(oldValue, newValue);
79
+ break;
80
+ default:
81
+ super.attributeChangedCallback(attr, oldValue, newValue);
82
+ }
83
+ }
84
+
85
+ _handleThreadIdUpdate(oldValue, newValue) {
86
+ oldValue = oldValue || undefined; // null -> undefined
87
+ newValue = newValue || undefined;
88
+ if (oldValue === newValue || !this._client) {
89
+ return;
90
+ }
91
+ this._setWorkflow(this._getWorkflow(this._client));
92
+ }
93
+
94
+ }
@@ -22,6 +22,7 @@ export { default as MisoThreadsElement } from './miso-threads.js';
22
22
  export { default as MisoNewThreadElement } from './miso-new-thread.js';
23
23
  export { default as MisoTitleElement } from './miso-title.js';
24
24
  export { default as MisoRenameElement } from './miso-rename.js';
25
+ export { default as MisoDeleteElement } from './miso-delete.js';
25
26
  export { default as MisoSubscriptionElement } from './miso-subscription.js';
26
27
  export { default as MisoMessagesElement } from './miso-messages.js';
27
28
  export { default as MisoErrorElement } from './miso-error.js';
@@ -0,0 +1,16 @@
1
+ import MisoComponentElement from './miso-component.js';
2
+ import { ROLE } from '../../constants.js';
3
+
4
+ export default class MisoDeleteElement extends MisoComponentElement {
5
+
6
+ constructor() {
7
+ super({
8
+ role: ROLE.DELETE,
9
+ });
10
+ }
11
+
12
+ static get tagName() {
13
+ return 'miso-delete';
14
+ }
15
+
16
+ }
@@ -3,6 +3,7 @@ import { LAYOUT_TYPE } from '../../constants.js';
3
3
  import TemplateBasedLayout from '../template.js';
4
4
  import { getIcon } from '../../asset/svgs.js';
5
5
  import prompt from '../../util/prompt.js';
6
+ import confirm from '../../util/confirm.js';
6
7
 
7
8
  const TYPE = LAYOUT_TYPE.BUTTON;
8
9
  const DEFAULT_CLASSNAME = 'miso-button';
@@ -10,7 +11,8 @@ const DEFAULT_CLASSNAME = 'miso-button';
10
11
  function root(layout, state) {
11
12
  const { className, role, templates } = layout;
12
13
  const roleAttr = role ? ` data-role="${role}"` : '';
13
- return `<button type="button" class="${className}"${roleAttr}>${templates.icon(layout, state)}${templates.text(layout, state)}</button>`;
14
+ const disabledAttr = layout._isDisabled(state) ? ' disabled' : '';
15
+ return `<button type="button" class="${className}"${roleAttr}${disabledAttr}>${templates.icon(layout, state)}${templates.text(layout, state)}</button>`;
14
16
  }
15
17
 
16
18
  function icon(layout) {
@@ -45,6 +47,19 @@ const DEFAULT_TEMPLATES = Object.freeze({
45
47
  * dialog submits nothing. Use it for actions that take a text input, such
46
48
  * as the rename button (role `rename`) of the conversation header, whose
47
49
  * role maps to `'thread.title'`.
50
+ *
51
+ * With a `confirm` option, the click asks through the shared confirm dialog
52
+ * first, and submits only when confirmed; the option's `message` may be a
53
+ * function of the control's current value. Use it for destructive actions,
54
+ * such as the delete button (role `delete`) of a thread item's context menu.
55
+ *
56
+ * The control's current value is the role's mapped data value, put through
57
+ * the `value` option when given — map the role to a record and let `value`
58
+ * pick the text the dialogs work with. A `disabled` option (a boolean, or a
59
+ * function of the mapped value) renders the button's disabled state from
60
+ * the data, re-evaluated on every commit — e.g. rename/delete disable while
61
+ * their thread record has no thread id yet (a thread being created is not
62
+ * addressable), enabling in place when the resolution patches the record.
48
63
  */
49
64
  export default class ButtonLayout extends TemplateBasedLayout {
50
65
 
@@ -84,7 +99,9 @@ export default class ButtonLayout extends TemplateBasedLayout {
84
99
  if (!button || button.disabled) {
85
100
  return;
86
101
  }
87
- this.options.prompt ? this._submitViaPrompt(event) : this._submit(event);
102
+ this.options.prompt ? this._submitViaPrompt(event) :
103
+ this.options.confirm ? this._submitViaConfirm(event) :
104
+ this._submit(event);
88
105
  }
89
106
 
90
107
  _submit(event, payload) {
@@ -104,13 +121,32 @@ export default class ButtonLayout extends TemplateBasedLayout {
104
121
  this._submit(event, { value });
105
122
  }
106
123
 
124
+ async _submitViaConfirm(event) {
125
+ const { message, ...options } = this.options.confirm;
126
+ const confirmed = await confirm({
127
+ ...options,
128
+ message: typeof message === 'function' ? message(this._currentValue()) : message,
129
+ });
130
+ if (!confirmed) {
131
+ return;
132
+ }
133
+ this._submit(event);
134
+ }
135
+
107
136
  /**
108
137
  * The current value of the control: the role's mapped data value, as of
109
- * the latest render.
138
+ * the latest render, put through the `value` option when given.
110
139
  */
111
140
  _currentValue() {
112
141
  const state = this._element && this._rendered.get(this._element);
113
- return state && state.value;
142
+ const raw = state && state.value;
143
+ const { value } = this.options;
144
+ return typeof value === 'function' ? value(raw) : raw;
145
+ }
146
+
147
+ _isDisabled(state) {
148
+ const { disabled } = this.options;
149
+ return typeof disabled === 'function' ? !!disabled(state && state.value) : !!disabled;
114
150
  }
115
151
 
116
152
  }
@@ -11,7 +11,8 @@ function root(layout, state) {
11
11
  const checked = isChecked(state);
12
12
  const roleAttr = role ? ` data-role="${role}"` : '';
13
13
  const checkedAttr = checked ? ' data-checked' : '';
14
- return `<button type="button" class="${className}" role="switch" aria-checked="${checked}"${roleAttr}${checkedAttr}>${templates.icon(layout, state)}${templates.text(layout, state)}</button>`;
14
+ const disabledAttr = layout._isDisabled(state) ? ' disabled' : '';
15
+ return `<button type="button" class="${className}" role="switch" aria-checked="${checked}"${roleAttr}${checkedAttr}${disabledAttr}>${templates.icon(layout, state)}${templates.text(layout, state)}</button>`;
15
16
  }
16
17
 
17
18
  function icon(layout, state) {
@@ -0,0 +1,99 @@
1
+ import { LAYOUT_TYPE } from '../../constants.js';
2
+ import ContainerLayout from './container.js';
3
+
4
+ const TYPE = LAYOUT_TYPE.ITEM_CONTAINER;
5
+
6
+ /**
7
+ * The container layout of a collection's item subworkflow (a thread of the
8
+ * thread list, a message of the conversation panel): the standard container
9
+ * duties (status attributes, banner, trackings), plus the item-level
10
+ * behaviors — currently the context menu (vertical dots + popup).
11
+ *
12
+ * The menu is handled by contract, not rendered here: the item template
13
+ * supplies the markup (e.g. the threads layout's threadMenuBlock), marking
14
+ * the toggle button `data-role="item-menu-button"` and the popup
15
+ * `data-role="item-menu"`, and this layout drives their open/close behavior
16
+ * — toggle on the button; any other click on the item (a menu action
17
+ * included) closes it, and so does a click outside the item, which is also
18
+ * how opening one item's menu closes another's. The menu contents are
19
+ * typically role elements binding to the item workflow (e.g. the thread
20
+ * item's `<miso-rename>`/`<miso-delete>` buttons), so the actions stay the
21
+ * workflow's own controls. Collection shells treat clicks carrying the
22
+ * menu data-roles as item-internal (e.g. the threads layout does not
23
+ * select); an item template without menu markup simply has nothing to
24
+ * drive. Ownership is guarded: the layout only ever drives its own item's
25
+ * menu — a menu or click inside a nested container element is that
26
+ * container's own layout's — so the layout is inert on the list panel
27
+ * itself, whose options bag it shares with the items.
28
+ */
29
+ export default class ItemContainerLayout extends ContainerLayout {
30
+
31
+ static get type() {
32
+ return TYPE;
33
+ }
34
+
35
+ initialize(view) {
36
+ this._unsubscribes.push(view.proxyElement.on('click', event => this._handleClick(event)));
37
+ if (typeof document !== 'undefined') {
38
+ const onDocumentClick = (event) => {
39
+ const element = this._element;
40
+ if (element && !element.contains(event.target)) {
41
+ this._closeMenu();
42
+ }
43
+ };
44
+ document.addEventListener('click', onDocumentClick);
45
+ this._unsubscribes.push(() => document.removeEventListener('click', onDocumentClick));
46
+ }
47
+ }
48
+
49
+ // menu //
50
+ _handleClick(event) {
51
+ // only left click
52
+ if (event.button !== 0) {
53
+ return;
54
+ }
55
+ // a click inside a nested container element belongs to that
56
+ // container's own layout (this layout may also host the list panel,
57
+ // whose subtree holds the items)
58
+ if (!this._owns(event.target)) {
59
+ return;
60
+ }
61
+ if (event.target.closest(`[data-role="item-menu-button"]`)) {
62
+ this._toggleMenu();
63
+ return;
64
+ }
65
+ // any other click on the item — a menu action included — closes the menu
66
+ this._closeMenu();
67
+ }
68
+
69
+ _getMenu() {
70
+ const menu = this._element && this._element.querySelector(`[data-role="item-menu"]`);
71
+ // only its own item's menu: one found inside a nested container
72
+ // element is that container's own layout's to drive
73
+ return menu && this._owns(menu) ? menu : undefined;
74
+ }
75
+
76
+ _owns(node) {
77
+ for (let el = node; el && el !== this._element; el = el.parentElement) {
78
+ if (el.isContainer) {
79
+ return false;
80
+ }
81
+ }
82
+ return true;
83
+ }
84
+
85
+ _toggleMenu() {
86
+ const menu = this._getMenu();
87
+ if (menu) {
88
+ menu.hidden = !menu.hidden;
89
+ }
90
+ }
91
+
92
+ _closeMenu() {
93
+ const menu = this._getMenu();
94
+ if (menu && !menu.hidden) {
95
+ menu.hidden = true;
96
+ }
97
+ }
98
+
99
+ }
@@ -1,5 +1,6 @@
1
1
  export { default as TemplateBasedLayout } from './template.js';
2
2
  export { default as ContainerLayout } from './container/container.js';
3
+ export { default as ItemContainerLayout } from './container/item-container.js';
3
4
  export { default as ErrorLayout } from './error/error.js';
4
5
  export { default as BannerLayout } from './banner/banner.js';
5
6
  export { default as CollectionLayout } from './list/collection.js';
@@ -14,6 +15,7 @@ export { default as SearchBoxLayout } from './input/search-box.js';
14
15
  export { default as OptionListLayout } from './input/option-list.js';
15
16
  export { default as FeedbackLayout } from './radio/feedback.js';
16
17
  export { default as TextLayout } from './text/text.js';
18
+ export { default as QuestionLayout } from './text/question.js';
17
19
  export { default as TypewriterLayout } from './text/typewriter/index.js';
18
20
  export { default as AffiliationLayout } from './affiliation/affiliation.js';
19
21
  export { default as FacetsLayout } from './input/facets.js';