@aceshooting/lyra-ui 1.1.0 → 2.0.0
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.
- package/README.md +12 -1
- package/custom-elements.json +21460 -8489
- package/dist/components/app-rail/app-rail.d.ts +171 -0
- package/dist/components/app-rail/app-rail.js +483 -0
- package/dist/components/app-rail/app-rail.styles.d.ts +1 -0
- package/dist/components/app-rail/app-rail.styles.js +125 -0
- package/dist/components/attachment-chip/attachment-chip.d.ts +114 -0
- package/dist/components/attachment-chip/attachment-chip.js +312 -0
- package/dist/components/attachment-chip/attachment-chip.styles.d.ts +1 -0
- package/dist/components/attachment-chip/attachment-chip.styles.js +184 -0
- package/dist/components/attachment-trigger/attachment-trigger.d.ts +83 -0
- package/dist/components/attachment-trigger/attachment-trigger.js +258 -0
- package/dist/components/attachment-trigger/attachment-trigger.styles.d.ts +1 -0
- package/dist/components/attachment-trigger/attachment-trigger.styles.js +68 -0
- package/dist/components/chart/box-plot.js +11 -1
- package/dist/components/chart/chart-loader.d.ts +44 -13
- package/dist/components/chart/chart-loader.js +65 -18
- package/dist/components/chart/chart.d.ts +12 -2
- package/dist/components/chart/chart.js +121 -14
- package/dist/components/chart/histogram-bin.d.ts +4 -1
- package/dist/components/chart/histogram-bin.js +24 -14
- package/dist/components/chart/lite-chart.d.ts +63 -0
- package/dist/components/chart/lite-chart.js +181 -23
- package/dist/components/chart/lite-chart.styles.js +9 -0
- package/dist/components/chat-composer/chat-composer.d.ts +92 -0
- package/dist/components/chat-composer/chat-composer.js +285 -0
- package/dist/components/chat-composer/chat-composer.styles.d.ts +1 -0
- package/dist/components/chat-composer/chat-composer.styles.js +135 -0
- package/dist/components/chat-message/chat-message.d.ts +113 -0
- package/dist/components/chat-message/chat-message.js +291 -0
- package/dist/components/chat-message/chat-message.styles.d.ts +1 -0
- package/dist/components/chat-message/chat-message.styles.js +203 -0
- package/dist/components/checkbox/checkbox.d.ts +59 -0
- package/dist/components/checkbox/checkbox.js +230 -0
- package/dist/components/checkbox/checkbox.styles.d.ts +1 -0
- package/dist/components/checkbox/checkbox.styles.js +70 -0
- package/dist/components/chip/chip-group.d.ts +56 -0
- package/dist/components/chip/chip-group.js +124 -0
- package/dist/components/chip/chip-group.styles.d.ts +1 -0
- package/dist/components/chip/chip-group.styles.js +57 -0
- package/dist/components/chip/chip.d.ts +64 -0
- package/dist/components/chip/chip.js +115 -0
- package/dist/components/chip/chip.styles.d.ts +1 -0
- package/dist/components/chip/chip.styles.js +115 -0
- package/dist/components/citation-badge/citation-badge.d.ts +113 -0
- package/dist/components/citation-badge/citation-badge.js +300 -0
- package/dist/components/citation-badge/citation-badge.styles.d.ts +1 -0
- package/dist/components/citation-badge/citation-badge.styles.js +96 -0
- package/dist/components/code-block/code-block.d.ts +89 -0
- package/dist/components/code-block/code-block.js +317 -0
- package/dist/components/code-block/code-block.styles.d.ts +1 -0
- package/dist/components/code-block/code-block.styles.js +187 -0
- package/dist/components/code-block/code-loader.d.ts +44 -0
- package/dist/components/code-block/code-loader.js +79 -0
- package/dist/components/combobox/combobox.d.ts +19 -1
- package/dist/components/combobox/combobox.js +84 -14
- package/dist/components/combobox/option.d.ts +5 -1
- package/dist/components/combobox/option.js +35 -0
- package/dist/components/context-meter/context-meter.d.ts +53 -0
- package/dist/components/context-meter/context-meter.js +150 -0
- package/dist/components/context-meter/context-meter.styles.d.ts +1 -0
- package/dist/components/context-meter/context-meter.styles.js +120 -0
- package/dist/components/conversation-item/conversation-item.d.ts +128 -0
- package/dist/components/conversation-item/conversation-item.js +322 -0
- package/dist/components/conversation-item/conversation-item.styles.d.ts +1 -0
- package/dist/components/conversation-item/conversation-item.styles.js +155 -0
- package/dist/components/date-picker/date-input.d.ts +24 -0
- package/dist/components/date-picker/date-input.js +134 -22
- package/dist/components/date-picker/date-picker.d.ts +7 -0
- package/dist/components/date-picker/date-picker.js +85 -16
- package/dist/components/dialog/confirm.d.ts +40 -0
- package/dist/components/dialog/confirm.js +79 -0
- package/dist/components/dialog/dialog.d.ts +85 -0
- package/dist/components/dialog/dialog.js +272 -0
- package/dist/components/dialog/dialog.styles.d.ts +1 -0
- package/dist/components/dialog/dialog.styles.js +52 -0
- package/dist/components/dock-panel/dock-panel.d.ts +134 -0
- package/dist/components/dock-panel/dock-panel.js +345 -0
- package/dist/components/dock-panel/dock-panel.styles.d.ts +1 -0
- package/dist/components/dock-panel/dock-panel.styles.js +145 -0
- package/dist/components/document-preview/document-preview.d.ts +135 -0
- package/dist/components/document-preview/document-preview.js +372 -0
- package/dist/components/document-preview/document-preview.styles.d.ts +1 -0
- package/dist/components/document-preview/document-preview.styles.js +191 -0
- package/dist/components/export-button/csv.js +1 -1
- package/dist/components/export-button/export-button.js +2 -0
- package/dist/components/flag/flag.d.ts +42 -5
- package/dist/components/flag/flag.js +70 -3
- package/dist/components/gauge/gauge.js +19 -10
- package/dist/components/generation-status/generation-status.d.ts +109 -0
- package/dist/components/generation-status/generation-status.js +273 -0
- package/dist/components/generation-status/generation-status.styles.d.ts +1 -0
- package/dist/components/generation-status/generation-status.styles.js +84 -0
- package/dist/components/heatmap/calendar-grid.d.ts +4 -1
- package/dist/components/heatmap/calendar-grid.js +25 -12
- package/dist/components/heatmap/heatmap-scale.d.ts +6 -0
- package/dist/components/heatmap/heatmap-scale.js +7 -1
- package/dist/components/heatmap/heatmap.d.ts +89 -3
- package/dist/components/heatmap/heatmap.js +158 -29
- package/dist/components/json-viewer/json-viewer.d.ts +53 -0
- package/dist/components/json-viewer/json-viewer.js +283 -0
- package/dist/components/json-viewer/json-viewer.styles.d.ts +1 -0
- package/dist/components/json-viewer/json-viewer.styles.js +176 -0
- package/dist/components/kbd/kbd.d.ts +77 -0
- package/dist/components/kbd/kbd.js +214 -0
- package/dist/components/kbd/kbd.styles.d.ts +1 -0
- package/dist/components/kbd/kbd.styles.js +52 -0
- package/dist/components/live-region/live-region.d.ts +81 -0
- package/dist/components/live-region/live-region.js +153 -0
- package/dist/components/live-region/live-region.styles.d.ts +1 -0
- package/dist/components/live-region/live-region.styles.js +10 -0
- package/dist/components/map/map.d.ts +8 -6
- package/dist/components/map/map.js +44 -8
- package/dist/components/markdown/markdown-loader.d.ts +34 -0
- package/dist/components/markdown/markdown-loader.js +43 -0
- package/dist/components/markdown/markdown.d.ts +81 -0
- package/dist/components/markdown/markdown.js +284 -0
- package/dist/components/markdown/markdown.styles.d.ts +1 -0
- package/dist/components/markdown/markdown.styles.js +82 -0
- package/dist/components/media-card/media-card.d.ts +122 -0
- package/dist/components/media-card/media-card.js +295 -0
- package/dist/components/media-card/media-card.styles.d.ts +1 -0
- package/dist/components/media-card/media-card.styles.js +132 -0
- package/dist/components/mention-popover/mention-popover.d.ts +200 -0
- package/dist/components/mention-popover/mention-popover.js +475 -0
- package/dist/components/mention-popover/mention-popover.styles.d.ts +1 -0
- package/dist/components/mention-popover/mention-popover.styles.js +101 -0
- package/dist/components/menu/menu-item.d.ts +62 -0
- package/dist/components/menu/menu-item.js +119 -0
- package/dist/components/menu/menu-item.styles.d.ts +1 -0
- package/dist/components/menu/menu-item.styles.js +67 -0
- package/dist/components/menu/menu.d.ts +143 -0
- package/dist/components/menu/menu.js +365 -0
- package/dist/components/menu/menu.styles.d.ts +1 -0
- package/dist/components/menu/menu.styles.js +66 -0
- package/dist/components/model-select/model-select.d.ts +125 -0
- package/dist/components/model-select/model-select.js +490 -0
- package/dist/components/model-select/model-select.styles.d.ts +1 -0
- package/dist/components/model-select/model-select.styles.js +189 -0
- package/dist/components/model-settings-panel/model-settings-panel.d.ts +69 -0
- package/dist/components/model-settings-panel/model-settings-panel.js +145 -0
- package/dist/components/model-settings-panel/model-settings-panel.styles.d.ts +1 -0
- package/dist/components/model-settings-panel/model-settings-panel.styles.js +82 -0
- package/dist/components/playback/playback.d.ts +13 -1
- package/dist/components/playback/playback.js +47 -14
- package/dist/components/responsive-panel/responsive-panel.d.ts +150 -0
- package/dist/components/responsive-panel/responsive-panel.js +365 -0
- package/dist/components/responsive-panel/responsive-panel.styles.d.ts +1 -0
- package/dist/components/responsive-panel/responsive-panel.styles.js +103 -0
- package/dist/components/result-card/result-card.d.ts +44 -0
- package/dist/components/result-card/result-card.js +83 -0
- package/dist/components/result-card/result-card.styles.d.ts +1 -0
- package/dist/components/result-card/result-card.styles.js +50 -0
- package/dist/components/result-card/result-field.d.ts +36 -0
- package/dist/components/result-card/result-field.js +87 -0
- package/dist/components/result-card/result-field.styles.d.ts +1 -0
- package/dist/components/result-card/result-field.styles.js +32 -0
- package/dist/components/select/select.d.ts +45 -1
- package/dist/components/select/select.js +114 -17
- package/dist/components/select/select.styles.js +26 -2
- package/dist/components/slider/slider.d.ts +89 -0
- package/dist/components/slider/slider.js +352 -0
- package/dist/components/slider/slider.styles.d.ts +1 -0
- package/dist/components/slider/slider.styles.js +87 -0
- package/dist/components/source-card/source-card.d.ts +92 -0
- package/dist/components/source-card/source-card.js +148 -0
- package/dist/components/source-card/source-card.styles.d.ts +1 -0
- package/dist/components/source-card/source-card.styles.js +69 -0
- package/dist/components/source-list/source-list.d.ts +63 -0
- package/dist/components/source-list/source-list.js +122 -0
- package/dist/components/source-list/source-list.styles.d.ts +1 -0
- package/dist/components/source-list/source-list.styles.js +59 -0
- package/dist/components/sparkline/sparkline.js +27 -13
- package/dist/components/split/split.d.ts +93 -1
- package/dist/components/split/split.js +284 -15
- package/dist/components/split/split.styles.js +30 -0
- package/dist/components/stat/stat.d.ts +22 -1
- package/dist/components/stat/stat.js +49 -3
- package/dist/components/stat/stat.styles.js +20 -0
- package/dist/components/stream-status/stream-status.d.ts +110 -0
- package/dist/components/stream-status/stream-status.js +210 -0
- package/dist/components/stream-status/stream-status.styles.d.ts +1 -0
- package/dist/components/stream-status/stream-status.styles.js +103 -0
- package/dist/components/streaming-text/streaming-text.d.ts +98 -0
- package/dist/components/streaming-text/streaming-text.js +209 -0
- package/dist/components/streaming-text/streaming-text.styles.d.ts +1 -0
- package/dist/components/streaming-text/streaming-text.styles.js +62 -0
- package/dist/components/switch/switch.d.ts +58 -0
- package/dist/components/switch/switch.js +179 -0
- package/dist/components/switch/switch.styles.d.ts +1 -0
- package/dist/components/switch/switch.styles.js +75 -0
- package/dist/components/table/table.d.ts +73 -4
- package/dist/components/table/table.js +169 -20
- package/dist/components/table/table.styles.js +6 -1
- package/dist/components/tabs/tabs.d.ts +51 -0
- package/dist/components/tabs/tabs.js +190 -0
- package/dist/components/tabs/tabs.styles.d.ts +1 -0
- package/dist/components/tabs/tabs.styles.js +63 -0
- package/dist/components/thinking-panel/thinking-panel.d.ts +121 -0
- package/dist/components/thinking-panel/thinking-panel.js +235 -0
- package/dist/components/thinking-panel/thinking-panel.styles.d.ts +1 -0
- package/dist/components/thinking-panel/thinking-panel.styles.js +117 -0
- package/dist/components/toast/toast-item.d.ts +1 -0
- package/dist/components/toast/toast-item.js +31 -6
- package/dist/components/tool-approval-dialog/tool-approval-dialog.d.ts +130 -0
- package/dist/components/tool-approval-dialog/tool-approval-dialog.js +403 -0
- package/dist/components/tool-approval-dialog/tool-approval-dialog.styles.d.ts +1 -0
- package/dist/components/tool-approval-dialog/tool-approval-dialog.styles.js +160 -0
- package/dist/components/tool-call-chip/tool-call-chip.d.ts +89 -0
- package/dist/components/tool-call-chip/tool-call-chip.js +288 -0
- package/dist/components/tool-call-chip/tool-call-chip.styles.d.ts +1 -0
- package/dist/components/tool-call-chip/tool-call-chip.styles.js +203 -0
- package/dist/components/tool-param-form/tool-param-form.d.ts +145 -0
- package/dist/components/tool-param-form/tool-param-form.js +366 -0
- package/dist/components/tool-param-form/tool-param-form.styles.d.ts +1 -0
- package/dist/components/tool-param-form/tool-param-form.styles.js +75 -0
- package/dist/components/tool-result-dialog/tool-result-dialog.d.ts +102 -0
- package/dist/components/tool-result-dialog/tool-result-dialog.js +393 -0
- package/dist/components/tool-result-dialog/tool-result-dialog.styles.d.ts +1 -0
- package/dist/components/tool-result-dialog/tool-result-dialog.styles.js +176 -0
- package/dist/components/tool-result-view/registry.d.ts +81 -0
- package/dist/components/tool-result-view/registry.js +84 -0
- package/dist/components/tool-result-view/tool-result-view.d.ts +55 -0
- package/dist/components/tool-result-view/tool-result-view.js +138 -0
- package/dist/components/tool-result-view/tool-result-view.styles.d.ts +1 -0
- package/dist/components/tool-result-view/tool-result-view.styles.js +13 -0
- package/dist/components/tool-select-dialog/tool-select-dialog.d.ts +151 -0
- package/dist/components/tool-select-dialog/tool-select-dialog.js +439 -0
- package/dist/components/tool-select-dialog/tool-select-dialog.styles.d.ts +1 -0
- package/dist/components/tool-select-dialog/tool-select-dialog.styles.js +182 -0
- package/dist/components/tree/tree-node.d.ts +11 -2
- package/dist/components/tree/tree-node.js +16 -6
- package/dist/components/tree/tree.d.ts +8 -2
- package/dist/components/tree/tree.js +18 -11
- package/dist/components/typing-indicator/typing-indicator.d.ts +60 -0
- package/dist/components/typing-indicator/typing-indicator.js +96 -0
- package/dist/components/typing-indicator/typing-indicator.styles.d.ts +1 -0
- package/dist/components/typing-indicator/typing-indicator.styles.js +132 -0
- package/dist/components/virtual-list/virtual-list.d.ts +173 -0
- package/dist/components/virtual-list/virtual-list.js +461 -0
- package/dist/components/virtual-list/virtual-list.styles.d.ts +1 -0
- package/dist/components/virtual-list/virtual-list.styles.js +44 -0
- package/dist/components/widget/widget.d.ts +10 -0
- package/dist/components/widget/widget.js +24 -1
- package/dist/components/widget/widget.styles.js +9 -2
- package/dist/components/word-cloud/word-cloud-layout.js +14 -1
- package/dist/components/word-cloud/word-cloud.d.ts +6 -0
- package/dist/components/word-cloud/word-cloud.js +25 -14
- package/dist/components/word-cloud/word-cloud.styles.js +4 -2
- package/dist/internal/announcer.d.ts +57 -0
- package/dist/internal/announcer.js +71 -0
- package/dist/internal/form-associated.d.ts +1 -0
- package/dist/internal/form-associated.js +55 -11
- package/dist/internal/group-by-recency.d.ts +50 -0
- package/dist/internal/group-by-recency.js +80 -0
- package/dist/internal/rtl.d.ts +23 -0
- package/dist/internal/rtl.js +28 -0
- package/dist/internal/scroll-lock.d.ts +10 -5
- package/dist/internal/scroll-lock.js +30 -14
- package/dist/internal/tokens.styles.js +6 -0
- package/dist/lyra.d.ts +130 -31
- package/dist/lyra.js +94 -26
- package/llms-full.txt +3921 -32
- package/llms.txt +16 -1
- package/package.json +23 -5
- package/dist/components/chart/bar-chart.stories.d.ts +0 -5
- package/dist/components/chart/bar-chart.stories.js +0 -20
- package/dist/components/chart/box-plot.stories.d.ts +0 -5
- package/dist/components/chart/box-plot.stories.js +0 -29
- package/dist/components/chart/bubble-chart.stories.d.ts +0 -5
- package/dist/components/chart/bubble-chart.stories.js +0 -26
- package/dist/components/chart/chart.stories.d.ts +0 -32
- package/dist/components/chart/chart.stories.js +0 -141
- package/dist/components/chart/doughnut-chart.stories.d.ts +0 -5
- package/dist/components/chart/doughnut-chart.stories.js +0 -26
- package/dist/components/chart/histogram.stories.d.ts +0 -5
- package/dist/components/chart/histogram.stories.js +0 -17
- package/dist/components/chart/line-chart.stories.d.ts +0 -22
- package/dist/components/chart/line-chart.stories.js +0 -99
- package/dist/components/chart/lite-chart.stories.d.ts +0 -12
- package/dist/components/chart/lite-chart.stories.js +0 -98
- package/dist/components/chart/pie-chart.stories.d.ts +0 -5
- package/dist/components/chart/pie-chart.stories.js +0 -26
- package/dist/components/chart/polar-area-chart.stories.d.ts +0 -5
- package/dist/components/chart/polar-area-chart.stories.js +0 -20
- package/dist/components/chart/radar-chart.stories.d.ts +0 -5
- package/dist/components/chart/radar-chart.stories.js +0 -20
- package/dist/components/chart/scatter-chart.stories.d.ts +0 -5
- package/dist/components/chart/scatter-chart.stories.js +0 -31
- package/dist/components/combobox/combobox.stories.d.ts +0 -24
- package/dist/components/combobox/combobox.stories.js +0 -102
- package/dist/components/date-picker/date-input.stories.d.ts +0 -10
- package/dist/components/date-picker/date-input.stories.js +0 -48
- package/dist/components/date-picker/date-picker.stories.d.ts +0 -10
- package/dist/components/date-picker/date-picker.stories.js +0 -25
- package/dist/components/empty/empty.stories.d.ts +0 -7
- package/dist/components/empty/empty.stories.js +0 -34
- package/dist/components/export-button/export-button.stories.d.ts +0 -7
- package/dist/components/export-button/export-button.stories.js +0 -37
- package/dist/components/file-input/file-input.stories.d.ts +0 -7
- package/dist/components/file-input/file-input.stories.js +0 -19
- package/dist/components/flag/flag.stories.d.ts +0 -5
- package/dist/components/flag/flag.stories.js +0 -17
- package/dist/components/gauge/gauge.stories.d.ts +0 -8
- package/dist/components/gauge/gauge.stories.js +0 -19
- package/dist/components/graph/graph.stories.d.ts +0 -8
- package/dist/components/graph/graph.stories.js +0 -68
- package/dist/components/heatmap/heatmap.stories.d.ts +0 -22
- package/dist/components/heatmap/heatmap.stories.js +0 -154
- package/dist/components/map/map.stories.d.ts +0 -15
- package/dist/components/map/map.stories.js +0 -135
- package/dist/components/playback/playback.stories.d.ts +0 -7
- package/dist/components/playback/playback.stories.js +0 -16
- package/dist/components/select/select.stories.d.ts +0 -19
- package/dist/components/select/select.stories.js +0 -75
- package/dist/components/skeleton/skeleton.stories.d.ts +0 -5
- package/dist/components/skeleton/skeleton.stories.js +0 -16
- package/dist/components/sparkline/sparkline.stories.d.ts +0 -11
- package/dist/components/sparkline/sparkline.stories.js +0 -39
- package/dist/components/split/split.stories.d.ts +0 -7
- package/dist/components/split/split.stories.js +0 -36
- package/dist/components/stat/stat.stories.d.ts +0 -8
- package/dist/components/stat/stat.stories.js +0 -64
- package/dist/components/table/table.stories.d.ts +0 -12
- package/dist/components/table/table.stories.js +0 -67
- package/dist/components/time-range/time-range.stories.d.ts +0 -8
- package/dist/components/time-range/time-range.stories.js +0 -26
- package/dist/components/toast/toast.stories.d.ts +0 -8
- package/dist/components/toast/toast.stories.js +0 -72
- package/dist/components/tree/tree.stories.d.ts +0 -9
- package/dist/components/tree/tree.stories.js +0 -52
- package/dist/components/widget/widget.stories.d.ts +0 -7
- package/dist/components/widget/widget.stories.js +0 -41
- package/dist/components/word-cloud/word-cloud.stories.d.ts +0 -10
- package/dist/components/word-cloud/word-cloud.stories.js +0 -55
package/llms-full.txt
CHANGED
|
@@ -23,6 +23,17 @@ here.
|
|
|
23
23
|
- **Chart family** — `lyra-chart` (core), typed subclasses, `lyra-histogram`, `lyra-box-plot`,
|
|
24
24
|
`lyra-lite-chart` (dependency-free bar/line alternative, no peer deps)
|
|
25
25
|
- **Map & file-input** — `lyra-map`, `lyra-file-input`
|
|
26
|
+
- **Conversation & Agent UI** — `lyra-dialog`/`confirm()`, `lyra-tabs`, `lyra-checkbox`,
|
|
27
|
+
`lyra-switch`, `lyra-json-viewer`, `lyra-live-region`, `lyra-markdown`, `lyra-chat-message`,
|
|
28
|
+
`lyra-typing-indicator`, `lyra-tool-call-chip`, `lyra-tool-result-view` (+ its renderer registry),
|
|
29
|
+
`lyra-tool-result-dialog`, `lyra-chat-composer`, `lyra-attachment-chip`, `lyra-stream-status`,
|
|
30
|
+
`lyra-virtual-list`, `lyra-conversation-item`, `lyra-model-select`, `lyra-slider`,
|
|
31
|
+
`lyra-tool-select-dialog`, `lyra-citation-badge`, `lyra-source-list`/`lyra-source-card`,
|
|
32
|
+
`lyra-app-rail`, `lyra-responsive-panel`, `lyra-mention-popover`, `lyra-streaming-text`,
|
|
33
|
+
`lyra-thinking-panel`, `lyra-generation-status`, `lyra-code-block`, `lyra-tool-approval-dialog`,
|
|
34
|
+
`lyra-tool-param-form`, `lyra-menu`/`lyra-menu-item`, `lyra-chip`/`lyra-chip-group`,
|
|
35
|
+
`lyra-model-settings-panel`, `lyra-context-meter`, `lyra-dock-panel`, `lyra-document-preview`,
|
|
36
|
+
`lyra-media-card`, `lyra-attachment-trigger`, `lyra-kbd`, `lyra-result-card`/`lyra-result-field`
|
|
26
37
|
- **Cross-cutting notes** — packaging and adoption caveats that apply across the whole library
|
|
27
38
|
|
|
28
39
|
---
|
|
@@ -90,8 +101,13 @@ Not a custom element itself — infrastructure every `lyra-*` component in the l
|
|
|
90
101
|
scroll lock (used by `lyra-widget`'s fullscreen mode); safe to acquire/release more than once
|
|
91
102
|
concurrently, restores the original `overflow` only once the last outstanding lock releases.
|
|
92
103
|
- **`rtl.ts` → `isRtl(el): boolean`** — reads `getComputedStyle(el).direction === 'rtl'`, for the
|
|
93
|
-
pointer/keyboard math (drag ratios, arrow-key direction) in `lyra-split
|
|
94
|
-
that reasons about physical left/right and can't rely on logical CSS properties
|
|
104
|
+
pointer/keyboard math (drag ratios, arrow-key direction) in `lyra-split`, `lyra-time-range`, and
|
|
105
|
+
`lyra-dock-panel` that reasons about physical left/right and can't rely on logical CSS properties
|
|
106
|
+
alone. The same module also exports `rtlAwareSide(side, el)` and `rtlAwarePlacement(placement,
|
|
107
|
+
el)`, which swap a physical `'left'`/`'right'` value (or the `left`/`right` component of a
|
|
108
|
+
Floating UI `Placement` string) under RTL and pass it through unchanged under LTR — for a caller
|
|
109
|
+
that means "anchor to my trailing edge" but has to hand `place()` a physical placement. `lyra-menu`
|
|
110
|
+
resolves its `placement` property through `rtlAwarePlacement()` before positioning.
|
|
95
111
|
|
|
96
112
|
**Known gotchas:**
|
|
97
113
|
- `formResetCallback()`'s restore-to-constructed-default behavior (both here and in
|
|
@@ -211,6 +227,9 @@ another unnamed combobox in the same form.
|
|
|
211
227
|
- naming drift from Web Awesome parity: the shipped attribute is **`with-clear`**, not
|
|
212
228
|
`clearable` (which is what `<wa-combobox>` uses) — don't copy-paste WA docs assuming a 1:1
|
|
213
229
|
attribute name here.
|
|
230
|
+
- a host-level `aria-label` attribute on `<lyra-combobox>` now takes priority over `label`/
|
|
231
|
+
`placeholder`/`"Combobox"` when resolving the accessible name on `[part="combobox-input"]` —
|
|
232
|
+
previously it was silently ignored. Matches the same fallback on `<lyra-select>`.
|
|
214
233
|
- no `size` variant (small/medium/large) unlike sibling `lyra-toast-item`.
|
|
215
234
|
- `aria-required` reflects `required` immediately; `aria-invalid`, by contrast, only reflects
|
|
216
235
|
**after the field has been touched** (first `blur`) — a `required` field with a validity error
|
|
@@ -242,6 +261,16 @@ combobox does, and the popup reuses `internal/positioner.ts` for placement.
|
|
|
242
261
|
Single-select only, with no `filter`/`source`/`with-clear`/`max-options-visible`/`empty-text`/
|
|
243
262
|
`max-render`/`multiple` surface — reach for `<lyra-combobox>` instead whenever any of those apply.
|
|
244
263
|
|
|
264
|
+
**Single-option auto-commit.** When exactly one option is enabled (regardless of how many disabled
|
|
265
|
+
ones exist alongside it), the popup never opens at all: a click, Enter, Space, ArrowDown, or ArrowUp
|
|
266
|
+
on the trigger commits that sole option directly, and the trigger renders as a plain `role="button"`
|
|
267
|
+
with no chevron/`aria-haspopup`/`aria-expanded`/`aria-controls`/`aria-activedescendant` rather than a
|
|
268
|
+
combobox with a permanently inert popup state. This is unconditional default behavior, not
|
|
269
|
+
opt-in — opening a one-row list to pick the only available choice is pure friction with no real
|
|
270
|
+
decision behind it. It never changes `value`/validity defaults on its own: an unselected
|
|
271
|
+
single-option select stays unselected (and a `required` one stays invalid) exactly like the
|
|
272
|
+
multi-option case, until the trigger is actually activated.
|
|
273
|
+
|
|
245
274
|
**Properties:**
|
|
246
275
|
- `placeholder: string = ''`
|
|
247
276
|
- `disabled: boolean = false` (reflected)
|
|
@@ -252,6 +281,8 @@ Single-select only, with no `filter`/`source`/`with-clear`/`max-options-visible`
|
|
|
252
281
|
- `errorText: string = ''` (attribute `error-text` — static error copy shown below the hint;
|
|
253
282
|
overridden by slotted `error` content when provided)
|
|
254
283
|
- `open: boolean = false` (reflected)
|
|
284
|
+
- `size: 'xs'|'s'|'m'|'l'|'xl' = 'm'` (reflected — same scale as `lyra-toast-item`'s `size`, for
|
|
285
|
+
compact toolbar placements that don't fit the default trigger height)
|
|
255
286
|
- `value: string` — a getter/setter; always a single string (no `multiple` mode)
|
|
256
287
|
|
|
257
288
|
**Events:** `change` (native-style — selection changed), `input` (fired alongside `change` on every
|
|
@@ -293,6 +324,13 @@ secondary line, when `sub` is set), `expand-icon`, `error`, `hint`
|
|
|
293
324
|
- `aria-required` on the trigger reflects `required` immediately; `aria-invalid` only reflects once
|
|
294
325
|
the trigger has been blurred (touched) at least once, mirroring `lyra-combobox`'s own input.
|
|
295
326
|
Blurring the trigger (Tab away) closes an open listbox, the same as a native `<select>`'s popup.
|
|
327
|
+
- The trigger's accessible name now checks a host-level `aria-label` attribute first, before falling
|
|
328
|
+
back to `label`/`placeholder`/`"Select"` — a plain `aria-label` on `<lyra-select>` is no longer
|
|
329
|
+
silently ignored.
|
|
330
|
+
- A select with exactly one enabled option never exposes `role="combobox"`/opens a listbox at
|
|
331
|
+
all — see "Single-option auto-commit" above. Testing/automation code that always expects a
|
|
332
|
+
`role="combobox"` trigger, or that opens the listbox before asserting on a row, needs at least two
|
|
333
|
+
enabled options to observe the normal dropdown chrome.
|
|
296
334
|
|
|
297
335
|
---
|
|
298
336
|
|
|
@@ -588,9 +626,12 @@ Country/language flag image. Flag artwork ships in a **separate, optional peer p
|
|
|
588
626
|
the small per-flag async hop when you already have the URL at build time, e.g. from
|
|
589
627
|
`import frUrl from '@aceshooting/lyra-flags/flags/fr.svg?url'`. `label` is effectively required
|
|
590
628
|
alongside `src` since there's no `country`/`language` to derive a fallback `alt` from.)
|
|
591
|
-
- `label?: string` (accessible name / `alt` text — **defaults to
|
|
592
|
-
country* code if omitted**, see gotchas)
|
|
629
|
+
- `label?: string` (accessible name / `alt` text — **defaults to a localized, human-readable region
|
|
630
|
+
name derived from the *resolved country* code via `Intl.DisplayNames` if omitted**, see gotchas)
|
|
593
631
|
- `round: boolean = false` (reflected — circular crop)
|
|
632
|
+
- `detailed: boolean = false` (reflected — requests the pristine, pre-optimization source SVG
|
|
633
|
+
instead of the default icon-optimized one; a no-op for the majority of codes whose source art was
|
|
634
|
+
never large enough to need optimizing. Has no effect when `src` is set. See gotchas.)
|
|
594
635
|
|
|
595
636
|
**Events:** none.
|
|
596
637
|
|
|
@@ -627,11 +668,12 @@ pnpm add @aceshooting/lyra-flags # required peer — without it, <lyra-flag> r
|
|
|
627
668
|
- Rendering is async even when the peer *is* installed: `src` resolves after an `import()` +
|
|
628
669
|
resolver call, so there's a brief loading-skeleton window on first paint/attribute change — don't
|
|
629
670
|
assume the `<img>` exists synchronously right after setting `country`/`language`.
|
|
630
|
-
- if `label` is omitted, the accessible name (`alt`) falls back to
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
671
|
+
- if `label` is omitted, the accessible name (`alt`) falls back to a localized region name via
|
|
672
|
+
`Intl.DisplayNames([navigator.language], { type: 'region' })` (e.g. `"United Kingdom"`) instead of
|
|
673
|
+
a bare code — for `language="en"` that's derived from `"GB"` (the mapped country), not `"EN"` (the
|
|
674
|
+
language tag itself). Falls back further to the bare uppercase code if `Intl.DisplayNames` throws
|
|
675
|
+
for an unrecognized region or isn't available. You can still pass an explicit `label` (e.g.
|
|
676
|
+
`"France"`) to override the derived name.
|
|
635
677
|
- an invalid `country` (anything not matching the ISO 3166-1 alpha-2 shape, e.g. containing `../`)
|
|
636
678
|
is rejected before it ever reaches the peer's `flagUrl()` resolver — treated the same as an
|
|
637
679
|
unknown/missing flag rather than being passed through. `languageToCountry()`'s region-subtag path
|
|
@@ -647,6 +689,13 @@ pnpm add @aceshooting/lyra-flags # required peer — without it, <lyra-flag> r
|
|
|
647
689
|
the flags actually requested at runtime (verified with a real Vite build — referencing 2 codes
|
|
648
690
|
shipped ~28 KB total, not all 249). If you already have a flag's URL at build time, `src` skips
|
|
649
691
|
the peer-package round trip (and its loading-skeleton flash) entirely.
|
|
692
|
+
- 65 of `@aceshooting/lyra-flags`' 249 flags (any whose design includes a detailed coat of
|
|
693
|
+
arms/seal/emblem, e.g. `es`, `pt`, `sv`) ship a second, pristine full-detail source SVG alongside
|
|
694
|
+
the default icon-optimized one (`flagUrl(code, { variant: 'detailed' })`, or `detailed` on
|
|
695
|
+
`<lyra-flag>`) — the default variant is the one `country`/`language` resolve to unless `detailed`
|
|
696
|
+
is set, and is what almost every consumer wants (an icon-scale rendering, ~65% smaller on average
|
|
697
|
+
for the 65 affected codes, no visible fidelity loss at that scale). `detailed` is a safe no-op for
|
|
698
|
+
the other 184 codes — same file either way.
|
|
650
699
|
|
|
651
700
|
---
|
|
652
701
|
|
|
@@ -745,23 +794,37 @@ KPI/stat card — value + unit + label + optional icon/trend/caption.
|
|
|
745
794
|
- `goodDirection: 'up'|'down' = 'up'` (attribute `good-direction`) — which trend direction counts
|
|
746
795
|
as "good"; inverts arrow/color polarity for cost/latency/error-rate-style metrics where a
|
|
747
796
|
*decrease* is the win.
|
|
748
|
-
- `rows: StatRow[] = []` (attribute: false) — `StatRow { label: string; value: string
|
|
749
|
-
a simple label/value breakdown list (`[part="rows"]`/`[part="row"]
|
|
750
|
-
`[part="row-value"]`) beneath the caption, hidden entirely when empty
|
|
797
|
+
- `rows: StatRow[] = []` (attribute: false) — `StatRow { label: string; value: string; exactValue?:
|
|
798
|
+
string }`; rendered as a simple label/value breakdown list (`[part="rows"]`/`[part="row"]`/
|
|
799
|
+
`[part="row-label"]`/`[part="row-value"]`) beneath the caption, hidden entirely when empty. A row's
|
|
800
|
+
optional `exactValue` mirrors the headline `exactValue`/`exact-value` pattern: rendered as a `title`
|
|
801
|
+
tooltip on that row's `[part="row-value"]` and gives it `tabindex="0"`, independently per row —
|
|
802
|
+
unset rows are unaffected.
|
|
751
803
|
- `emphasis: boolean = false` (reflected) — visual emphasis (e.g. for a "headline" stat in a group):
|
|
752
804
|
a brand-colored accent edge, orthogonal to the status `variant`; status semantics still win over
|
|
753
805
|
it — `emphasis` only additionally tints `[part="value"]` brand-colored when `variant` is still
|
|
754
806
|
`'neutral'`, never overriding an actual `success`/`warning`/`danger` value color
|
|
807
|
+
- `exactValue: string = ''` (attribute `exact-value`) — rendered as a `title` attribute on
|
|
808
|
+
`[part="value"]` for a hover tooltip (e.g. `value="$1.2K" exact-value="$1,204.37"`); also gives
|
|
809
|
+
`[part="value"]` `tabindex="0"` (only when set) so the tooltip is reachable by keyboard focus, not
|
|
810
|
+
just hover
|
|
811
|
+
- `sub: string = ''` — a secondary line distinct from `caption`, e.g. a comparison-period label,
|
|
812
|
+
rendered as `[part="sub"]` between the trend pill and the caption; hidden entirely when unset
|
|
813
|
+
- `prose: boolean = false` (reflected) — CSS-only variant that shrinks/lightens `[part="value"]` and
|
|
814
|
+
hides `[part="unit"]`, for rendering a loading/status message in place of a numeric value
|
|
815
|
+
- `compact: boolean = false` (reflected) — tighter card padding; same convention as `lyra-empty`'s and
|
|
816
|
+
`lyra-widget`'s `compact`
|
|
755
817
|
|
|
756
818
|
**Events:** none.
|
|
757
819
|
|
|
758
820
|
**Slots:** default (leading icon), `caption` (rich caption content — overrides the `caption`
|
|
759
821
|
attribute when slotted content is provided), `spark` (a sparkline, e.g. `<lyra-sparkline
|
|
760
822
|
slot="spark">`, or other compact trend visual — `lyra-stat` only reserves the slot and doesn't
|
|
761
|
-
render one itself)
|
|
823
|
+
render one itself), `sub` (rich sub-line content — overrides the `sub` attribute when slotted content
|
|
824
|
+
is provided)
|
|
762
825
|
|
|
763
|
-
**CSS parts:** `base`, `icon`, `label`, `value-row`, `value`, `unit`, `trend`, `
|
|
764
|
-
`rows`, `row`, `row-label`, `row-value`
|
|
826
|
+
**CSS parts:** `base`, `icon`, `label`, `value-row`, `value`, `unit`, `trend`, `sub`, `spark`,
|
|
827
|
+
`caption`, `rows`, `row`, `row-label`, `row-value`
|
|
765
828
|
|
|
766
829
|
**Themeable custom properties:** shared tokens only (`--lyra-color-success/-warning/-danger` drive
|
|
767
830
|
the `variant`-colored value text and up/down trend pill; `--lyra-color-brand` drives `emphasis`'s
|
|
@@ -796,9 +859,17 @@ of `rows` — this component only renders and emits intents.
|
|
|
796
859
|
`priority` progressively hides that column via a `@container` query as `[part='base']` narrows
|
|
797
860
|
(`'low'` hides first, under a ~900px container width; `'medium'` next, under ~640px; both
|
|
798
861
|
breakpoints are fixed in `table.styles.ts`, not themeable tokens), reversible via
|
|
799
|
-
`[part='reveal-columns-button']`
|
|
800
|
-
|
|
801
|
-
|
|
862
|
+
`[part='reveal-columns-button']`; `sticky` pins that column's header cell/cells to the inline-start
|
|
863
|
+
edge (`position: sticky`) while the table scrolls horizontally
|
|
864
|
+
- `columnsHidden: boolean = false` (attribute `columns-hidden`, reflected) — computed/read-only: true
|
|
865
|
+
when a `priority` column is *actually* hidden right now by the `@container` breakpoints above, or
|
|
866
|
+
`showAllColumns` force-visible mode is currently active. Measured via a `ResizeObserver` on
|
|
867
|
+
`[part='base']` plus a post-render DOM check, so it settles one render cycle after a `columns`/
|
|
868
|
+
`rows`/width change lands — poll for the settled value (e.g. `await el.updateComplete;` twice, or
|
|
869
|
+
`waitUntil()`) rather than assuming a single `updateComplete` covers it. Setting it directly has no
|
|
870
|
+
lasting effect; it's recomputed on the next render or resize. `[part='reveal-columns-button']` now
|
|
871
|
+
renders exactly when `columnsHidden` is true — no longer whenever any column merely *declares* a
|
|
872
|
+
`priority` regardless of whether anything is actually hidden
|
|
802
873
|
- `rows: T[] = []` (attribute: false)
|
|
803
874
|
- `sortKey: string = ''` (attribute `sort-key`)
|
|
804
875
|
- `sortDir: 'asc'|'desc' = 'asc'` (attribute `sort-dir`)
|
|
@@ -816,13 +887,14 @@ of `rows` — this component only renders and emits intents.
|
|
|
816
887
|
button's label once they've been revealed)
|
|
817
888
|
|
|
818
889
|
**Events:** `lyra-sort` (`detail: { key }`, fired on sortable-header activation), `lyra-row-click`
|
|
819
|
-
(`detail: { row }`), `lyra-load-more` (fired on the "load more" button)
|
|
890
|
+
(`detail: { row }`), `lyra-load-more` (fired on the "load more" button), `lyra-columns-hidden-change`
|
|
891
|
+
(`detail: { hidden: boolean }`, fired only on a real `columnsHidden` transition)
|
|
820
892
|
|
|
821
893
|
**Slots:** none — content comes entirely from `columns`/`rows`.
|
|
822
894
|
|
|
823
895
|
**CSS parts:** `base`, `table`, `head`, `header-cell`, `row`, `cell`, `more-button`, `sort-icon` (a
|
|
824
896
|
chevron indicator shown on the active sortable header, rotated per `sortDir`), `reveal-columns-button`
|
|
825
|
-
(shown only when
|
|
897
|
+
(shown only when `columnsHidden` is true)
|
|
826
898
|
|
|
827
899
|
**Themeable custom properties:** `--lyra-table-max-height` (default `none`; controls the scrollable
|
|
828
900
|
body's `max-block-size`).
|
|
@@ -997,13 +1069,35 @@ auto-inserted between each adjacent pair.
|
|
|
997
1069
|
regardless — only the effective drag/keyboard clamp bounds (and the rendered `flex-basis`, via a
|
|
998
1070
|
native CSS `clamp()` so a constrained panel stays pinned between its px bounds across container
|
|
999
1071
|
resizes with no extra `ResizeObserver`) change for a constrained panel.
|
|
1072
|
+
- `collapse: 'start'|'end'|'none' = 'none'` (reflected) — opt-in responsive collapse for one panel:
|
|
1073
|
+
`'start'`/`'end'` is a *logical* position (RTL-aware, matching CSS logical properties — the panel at
|
|
1074
|
+
the document's visual leading/trailing edge, not a raw array index). `lyra-split` only owns the
|
|
1075
|
+
width-collapse mechanics/state signaling below; it renders no icon-only UI itself — the collapsing
|
|
1076
|
+
panel's own slotted content is expected to adapt itself (e.g. via its own `@container` query reading
|
|
1077
|
+
the panel's clamped width or `data-collapse-state`, see below).
|
|
1078
|
+
- `railWidth: string = '3.5rem'` (attribute `rail-width`) — the fixed CSS length the collapsing panel
|
|
1079
|
+
clamps to in `'rail'` state.
|
|
1080
|
+
- `railBreakpoint: number = 640` (attribute `rail-breakpoint`, px) — below this container width (a
|
|
1081
|
+
`ResizeObserver` on `[part='base']`, active only while `collapse !== 'none'`), the collapsing panel
|
|
1082
|
+
switches from its normal drag-resizable percent width to the fixed `railWidth`.
|
|
1083
|
+
- `floatBreakpoint: number = 400` (attribute `float-breakpoint`, px) — below this narrower container
|
|
1084
|
+
width, the collapsing panel instead becomes an absolutely-positioned overlay ("floating card") on
|
|
1085
|
+
top of the other pane(s), removed from the normal flex flow; the sibling(s) take the full width.
|
|
1086
|
+
|
|
1087
|
+
`collapse`'s three resulting states — `'wide'` (default, today's plain layout) / `'rail'` / `'floating'`
|
|
1088
|
+
— are exposed as: a `data-collapse-state` attribute on both the host and the collapsing panel element
|
|
1089
|
+
itself (absent for `'wide'`/`collapse="none"`); and the `lyra-split-collapse-change` event below. The
|
|
1090
|
+
divider adjacent to the collapsed panel is drag/keyboard-disabled (`aria-disabled="true"`) while
|
|
1091
|
+
collapsed. `collapse="none"` (the default) is byte-for-byte identical to pre-collapse-feature behavior.
|
|
1000
1092
|
|
|
1001
1093
|
**Events:** `lyra-resize` (`detail: { sizes }`, fired on every drag step/release **and** every
|
|
1002
|
-
keyboard step)
|
|
1094
|
+
keyboard step), `lyra-split-collapse-change` (`detail: { state: 'wide'|'rail'|'floating' }`, fired only
|
|
1095
|
+
on a real `collapse`-state transition, never on every resize/render)
|
|
1003
1096
|
|
|
1004
1097
|
**Slots:** default (each direct child element is one panel).
|
|
1005
1098
|
|
|
1006
|
-
**CSS parts:** `base`, `divider`
|
|
1099
|
+
**CSS parts:** `base` (`position: relative`, so the `'floating'` state can anchor to it), `divider`
|
|
1100
|
+
(carries `aria-disabled="true"` and is drag/keyboard-inert while its adjacent panel is collapsed)
|
|
1007
1101
|
|
|
1008
1102
|
**Themeable custom properties:** shared tokens only.
|
|
1009
1103
|
|
|
@@ -1063,6 +1157,12 @@ position) survives the transition.
|
|
|
1063
1157
|
- `collapsed: boolean = false` (reflected)
|
|
1064
1158
|
- `expandable: boolean = false` (reflected — shows the fullscreen toggle button)
|
|
1065
1159
|
- `fullscreen: boolean = false` (reflected)
|
|
1160
|
+
- `fullscreenInset: string = ''` (attribute `fullscreen-inset`) — raw CSS `inset` shorthand applied to
|
|
1161
|
+
`[part="base"]` and `[part="backdrop"]` while fullscreen instead of the default
|
|
1162
|
+
`var(--lyra-space-l)` on every side, e.g. `"0 0 0 240px"` to leave a 240px persistent
|
|
1163
|
+
sidebar/toolbar visible during fullscreen
|
|
1164
|
+
- `compact: boolean = false` (reflected) — tighter header/body padding, same convention as
|
|
1165
|
+
`lyra-empty`'s `compact`
|
|
1066
1166
|
|
|
1067
1167
|
**Events:** `lyra-collapse-change` (`detail: boolean`, the new `collapsed` state),
|
|
1068
1168
|
`lyra-fullscreen-change` (`detail: boolean`, the new `fullscreen` state — also fired when fullscreen
|
|
@@ -1095,10 +1195,12 @@ to the panel's own focusable content (`actions` slot → collapse/fullscreen but
|
|
|
1095
1195
|
matching visual tab order — resolved shadow-piercingly, so a slotted custom element's real
|
|
1096
1196
|
focusable target inside its own shadow root is found too) so keyboard focus can't escape to page
|
|
1097
1197
|
content hidden behind the backdrop. Escape or clicking the backdrop exits fullscreen and returns
|
|
1098
|
-
focus to whichever button triggered it.
|
|
1198
|
+
focus to whichever button triggered it. Set `fullscreen-inset` (e.g. `"0 0 0 240px"`) to reserve
|
|
1199
|
+
space for a persistent sidebar/toolbar that should stay visible instead of being covered by the
|
|
1200
|
+
fullscreen panel/backdrop — it overrides the default `var(--lyra-space-l)` inset on every side for
|
|
1201
|
+
both `[part="base"]` and `[part="backdrop"]`. Set `compact` for tighter header/body padding.
|
|
1099
1202
|
|
|
1100
1203
|
**Known gotchas:**
|
|
1101
|
-
- no size/density variant — header and body padding are fixed.
|
|
1102
1204
|
- a reconnect that preserves the same element instance (e.g. a drag-and-drop reparent) restores the
|
|
1103
1205
|
scroll lock and the Tab-trap `keydown` listener if `fullscreen` was still `true` across the
|
|
1104
1206
|
move — `disconnectedCallback`/`connectedCallback` fire back-to-back with no update in between, so
|
|
@@ -1327,6 +1429,17 @@ click, or Enter/Space on the focused cell, fires `lyra-cell-click`.
|
|
|
1327
1429
|
by `date` (whichever pair matches the active `mode`; the other fields are ignored). Draws a
|
|
1328
1430
|
stroked ring over the matching cell; an annotation with a `label` also gets its own
|
|
1329
1431
|
`[part="legend-annotation"]` entry in the legend.
|
|
1432
|
+
- `cellText?: (pos: MatrixCellPos | CalendarCellPos, value: number) => string` (attribute: false) —
|
|
1433
|
+
formats the per-cell hover tooltip and keyboard live-region announcement text; receives the cell
|
|
1434
|
+
position (`{ row, col }` in matrix mode, `{ week, weekday }` in calendar mode) and its value.
|
|
1435
|
+
Unset (the default) falls back to the built-in English "Row X, Col Y: value" / "Mon DD: value"
|
|
1436
|
+
template — additive, not breaking.
|
|
1437
|
+
- `columnX?: (index: number) => number` (attribute: false, calendar mode only) — overrides the
|
|
1438
|
+
internal week-column x-coordinate formula (`CAL_PAD_LEFT + week * (CAL_CELL + CAL_GAP)`) used
|
|
1439
|
+
consistently across drawing, hit-testing, the focus ring, and month-label positioning, so a
|
|
1440
|
+
consumer can pixel-align this calendar's week columns with a sibling `<lyra-lite-chart>`'s bars
|
|
1441
|
+
(see that component's own `barX`) by supplying the same coordinate function to both. Unset (the
|
|
1442
|
+
default) is the original formula, unchanged.
|
|
1330
1443
|
|
|
1331
1444
|
**Events:** `lyra-cell-click` (fired on click, or Enter/Space on the keyboard-focused cell —
|
|
1332
1445
|
`detail: { row, col, value }` in matrix mode, `detail: { date, value }` in calendar mode)
|
|
@@ -1387,16 +1500,22 @@ color) and `--lyra-space-xs`.
|
|
|
1387
1500
|
since `draw()` unconditionally sets an inline `canvas.style.width/height` that wins over it.
|
|
1388
1501
|
Calendar mode ignores `fit-to-width` entirely — its canvas is always sized from the computed
|
|
1389
1502
|
`weekCount`.
|
|
1390
|
-
- the host
|
|
1391
|
-
(calendar mode: a day-count + range summary instead)
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1503
|
+
- the host is `role="group"` (not `role="img"`) with a dimensions+range summary `aria-label`
|
|
1504
|
+
(calendar mode: a day-count + range summary instead) — `[part="canvas"]` inside it is a real
|
|
1505
|
+
focusable, keyboard-operable, per-cell-interactive control (roving arrow-key focus,
|
|
1506
|
+
`[part="live-region"]` announcements, `lyra-cell-click`), and `role="img"` is documented (ARIA) to
|
|
1507
|
+
flatten its subtree to a single image for some assistive tech, which conflicted with that
|
|
1508
|
+
focusable descendant — fixed, matching `lyra-lite-chart`/`lyra-word-cloud`'s existing `role="group"`
|
|
1509
|
+
pattern.
|
|
1397
1510
|
- `NaN`/non-finite cell values in matrix mode are correctly treated as no-data now (alongside `-1`),
|
|
1398
1511
|
and repeated DPR crossings (moving the window across displays with different pixel ratios) no
|
|
1399
1512
|
longer leak a `MediaQueryList` listener per crossing — both previously-known issues are fixed.
|
|
1513
|
+
- calendar mode's date labels (used by the default `cellText` template and the tooltip/live-region
|
|
1514
|
+
text) now format via the runtime locale (`toLocaleString(undefined, ...)`) instead of a hardcoded
|
|
1515
|
+
`'en'` — fixed. The canvas-drawn axis chrome is now locale-aware too: month labels use
|
|
1516
|
+
`toLocaleString(undefined, ...)` (previously hardcoded `'en'`) and weekday labels are derived via
|
|
1517
|
+
`Intl.DateTimeFormat(undefined, { weekday: 'short' })` (previously a literal English `['', 'Mon',
|
|
1518
|
+
'', 'Wed', '', 'Fri', '']` array) — same sparse every-other-day spacing, just locale-correct text.
|
|
1400
1519
|
|
|
1401
1520
|
---
|
|
1402
1521
|
|
|
@@ -1416,7 +1535,7 @@ A force-directed node-link diagram with pan/zoom/drag, built on `d3-force`.
|
|
|
1416
1535
|
- `linkDistance: number = 100` (attribute `link-distance` — live-reactive, see gotchas)
|
|
1417
1536
|
- `minZoom: number = 0.1` (attribute `min-zoom`)
|
|
1418
1537
|
- `maxZoom: number = 8` (attribute `max-zoom`)
|
|
1419
|
-
- `seed?: number`
|
|
1538
|
+
- `seed?: number` — when set, seeds each node's initial x/y deterministically
|
|
1420
1539
|
(keyed by node **id**, not array index/order) instead of `forceSimulation()`'s own random start,
|
|
1421
1540
|
and settles the simulation synchronously instead of animating the settle (same effect
|
|
1422
1541
|
`prefers-reduced-motion` has, see gotchas)
|
|
@@ -1533,6 +1652,11 @@ descendants of the treeitem, matching the WAI-ARIA treeitem pattern's containmen
|
|
|
1533
1652
|
- `item: TreeItem` (required, attribute: false)
|
|
1534
1653
|
- `depth: number = 0`
|
|
1535
1654
|
- `expanded: boolean = false` (reflected)
|
|
1655
|
+
- `activeId: string | null = null` (attribute: false) — the id of the tree's roving-tabindex-focused
|
|
1656
|
+
item, pushed down from `<lyra-tree>`; normally set internally, not by consumers
|
|
1657
|
+
- `setSize: number = 1`, `posInSet: number = 1` (attribute: false) — this node's `aria-setsize`/
|
|
1658
|
+
`aria-posinset` values among its siblings, pushed down from `<lyra-tree>`; normally set internally,
|
|
1659
|
+
not by consumers
|
|
1536
1660
|
|
|
1537
1661
|
**Methods:** `expand()`, `collapse()` (each a no-op if already in that state, or a leaf), `select()`
|
|
1538
1662
|
(fires `lyra-node-select`).
|
|
@@ -1667,6 +1791,14 @@ respectively; plus shared `--lyra-space-xs`.
|
|
|
1667
1791
|
passthrough is deep-merged with `__proto__`/`constructor`/`prototype` keys skipped unconditionally,
|
|
1668
1792
|
so a JSON-sourced `config` (e.g. parsed from an API response) can't reach up and pollute
|
|
1669
1793
|
`Object.prototype` through the merge.
|
|
1794
|
+
- lazy-redraw + memoization: an `IntersectionObserver` gates `draw()` — while the host is scrolled
|
|
1795
|
+
off-screen, property changes that would otherwise trigger a Chart.js redraw are skipped (and a
|
|
1796
|
+
single redraw fires once it re-enters the viewport). Independently, a content-signature check
|
|
1797
|
+
(`type`, `labels`, `datasets`, `legend`, `area`, `xLabel`, `yLabel`, `y2Label`, `beginAtZero`,
|
|
1798
|
+
`horizontal`, `stacked`, `config`) skips calling into Chart.js when none of those have actually
|
|
1799
|
+
changed since the last draw (e.g. an unrelated property/state update, or a bare `requestUpdate()`).
|
|
1800
|
+
`refreshTheme()` calls `draw()` directly and is unaffected by either gate — it always redraws, since
|
|
1801
|
+
a theme change isn't reflected in any tracked property.
|
|
1670
1802
|
|
|
1671
1803
|
---
|
|
1672
1804
|
|
|
@@ -1691,11 +1823,40 @@ passthrough). Not a subclass of `LyraChart`.
|
|
|
1691
1823
|
- `beginAtZero: boolean = true` (attribute `begin-at-zero`)
|
|
1692
1824
|
- `stacked: boolean = false` — sums each category's bars into one segmented bar instead of grouping
|
|
1693
1825
|
them side by side; ignored for `type="line"`
|
|
1826
|
+
- `tickFormat?: (value: number) => string` (attribute: false) — formats a y-axis tick value for
|
|
1827
|
+
display (e.g. `(v) => \`$${v.toFixed(2)}\`` for currency, or a duration formatter for `"42s"`).
|
|
1828
|
+
Falls back to the built-in "nice numbers" formatter when unset.
|
|
1829
|
+
- `layout: 'fit' | 'scroll' = 'fit'` (reflected) — `'fit'` (default) is the original squeeze-the-
|
|
1830
|
+
whole-plot-to-host-width behavior, unchanged. `'scroll'` gives bars a fixed `barWidth` instead: plot
|
|
1831
|
+
content width becomes `categoryCount * barWidth` (can exceed the host's measured width), and
|
|
1832
|
+
`[part='base']` becomes horizontally `overflow-x: auto` so the user scrolls to see every bar at a
|
|
1833
|
+
legible fixed width instead of them compressing as category count grows. Bar type only.
|
|
1834
|
+
- `barWidth: number = 32` (attribute `bar-width`, px) — each bar's fixed width in `layout="scroll"`
|
|
1835
|
+
mode; ignored in the default `'fit'` mode.
|
|
1836
|
+
- `maxLabels?: number` (attribute `max-labels`, type Number) — decimates which category axis labels
|
|
1837
|
+
actually render *text* when `labels.length > maxLabels` (bars themselves are never decimated, only
|
|
1838
|
+
their axis `<text>` labels): always shows the first and last label, and roughly evenly distributes
|
|
1839
|
+
the rest between them. Works in either `layout` mode. Unset (the default) renders every label,
|
|
1840
|
+
unchanged.
|
|
1841
|
+
- `barX?: (index: number) => number` (attribute: false, bar type only) — overrides the internal
|
|
1842
|
+
per-category x-origin formula (`plotX + i * slot`) used by both bars and their axis labels, so a
|
|
1843
|
+
consumer can pixel-align this chart's bars with a sibling `<lyra-heatmap>` calendar's week columns
|
|
1844
|
+
(see that component's own `columnX`) by supplying the same coordinate function to both. Unset (the
|
|
1845
|
+
default) is the original formula, unchanged.
|
|
1694
1846
|
|
|
1695
1847
|
**Events:** `lyra-point-click` — fired when a bar/point is activated (click, or Enter/Space while
|
|
1696
1848
|
focused). `detail: { datasetIndex: number, index: number, label: string | undefined, value: number
|
|
1697
1849
|
| null }` — same shape as `lyra-chart`'s `lyra-point-click`.
|
|
1698
1850
|
|
|
1851
|
+
**Performance:** rendering is `IntersectionObserver`-gated and content-signature-memoized — `render()`
|
|
1852
|
+
skips recomputing the grid/marks (and reuses the previous `TemplateResult`) while the host is scrolled
|
|
1853
|
+
off-screen, or when none of the content-affecting properties (`type`, `labels`, `datasets`, `legend`,
|
|
1854
|
+
`xLabel`, `yLabel`, `beginAtZero`, `stacked`, `layout`, `barWidth`, `maxLabels`, or the measured plot
|
|
1855
|
+
size) have changed since the last render. A `tickFormat`/`barX` identity change alone (with everything
|
|
1856
|
+
else unchanged) does not force a redraw — in practice both are stable across a component's lifetime,
|
|
1857
|
+
and any data change that would make a different output visible already changes `datasets`/`labels`
|
|
1858
|
+
too.
|
|
1859
|
+
|
|
1699
1860
|
**Slots:** none.
|
|
1700
1861
|
|
|
1701
1862
|
**CSS parts:** `base`, `grid-line`, `axis-label`, `axis-title`, `bar`, `line`, `point`, `legend`,
|
|
@@ -1999,6 +2160,3734 @@ an extension-only `accept` list.
|
|
|
1999
2160
|
|
|
2000
2161
|
---
|
|
2001
2162
|
|
|
2163
|
+
## `lyra-dialog` / `confirm()`
|
|
2164
|
+
|
|
2165
|
+
General-purpose modal/overlay plus a promise-based confirmation helper built on top of it.
|
|
2166
|
+
|
|
2167
|
+
### `lyra-dialog`
|
|
2168
|
+
|
|
2169
|
+
A modal/overlay: `role="dialog"`, focus-trapped while open, dismissible via Escape or a backdrop
|
|
2170
|
+
click, and scroll-locks the document for as long as it's open. Chrome stays minimal — no built-in
|
|
2171
|
+
title bar or close button; a consumer supplies a heading and any close affordance itself via the
|
|
2172
|
+
default/`footer` slots.
|
|
2173
|
+
|
|
2174
|
+
**Properties:**
|
|
2175
|
+
- `open: boolean = false` (reflected) — there is no separate `show()`/`hide()` pair; set this (or
|
|
2176
|
+
call `close()`)
|
|
2177
|
+
- `label: string = ''` — accessible name used only when no heading is slotted (see below)
|
|
2178
|
+
|
|
2179
|
+
**Methods:** `close(reason: DialogCloseReason = 'api'): void` — closes the dialog, emits
|
|
2180
|
+
`lyra-dialog-close` with `reason`, and returns focus to whatever had it right before the dialog
|
|
2181
|
+
opened. `DialogCloseReason = 'escape' | 'backdrop' | 'api' | string` — `'escape'`/`'backdrop'` are
|
|
2182
|
+
emitted by the dialog's own built-in dismiss triggers; any other string is whatever a caller passes
|
|
2183
|
+
(e.g. a footer Cancel button calling `dlg.close('cancel')`, or `confirm()`'s own `'confirm'`/`'cancel'`).
|
|
2184
|
+
|
|
2185
|
+
**Events:** `lyra-dialog-close` (`detail: DialogCloseReason`) — fired on every dismissal path
|
|
2186
|
+
(Escape, backdrop click, or any `close()` call).
|
|
2187
|
+
|
|
2188
|
+
**Slots:** default (the dialog body), `footer` (action buttons, rendered in a bottom row, hidden
|
|
2189
|
+
entirely when empty)
|
|
2190
|
+
|
|
2191
|
+
**CSS parts:** `backdrop` (the full-viewport scrim), `panel` (the dialog panel, `role="dialog"`
|
|
2192
|
+
while open), `label` (the invisible label-text element used for `aria-labelledby` when no heading
|
|
2193
|
+
is slotted), `body` (wrapper around the default slot), `footer` (wrapper around the `footer` slot)
|
|
2194
|
+
|
|
2195
|
+
**Themeable custom properties:** `--lyra-dialog-overlay-color` (default `rgb(0 0 0 / 0.5)` — the
|
|
2196
|
+
backdrop scrim color; component-specific since no shared `--wa-*`/`--lyra-*` overlay token exists),
|
|
2197
|
+
plus shared tokens `--lyra-space-l/-m/-s`, `--lyra-color-surface/-border`, `--lyra-radius`,
|
|
2198
|
+
`--lyra-shadow`.
|
|
2199
|
+
|
|
2200
|
+
**Optional peer deps:** none.
|
|
2201
|
+
|
|
2202
|
+
```html
|
|
2203
|
+
<lyra-dialog id="dlg">
|
|
2204
|
+
<h2>Delete item?</h2>
|
|
2205
|
+
<p>This cannot be undone.</p>
|
|
2206
|
+
<div slot="footer">
|
|
2207
|
+
<button id="cancel">Cancel</button>
|
|
2208
|
+
<button id="confirm">Delete</button>
|
|
2209
|
+
</div>
|
|
2210
|
+
</lyra-dialog>
|
|
2211
|
+
<script type="module">
|
|
2212
|
+
const dlg = document.getElementById('dlg');
|
|
2213
|
+
dlg.open = true;
|
|
2214
|
+
dlg.addEventListener('lyra-dialog-close', (e) => console.log('closed:', e.detail));
|
|
2215
|
+
document.getElementById('cancel').addEventListener('click', () => dlg.close('cancel'));
|
|
2216
|
+
</script>
|
|
2217
|
+
```
|
|
2218
|
+
|
|
2219
|
+
Accessible name resolution: if a heading element (`h1`–`h6` or `[role="heading"]`) is a *direct
|
|
2220
|
+
child* (not inside `slot="footer"`), its text content becomes `aria-label` on the panel. Otherwise,
|
|
2221
|
+
when `label` is set, an invisible (`.sr-only`, exposed as the `label` part) element carrying that
|
|
2222
|
+
text is rendered inside the panel and `aria-labelledby` points at it instead. Either way `label`
|
|
2223
|
+
itself never renders visible chrome — a slotted heading is what a sighted user sees. The heading
|
|
2224
|
+
case deliberately uses `aria-label` (a copied string) rather than `aria-labelledby` pointing at the
|
|
2225
|
+
heading's `id`, because the heading is light-DOM content while `[part="panel"]` lives in shadow DOM
|
|
2226
|
+
and an ID-reference attribute can't resolve across that boundary.
|
|
2227
|
+
|
|
2228
|
+
**Known gotchas:**
|
|
2229
|
+
- `role="dialog"`/`aria-modal="true"` are only present on `[part="panel"]` while `open` is `true` —
|
|
2230
|
+
inspecting closed markup won't show them.
|
|
2231
|
+
- Heading detection only rescans on `slotchange`, not on every render — mutating an already-slotted
|
|
2232
|
+
heading's `textContent` in place (rather than replacing the node) won't retroactively update
|
|
2233
|
+
`aria-label`; set `label` instead for a title that needs to change live.
|
|
2234
|
+
- Only *direct* children are scanned for a heading — one nested several layers deep, or inside a
|
|
2235
|
+
slotted custom element's own shadow root, is left to the consumer to label explicitly via `label`.
|
|
2236
|
+
- A reconnect that preserves the same element instance (e.g. a drag-and-drop reparent) restores the
|
|
2237
|
+
scroll lock and the Escape/Tab-trap `keydown` listener if `open` was still `true` across the
|
|
2238
|
+
move — `disconnectedCallback`/`connectedCallback` fire back-to-back with no update in between, so
|
|
2239
|
+
`willUpdate()` alone wouldn't otherwise notice.
|
|
2240
|
+
- Tab-trap focus order follows the default (body) slot, then the `footer` slot — the same order the
|
|
2241
|
+
flattened tree already tabs through — and is resolved shadow-piercingly, so a slotted custom
|
|
2242
|
+
element's real focusable target inside its own shadow root is found even though the host tag
|
|
2243
|
+
itself isn't a native focusable element.
|
|
2244
|
+
|
|
2245
|
+
### `confirm()`
|
|
2246
|
+
|
|
2247
|
+
A drop-in async replacement for `window.confirm()`, built on `<lyra-dialog>`.
|
|
2248
|
+
|
|
2249
|
+
```ts
|
|
2250
|
+
import { confirm } from '@aceshooting/lyra-ui';
|
|
2251
|
+
|
|
2252
|
+
const ok = await confirm({
|
|
2253
|
+
title: 'Delete conversation?',
|
|
2254
|
+
description: 'This cannot be undone.',
|
|
2255
|
+
confirmLabel: 'Delete',
|
|
2256
|
+
tone: 'danger',
|
|
2257
|
+
});
|
|
2258
|
+
if (ok) deleteConversation();
|
|
2259
|
+
```
|
|
2260
|
+
|
|
2261
|
+
`confirm(options: ConfirmOptions): Promise<boolean>` where
|
|
2262
|
+
`ConfirmOptions = { title: string; description?: string; confirmLabel?: string /* = 'Confirm' */; cancelLabel?: string /* = 'Cancel' */; tone?: 'neutral' | 'danger' /* = 'neutral' */ }`.
|
|
2263
|
+
|
|
2264
|
+
Resolves `true` only when the confirm button is pressed — Escape, a backdrop click, and the cancel
|
|
2265
|
+
button all resolve `false`. Mounts a transient `<lyra-dialog>` on `document.body` for the duration
|
|
2266
|
+
of the call and removes it once settled, rather than reusing a persistent page-level region
|
|
2267
|
+
(contrast `lyra-toast`'s `toaster.ts`): a confirmation modal has no stacking/queueing concerns —
|
|
2268
|
+
only one is ever meant to be open at a time — so a mount-and-remove per call keeps its lifetime
|
|
2269
|
+
trivially tied to the returned promise. `title` becomes a slotted `<h2>`, which per `<lyra-dialog>`'s
|
|
2270
|
+
own heading-detection also drives the dialog's accessible name; `description`, if provided, becomes
|
|
2271
|
+
a slotted `<p>`. `tone: 'danger'` fills the confirm button with `--lyra-color-danger` instead of
|
|
2272
|
+
`--lyra-color-brand`, for destructive actions. Confirm/cancel buttons are plain inline-styled
|
|
2273
|
+
`<button>` elements (no shared button component exists in this library yet), but every color value
|
|
2274
|
+
used is still a `--lyra-*` token reference, never a raw literal.
|
|
2275
|
+
|
|
2276
|
+
**Known gotchas:**
|
|
2277
|
+
- Every dismissal path (confirm button, cancel button, Escape, backdrop click) funnels through
|
|
2278
|
+
`<lyra-dialog>`'s own `close()`/`lyra-dialog-close` event, so there is exactly one place that
|
|
2279
|
+
resolves the promise and tears the dialog down — a consumer never needs to (and shouldn't) call
|
|
2280
|
+
`.remove()` itself.
|
|
2281
|
+
- Both buttons reuse `--lyra-color-on-brand` for their text color, even in the `danger` tone — there
|
|
2282
|
+
is no separate `--lyra-color-on-danger` token, and `--lyra-color-on-brand` happens to be white in
|
|
2283
|
+
both light and dark themes so it reads fine over the danger fill too.
|
|
2284
|
+
- Importing `confirm` alone is enough to register `<lyra-dialog>` — `confirm.ts` imports
|
|
2285
|
+
`./dialog.js` for its side effect, so a consumer doesn't need a separate import for the dialog
|
|
2286
|
+
element.
|
|
2287
|
+
|
|
2288
|
+
---
|
|
2289
|
+
|
|
2290
|
+
## `lyra-tabs`
|
|
2291
|
+
|
|
2292
|
+
A tab strip whose panels are direct light-DOM children, each carrying `slot="<id>"` (the panel's
|
|
2293
|
+
stable id) and `label="<text>"` (the tab button's text). One named `<slot>` is rendered per distinct
|
|
2294
|
+
`slot` name found among the current children — a child with no `label`, or a name with no matching
|
|
2295
|
+
child, simply never produces a tab. Implements the WAI-ARIA APG tabs pattern with automatic
|
|
2296
|
+
activation: Left/Right (swapped under RTL) move focus *and* selection together, Home/End jump to the
|
|
2297
|
+
first/last enabled tab, and a roving `tabindex` follows whichever tab is currently selected.
|
|
2298
|
+
|
|
2299
|
+
**Properties:**
|
|
2300
|
+
- `active: string = ''` (reflected) — the active tab's `slot`/id; falls back to the first enabled
|
|
2301
|
+
tab whenever the current value doesn't resolve to one (including on every children/attribute
|
|
2302
|
+
change, tracked via a `MutationObserver`)
|
|
2303
|
+
|
|
2304
|
+
**Events:** `lyra-tabs-change` (`detail: { tabId: string }`) — fired when the active tab changes via
|
|
2305
|
+
click or keyboard. Not fired when `active` self-corrects to a valid tab (initial default, or a tab
|
|
2306
|
+
disappearing/becoming disabled underneath the current selection).
|
|
2307
|
+
|
|
2308
|
+
**Slots:** default — direct children with `slot="<id>" label="<text>"` (and optionally `disabled`);
|
|
2309
|
+
one becomes each tab's panel.
|
|
2310
|
+
|
|
2311
|
+
**CSS parts:** `base` (root wrapper around the tablist and panels), `tablist` (the `role="tablist"`
|
|
2312
|
+
row of tab buttons), `tab` (a single tab button), `panel` (a single `role="tabpanel"` wrapper, one
|
|
2313
|
+
per tab, hidden unless active)
|
|
2314
|
+
|
|
2315
|
+
**Themeable custom properties:** shared tokens only — `--lyra-space-s/-m`,
|
|
2316
|
+
`--lyra-color-border/-text-quiet/-text/-brand`, `--lyra-transition-fast`, `--lyra-radius`,
|
|
2317
|
+
`--lyra-focus-ring-width/-color/-offset`, `--lyra-opacity-disabled`.
|
|
2318
|
+
|
|
2319
|
+
**Optional peer deps:** none.
|
|
2320
|
+
|
|
2321
|
+
```html
|
|
2322
|
+
<lyra-tabs active="general">
|
|
2323
|
+
<div slot="general" label="General">General settings…</div>
|
|
2324
|
+
<div slot="advanced" label="Advanced" disabled>Advanced settings…</div>
|
|
2325
|
+
</lyra-tabs>
|
|
2326
|
+
<script type="module">
|
|
2327
|
+
document.querySelector('lyra-tabs').addEventListener('lyra-tabs-change', (e) => console.log(e.detail.tabId));
|
|
2328
|
+
</script>
|
|
2329
|
+
```
|
|
2330
|
+
|
|
2331
|
+
**Known gotchas:**
|
|
2332
|
+
- Tabs are rebuilt from direct children via a `MutationObserver` watching `childList` plus
|
|
2333
|
+
`attributeFilter: ['slot', 'label', 'disabled']` — not `slotchange` — because a brand-new tab's
|
|
2334
|
+
`slot` name has no matching `<slot>` to fire `slotchange` on until this component has already
|
|
2335
|
+
rendered one for it, and neither `slotchange` nor any Lit lifecycle hook observes a plain
|
|
2336
|
+
attribute edit on a light-DOM child at all.
|
|
2337
|
+
- If two children share the same `slot` name, the *first* one wins for the tab button's label
|
|
2338
|
+
(matches native slot assignment: both would render into the one panel, but only one label can back
|
|
2339
|
+
the button).
|
|
2340
|
+
- Left/Right are swapped under RTL (read via `internal/rtl.ts`'s `isRtl()`); Up/Down are not used —
|
|
2341
|
+
this is a horizontal strip only.
|
|
2342
|
+
|
|
2343
|
+
---
|
|
2344
|
+
|
|
2345
|
+
## `lyra-checkbox`
|
|
2346
|
+
|
|
2347
|
+
A boolean form control. `role="checkbox"` with an `aria-checked` that can also be `"mixed"`, and a
|
|
2348
|
+
visual box/checkmark. Structurally the same idea as `<lyra-switch>` (form-associated via
|
|
2349
|
+
`ElementInternals`, click and Space/Enter both toggle) but with checkbox semantics.
|
|
2350
|
+
|
|
2351
|
+
**Properties:**
|
|
2352
|
+
- `checked: boolean = false` (reflected)
|
|
2353
|
+
- `indeterminate: boolean = false` (reflected) — visual-only mixed state; does not affect `checked`,
|
|
2354
|
+
and is cleared back to `false` by any user interaction (click or keyboard), matching native
|
|
2355
|
+
`<input type="checkbox">`
|
|
2356
|
+
- `disabled: boolean = false` (reflected)
|
|
2357
|
+
- `required: boolean = false` (reflected — enforced via `internals.setValidity()`)
|
|
2358
|
+
- `name: string = ''`
|
|
2359
|
+
- `value: string = 'on'` — only contributed to form submission while `checked` (a native checkbox
|
|
2360
|
+
submits nothing at all, not even an empty string, while unchecked)
|
|
2361
|
+
|
|
2362
|
+
**Events:** `lyra-change` (`detail: { checked: boolean }`) — fired on a user toggle (click or
|
|
2363
|
+
Space/Enter); not fired for a programmatic `.checked` assignment.
|
|
2364
|
+
|
|
2365
|
+
**Slots:** default — label text, rendered next to the box. Clicking it toggles the checkbox, the
|
|
2366
|
+
same as clicking a native checkbox's associated `<label>`. If left empty, set `aria-label` on the
|
|
2367
|
+
host so the control still has an accessible name.
|
|
2368
|
+
|
|
2369
|
+
**CSS parts:** `base` (the whole interactive control, `role="checkbox"`), `box` (the small square
|
|
2370
|
+
showing the checkmark/indeterminate dash), `checkmark` (the checkmark or indeterminate-dash glyph),
|
|
2371
|
+
`label` (wrapper around the default slot)
|
|
2372
|
+
|
|
2373
|
+
**Themeable custom properties:** shared tokens only — `--lyra-space-s`, `--lyra-icon-button-size`,
|
|
2374
|
+
`--lyra-color-border/-surface/-on-brand/-brand/-text`, `--lyra-radius`, `--lyra-transition-fast`,
|
|
2375
|
+
`--lyra-focus-ring-width/-color/-offset`, `--lyra-opacity-disabled`.
|
|
2376
|
+
|
|
2377
|
+
**Optional peer deps:** none.
|
|
2378
|
+
|
|
2379
|
+
```html
|
|
2380
|
+
<lyra-checkbox name="terms" required>Accept the terms and conditions</lyra-checkbox>
|
|
2381
|
+
<script type="module">
|
|
2382
|
+
document
|
|
2383
|
+
.querySelector('lyra-checkbox')
|
|
2384
|
+
.addEventListener('lyra-change', (e) => console.log(e.detail.checked));
|
|
2385
|
+
</script>
|
|
2386
|
+
```
|
|
2387
|
+
|
|
2388
|
+
Form-associated via a directly-attached `ElementInternals` (not the shared `FormAssociated` mixin,
|
|
2389
|
+
whose `value` accessor assumes a plain string default flow) with its own hand-rolled
|
|
2390
|
+
`updateValidity()` — same shape as `<lyra-combobox>`'s and `<lyra-switch>`'s direct-`ElementInternals`
|
|
2391
|
+
handling.
|
|
2392
|
+
|
|
2393
|
+
**Known gotchas:**
|
|
2394
|
+
- `formResetCallback()` restores `checked` to whatever the declarative `checked` attribute parsed to
|
|
2395
|
+
at first connect — captured once via a one-shot flag (not from `attributeChangedCallback` alone,
|
|
2396
|
+
since `checked` reflects and that would wrongly re-capture on every later user toggle). A later
|
|
2397
|
+
`el.checked = true` assignment never redefines the reset default.
|
|
2398
|
+
- `indeterminate` is visual-only and silently clears on any user click/keypress — a consumer relying
|
|
2399
|
+
on it staying `true` after a user interacts with the box will be surprised.
|
|
2400
|
+
- The rendered `aria-label` is copied from the host's own `aria-label` attribute at render time; if
|
|
2401
|
+
neither that nor slotted label text is present, the control has no accessible name.
|
|
2402
|
+
|
|
2403
|
+
---
|
|
2404
|
+
|
|
2405
|
+
## `lyra-switch`
|
|
2406
|
+
|
|
2407
|
+
A boolean toggle-switch form control. `role="switch"` with `aria-checked` read as an on/off state
|
|
2408
|
+
rather than checked/unchecked, and no indeterminate state. Structurally the same idea as
|
|
2409
|
+
`<lyra-checkbox>` (form-associated via `ElementInternals`, click and Space/Enter both toggle).
|
|
2410
|
+
|
|
2411
|
+
**Properties:**
|
|
2412
|
+
- `checked: boolean = false` (reflected)
|
|
2413
|
+
- `disabled: boolean = false` (reflected)
|
|
2414
|
+
- `required: boolean = false` (reflected — enforced via `internals.setValidity()`)
|
|
2415
|
+
- `name: string = ''`
|
|
2416
|
+
- `value: string = 'on'` — only contributed to form submission while `checked`
|
|
2417
|
+
|
|
2418
|
+
**Events:** `lyra-change` (`detail: { checked: boolean }`) — fired on a user toggle (click or
|
|
2419
|
+
Space/Enter); not fired for a programmatic `.checked` assignment.
|
|
2420
|
+
|
|
2421
|
+
**Slots:** default — label text, rendered next to the track. Clicking it toggles the switch, the
|
|
2422
|
+
same as clicking a checkbox's associated `<label>`. If left empty, set `aria-label` on the host so
|
|
2423
|
+
the control still has an accessible name.
|
|
2424
|
+
|
|
2425
|
+
**CSS parts:** `base` (the whole interactive control, `role="switch"`), `track` (the pill-shaped
|
|
2426
|
+
background), `thumb` (the circular knob that slides across the track), `label` (wrapper around the
|
|
2427
|
+
default slot)
|
|
2428
|
+
|
|
2429
|
+
**Themeable custom properties:** `--track-inline-size` (default `2.25rem`), `--track-block-size`
|
|
2430
|
+
(default `1.25rem`), `--thumb-offset` (default `2px`) — component-local geometry knobs, set on
|
|
2431
|
+
`:host`, since a fully-rounded pill/thumb needs a radius well past the shared `--lyra-radius`
|
|
2432
|
+
default — plus shared tokens `--lyra-space-s`, `--lyra-color-border/-brand/-surface/-text`,
|
|
2433
|
+
`--lyra-transition-fast`, `--lyra-focus-ring-width/-color/-offset`, `--lyra-opacity-disabled`.
|
|
2434
|
+
|
|
2435
|
+
**Optional peer deps:** none.
|
|
2436
|
+
|
|
2437
|
+
```html
|
|
2438
|
+
<lyra-switch name="notifications" checked>Enable notifications</lyra-switch>
|
|
2439
|
+
<script type="module">
|
|
2440
|
+
document
|
|
2441
|
+
.querySelector('lyra-switch')
|
|
2442
|
+
.addEventListener('lyra-change', (e) => console.log(e.detail.checked));
|
|
2443
|
+
</script>
|
|
2444
|
+
```
|
|
2445
|
+
|
|
2446
|
+
Form-associated the same way as `<lyra-checkbox>`: a directly-attached `ElementInternals` with a
|
|
2447
|
+
hand-rolled `updateValidity()`, not the shared `FormAssociated` mixin. The thumb animates the
|
|
2448
|
+
logical `inset-inline-start` property (not a physical `transform: translateX()`), so the slide
|
|
2449
|
+
direction mirrors correctly under `dir="rtl"`.
|
|
2450
|
+
|
|
2451
|
+
**Known gotchas:**
|
|
2452
|
+
- `formResetCallback()` restores `checked` to the value captured from the declarative `checked`
|
|
2453
|
+
attribute at first connect (same one-shot-flag capture as `<lyra-checkbox>`) — a later `.checked =
|
|
2454
|
+
true` property assignment never redefines what `form.reset()` restores to.
|
|
2455
|
+
- The rendered `aria-label` is copied from the host's own `aria-label` attribute at render time; with
|
|
2456
|
+
neither that nor slotted label text, the control has no accessible name.
|
|
2457
|
+
|
|
2458
|
+
---
|
|
2459
|
+
|
|
2460
|
+
## `lyra-json-viewer`
|
|
2461
|
+
|
|
2462
|
+
A collapsible, copyable tree view for an arbitrary JSON-serializable value (object, array, string,
|
|
2463
|
+
number, boolean, null, or `undefined`). Serves as a fallback renderer wherever a raw payload needs
|
|
2464
|
+
inspecting without a bespoke view. Expand/collapse state is keyed by structural path (not object
|
|
2465
|
+
identity), so it survives a `data` reassignment that keeps the same shape — e.g. a streaming result
|
|
2466
|
+
being patched in place.
|
|
2467
|
+
|
|
2468
|
+
**Properties:**
|
|
2469
|
+
- `data: unknown` (attribute `false` — property-only, not settable via an HTML attribute)
|
|
2470
|
+
- `collapsedDepth?: number` (attribute `collapsed-depth`) — nodes at or beyond this nesting depth
|
|
2471
|
+
(root = `0`) start collapsed; omitted/`undefined` means nothing auto-collapses
|
|
2472
|
+
- `maxHeight: string = ''` (attribute `max-height`) — a CSS length (e.g. `"20rem"`); once set, the
|
|
2473
|
+
viewer scrolls internally past this height instead of growing the page
|
|
2474
|
+
- `copyable: boolean = false` (reflected) — shows copy-to-clipboard affordances: one for the whole
|
|
2475
|
+
value, plus one per node
|
|
2476
|
+
- `search: string = ''` — case-insensitive substring match against keys/values; matches are
|
|
2477
|
+
highlighted and their ancestors auto-expanded
|
|
2478
|
+
|
|
2479
|
+
**Events:** `lyra-copy` (`detail: { text: string }`) — fired by the top-level copy button or a
|
|
2480
|
+
per-node one. Fires even when `navigator.clipboard` silently failed or is unavailable (insecure
|
|
2481
|
+
context, older browser, a stubbed-out test environment), so a consumer can still observe copy
|
|
2482
|
+
*intent*.
|
|
2483
|
+
|
|
2484
|
+
**Slots:** none — the tree is rendered entirely from `data`.
|
|
2485
|
+
|
|
2486
|
+
**CSS parts:** `base` (root scroll container, respects `max-height`), `toolbar` (wrapper around the
|
|
2487
|
+
top-level copy button, only rendered when `copyable`), `tree` (wrapper around the rendered node
|
|
2488
|
+
tree), `key` (an object property key or array index label), `value` (a primitive value's text —
|
|
2489
|
+
carries `data-type` of `string`/`number`/`boolean`/`null`/`undefined` for per-type coloring, and
|
|
2490
|
+
`data-match` while it matches `search`), `bracket` (a `{`, `}`, `[`, or `]` delimiter), `toggle` (a
|
|
2491
|
+
container node's expand/collapse button; hidden but present for row alignment on leaf/empty nodes),
|
|
2492
|
+
`copy-button` (a copy-to-clipboard button — the top-level one in `toolbar`, or a per-node one; only
|
|
2493
|
+
rendered when `copyable`)
|
|
2494
|
+
|
|
2495
|
+
**Themeable custom properties:** `--lyra-json-viewer-max-height` (default `none` — grows with content
|
|
2496
|
+
until `max-height` is set), `--lyra-json-viewer-font` (default `ui-monospace, SFMono-Regular, Menlo,
|
|
2497
|
+
Consolas, monospace` — component-specific since no shared monospace token exists), plus shared tokens
|
|
2498
|
+
`--lyra-color-border/-surface/-text/-text-quiet/-brand/-brand-quiet/-success/-warning/-warning-quiet`,
|
|
2499
|
+
`--lyra-radius`, `--lyra-space-xs/-s/-l`, `--lyra-focus-ring-width/-color/-offset`,
|
|
2500
|
+
`--lyra-transition-fast`.
|
|
2501
|
+
|
|
2502
|
+
**Optional peer deps:** none.
|
|
2503
|
+
|
|
2504
|
+
```ts
|
|
2505
|
+
import { html } from 'lit';
|
|
2506
|
+
import '@aceshooting/lyra-ui/components/json-viewer/json-viewer.js';
|
|
2507
|
+
|
|
2508
|
+
html`<lyra-json-viewer .data=${apiResponse} copyable max-height="24rem" search=${query}></lyra-json-viewer>`;
|
|
2509
|
+
```
|
|
2510
|
+
|
|
2511
|
+
```html
|
|
2512
|
+
<lyra-json-viewer copyable max-height="24rem"></lyra-json-viewer>
|
|
2513
|
+
<script type="module">
|
|
2514
|
+
document.querySelector('lyra-json-viewer').data = { hello: 'world', items: [1, 2, 3] };
|
|
2515
|
+
</script>
|
|
2516
|
+
```
|
|
2517
|
+
|
|
2518
|
+
**Known gotchas:**
|
|
2519
|
+
- `data` is property-only (`attribute: false`) — it must be set via `.data = ...` or a lit-html `.data=${...}`
|
|
2520
|
+
binding, never as a plain HTML attribute.
|
|
2521
|
+
- Search highlighting auto-expands only the *ancestors* of a match, not the whole tree — a
|
|
2522
|
+
non-matching sibling subtree elsewhere stays collapsed (or expanded) exactly as it already was.
|
|
2523
|
+
- An explicit per-node expand/collapse (from clicking a node's `toggle` button) permanently overrides
|
|
2524
|
+
both `collapsedDepth` and any search-driven auto-expand for that path, until `data` is reassigned
|
|
2525
|
+
with a different shape.
|
|
2526
|
+
- Per-node copy buttons call `stopPropagation()` on click so clicking one doesn't also toggle the
|
|
2527
|
+
row's expand/collapse state.
|
|
2528
|
+
|
|
2529
|
+
---
|
|
2530
|
+
|
|
2531
|
+
## `lyra-live-region` (+ internal `Announcer`)
|
|
2532
|
+
|
|
2533
|
+
A throttled screen-reader announcement helper, split into a DOM-free coalescing engine
|
|
2534
|
+
(`internal/announcer.ts`'s `Announcer` class) and a real custom element that wraps it.
|
|
2535
|
+
|
|
2536
|
+
### Internal: `Announcer` (`internal/announcer.ts`)
|
|
2537
|
+
|
|
2538
|
+
Not a custom element — pure timing/coalescing logic with no DOM dependency, composed by
|
|
2539
|
+
`<lyra-live-region>` (below) and intended for reuse by any other component that needs throttled
|
|
2540
|
+
announcements (a stream-status indicator, a tool-call chip's status transitions, a chat message's
|
|
2541
|
+
streaming state).
|
|
2542
|
+
|
|
2543
|
+
Streaming UIs (token-by-token chat responses, progress ticks, etc.) naturally produce far more
|
|
2544
|
+
candidate announcements than a screen-reader user can usefully absorb — reading every incremental
|
|
2545
|
+
chunk aloud is spam, not information. `Announcer` collapses a burst of `announce()` calls arriving
|
|
2546
|
+
within `throttleMs` of the *first* call in that burst down to a single trailing-edge flush of the
|
|
2547
|
+
latest text: superseded intermediate text is dropped outright, never queued or concatenated.
|
|
2548
|
+
|
|
2549
|
+
- `new Announcer(options: AnnouncerOptions)` where
|
|
2550
|
+
`AnnouncerOptions = { throttleMs?: number /* = 500 */; onFlush: (text: string) => void }`.
|
|
2551
|
+
- `announce(text: string, options?: AnnounceOptions)` where `AnnounceOptions = { force?: boolean }` —
|
|
2552
|
+
queues `text`, overwriting whatever an earlier call in the same burst queued. Only the *first*
|
|
2553
|
+
call of a burst schedules the flush timer, so the deadline stays anchored to that first call
|
|
2554
|
+
rather than being pushed back by every subsequent call. `{ force: true }` bypasses any
|
|
2555
|
+
in-progress window and flushes immediately, so a terminal message (e.g. "response complete") is
|
|
2556
|
+
never swallowed mid-burst.
|
|
2557
|
+
- `cancel()` — drops any pending (not yet flushed) text without invoking `onFlush`.
|
|
2558
|
+
- `pendingText: string | undefined` — the latest text awaiting flush, if a burst is in progress.
|
|
2559
|
+
- `isPending: boolean` — whether a flush is currently scheduled.
|
|
2560
|
+
- `throttleMs` — a plain public field, safe to change between bursts; a flush already scheduled
|
|
2561
|
+
keeps the deadline it was scheduled with.
|
|
2562
|
+
|
|
2563
|
+
### `lyra-live-region`
|
|
2564
|
+
|
|
2565
|
+
A visually-hidden ARIA live region that throttles and coalesces announcements instead of relaying
|
|
2566
|
+
every call verbatim, by composing an internal `Announcer`. A consumer typically mounts one
|
|
2567
|
+
`<lyra-live-region>` per page/surface (much like `<lyra-toast>` is one region per placement) and
|
|
2568
|
+
keeps a reference to call `announce()` from application code or a parent component.
|
|
2569
|
+
|
|
2570
|
+
**Properties:**
|
|
2571
|
+
- `mode: 'polite' | 'assertive' = 'polite'` (reflected) — `'polite'` renders `role="status"` +
|
|
2572
|
+
`aria-live="polite"` (waits for the user to be idle); `'assertive'` renders `role="alert"` +
|
|
2573
|
+
`aria-live="assertive"` (interrupts)
|
|
2574
|
+
- `throttleMs: number = 500` (attribute `throttle-ms`) — the coalescing window; see `Announcer`
|
|
2575
|
+
above
|
|
2576
|
+
|
|
2577
|
+
**Methods:** `announce(text: string, options?: AnnounceOptions): void` — queues `text` for
|
|
2578
|
+
announcement through the internal `Announcer`; `{ force: true }` bypasses the current throttle
|
|
2579
|
+
window and flushes immediately.
|
|
2580
|
+
|
|
2581
|
+
**Events:** none.
|
|
2582
|
+
|
|
2583
|
+
**Slots:** none.
|
|
2584
|
+
|
|
2585
|
+
**CSS parts:** `region` — the visually-hidden element carrying `role`/`aria-live`.
|
|
2586
|
+
|
|
2587
|
+
**Themeable custom properties:** none component-specific — the region is hidden via the shared
|
|
2588
|
+
`.sr-only` helper class (`internal/a11y.ts`), not tokenized CSS.
|
|
2589
|
+
|
|
2590
|
+
**Optional peer deps:** none.
|
|
2591
|
+
|
|
2592
|
+
```html
|
|
2593
|
+
<!-- once, near the root of a page/surface -->
|
|
2594
|
+
<lyra-live-region id="live" mode="polite"></lyra-live-region>
|
|
2595
|
+
<script type="module">
|
|
2596
|
+
const live = document.getElementById('live');
|
|
2597
|
+
// streaming tokens: fine to call on every chunk, only the trailing state lands
|
|
2598
|
+
live.announce(`${partialText} …`);
|
|
2599
|
+
// stream finished: always announced, even mid-throttle-window
|
|
2600
|
+
live.announce('Response complete', { force: true });
|
|
2601
|
+
</script>
|
|
2602
|
+
```
|
|
2603
|
+
|
|
2604
|
+
A parent Lit component would instead hold the reference via `@query('lyra-live-region')`.
|
|
2605
|
+
|
|
2606
|
+
**Known gotchas:**
|
|
2607
|
+
- Re-announcing text identical to what was last written is special-cased: screen readers announce a
|
|
2608
|
+
live region only on text-content *change*, so the component clears `textContent` first and
|
|
2609
|
+
re-sets it on the next animation frame (not the same tick, which can coalesce into nothing ever
|
|
2610
|
+
appearing to change) to give assistive tech a real empty-to-populated transition to observe.
|
|
2611
|
+
- The region's DOM is tracked outside Lit's own template bindings (`write()` mutates
|
|
2612
|
+
`regionEl.textContent` directly) — an `announce()`/flush landing before `firstUpdated()` has run
|
|
2613
|
+
(e.g. a consumer creates, appends, and calls `announce()` synchronously) is buffered and applied
|
|
2614
|
+
on the next `firstUpdated()` rather than dropped.
|
|
2615
|
+
- `disconnectedCallback()` cancels any pending (unflushed) announcement and any in-flight re-announce
|
|
2616
|
+
animation-frame callback — an element removed mid-throttle-window silently drops whatever was
|
|
2617
|
+
queued.
|
|
2618
|
+
- Changing `throttle-ms` updates the live `Announcer`'s window immediately, but a flush already
|
|
2619
|
+
scheduled under the old window keeps the deadline it was scheduled with.
|
|
2620
|
+
|
|
2621
|
+
---
|
|
2622
|
+
|
|
2623
|
+
## `lyra-markdown`
|
|
2624
|
+
|
|
2625
|
+
Sanitized Markdown-to-HTML rendering (GFM tables, fenced code blocks, links, blockquotes) built on
|
|
2626
|
+
two optional peer dependencies — `marked` (parsing) and `dompurify` (sanitizing) — both lazy-loaded
|
|
2627
|
+
independently via `markdown-loader.ts`'s `loadMarkdownDeps()` on first connect, cached per page the
|
|
2628
|
+
same way `chart-loader.ts`/`map-loader.ts` cache their load promise so every `<lyra-markdown>`
|
|
2629
|
+
instance on a page shares one load. `heading`/`code`/`blockquote`/`table`/`link` tokens are rendered
|
|
2630
|
+
through a `marked` renderer override that injects `part="..."` attributes directly into the produced
|
|
2631
|
+
HTML in a single pass (no second DOM walk after insertion).
|
|
2632
|
+
|
|
2633
|
+
**Properties:**
|
|
2634
|
+
- `content: string = ''` — the Markdown source to render
|
|
2635
|
+
- `sanitize: boolean = true` — sanitize `marked`'s HTML output with DOMPurify before rendering
|
|
2636
|
+
- `gfm: boolean = true` — GitHub-flavored Markdown (tables, strikethrough, autolinks, task lists)
|
|
2637
|
+
- `linkTarget: string = '_blank'` (attribute `link-target`) — `target` applied to every rendered
|
|
2638
|
+
`<a>`; `rel="noopener noreferrer"` is always added alongside it regardless of this value
|
|
2639
|
+
- `internalLinkPrefix: string = ''` (attribute `internal-link-prefix`) — when set, a rendered link
|
|
2640
|
+
whose `href` *attribute* (not the browser-resolved `.href` property) starts with this prefix is
|
|
2641
|
+
intercepted on click and reported via `lyra-link-click` instead of navigating; empty (the default)
|
|
2642
|
+
means every link is treated as external
|
|
2643
|
+
- `streaming: boolean = false` (reflected) — forward-compatible hint for a future streaming renderer
|
|
2644
|
+
expected to build on this component (coalescing partial tokens as they arrive); setting it has no
|
|
2645
|
+
rendering effect yet, it only lets a consumer already target `lyra-markdown[streaming]` in CSS
|
|
2646
|
+
|
|
2647
|
+
**Events:** `lyra-link-click` (`detail: { href: string, internal: true }`, fired — with the click
|
|
2648
|
+
prevented — when a rendered link's `href` starts with `internal-link-prefix`; ordinary external
|
|
2649
|
+
links navigate normally and never fire this), `lyra-render-error` (`detail: { error: unknown }`,
|
|
2650
|
+
fired whenever rendering falls back to plain text — see the fallback matrix below)
|
|
2651
|
+
|
|
2652
|
+
**Slots:** none — content comes from the `content` property, not light-DOM children.
|
|
2653
|
+
|
|
2654
|
+
**CSS parts:** `content` (the wrapper around the rendered or plain-text-fallback output), `heading`
|
|
2655
|
+
(every rendered `<h1>`–`<h6>`), `code-block` (every rendered fenced/indented `<pre>`), `link` (every
|
|
2656
|
+
rendered `<a>`), `table` (every rendered `<table>`), `blockquote` (every rendered `<blockquote>`)
|
|
2657
|
+
|
|
2658
|
+
**Themeable custom properties:** `--lyra-markdown-font-mono` (default `ui-monospace, SFMono-Regular,
|
|
2659
|
+
Menlo, Consolas, monospace` — the code/code-block font; component-specific since no shared
|
|
2660
|
+
`--wa-*`/`--lyra-*` monospace token exists), plus shared tokens `--lyra-space-xs/-s/-m/-l`,
|
|
2661
|
+
`--lyra-color-brand-quiet`, `--lyra-color-brand`, `--lyra-color-border`, `--lyra-color-text-quiet`,
|
|
2662
|
+
`--lyra-radius`.
|
|
2663
|
+
|
|
2664
|
+
**Optional peer deps:** `marked`, `dompurify` (both lazy-loaded via `markdown-loader.ts`'s
|
|
2665
|
+
`loadMarkdownDeps()`, mirroring `chart-loader.ts`'s two-independent-optional-peers shape). Each half
|
|
2666
|
+
is loaded and caught independently — a consumer who installs only `marked` and explicitly sets
|
|
2667
|
+
`sanitize="false"` (so `dompurify` is never needed) is a valid, supported combination.
|
|
2668
|
+
|
|
2669
|
+
```html
|
|
2670
|
+
<lyra-markdown
|
|
2671
|
+
content="# Report See the [setup guide](/docs/setup) for details."
|
|
2672
|
+
internal-link-prefix="/docs/"
|
|
2673
|
+
></lyra-markdown>
|
|
2674
|
+
<script>
|
|
2675
|
+
document.querySelector('lyra-markdown').addEventListener('lyra-link-click', (e) => {
|
|
2676
|
+
router.navigate(e.detail.href);
|
|
2677
|
+
});
|
|
2678
|
+
</script>
|
|
2679
|
+
```
|
|
2680
|
+
|
|
2681
|
+
Rendering never ships unsanitized or broken markup silently. If `marked` fails to load, or throws
|
|
2682
|
+
while parsing malformed input, the component falls back to plain text (`white-space: pre-wrap`, no
|
|
2683
|
+
HTML parsing at all — the raw `content` string itself) and fires `lyra-render-error`. If `sanitize`
|
|
2684
|
+
is `true` (the default) and `dompurify` fails to load, the component *also* falls back to plain text
|
|
2685
|
+
+ `lyra-render-error` — it never renders `marked`'s raw HTML output when sanitization was requested
|
|
2686
|
+
(or defaulted to) but is unavailable, even though `marked` itself loaded fine. If `sanitize` is
|
|
2687
|
+
explicitly `false`, `marked`'s raw output renders as-is regardless of whether `dompurify` is
|
|
2688
|
+
installed. While the optional peers are still resolving, the host carries `aria-busy="true"` (set/
|
|
2689
|
+
cleared in `updated()` based on whether the deps have loaded) and shows the same plain-text fallback
|
|
2690
|
+
rendering — there's no separate loading skeleton, since the un-rendered Markdown source is already
|
|
2691
|
+
legible text in the meantime.
|
|
2692
|
+
|
|
2693
|
+
**Known gotchas:**
|
|
2694
|
+
- a malformed percent-escape or lone UTF-16 surrogate in a link's raw `href` makes the internal
|
|
2695
|
+
`encodeURI`-based validity guard throw, silently dropping just that anchor (the link text still
|
|
2696
|
+
renders, with no `href`) — mirrors `marked`'s own default `link()` renderer's defensive behavior.
|
|
2697
|
+
- `target` is not in DOMPurify's default attribute allowlist (unlike `part`/`rel`/`class`, which
|
|
2698
|
+
already are), so sanitization is called with `ADD_ATTR: ['target']` — without that, every rendered
|
|
2699
|
+
link's `target` would be silently stripped by sanitization even though the anchor itself survives.
|
|
2700
|
+
- a fresh `marked.Marked()` instance (with a fresh renderer) is built on every single parse rather
|
|
2701
|
+
than cached, specifically so the renderer's `link()` override always closes over the *current*
|
|
2702
|
+
`linkTarget` — `marked`'s `.use()` otherwise persists whatever renderer it was given for the
|
|
2703
|
+
instance's lifetime, which would go stale if `linkTarget` changed after a cached instance's first use.
|
|
2704
|
+
- `internal-link-prefix` matching compares against the raw `href` *attribute*, not the resolved
|
|
2705
|
+
`.href` IDL property (always an absolute URL in the browser) — a prefix like `/docs/` matches a
|
|
2706
|
+
relative markdown link but would never match against the resolved property.
|
|
2707
|
+
- rendered output goes through `unsafeHTML`; with `sanitize="false"` the component renders whatever
|
|
2708
|
+
HTML `marked` produces from `content` completely unsanitized, so untrusted `content` must never be
|
|
2709
|
+
paired with `sanitize="false"`.
|
|
2710
|
+
|
|
2711
|
+
---
|
|
2712
|
+
|
|
2713
|
+
## `lyra-chat-message`
|
|
2714
|
+
|
|
2715
|
+
A role-based message bubble *shell* for a chat/agent conversation surface. It renders none of the
|
|
2716
|
+
message content itself — the default slot carries whatever a consumer wants to display (plain text,
|
|
2717
|
+
a `<lyra-markdown>`, a custom template, anything) and this component only supplies the surrounding
|
|
2718
|
+
chrome: alignment/coloring by `role`, an avatar/badges header row, an optional collapse toggle, an
|
|
2719
|
+
attachments strip, and a status-aware footer (a live-updating status dot + text, the formatted
|
|
2720
|
+
`timestamp`, a built-in retry affordance for `status="failed"`, and an `actions` slot for everything
|
|
2721
|
+
else). No built-in copy button is rendered — slot a copy control into `actions` and fire `lyra-copy`
|
|
2722
|
+
from it if you want one (matching `<lyra-json-viewer>`'s copy-affordance event name for anything
|
|
2723
|
+
listening at the conversation-surface level).
|
|
2724
|
+
|
|
2725
|
+
**Properties:**
|
|
2726
|
+
- `role: ChatMessageRole = 'assistant'` (`'user' | 'assistant' | 'system'`) — reflects to
|
|
2727
|
+
`data-role`, **not** the bare `role` attribute (those role strings aren't valid ARIA role tokens
|
|
2728
|
+
and reflecting to `role` would collide with the element's own ARIA role); a plain `role="..."`
|
|
2729
|
+
attribute set directly in markup is ignored entirely
|
|
2730
|
+
- `status: ChatMessageStatus = 'sent'` (`'sending' | 'sent' | 'failed' | 'streaming'`, reflected) —
|
|
2731
|
+
drives the footer's status dot/text, `status="failed"`'s danger treatment on the bubble, and the
|
|
2732
|
+
built-in retry button
|
|
2733
|
+
- `timestamp?: Date | string` (attribute: false) — accepts a `Date` or anything `new Date()` can
|
|
2734
|
+
parse; invalid input is treated the same as unset (no timestamp rendered)
|
|
2735
|
+
- `formatTimestamp?: (date: Date) => string` (attribute: false) — overrides the default
|
|
2736
|
+
`hour:minute` (`Intl.DateTimeFormat`, runtime locale) rendering of `timestamp`
|
|
2737
|
+
- `collapsible: boolean = false` (reflected) — shows the built-in collapse/expand toggle in the header
|
|
2738
|
+
- `collapsed: boolean = false` (reflected) — whether the message body is hidden; effective whenever
|
|
2739
|
+
set, independent of `collapsible` (which only controls whether the toggle button itself is
|
|
2740
|
+
rendered) — mirrors `lyra-widget`'s identical `collapsible`/`collapsed` pair
|
|
2741
|
+
|
|
2742
|
+
**Events:** `lyra-retry` (no detail payload; fired by the built-in retry button, only rendered when
|
|
2743
|
+
`status="failed"`), `lyra-collapse-toggle` (`detail: boolean`, the new `collapsed` state — fired when
|
|
2744
|
+
the user activates the built-in collapse button)
|
|
2745
|
+
|
|
2746
|
+
**Slots:** default (the message body), `avatar` (an avatar/icon for the message author), `badges`
|
|
2747
|
+
(small status/metric chips — e.g. token count, latency, model name — entirely app-supplied), `actions`
|
|
2748
|
+
(action controls such as copy/retry, rendered at the end of the footer), `attachments` (file/image
|
|
2749
|
+
attachment chips, rendered below the message body)
|
|
2750
|
+
|
|
2751
|
+
**CSS parts:** `bubble`, `header` (hidden entirely when nothing is in it), `avatar`, `badges`,
|
|
2752
|
+
`collapse-button` (only rendered when `collapsible`), `body` (hidden while `collapsed`),
|
|
2753
|
+
`attachments`, `footer` (hidden entirely when nothing is in it), `status-indicator` (a small
|
|
2754
|
+
decorative `aria-hidden` dot, absent while `status="sent"`), `status-text` (the visible text twin of
|
|
2755
|
+
`status-indicator`), `timestamp`, `retry-button` (only rendered when `status="failed"`), `actions`
|
|
2756
|
+
|
|
2757
|
+
**Themeable custom properties:** `--lyra-chat-message-max-width` (default `80%` — the bubble's max
|
|
2758
|
+
inline size; component-specific, no shared width token exists), plus shared tokens
|
|
2759
|
+
`--lyra-space-xs/-m`, `--lyra-color-border`, `--lyra-color-surface`, `--lyra-color-brand-quiet`,
|
|
2760
|
+
`--lyra-color-brand`, `--lyra-color-text-quiet`, `--lyra-color-danger`, `--lyra-color-danger-quiet`,
|
|
2761
|
+
`--lyra-radius`, `--lyra-icon-button-size`, `--lyra-focus-ring-*`, `--lyra-transition-fast`.
|
|
2762
|
+
|
|
2763
|
+
**Optional peer deps:** none. Internally renders a `<lyra-live-region>` (a first-party sibling
|
|
2764
|
+
component, auto-imported alongside this one, not an npm peer) for the status-transition
|
|
2765
|
+
announcements described below.
|
|
2766
|
+
|
|
2767
|
+
```html
|
|
2768
|
+
<lyra-chat-message data-role="assistant" status="streaming">
|
|
2769
|
+
<span slot="avatar">🤖</span>
|
|
2770
|
+
<span slot="badges">gpt-5.4 · 1.2s</span>
|
|
2771
|
+
<lyra-markdown content="Here's what I found…"></lyra-markdown>
|
|
2772
|
+
<button slot="actions">Copy</button>
|
|
2773
|
+
</lyra-chat-message>
|
|
2774
|
+
<script>
|
|
2775
|
+
document.querySelector('lyra-chat-message').addEventListener('lyra-retry', () => resend());
|
|
2776
|
+
</script>
|
|
2777
|
+
```
|
|
2778
|
+
|
|
2779
|
+
Accessibility of `status`: the current status is always available as plain visible text
|
|
2780
|
+
(`[part="status-text"]`), never color alone. A transition *to* `"failed"`, or *from* `"streaming"` to
|
|
2781
|
+
`"sent"` (a stream finishing), is additionally announced through the internal `<lyra-live-region>` —
|
|
2782
|
+
`"failed"` announces assertively (`"Message failed to send."`), a streaming→sent completion announces
|
|
2783
|
+
politely (`"Message complete."`) — so a screen-reader user not currently focused on this message
|
|
2784
|
+
still learns about it. No other status transition is announced (e.g. `streaming`→`sending`, or
|
|
2785
|
+
`sending`→`sent` without having passed through `streaming`, produce no announcement). This differs
|
|
2786
|
+
from `<lyra-typing-indicator>`'s deliberately simpler `role="status"` approach, appropriate there
|
|
2787
|
+
since that component only ever announces once (its own mount); this component's `status` can flip
|
|
2788
|
+
between several values across a single element's lifetime.
|
|
2789
|
+
|
|
2790
|
+
**Known gotchas:**
|
|
2791
|
+
- mounting a message with `status="failed"` (or any other non-`"sent"` status) already set does
|
|
2792
|
+
**not** announce anything — only a genuine *later* transition (`changed.get('status') !==
|
|
2793
|
+
undefined`, i.e. not the very first update) triggers the live-region announcement.
|
|
2794
|
+
- `lyra-retry` fires with no detail payload at all (`undefined`), not e.g. `{ status: 'failed' }`.
|
|
2795
|
+
- the header/footer/avatar/badges/attachments/actions wrappers are shown/hidden via the `hidden`
|
|
2796
|
+
attribute, not conditional templating. Whether each slot currently has content is checked once via
|
|
2797
|
+
a light-DOM children scan on the very first update (`willUpdate`, gated on `!this.hasUpdated`) and
|
|
2798
|
+
thereafter only via each slot's own `slotchange` listener — content added directly with
|
|
2799
|
+
`appendChild` after first paint still triggers native `slotchange`, so this works transparently,
|
|
2800
|
+
but any code that manually re-parents already-slotted nodes without a real slot-assignment change
|
|
2801
|
+
won't refresh the corresponding wrapper's visibility.
|
|
2802
|
+
- `role` intentionally reflects to `data-role`; CSS or selectors that key off role must target
|
|
2803
|
+
`[data-role="user"]` etc., not `[role="user"]`.
|
|
2804
|
+
|
|
2805
|
+
---
|
|
2806
|
+
|
|
2807
|
+
## `lyra-typing-indicator`
|
|
2808
|
+
|
|
2809
|
+
A purely presentational "assistant is responding" presence cue — no events, no interactivity. A
|
|
2810
|
+
consumer mounts it while a response is being generated and removes (or hides) it once real content
|
|
2811
|
+
arrives. Three visual variants share one component rather than three separate tags, since callers
|
|
2812
|
+
pick between them along a single axis (how the surrounding surface wants the cue to read) and
|
|
2813
|
+
nothing else about the component differs: `dots` (default, three dots with a staggered bounce — the
|
|
2814
|
+
classic "typing…" affordance for a standalone status line), `pulse` (a single breathing dot, a
|
|
2815
|
+
quieter cue for a tight space, e.g. next to an avatar), `cursor` (a blinking vertical bar, meant to
|
|
2816
|
+
sit inline at the tail end of streamed text still being appended to).
|
|
2817
|
+
|
|
2818
|
+
**Properties:**
|
|
2819
|
+
- `variant: TypingIndicatorVariant = 'dots'` (`'dots' | 'pulse' | 'cursor'`, reflected)
|
|
2820
|
+
- `label: string = 'Thinking…'` — the accessible name, exposed via `role="status"`; not re-announced
|
|
2821
|
+
on every animation frame, only on mount and on any later change to this property
|
|
2822
|
+
- `size: TypingIndicatorSize = 'md'` (`'sm' | 'md'`, reflected) — compact sizing for dense layouts
|
|
2823
|
+
|
|
2824
|
+
**Events:** none — purely presentational.
|
|
2825
|
+
|
|
2826
|
+
**Slots:** none.
|
|
2827
|
+
|
|
2828
|
+
**CSS parts:** `base` (the decorative, `aria-hidden`, wrapper around the animated shape), `dot`
|
|
2829
|
+
(each of the three dots in the `dots` variant), `pulse` (the single pulsing dot in the `pulse`
|
|
2830
|
+
variant), `cursor` (the blinking bar in the `cursor` variant)
|
|
2831
|
+
|
|
2832
|
+
**Themeable custom properties:** `--lyra-typing-dot-size` (default `0.5rem`, `0.375rem` at
|
|
2833
|
+
`size="sm"`), `--lyra-typing-gap` (default `0.25rem`, `0.1875rem` at `size="sm"`),
|
|
2834
|
+
`--lyra-typing-cursor-width` (default `0.125rem`, `0.09375rem` at `size="sm"`),
|
|
2835
|
+
`--lyra-typing-cursor-height` (default `1em`, unaffected by `size`) — all component-specific; plus
|
|
2836
|
+
the shared `--lyra-transition-base` token, used as each variant's full `animation:` shorthand
|
|
2837
|
+
(duration + timing-function together) since it drives a continuous ambient loop rather than a
|
|
2838
|
+
discrete state flip (which would instead use `--lyra-transition-fast`).
|
|
2839
|
+
|
|
2840
|
+
**Optional peer deps:** none.
|
|
2841
|
+
|
|
2842
|
+
```html
|
|
2843
|
+
<lyra-typing-indicator label="Assistant is responding…"></lyra-typing-indicator>
|
|
2844
|
+
<lyra-typing-indicator variant="pulse" size="sm"></lyra-typing-indicator>
|
|
2845
|
+
<lyra-typing-indicator variant="cursor"></lyra-typing-indicator>
|
|
2846
|
+
```
|
|
2847
|
+
|
|
2848
|
+
Accessibility: since this indicator typically mounts and unmounts around a real generation lifecycle
|
|
2849
|
+
(appears when a response starts, disappears once one arrives) rather than emitting a stream of
|
|
2850
|
+
updates of its own, it does **not** route through `<lyra-live-region>`/the internal `Announcer` —
|
|
2851
|
+
that machinery exists to coalesce many rapidly-changing announcements into one, and there is only
|
|
2852
|
+
ever a single announcement here: the mount itself. `role="status"` plus an accessible name derived
|
|
2853
|
+
from `label` is set both as `aria-label` on the host *and* as a visually-hidden text node
|
|
2854
|
+
(`.sr-only`) in the shadow tree, so the name survives even if only one of the two is picked up by a
|
|
2855
|
+
given assistive-tech/browser pairing. The animated shape itself is `aria-hidden="true"` — it's
|
|
2856
|
+
decorative; `label` is the entire accessible content, nothing narrates individual animation frames.
|
|
2857
|
+
|
|
2858
|
+
**Known gotchas:**
|
|
2859
|
+
- under `prefers-reduced-motion: reduce`, every variant collapses to its plain, fully-visible resting
|
|
2860
|
+
state (`opacity: 1`, no transform, `animation: none !important`) rather than freezing on whatever
|
|
2861
|
+
frame the animation happened to be on — notably relevant for `cursor`, which would otherwise risk
|
|
2862
|
+
freezing on its invisible ("off") blink half.
|
|
2863
|
+
- the two dot-bounce stagger delays (`60ms`, `120ms`) are fixed literals, not `calc()`-derived
|
|
2864
|
+
fractions of `--lyra-transition-base` — that token is a *compound* `duration timing-function` value
|
|
2865
|
+
(like every `transition:`/`animation:` shorthand token in this library), so it can't be decomposed
|
|
2866
|
+
arithmetically; the two delays just approximate a third and two-thirds of the token's own default
|
|
2867
|
+
duration.
|
|
2868
|
+
- `size="sm"` shrinks the dot size, gap, and cursor width, but **not** `--lyra-typing-cursor-height`
|
|
2869
|
+
(still `1em` at any size) — the cursor bar's height is meant to track surrounding text size via
|
|
2870
|
+
`1em`, not the component's own `size` property.
|
|
2871
|
+
|
|
2872
|
+
---
|
|
2873
|
+
|
|
2874
|
+
## `lyra-tool-call-chip`
|
|
2875
|
+
|
|
2876
|
+
A compact inline pill representing one tool/function call an agent made mid-conversation, e.g.
|
|
2877
|
+
`web_search: Searching web…` with a `running` spinner. First-party invention (no Web Awesome
|
|
2878
|
+
equivalent). It owns no detail surface of its own — activating it (click or Enter/Space while
|
|
2879
|
+
focused) only fires `lyra-tool-chip-select`; a consumer wires that to opening a
|
|
2880
|
+
`<lyra-tool-result-dialog>` (or anything else) at the call site, keeping the chip reusable wherever
|
|
2881
|
+
a compact call summary is useful, with or without a detail surface behind it.
|
|
2882
|
+
|
|
2883
|
+
**Properties:**
|
|
2884
|
+
- `name: string = ''` — the tool/function name, e.g. `web_search`
|
|
2885
|
+
- `category: string = ''` — optional grouping label, e.g. `research`
|
|
2886
|
+
- `status: 'pending'|'running'|'success'|'error'|'denied' = 'pending'` (reflected) — drives the
|
|
2887
|
+
glyph, accent color, and `status-text`; same status vocabulary as `<lyra-tool-result-dialog>` so a
|
|
2888
|
+
call's chip and its detail dialog always agree
|
|
2889
|
+
- `summary: string = ''` — short human-readable status text, e.g. `Searching web…`
|
|
2890
|
+
- `durationMs?: number` (attribute `duration-ms`) — how long the call took, in milliseconds; the
|
|
2891
|
+
`duration` part is omitted entirely when unset
|
|
2892
|
+
- `icon: string = ''` — literal icon hint (e.g. an emoji) rendered when the `icon` slot is empty;
|
|
2893
|
+
ignored once anything is assigned to `slot="icon"`
|
|
2894
|
+
- `callId: string = ''` (attribute `call-id`) — unique identifier for this invocation, echoed back
|
|
2895
|
+
in `lyra-tool-chip-select`'s detail so a listener can correlate the click with the call it fired for
|
|
2896
|
+
|
|
2897
|
+
**Events:** `lyra-tool-chip-select` (`detail: { name: string; callId: string }`) — fired on click or
|
|
2898
|
+
Enter/Space activation of the pill.
|
|
2899
|
+
|
|
2900
|
+
**Slots:** default (rich tooltip/detail content — e.g. the tool's raw arguments or a short preview —
|
|
2901
|
+
shown in a floating tooltip on hover/focus; nothing renders at all, no hover affordance, when this
|
|
2902
|
+
slot is empty), `icon` (overrides the built-in per-status glyph entirely via native slot-fallback
|
|
2903
|
+
content — assigned content wins; otherwise the `icon` prop is rendered as a literal hint; otherwise
|
|
2904
|
+
the built-in glyph for the current `status` is used)
|
|
2905
|
+
|
|
2906
|
+
**CSS parts:** `base` (the clickable `<button>`), `icon`, `label` (wrapper around `category`, `name`,
|
|
2907
|
+
`summary`), `category`, `name`, `summary`, `meta` (wrapper around `status-text` and `duration`),
|
|
2908
|
+
`status-text`, `duration`, `tooltip` (the floating detail popup, only meaningful while open)
|
|
2909
|
+
|
|
2910
|
+
**Themeable custom properties:** no component-specific custom properties (per-status accent/
|
|
2911
|
+
background/border are internal `--lyra-tool-call-chip-*` variables swapped by `:host([status])`
|
|
2912
|
+
rules, not part of the public theming API); shared tokens referenced: `--lyra-color-text-quiet`,
|
|
2913
|
+
`--lyra-color-surface`, `--lyra-color-border`, `--lyra-color-brand`/`-brand-quiet`,
|
|
2914
|
+
`--lyra-color-success`/`-success-quiet`, `--lyra-color-danger`/`-danger-quiet`,
|
|
2915
|
+
`--lyra-color-warning`/`-warning-quiet`, `--lyra-color-text`, `--lyra-space-xs/-s/-m`, `--lyra-radius`,
|
|
2916
|
+
`--lyra-shadow`, `--lyra-focus-ring-*`, `--lyra-transition-fast`.
|
|
2917
|
+
|
|
2918
|
+
**Optional peer deps:** none.
|
|
2919
|
+
|
|
2920
|
+
```html
|
|
2921
|
+
<lyra-tool-call-chip
|
|
2922
|
+
name="web_search"
|
|
2923
|
+
category="research"
|
|
2924
|
+
status="running"
|
|
2925
|
+
summary="Searching web…"
|
|
2926
|
+
duration-ms="820"
|
|
2927
|
+
call-id="call_123"
|
|
2928
|
+
@lyra-tool-chip-select=${(e) => openDetail(e.detail.callId)}
|
|
2929
|
+
>
|
|
2930
|
+
<pre slot="icon" style="display:none"></pre>
|
|
2931
|
+
<code>{"query": "lyra ui components"}</code>
|
|
2932
|
+
</lyra-tool-call-chip>
|
|
2933
|
+
```
|
|
2934
|
+
|
|
2935
|
+
The default slot's tooltip is positioned with the same `internal/positioner.js` `place()` helper
|
|
2936
|
+
`<lyra-combobox>` uses for its listbox (`placement: 'top-start'`), and appears/disappears instantly
|
|
2937
|
+
on hover/focus/blur/mouseleave with no fade transition and no "pointer moved into the tooltip"
|
|
2938
|
+
tracking — it's documented as read-only preview content, not an interactive surface meant to retain
|
|
2939
|
+
focus of its own. `denied` gets its own warning-toned glyph and color (a policy rejection, not a
|
|
2940
|
+
runtime failure) distinct from `error`'s danger tone, matching `<lyra-tool-result-dialog>`'s
|
|
2941
|
+
identical status vocabulary so a call reads the same way in both places. Duration formatting is
|
|
2942
|
+
sub-1000ms `"820ms"`, else trimmed to at most one decimal of seconds (`"1.5s"`, `"2s"`).
|
|
2943
|
+
|
|
2944
|
+
**Known gotchas:**
|
|
2945
|
+
- the default slot is checked for emptiness by scanning `Array.from(this.children)` for elements
|
|
2946
|
+
once on first update, then kept in sync via `slotchange` — only *element* children count (a bare
|
|
2947
|
+
text node assigned to the default slot won't trigger the tooltip)
|
|
2948
|
+
- `aria-label` on the host element (if you set one) wins over the component's own generated
|
|
2949
|
+
accessible label (`"name — summary — Status — duration"`); otherwise that generated string is
|
|
2950
|
+
what's announced
|
|
2951
|
+
- Escape only dismisses the tooltip when it's open; it does not fire any event or otherwise affect
|
|
2952
|
+
`status`/`open` state, since the chip has no "open" state of its own beyond the tooltip
|
|
2953
|
+
|
|
2954
|
+
---
|
|
2955
|
+
|
|
2956
|
+
## `lyra-tool-result-view`
|
|
2957
|
+
|
|
2958
|
+
Renders a tool call's result via whichever custom renderer a host app has registered for it,
|
|
2959
|
+
falling back to `<lyra-json-viewer>` whenever no renderer matches, a renderer's optional `load()`
|
|
2960
|
+
rejects, or its `render()` throws. First-party invention (no Web Awesome equivalent). This component
|
|
2961
|
+
owns none of the actual visual weight of a populated tool result — that's entirely whatever the
|
|
2962
|
+
registered renderer returns; `<lyra-tool-result-view>` is just the dispatch + fallback + loading-state
|
|
2963
|
+
shell around it.
|
|
2964
|
+
|
|
2965
|
+
**Properties:**
|
|
2966
|
+
- `registry?: ToolRendererRegistry` (property only, no attribute) — a custom `Map<string,
|
|
2967
|
+
ToolRendererDefinition>` to dispatch against instead of the module-level default registry (see
|
|
2968
|
+
`registry.ts` below)
|
|
2969
|
+
- `toolName: string = ''` (attribute `tool-name`) — the tool's name; the primary dispatch key
|
|
2970
|
+
- `result: unknown` (property only, no attribute) — the tool call's result payload, handed to the
|
|
2971
|
+
matched renderer's `render()` (and to `matches()` for shape-based dispatch, and to the
|
|
2972
|
+
`<lyra-json-viewer>` fallback)
|
|
2973
|
+
- `args: unknown` (property only, no attribute) — the tool call's original arguments, if available,
|
|
2974
|
+
handed to the matched renderer's `render()` alongside `result`
|
|
2975
|
+
- `fallback: string = 'json'` (reflected) — forward-compatible fallback-kind selector; only `"json"`
|
|
2976
|
+
(an unconditional `<lyra-json-viewer>`) is implemented today, but the property/attribute is still
|
|
2977
|
+
accepted so markup that already sets `fallback="…"` for a future value doesn't need to change again
|
|
2978
|
+
once more fallback kinds land
|
|
2979
|
+
|
|
2980
|
+
**Events:** `lyra-render-error` (`detail: { toolName: string; error: unknown }`) — fired immediately
|
|
2981
|
+
before falling back to `<lyra-json-viewer>`, whether because no renderer matched, a renderer's
|
|
2982
|
+
`load()` rejected, or its `render()` threw.
|
|
2983
|
+
|
|
2984
|
+
**Slots:** none.
|
|
2985
|
+
|
|
2986
|
+
**CSS parts:** `base` — the root wrapper around the resolved renderer's output (or the loading/
|
|
2987
|
+
fallback view).
|
|
2988
|
+
|
|
2989
|
+
**Themeable custom properties:** none — the component's own styling is deliberately minimal (just
|
|
2990
|
+
enough to not collapse to a zero-height inline box); all visible styling comes from whatever
|
|
2991
|
+
renderer/`<lyra-skeleton>`/`<lyra-json-viewer>` child is currently mounted.
|
|
2992
|
+
|
|
2993
|
+
**Optional peer deps:** none required by the component itself — individual registered renderers may
|
|
2994
|
+
of course pull in whatever they need (a charting library, a markdown renderer), which is exactly what
|
|
2995
|
+
the lazy `load()` path in the registry exists for.
|
|
2996
|
+
|
|
2997
|
+
```html
|
|
2998
|
+
<lyra-tool-result-view
|
|
2999
|
+
tool-name="get_weather"
|
|
3000
|
+
.result=${{ tempC: 21, condition: 'cloudy' }}
|
|
3001
|
+
.args=${{ city: 'Brussels' }}
|
|
3002
|
+
@lyra-render-error=${(e) => console.warn('renderer failed', e.detail)}
|
|
3003
|
+
></lyra-tool-result-view>
|
|
3004
|
+
```
|
|
3005
|
+
|
|
3006
|
+
### `registerToolRenderer()` and the tool-renderer registry (`registry.ts`)
|
|
3007
|
+
|
|
3008
|
+
A type-keyed dispatch registry — a tiny plugin system so a host app can teach
|
|
3009
|
+
`<lyra-tool-result-view>` how to draw the result of e.g. a `get_weather` or `run_query` tool call
|
|
3010
|
+
without this library knowing anything about either. Every registered instance dispatches against
|
|
3011
|
+
this same module-level registry unless a given `<lyra-tool-result-view>`'s `registry` property is
|
|
3012
|
+
set to a different `Map` instance.
|
|
3013
|
+
|
|
3014
|
+
**`ToolRendererDefinition`** — the shape of one registered renderer:
|
|
3015
|
+
- `render?: (result: unknown, args: unknown) => unknown` — renders the result (and the args that
|
|
3016
|
+
produced it) as UI. Typed as `unknown` rather than Lit's `TemplateResult` so any lit-html-renderable
|
|
3017
|
+
value works (a plain string, a DOM node, an array of templates) — consumers already own their own
|
|
3018
|
+
Lit import and don't need this module to add one
|
|
3019
|
+
- `matches?: (payload: unknown) => boolean` — facade/shape-based dispatch predicate, consulted only
|
|
3020
|
+
when no exact `toolName` key matches (see dispatch order below); only ever consulted *before*
|
|
3021
|
+
`load` resolves when supplied inline at registration time — a definition that needs shape-based
|
|
3022
|
+
dispatch and also wants to lazy-load its `render` should register a lightweight synchronous
|
|
3023
|
+
`matches` up front alongside `load`
|
|
3024
|
+
- `load?: () => Promise<ToolRendererDefinition | { default: ToolRendererDefinition }>` — lazy loader
|
|
3025
|
+
for a code-split renderer, so a host app can defer the cost of a rarely-used or heavy renderer
|
|
3026
|
+
(e.g. one pulling in a charting library) instead of paying for it on every page that merely
|
|
3027
|
+
registers it. Resolves to either a definition directly, or a `{ default }`-shaped module namespace
|
|
3028
|
+
object, so `load: () => import('./my-renderer.js')` works unmodified when that module's default
|
|
3029
|
+
export is itself a `ToolRendererDefinition`
|
|
3030
|
+
|
|
3031
|
+
**Exports:**
|
|
3032
|
+
- `registerToolRenderer(name: string, def: ToolRendererDefinition): void` — registers (or
|
|
3033
|
+
overwrites) the renderer for `name` in the module-level default registry
|
|
3034
|
+
- `getDefaultToolRendererRegistry(): ToolRendererRegistry` — returns the default `Map` that
|
|
3035
|
+
`registerToolRenderer()` writes to and every `<lyra-tool-result-view>` reads from unless its own
|
|
3036
|
+
`registry` prop is set
|
|
3037
|
+
- `findToolRenderer(toolName: string, payload: unknown, registry?: ToolRendererRegistry):
|
|
3038
|
+
ToolRendererDefinition | undefined` — the dispatch function `<lyra-tool-result-view>` calls
|
|
3039
|
+
internally on every resolve; exposed for direct use/testing too
|
|
3040
|
+
- `loadToolRenderer(def: ToolRendererDefinition): Promise<ToolRendererDefinition>` — resolves `def`
|
|
3041
|
+
to a definition guaranteed to carry a real `render`, awaiting/unwrapping `def.load()` when present
|
|
3042
|
+
(or returning `def` unchanged otherwise)
|
|
3043
|
+
- `clearToolRenderers(): void` — test-only utility that empties the default registry and its
|
|
3044
|
+
`load()` cache, so one test's `registerToolRenderer()` calls can't leak into the next
|
|
3045
|
+
|
|
3046
|
+
**Dispatch order** (`findToolRenderer`), exactly as `<lyra-tool-result-view>`'s own `resolve()` uses
|
|
3047
|
+
it:
|
|
3048
|
+
1. An exact `toolName` key match in the registry.
|
|
3049
|
+
2. Failing that, the first entry — in registration order, since a `Map` already iterates that way —
|
|
3050
|
+
whose `matches(payload)` returns `true`. Useful when several tool names share one result shape
|
|
3051
|
+
(e.g. every `*_search` tool returning `{ results: [...] }`) or when the caller doesn't reliably
|
|
3052
|
+
know the tool name at all.
|
|
3053
|
+
3. `undefined` if neither matches — `<lyra-tool-result-view>` falls back to `<lyra-json-viewer>` and
|
|
3054
|
+
fires `lyra-render-error`.
|
|
3055
|
+
|
|
3056
|
+
Once a definition is found, if it carries `load`, `<lyra-tool-result-view>` shows a
|
|
3057
|
+
`<lyra-skeleton variant="rect" height="4rem">` while `loadToolRenderer()` resolves it. The resolved
|
|
3058
|
+
`load()` promise is cached keyed by *definition object identity* (a `WeakMap`, not by tool-name
|
|
3059
|
+
string) — two different registries that happen to reuse the same tool-name string get independently
|
|
3060
|
+
cached loads, and any given lazy definition's `load()` runs at most once no matter how many times
|
|
3061
|
+
it's dispatched to, across every `<lyra-tool-result-view>` instance that resolves to it. A **rejected**
|
|
3062
|
+
`load()` is *not* cached — the definition stays registered, so a later resolution attempt (e.g. after
|
|
3063
|
+
a transient network failure) gets a fresh `load()` call rather than being stuck replaying one failed
|
|
3064
|
+
promise forever.
|
|
3065
|
+
|
|
3066
|
+
```ts
|
|
3067
|
+
import { registerToolRenderer } from '@aceshooting/lyra-ui/tool-result-view/registry.js';
|
|
3068
|
+
|
|
3069
|
+
registerToolRenderer('get_weather', {
|
|
3070
|
+
render: (result, args) => html`<weather-card .data=${result} .city=${args?.city}></weather-card>`,
|
|
3071
|
+
});
|
|
3072
|
+
|
|
3073
|
+
// Lazily loaded, shape-based fallback for every *_search tool:
|
|
3074
|
+
registerToolRenderer('web_search', {
|
|
3075
|
+
matches: (payload) => typeof payload === 'object' && payload !== null && 'results' in payload,
|
|
3076
|
+
load: () => import('./search-result-renderer.js'), // default export is a ToolRendererDefinition
|
|
3077
|
+
});
|
|
3078
|
+
```
|
|
3079
|
+
|
|
3080
|
+
**Known gotchas:**
|
|
3081
|
+
- `<lyra-tool-result-view>` re-resolves (re-runs the full dispatch → load → render pipeline)
|
|
3082
|
+
whenever `toolName`, `result`, `args`, or `registry` changes, or on first update — a stale
|
|
3083
|
+
in-flight `load()` superseded by a newer change is detected via an internal generation counter and
|
|
3084
|
+
its result is discarded rather than clobbering a more recent render
|
|
3085
|
+
- registering under the same `name` twice silently overwrites the earlier definition — there is no
|
|
3086
|
+
warning or error
|
|
3087
|
+
- `matches` is a linear scan over every registered definition's `matches` in registration order; it
|
|
3088
|
+
only runs when the exact-name lookup misses, so tool names with a direct registration never pay
|
|
3089
|
+
that scan cost
|
|
3090
|
+
- the `fallback` property currently only accepts the effective value `"json"` — setting it to
|
|
3091
|
+
anything else does not change fallback behavior, it's forward-compatible plumbing only
|
|
3092
|
+
|
|
3093
|
+
---
|
|
3094
|
+
|
|
3095
|
+
## `lyra-tool-result-dialog`
|
|
3096
|
+
|
|
3097
|
+
A full tool-call detail overlay: a status/duration header plus a `body` slot where a consumer
|
|
3098
|
+
typically places a `<lyra-tabs>` with Input/Preview/JSON/Raw panels. First-party invention (no Web
|
|
3099
|
+
Awesome equivalent). This component knows nothing about what's inside that slot — it only supplies
|
|
3100
|
+
the modal chrome around it. It is its own standalone overlay implementation (`role="dialog"`,
|
|
3101
|
+
focus-trapped, Escape/backdrop-dismissible, scroll-locking) rather than nesting a `<lyra-dialog>` in
|
|
3102
|
+
its shadow template, so that slot-forwarding doesn't put a forwarding `<slot>` where a slotted
|
|
3103
|
+
`<lyra-tabs>`'s own light-DOM child scan expects real projected content.
|
|
3104
|
+
|
|
3105
|
+
**Properties:**
|
|
3106
|
+
- `open: boolean = false` (reflected) — whether the dialog is open; set this (or call `close()`) —
|
|
3107
|
+
there is no separate `show()`/`hide()` pair
|
|
3108
|
+
- `toolName: string = ''` (attribute `tool-name`) — the tool's name, rendered prominently in the
|
|
3109
|
+
header
|
|
3110
|
+
- `status: 'pending'|'running'|'success'|'error'|'denied' = 'pending'` (reflected) — drives the
|
|
3111
|
+
header's status badge; same status vocabulary as `<lyra-tool-call-chip>`
|
|
3112
|
+
- `durationMs?: number` (attribute `duration-ms`) — how long the call took, in milliseconds; omitted
|
|
3113
|
+
from the header entirely when unset
|
|
3114
|
+
- `maximized: boolean = false` (reflected) — near-fullscreen presentation of the same open dialog
|
|
3115
|
+
|
|
3116
|
+
**Methods:** `close(reason: ToolResultDialogCloseReason = 'api'): void` — closes the dialog (no-op if
|
|
3117
|
+
already closed), emits `lyra-dialog-close` with `reason`, and returns focus to whatever had it before
|
|
3118
|
+
the dialog opened. Built-in triggers call this with `'escape'`/`'backdrop'`/`'close-button'`; a
|
|
3119
|
+
consumer's own close affordance (e.g. a footer action button) should call it directly with its own
|
|
3120
|
+
reason string so every dismissal path funnels through the same event.
|
|
3121
|
+
|
|
3122
|
+
**Events:** `lyra-dialog-close` (`detail: ToolResultDialogCloseReason` — `'escape'|'backdrop'|
|
|
3123
|
+
'close-button'|'api'|string`) fired exactly once per dismissal; `lyra-maximize-change` (`detail:
|
|
3124
|
+
boolean`, the new `maximized` state) fired when the header's maximize/restore toggle is clicked.
|
|
3125
|
+
|
|
3126
|
+
**Slots:** `body` (the dialog's main content — typically a `<lyra-tabs>` with Input/Preview/JSON/Raw
|
|
3127
|
+
panels, entirely consumer-assembled), `footer` (optional action buttons, rendered in a bottom row —
|
|
3128
|
+
the footer row itself is hidden via `[hidden]` when nothing is slotted)
|
|
3129
|
+
|
|
3130
|
+
**CSS parts:** `backdrop`, `panel` (`role="dialog"` while open), `header`, `title` (wrapper around
|
|
3131
|
+
tool name/status/duration), `tool-name`, `status`, `duration`, `header-actions`, `maximize-button`,
|
|
3132
|
+
`close-button`, `body`, `footer`
|
|
3133
|
+
|
|
3134
|
+
**Themeable custom properties:** `--lyra-tool-result-dialog-overlay-color` (default `rgb(0 0 0 /
|
|
3135
|
+
0.5)` — the backdrop scrim color; component-specific since no shared overlay token exists),
|
|
3136
|
+
`--lyra-tool-result-dialog-maximized-inset` (default `var(--lyra-space-l)` — inset applied to the
|
|
3137
|
+
panel while `[maximized]`, overridable e.g. to leave a persistent app rail visible), plus shared
|
|
3138
|
+
tokens `--lyra-color-surface/-border/-text-quiet/-brand/-brand-quiet/-success/-success-quiet/
|
|
3139
|
+
-danger/-danger-quiet/-warning/-warning-quiet`, `--lyra-space-*`, `--lyra-radius`, `--lyra-shadow`,
|
|
3140
|
+
`--lyra-icon-button-size`, `--lyra-focus-ring-*`, `--lyra-transition-base`.
|
|
3141
|
+
|
|
3142
|
+
**Optional peer deps:** none.
|
|
3143
|
+
|
|
3144
|
+
```html
|
|
3145
|
+
<lyra-tool-result-dialog
|
|
3146
|
+
tool-name="run_query"
|
|
3147
|
+
status="success"
|
|
3148
|
+
duration-ms="1240"
|
|
3149
|
+
?open=${dialogOpen}
|
|
3150
|
+
@lyra-dialog-close=${(e) => (dialogOpen = false)}
|
|
3151
|
+
@lyra-maximize-change=${(e) => console.log('maximized:', e.detail)}
|
|
3152
|
+
>
|
|
3153
|
+
<lyra-tabs slot="body">
|
|
3154
|
+
<lyra-tab-panel label="Preview">…</lyra-tab-panel>
|
|
3155
|
+
<lyra-tab-panel label="JSON"><lyra-json-viewer .data=${result}></lyra-json-viewer></lyra-tab-panel>
|
|
3156
|
+
</lyra-tabs>
|
|
3157
|
+
<button slot="footer">Rerun</button>
|
|
3158
|
+
</lyra-tool-result-dialog>
|
|
3159
|
+
```
|
|
3160
|
+
|
|
3161
|
+
While open, `[part="panel"]` takes `role="dialog"` + `aria-modal="true"` with `aria-labelledby`
|
|
3162
|
+
pointing at the tool-name element, document scroll is locked, and Tab/Shift+Tab are bounded to the
|
|
3163
|
+
panel's own focusable content in header-buttons → `body` slot → `footer` slot order (resolved
|
|
3164
|
+
shadow-piercingly, so a slotted custom element's real focusable target inside its own shadow root is
|
|
3165
|
+
found too). On open, focus moves to the first focusable element (falling back to the panel itself);
|
|
3166
|
+
on close, focus returns to whatever element triggered the open (captured at open time via the active
|
|
3167
|
+
element, since the trigger typically lives entirely outside this component). `maximized` toggles
|
|
3168
|
+
between the constrained modal size and a near-fullscreen size within the same open dialog and
|
|
3169
|
+
open/close lifecycle — unlike `<lyra-widget>`'s fullscreen mode there's no separate non-modal resting
|
|
3170
|
+
state, so no additional scroll-lock/focus-trap bookkeeping is needed for that transition alone.
|
|
3171
|
+
|
|
3172
|
+
**Known gotchas:**
|
|
3173
|
+
- a reconnect that preserves the same element instance (e.g. a drag-and-drop reparent) restores the
|
|
3174
|
+
scroll lock and the document `keydown` listener if `open` was still `true` across the move —
|
|
3175
|
+
`disconnectedCallback`/`connectedCallback` fire back-to-back with no intervening update, so
|
|
3176
|
+
`willUpdate` never reruns to notice `open` didn't change
|
|
3177
|
+
- this component deliberately does **not** compose `<lyra-dialog>` internally, even though the two
|
|
3178
|
+
share nearly identical overlay mechanics (focus trap, scroll lock, Escape/backdrop dismiss) — the
|
|
3179
|
+
duplication is intentional so a slotted `<lyra-tabs>` (or any other light-DOM-scanning child) sees
|
|
3180
|
+
real projected content rather than a forwarding `<slot>` from a nested dialog
|
|
3181
|
+
- `close()` is a no-op when `open` is already `false` — calling it twice in a row only fires
|
|
3182
|
+
`lyra-dialog-close` once
|
|
3183
|
+
- the `maximize`/`close` buttons are always the first elements in the Tab order while open,
|
|
3184
|
+
regardless of visual position, followed by `body` then `footer` content
|
|
3185
|
+
|
|
3186
|
+
---
|
|
3187
|
+
|
|
3188
|
+
## `lyra-chat-composer`
|
|
3189
|
+
|
|
3190
|
+
The message input for a chat/agent conversation surface: an auto-resizing `<textarea>` plus a
|
|
3191
|
+
built-in send/stop button. **Form-associated** via the shared `FormAssociated` mixin (same shape as
|
|
3192
|
+
`<lyra-date-input>`) — `name: string = ''`, `value: string = ''`, `disabled: boolean = false`
|
|
3193
|
+
(reflected), `required: boolean = false` (reflected) are all inherited, along with
|
|
3194
|
+
`checkValidity()`/`reportValidity()`, so it participates in native `<form>` submission/validation/
|
|
3195
|
+
reset like any other text control.
|
|
3196
|
+
|
|
3197
|
+
**Properties (own):**
|
|
3198
|
+
- `placeholder: string = ''`
|
|
3199
|
+
- `minRows: number = 1` (attribute `min-rows`) — floored to `1` at render time
|
|
3200
|
+
- `maxRows: number = 8` (attribute `max-rows`) — floored to at least `minRows`
|
|
3201
|
+
- `status: ChatComposerStatus = 'idle'` (reflected) — `'idle' | 'sending' | 'streaming'`; drives the
|
|
3202
|
+
built-in button's icon/label (send vs. stop) and whether Enter still submits
|
|
3203
|
+
- `submitOnEnter: boolean = true` (reflected, attribute `submit-on-enter`) — when `false`, Enter
|
|
3204
|
+
always inserts a newline instead of submitting
|
|
3205
|
+
|
|
3206
|
+
**Methods (own):** none beyond the inherited `checkValidity()`/`reportValidity()`.
|
|
3207
|
+
|
|
3208
|
+
**Events:**
|
|
3209
|
+
- `lyra-input` (`detail: { value }`) — fired on every user-driven edit of the textarea, not a
|
|
3210
|
+
programmatic `.value` assignment
|
|
3211
|
+
- `lyra-submit` (`detail: { value }`) — fired by Enter (per `submit-on-enter`) or the built-in
|
|
3212
|
+
button while `status="idle"`. `detail.value` is always the exact, untrimmed current value;
|
|
3213
|
+
trimming is left to the consumer. Submitting does **not** clear `value`
|
|
3214
|
+
- `lyra-stop` (no detail) — fired by the built-in button while `status` is `"sending"` or
|
|
3215
|
+
`"streaming"`
|
|
3216
|
+
|
|
3217
|
+
**Slots:** `leading` (content before the textarea, e.g. an attach-file trigger button), `chips` (an
|
|
3218
|
+
attachment tray rendered above the input row), `trailing` (overrides the built-in send/stop button
|
|
3219
|
+
entirely when it has assigned content)
|
|
3220
|
+
|
|
3221
|
+
**CSS parts:** `base`, `chips`, `row`, `leading`, `textarea`, `trailing`, `action-button`
|
|
3222
|
+
|
|
3223
|
+
**Themeable custom properties:** no component-specific custom properties; consumes shared tokens
|
|
3224
|
+
`--lyra-space-xs`, `--lyra-space-s`, `--lyra-color-border`, `--lyra-color-surface`,
|
|
3225
|
+
`--lyra-color-brand`, `--lyra-color-on-brand`, `--lyra-color-text-quiet`, `--lyra-radius`,
|
|
3226
|
+
`--lyra-icon-button-size`, `--lyra-transition-fast`, `--lyra-opacity-disabled`,
|
|
3227
|
+
`--lyra-focus-ring-width`, `--lyra-focus-ring-color`, `--lyra-focus-ring-offset`.
|
|
3228
|
+
|
|
3229
|
+
**Optional peer deps:** none.
|
|
3230
|
+
|
|
3231
|
+
```html
|
|
3232
|
+
<lyra-chat-composer
|
|
3233
|
+
id="composer"
|
|
3234
|
+
name="message"
|
|
3235
|
+
placeholder="Message the assistant…"
|
|
3236
|
+
min-rows="1"
|
|
3237
|
+
max-rows="8"
|
|
3238
|
+
></lyra-chat-composer>
|
|
3239
|
+
<script type="module">
|
|
3240
|
+
const composer = document.getElementById('composer');
|
|
3241
|
+
composer.addEventListener('lyra-submit', (e) => {
|
|
3242
|
+
sendMessage(e.detail.value);
|
|
3243
|
+
composer.value = ''; // the composer never clears itself
|
|
3244
|
+
composer.status = 'sending';
|
|
3245
|
+
});
|
|
3246
|
+
composer.addEventListener('lyra-stop', () => stopGeneration());
|
|
3247
|
+
</script>
|
|
3248
|
+
```
|
|
3249
|
+
|
|
3250
|
+
Auto-resize (`resizeTextarea()`) reads the textarea's own *computed* line-height/padding/border at
|
|
3251
|
+
call time rather than assuming a fixed px-per-row constant, so it stays correct under a consumer's
|
|
3252
|
+
own font-size/line-height overrides; it grows between `min-rows` and `max-rows`, then switches to
|
|
3253
|
+
internal scrolling (`overflow-y: auto`) past `max-rows`. Enter-to-send only fires while
|
|
3254
|
+
`submit-on-enter` is `true` (the default): plain Enter submits and prevents the default newline;
|
|
3255
|
+
Shift+Enter always inserts a newline regardless of `submit-on-enter`; an IME composition step
|
|
3256
|
+
(checked via `isComposing`, with `keyCode === 229` as a defense-in-depth fallback for browsers that
|
|
3257
|
+
report `isComposing` inconsistently) is never treated as a submit trigger; and while `status` isn't
|
|
3258
|
+
`"idle"`, Enter is left alone to insert a newline instead of resubmitting — the textarea itself is
|
|
3259
|
+
**not** auto-disabled during `sending`/`streaming`, so a user can keep composing their next message
|
|
3260
|
+
while a previous one is in flight.
|
|
3261
|
+
|
|
3262
|
+
**Known gotchas:**
|
|
3263
|
+
- `lyra-submit` never clears `value` — the consumer must clear it once a submission is actually
|
|
3264
|
+
accepted, so a failed send can leave the text in place for retry.
|
|
3265
|
+
- While `status !== 'idle'`, only the built-in button's behavior changes (it emits `lyra-stop`
|
|
3266
|
+
instead of `lyra-submit`); the textarea keeps accepting input and Enter keeps inserting newlines
|
|
3267
|
+
rather than being blocked.
|
|
3268
|
+
- Auto-resize requires a concrete, unitless `line-height` on the textarea (the component sets
|
|
3269
|
+
`line-height: 1.5` in its own styles) — the UA default of `normal` has no single resolved px
|
|
3270
|
+
figure to measure rows against, so overriding `line-height` to a keyword breaks row sizing.
|
|
3271
|
+
- The `trailing` slot fully replaces the built-in action button rather than rendering alongside it —
|
|
3272
|
+
once it has assigned content, the library's send/stop icon, its `aria-label`, and its
|
|
3273
|
+
`status`-driven busy styling all disappear, so a custom trailing control needs its own send/stop
|
|
3274
|
+
handling.
|
|
3275
|
+
- `[part="chips"]`/`[part="leading"]` are hidden via a JS-tracked `[hidden]` attribute rather than a
|
|
3276
|
+
CSS `:empty` selector, because each always contains a literal `<slot>` child regardless of
|
|
3277
|
+
assigned content.
|
|
3278
|
+
|
|
3279
|
+
---
|
|
3280
|
+
|
|
3281
|
+
## `lyra-attachment-chip`
|
|
3282
|
+
|
|
3283
|
+
A compact chip representing one file queued for (or already part of) a chat message — used in a
|
|
3284
|
+
composer's pre-send attachment tray or a sent message's attachments display. Two independent ways
|
|
3285
|
+
to populate it: set `file` to a real `File` (fresh from a picker/drop), from which `name`/`size`/
|
|
3286
|
+
`mime-type` and the image thumbnail are all auto-derived; or set the plain `name`/`size`/
|
|
3287
|
+
`mime-type`/`thumbnail-src` props instead, for reconstructing a chip from server-persisted
|
|
3288
|
+
attachment metadata after a page reload, when no real `File` object exists any more. `file` always
|
|
3289
|
+
wins when both are present.
|
|
3290
|
+
|
|
3291
|
+
**Properties:**
|
|
3292
|
+
- `file?: File` (attribute `false`, i.e. property-only) — when set, `name`/`size`/`mimeType`/the
|
|
3293
|
+
image thumbnail are all derived from it, taking precedence over the independent props below
|
|
3294
|
+
- `name: string = ''` — filename, used only while `file` is unset
|
|
3295
|
+
- `size: number = 0` — file size in bytes, used only while `file` is unset
|
|
3296
|
+
- `mimeType: string = ''` (attribute `mime-type`) — used only while `file` is unset
|
|
3297
|
+
- `thumbnailSrc: string = ''` (attribute `thumbnail-src`) — thumbnail image URL, used only while
|
|
3298
|
+
`file` is unset; rendered whenever present regardless of `mimeType` (no `file`-derived equivalent
|
|
3299
|
+
exists for a non-image file)
|
|
3300
|
+
- `status: AttachmentChipStatus = 'pending'` (reflected) — `'pending' | 'uploading' | 'error' |
|
|
3301
|
+
'done'`; drives the accent tint and which of `progress`/`spinner`/`retry-button` renders
|
|
3302
|
+
- `progress: number = 0` — upload completion, 0-100; only meaningful while `status="uploading"`, a
|
|
3303
|
+
value of `0` or `NaN` falls back to the indeterminate spinner
|
|
3304
|
+
- `removable: boolean = true` (reflected) — shows the remove (×) button
|
|
3305
|
+
|
|
3306
|
+
The component identifies *which* attachment a `lyra-remove`/`lyra-retry` event is about via the
|
|
3307
|
+
platform's own `id` attribute/property rather than a second, differently-named prop. Set `id="..."`
|
|
3308
|
+
when you have a stable server-side attachment id; when unset and `file` is set, a stable id is
|
|
3309
|
+
derived from `` `${file.name}:${file.size}:${file.lastModified}` ``; when neither is available, a
|
|
3310
|
+
generated internal id is used as a last resort.
|
|
3311
|
+
|
|
3312
|
+
**Events:** `lyra-remove` (`detail: { id }`, only rendered while `removable`), `lyra-retry`
|
|
3313
|
+
(`detail: { id }`, only rendered while `status="error"`)
|
|
3314
|
+
|
|
3315
|
+
**Slots:** none.
|
|
3316
|
+
|
|
3317
|
+
**CSS parts:** `base`, `thumbnail`, `meta`, `name`, `size`, `status-text`, `progress`,
|
|
3318
|
+
`progress-fill`, `spinner`, `retry-button`, `remove-button`
|
|
3319
|
+
|
|
3320
|
+
**Themeable custom properties:** `--lyra-attachment-chip-accent` (default
|
|
3321
|
+
`var(--lyra-color-text-quiet)`), `--lyra-attachment-chip-bg` (default `var(--lyra-color-surface)`),
|
|
3322
|
+
`--lyra-attachment-chip-border` (default `var(--lyra-color-border)`) — this trio is swapped per
|
|
3323
|
+
`status` (`uploading` → brand/brand-quiet/transparent, `error` → danger/danger-quiet/transparent,
|
|
3324
|
+
`done` → success/success-quiet/transparent); plus shared tokens `--lyra-space-xs`, `--lyra-space-s`,
|
|
3325
|
+
`--lyra-radius`, `--lyra-color-text`, `--lyra-color-danger`, `--lyra-icon-button-size`,
|
|
3326
|
+
`--lyra-transition-fast`, `--lyra-transition-base`, `--lyra-focus-ring-width`,
|
|
3327
|
+
`--lyra-focus-ring-color`, `--lyra-focus-ring-offset`.
|
|
3328
|
+
|
|
3329
|
+
**Optional peer deps:** none.
|
|
3330
|
+
|
|
3331
|
+
Also exported from the package root: `formatFileSize(bytes: number): string` — `512` → `"512 B"`
|
|
3332
|
+
(whole bytes never get a decimal), `2415919` → `"2.3 MB"` (every unit past bytes gets exactly one
|
|
3333
|
+
decimal place), and a negative or non-finite input (`NaN`, `Infinity`) returns `""` so an unknown
|
|
3334
|
+
size renders nothing instead of `"NaN B"`.
|
|
3335
|
+
|
|
3336
|
+
```html
|
|
3337
|
+
<lyra-attachment-chip name="report.pdf" size="245000" mime-type="application/pdf" status="done"></lyra-attachment-chip>
|
|
3338
|
+
<lyra-attachment-chip id="att-2" status="uploading" progress="42"></lyra-attachment-chip>
|
|
3339
|
+
<script type="module">
|
|
3340
|
+
import { formatFileSize } from '@aceshooting/lyra-ui';
|
|
3341
|
+
|
|
3342
|
+
const chip = document.createElement('lyra-attachment-chip');
|
|
3343
|
+
chip.file = pickedFile; // name/size/mime-type/thumbnail all derived from the File
|
|
3344
|
+
chip.addEventListener('lyra-remove', (e) => removeAttachment(e.detail.id));
|
|
3345
|
+
chip.addEventListener('lyra-retry', (e) => retryUpload(e.detail.id));
|
|
3346
|
+
console.log(formatFileSize(pickedFile.size));
|
|
3347
|
+
</script>
|
|
3348
|
+
```
|
|
3349
|
+
|
|
3350
|
+
The image thumbnail for a real `File` is a lazily-created `URL.createObjectURL()` blob URL —
|
|
3351
|
+
created only from `render()`, i.e. only once a thumbnail is actually about to paint, never eagerly
|
|
3352
|
+
on `file` assignment — and revoked automatically once `file` changes to a different `File` (or to
|
|
3353
|
+
`undefined`) and on disconnect, so reassigning `file` several times before the next paint never
|
|
3354
|
+
leaks URLs that were created but never shown.
|
|
3355
|
+
|
|
3356
|
+
**Known gotchas:**
|
|
3357
|
+
- `file` always wins over `name`/`size`/`mimeType` when both are set — assigning those props while
|
|
3358
|
+
`file` is also set has no visible effect on the rendered chip.
|
|
3359
|
+
- A `0`-byte size and an unset size are indistinguishable (there's no separate flag for "genuinely
|
|
3360
|
+
empty file"); the `size` part is hidden entirely rather than showing a literal `"0 B"`.
|
|
3361
|
+
- `progress` only renders as a numeric bar when `status="uploading"` **and** `progress` is finite
|
|
3362
|
+
and `> 0`; otherwise it's either nothing (non-`uploading` status) or the indeterminate spinner
|
|
3363
|
+
(`uploading` with no known progress).
|
|
3364
|
+
- `thumbnailSrc` is used whenever present regardless of `mimeType` — pass a URL that's already known
|
|
3365
|
+
to be an image; there's no non-image fallback check for it the way there is for `file`.
|
|
3366
|
+
|
|
3367
|
+
---
|
|
3368
|
+
|
|
3369
|
+
## `lyra-stream-status`
|
|
3370
|
+
|
|
3371
|
+
A compact status indicator for a single streaming connection (SSE, WebSocket, long-poll, …), with
|
|
3372
|
+
built-in heartbeat-aware stall detection. First-party invention (no Web Awesome equivalent). The
|
|
3373
|
+
host drives `phase` directly for `idle`/`connecting`/`streaming`, and calls the imperative
|
|
3374
|
+
`recordActivity()` method on every *semantic* frame received while streaming — a real content
|
|
3375
|
+
chunk, never a transport-level keep-alive ping. This component has no payload-inspection logic of
|
|
3376
|
+
its own: "ignore heartbeats" is entirely call-site discipline, which is exactly why a connection
|
|
3377
|
+
that's only sending keep-alives (no real content) for longer than `stall-threshold-ms` correctly
|
|
3378
|
+
reads as stalled.
|
|
3379
|
+
|
|
3380
|
+
**Properties:**
|
|
3381
|
+
- `phase: 'idle' | 'connecting' | 'streaming' | 'stalled' = 'idle'` (reflected) — current
|
|
3382
|
+
connection phase. Fully public and directly settable by the host at any time, including a manual
|
|
3383
|
+
override to `'stalled'`; the component never fights a host-driven reassignment.
|
|
3384
|
+
- `stallThresholdMs: number = 10000` (attribute `stall-threshold-ms`) — how long `phase` may stay
|
|
3385
|
+
`'streaming'` with no `recordActivity()` call before the component auto-transitions to
|
|
3386
|
+
`'stalled'`. A non-finite or `<= 0` value disables the stall timer entirely (arming becomes a
|
|
3387
|
+
no-op, so the phase will never auto-stall).
|
|
3388
|
+
|
|
3389
|
+
**Methods:**
|
|
3390
|
+
- `recordActivity(): void` — call on every semantic (non-heartbeat) frame received while
|
|
3391
|
+
streaming.
|
|
3392
|
+
- While `phase === 'streaming'`: (re)arms the stall timer, pushing the stall deadline
|
|
3393
|
+
`stallThresholdMs` further out.
|
|
3394
|
+
- While `phase === 'stalled'`: recovers — `phase` becomes `'streaming'` again (firing
|
|
3395
|
+
`lyra-recover` and arming the timer fresh, via the same transition handling a direct host
|
|
3396
|
+
assignment would also go through).
|
|
3397
|
+
- While `phase` is `'idle'` or `'connecting'`: a no-op. Safe to call defensively before formally
|
|
3398
|
+
flipping to `'streaming'`; it never throws or starts a timer early.
|
|
3399
|
+
|
|
3400
|
+
**Events:** `lyra-stall` (no detail payload) — fires whenever `phase` transitions into `'stalled'`
|
|
3401
|
+
from any other phase, whether timer-driven or via a direct host assignment. `lyra-recover` (no
|
|
3402
|
+
detail payload) — fires whenever `phase` transitions out of `'stalled'` to any other phase,
|
|
3403
|
+
whether via `recordActivity()` or a direct host assignment. Neither fires for a same-value
|
|
3404
|
+
reassignment, and neither fires for whatever phase the element happens to *mount* with — only a
|
|
3405
|
+
later change counts as a transition.
|
|
3406
|
+
|
|
3407
|
+
**Slots:** default (custom copy shown only while `phase="stalled"`, e.g. "Taking longer than
|
|
3408
|
+
usual…" — falls back to a built-in default message when nothing is slotted), `actions` (a
|
|
3409
|
+
stop/retry button row; always present in the template regardless of `phase` — its wrapper's
|
|
3410
|
+
visibility is driven purely by whether anything is slotted into it, not by `phase`)
|
|
3411
|
+
|
|
3412
|
+
**CSS parts:** `base`, `indicator`, `message`, `actions`
|
|
3413
|
+
|
|
3414
|
+
**Themeable custom properties:** shared tokens only — `--lyra-color-text-quiet` (idle dot color),
|
|
3415
|
+
`--lyra-color-brand` (connecting/streaming dot color), `--lyra-color-warning` (stalled dot color,
|
|
3416
|
+
message text color, stalled border), `--lyra-color-warning-quiet` (stalled background tint),
|
|
3417
|
+
`--lyra-space-s` / `--lyra-space-xs` (base gap, stalled padding, actions gap), `--lyra-radius`
|
|
3418
|
+
(base corner radius), `--lyra-transition-base` (background/border-color transitions and the
|
|
3419
|
+
streaming pulse animation's cycle length). The component also sets two internal, phase-driven
|
|
3420
|
+
custom properties (`--lyra-stream-status-dot-color`, `--lyra-stream-status-dot-opacity`) on
|
|
3421
|
+
`:host`, but its own `:host([phase="..."])` rules outrank a page-level override for every phase
|
|
3422
|
+
except the default `idle`, so these aren't a practical external theming hook.
|
|
3423
|
+
|
|
3424
|
+
**Optional peer deps:** none.
|
|
3425
|
+
|
|
3426
|
+
```html
|
|
3427
|
+
<lyra-stream-status phase="streaming" stall-threshold-ms="8000">
|
|
3428
|
+
<span slot="actions"><button>Stop</button></span>
|
|
3429
|
+
</lyra-stream-status>
|
|
3430
|
+
```
|
|
3431
|
+
```ts
|
|
3432
|
+
const status = document.querySelector('lyra-stream-status')!;
|
|
3433
|
+
status.addEventListener('lyra-stall', () => console.warn('stream stalled'));
|
|
3434
|
+
status.addEventListener('lyra-recover', () => console.info('stream recovered'));
|
|
3435
|
+
|
|
3436
|
+
// on every real content chunk from the transport (never on a keep-alive ping):
|
|
3437
|
+
status.recordActivity();
|
|
3438
|
+
```
|
|
3439
|
+
|
|
3440
|
+
Internally, the inactivity timer runs only while `phase === 'streaming'`. It's (re)armed whenever
|
|
3441
|
+
the phase transitions to `'streaming'` (directly, or via `recordActivity()` recovering from
|
|
3442
|
+
`'stalled'`) and on every subsequent `recordActivity()` call while already streaming; it's disarmed
|
|
3443
|
+
the instant `phase` becomes anything else, including a host-driven reassignment away from
|
|
3444
|
+
`'streaming'` — so a stale timer can never fire a stall transition after the host has already moved
|
|
3445
|
+
on. Phase transitions into/out of `'stalled'` are announced through an internal
|
|
3446
|
+
`<lyra-live-region>` rather than a hand-rolled `aria-live` region: entering `'stalled'` announces
|
|
3447
|
+
"Connection stalled." with `mode="assertive"` (a stall can need the user's attention before they
|
|
3448
|
+
give up and navigate away); recovering announces "Connection restored." with `mode="polite"` (good
|
|
3449
|
+
news doesn't need to interrupt). Calling `recordActivity()` itself never announces anything, no
|
|
3450
|
+
matter how often the host calls it — only the phase *transition* announces, exactly once. The
|
|
3451
|
+
decorative indicator dot is `aria-hidden` (a color/motion cue only) and only pulses while
|
|
3452
|
+
`phase="streaming"`; `'stalled'` is styled as a warning tone, not danger, since a stall is usually
|
|
3453
|
+
recoverable — a host that wants to escalate after N stalls can scope its own CSS off
|
|
3454
|
+
`[phase="stalled"]`, or stop rendering this component and show its own danger-styled error state
|
|
3455
|
+
instead. The pulse animation is suppressed under `prefers-reduced-motion: reduce`.
|
|
3456
|
+
|
|
3457
|
+
**Known gotchas:**
|
|
3458
|
+
- `recordActivity()` is a plain instance method, not a reactive property — there's nothing to bind
|
|
3459
|
+
to in a template; call it directly from streaming/application code on every real chunk received.
|
|
3460
|
+
- Never call `recordActivity()` for a heartbeat/keep-alive ping. This component has no
|
|
3461
|
+
payload-inspection logic of its own, so a connection that's only sending pings (no real content)
|
|
3462
|
+
for longer than `stall-threshold-ms` is *supposed* to read as stalled — that's the entire
|
|
3463
|
+
point of the API.
|
|
3464
|
+
- Setting `stallThresholdMs` to `0`, a negative number, or a non-finite value disables the stall
|
|
3465
|
+
timer outright; the component will stay `'streaming'` forever until the host manually changes
|
|
3466
|
+
`phase`.
|
|
3467
|
+
- `phase` remains directly settable at all times; assigning `'stalled'` yourself fires `lyra-stall`
|
|
3468
|
+
and the assertive announcement exactly as if the timer had fired.
|
|
3469
|
+
- The `actions` slot's wrapper is always present in the DOM and toggled with the `hidden` attribute
|
|
3470
|
+
based on slotted content, not gated by `phase` — content placed there is visible regardless of
|
|
3471
|
+
the current phase.
|
|
3472
|
+
|
|
3473
|
+
---
|
|
3474
|
+
</markdown>
|
|
3475
|
+
|
|
3476
|
+
---
|
|
3477
|
+
|
|
3478
|
+
## `lyra-virtual-list`
|
|
3479
|
+
|
|
3480
|
+
A generic windowed/virtualized list host. Renders only the items within the current viewport (plus
|
|
3481
|
+
`overscan` padding rows on each side) as real DOM, regardless of how large `items` is, so a
|
|
3482
|
+
multi-thousand-row chat-history sidebar (or a long message thread) stays cheap to scroll. Content is
|
|
3483
|
+
entirely caller-supplied: `renderItem(item, index)` returns whatever `lit-html` value should represent
|
|
3484
|
+
that row, and `keyFunction(item, index)` gives it a stable identity for DOM reconciliation. First-party
|
|
3485
|
+
invention (no Web Awesome equivalent).
|
|
3486
|
+
|
|
3487
|
+
**Properties:**
|
|
3488
|
+
- `items: unknown[] = []` (attribute: false) — the full, non-windowed item collection. JS-only; set via
|
|
3489
|
+
a property/lit-html binding (`.items=`), not an HTML attribute.
|
|
3490
|
+
- `renderItem: (item: unknown, index: number) => unknown = () => nothing` (attribute: false) — renders
|
|
3491
|
+
one row's content, typically returning a `lit-html` `TemplateResult`. JS-only.
|
|
3492
|
+
- `keyFunction?: (item: unknown, index: number) => string | number` (attribute: false) — derives a
|
|
3493
|
+
row's stable reconciliation key. JS-only. Falls back to the item's index in `items` when omitted,
|
|
3494
|
+
which is only a safe identity while `items` never reorders/inserts/removes — provide this whenever
|
|
3495
|
+
possible, or scroll position and per-row DOM state (e.g. an `<audio>` element's playback position)
|
|
3496
|
+
can attach to the wrong row across a mutation.
|
|
3497
|
+
- `groups?: VirtualListGroup[]` (attribute: false) — reserved for a future sticky-header sectioning
|
|
3498
|
+
feature; accepted but **not currently read or rendered** — every row still renders flat, in `items`
|
|
3499
|
+
order.
|
|
3500
|
+
- `rowHeight: string = 'auto'` (attribute `row-height`) — `'auto'` measures each row's real height via
|
|
3501
|
+
`ResizeObserver`; a numeric string (e.g. `"56"`) fixes every row to that many pixels. Anything else
|
|
3502
|
+
(non-numeric, zero, negative, non-finite) silently falls back to `'auto'` rather than throwing.
|
|
3503
|
+
- `overscan: number = 6` — extra rows rendered beyond the visible viewport on each side.
|
|
3504
|
+
- `activeId: string = ''` (attribute `active-id`) — when set and it matches a row's `keyFunction`
|
|
3505
|
+
result (compared as a string), that row is smoothly scrolled into view whenever this changes, and
|
|
3506
|
+
rendered with `aria-current="true"`.
|
|
3507
|
+
- `loading: boolean = false` (reflected) — sets `aria-busy` on the scroll container and a `cursor:
|
|
3508
|
+
progress` style; does not by itself gate `lyra-load-more` (see below).
|
|
3509
|
+
- `hasMore: boolean = false` (attribute `has-more`, reflected) — when true, scrolling near the bottom
|
|
3510
|
+
fires `lyra-load-more` (gated by `loading`).
|
|
3511
|
+
|
|
3512
|
+
**Exported types:** `VirtualListRange { start: number; end: number }` (the `lyra-visible-range-changed`
|
|
3513
|
+
detail shape); `VirtualListGroup { key: string | number; label?: string; startIndex: number }` — a
|
|
3514
|
+
placeholder shape for the not-yet-implemented grouping feature above. No exported helper functions ship
|
|
3515
|
+
in this pass (a paired `groupByRecency()` helper referenced in the source's own class doc doesn't exist
|
|
3516
|
+
yet).
|
|
3517
|
+
|
|
3518
|
+
**Events:** `lyra-load-more` (no detail — fired once per approach to the bottom of the list while
|
|
3519
|
+
`has-more` is true and `loading` is false; does not refire on every scroll tick while still near the
|
|
3520
|
+
bottom — scrolling back away from the bottom and returning, or `items` growing enough to move the
|
|
3521
|
+
window away from the end, re-arms it), `lyra-visible-range-changed` (`detail: VirtualListRange`, the
|
|
3522
|
+
current visible, non-overscanned item index range — fired only when it actually changes)
|
|
3523
|
+
|
|
3524
|
+
**Slots:** none — all content comes from `renderItem`.
|
|
3525
|
+
|
|
3526
|
+
**CSS parts:** `base` (the scrollable container, `role="list"`, `tabindex="0"`), `spacer` (the
|
|
3527
|
+
full-content-height inner element establishing true scroll extent), `row` (one rendered row's
|
|
3528
|
+
absolutely-positioned wrapper, `role="listitem"`)
|
|
3529
|
+
|
|
3530
|
+
**Themeable custom properties:** `--lyra-virtual-list-height` (default `24rem` — the host's bounded
|
|
3531
|
+
scroll extent; component-specific since a virtualized list is meaningless without a sized viewport),
|
|
3532
|
+
plus shared `--lyra-focus-ring-width/-color/-offset` (inward-offset ring on `[part="base"]`, negative
|
|
3533
|
+
so it isn't clipped by the container's own `overflow: auto`).
|
|
3534
|
+
|
|
3535
|
+
**Optional peer deps:** none.
|
|
3536
|
+
|
|
3537
|
+
```html
|
|
3538
|
+
<lyra-virtual-list
|
|
3539
|
+
.items=${sessions}
|
|
3540
|
+
.renderItem=${(item, index) => html`
|
|
3541
|
+
<lyra-conversation-item
|
|
3542
|
+
id=${item.id}
|
|
3543
|
+
title=${item.title}
|
|
3544
|
+
.timestamp=${item.updatedAt}
|
|
3545
|
+
?active=${item.id === currentId}
|
|
3546
|
+
></lyra-conversation-item>
|
|
3547
|
+
`}
|
|
3548
|
+
.keyFunction=${(item) => item.id}
|
|
3549
|
+
active-id=${currentId}
|
|
3550
|
+
?has-more=${hasMorePages}
|
|
3551
|
+
?loading=${isLoadingMore}
|
|
3552
|
+
@lyra-load-more=${() => loadNextPage()}
|
|
3553
|
+
@lyra-visible-range-changed=${(e) => console.log('visible', e.detail.start, e.detail.end)}
|
|
3554
|
+
></lyra-virtual-list>
|
|
3555
|
+
```
|
|
3556
|
+
|
|
3557
|
+
Every row — in both `row-height` modes — is positioned by a `transform: translateY(offset)` computed
|
|
3558
|
+
from a single cumulative offsets array rebuilt on every update, rather than by page flow; this is what
|
|
3559
|
+
lets a small DOM window exist while the scrollbar still reflects the true total content height. In
|
|
3560
|
+
`row-height="auto"` mode, unmeasured rows contribute a fixed estimate to that array until their real
|
|
3561
|
+
`ResizeObserver`-reported height lands, so only rows after a newly-measured one shift on each
|
|
3562
|
+
measurement rather than the whole list reflowing. The offsets rebuild is an `O(n)` loop appropriate for
|
|
3563
|
+
hundreds to a few thousand rows (a scrollable history sidebar); it is not the right approach for a
|
|
3564
|
+
hundred-thousand-row list without further work.
|
|
3565
|
+
|
|
3566
|
+
**Known gotchas:**
|
|
3567
|
+
- `items`, `renderItem`, `keyFunction`, and `groups` are all `attribute: false` — they must be set as
|
|
3568
|
+
JS properties (`.items=`, `.renderItem=`, …), never as HTML attribute strings.
|
|
3569
|
+
- The container is `role="list"` with rows `role="listitem"`, deliberately not `listbox`/`option` —
|
|
3570
|
+
this component only provides windowing, not the roving-tabindex/`aria-activedescendant`
|
|
3571
|
+
keyboard-interaction contract a real `listbox` requires. `active-id` only scrolls a row into view and
|
|
3572
|
+
marks it `aria-current`; it is not a selection widget. Compose your own selection behavior on top if
|
|
3573
|
+
needed.
|
|
3574
|
+
- `[part="base"]` carries `tabindex="0"` unconditionally, since `renderItem`'s caller-supplied content
|
|
3575
|
+
isn't guaranteed to contain a focusable element and an otherwise-unreachable-by-keyboard scroll
|
|
3576
|
+
region would result.
|
|
3577
|
+
- `aria-setsize`/`aria-posinset` are computed from a row's real index in the full `items` array, not its
|
|
3578
|
+
position among the currently-rendered DOM window, so assistive tech still announces e.g. "item 12 of
|
|
3579
|
+
340" correctly even though only a handful of rows exist in the DOM at a time.
|
|
3580
|
+
- `groups` is accepted today purely for forward API compatibility; setting it has no visible effect
|
|
3581
|
+
yet.
|
|
3582
|
+
|
|
3583
|
+
---
|
|
3584
|
+
|
|
3585
|
+
## `lyra-conversation-item`
|
|
3586
|
+
|
|
3587
|
+
A selectable row representing one chat session in a history sidebar list. Usable standalone or as the
|
|
3588
|
+
`renderItem()` payload of `<lyra-virtual-list>`; has no dependency on that (or any) other component.
|
|
3589
|
+
First-party invention (no Web Awesome equivalent).
|
|
3590
|
+
|
|
3591
|
+
**Properties:**
|
|
3592
|
+
- `title: string = ''` — the session's display title. Falls back to "Untitled conversation" when empty
|
|
3593
|
+
(display only — the property itself is never mutated by that fallback).
|
|
3594
|
+
- `excerpt: string = ''` — a short preview snippet of the last message. Omit for no excerpt line.
|
|
3595
|
+
- `timestamp?: Date | string` (attribute: false) — accepts a `Date` or anything `new Date()` can parse
|
|
3596
|
+
(e.g. an ISO 8601 string); invalid input is treated as unset (no `<time>` rendered).
|
|
3597
|
+
- `formatTimestamp?: (date: Date) => string` (attribute: false) — overrides the default absolute-time
|
|
3598
|
+
rendering (clock time for same-day timestamps, otherwise a calendar date). Not a fuzzy "2 hours ago"
|
|
3599
|
+
relative string — bucketed relative grouping is a list-level concern, not this row's job.
|
|
3600
|
+
- `active: boolean = false` (reflected) — whether this is the currently-selected/open session; drives
|
|
3601
|
+
the brand-quiet background treatment.
|
|
3602
|
+
- `editable: boolean = true` (reflected) — whether inline-rename is available at all. When `false`, the
|
|
3603
|
+
rename button never renders and the row can never enter its editing state.
|
|
3604
|
+
|
|
3605
|
+
**Events:** `lyra-select` (no detail payload — identify the row via the platform `id` attribute on the
|
|
3606
|
+
event's `target`/`currentTarget`, the same convention `<lyra-attachment-chip>` uses; fires on a click on
|
|
3607
|
+
`[part="option"]` outside the rename button/`actions` slot, or Enter/Space while it's focused, only
|
|
3608
|
+
while not currently renaming), `lyra-rename` (`detail: { title: string }` — an in-place rename was
|
|
3609
|
+
committed via Enter or blur-while-editing; does not mutate `title` itself, this is a controlled
|
|
3610
|
+
component — not fired when the trimmed draft is empty or unchanged from the original `title`, treated
|
|
3611
|
+
as an implicit cancel)
|
|
3612
|
+
|
|
3613
|
+
**Slots:** `actions` — overflow/icon-button controls rendered at the trailing edge of the row (e.g. a
|
|
3614
|
+
future pin/delete button); only visually shown once it actually has assigned elements.
|
|
3615
|
+
|
|
3616
|
+
**CSS parts:** `base`, `option`, `content`, `title`, `title-input`, `rename-button`, `excerpt`,
|
|
3617
|
+
`timestamp`, `actions`
|
|
3618
|
+
|
|
3619
|
+
**Themeable custom properties:** shared tokens only — `--lyra-space-xs/-s/-m`, `--lyra-radius`,
|
|
3620
|
+
`--lyra-transition-fast`, `--lyra-color-text/-text-quiet/-brand/-brand-quiet/-surface`,
|
|
3621
|
+
`--lyra-focus-ring-width/-color/-offset`, `--lyra-icon-button-size`.
|
|
3622
|
+
|
|
3623
|
+
**Optional peer deps:** none.
|
|
3624
|
+
|
|
3625
|
+
```html
|
|
3626
|
+
<lyra-conversation-item
|
|
3627
|
+
id="sess_123"
|
|
3628
|
+
title="Q3 roadmap planning"
|
|
3629
|
+
excerpt="Let's revisit the timeline for the launch…"
|
|
3630
|
+
.timestamp=${session.updatedAt}
|
|
3631
|
+
?active=${session.id === currentSessionId}
|
|
3632
|
+
@lyra-select=${(e) => openSession(e.currentTarget.id)}
|
|
3633
|
+
@lyra-rename=${(e) => renameSession(e.currentTarget.id, e.detail.title)}
|
|
3634
|
+
>
|
|
3635
|
+
<button slot="actions" aria-label="Delete conversation">✕</button>
|
|
3636
|
+
</lyra-conversation-item>
|
|
3637
|
+
```
|
|
3638
|
+
|
|
3639
|
+
`role="option"` (not `"button"`) lives on `[part="option"]`, since this is meant to sit inside a
|
|
3640
|
+
`role="listbox"` history list; used truly standalone it's still fully operable, just missing that ARIA
|
|
3641
|
+
parent context. Because `role="option"` forbids focusable descendants, the rename button and the
|
|
3642
|
+
`actions` slot are rendered as DOM *siblings* of `[part="option"]` (not nested inside it) — the same
|
|
3643
|
+
constraint the in-place rename `<input>` runs into one level deeper, which is why `[part="option"]`
|
|
3644
|
+
sheds its `role`/`tabindex`/`aria-selected`/`aria-label` entirely for the duration of an edit rather than
|
|
3645
|
+
just visually swapping content.
|
|
3646
|
+
|
|
3647
|
+
**Known gotchas:**
|
|
3648
|
+
- `lyra-select` carries no detail payload at all — read the session id off the event's own `target`/
|
|
3649
|
+
`currentTarget`, not a `detail` field.
|
|
3650
|
+
- Renaming is a controlled interaction: committing `lyra-rename` never updates `title` locally: the
|
|
3651
|
+
consumer must apply the new title once it's actually persisted.
|
|
3652
|
+
- An empty or unchanged (post-trim) rename draft is treated as an implicit cancel — no `lyra-rename`
|
|
3653
|
+
fires, and the row silently reverts to showing `title`.
|
|
3654
|
+
- Rename is triggered only by the dedicated pencil-icon button, never a double-click on the title —
|
|
3655
|
+
double-click has no keyboard/screen-reader equivalent and would also swallow the row's own
|
|
3656
|
+
single-click `lyra-select`.
|
|
3657
|
+
- While renaming, `[part="option"]` has no `role`/`tabindex`/`aria-selected`/`aria-label` at all — a
|
|
3658
|
+
screen reader briefly stops announcing it as a listbox option for the duration of the edit.
|
|
3659
|
+
|
|
3660
|
+
---
|
|
3661
|
+
|
|
3662
|
+
## `lyra-model-select`
|
|
3663
|
+
|
|
3664
|
+
A provider/model picker that renders as a closed dropdown when a fixed `catalog` is available, or as a
|
|
3665
|
+
filterable free-text combobox when it isn't (or when `allow-custom` explicitly permits typing something
|
|
3666
|
+
outside the catalog). Form-associated (hand-rolled internals via `attachInternals()` directly, not the
|
|
3667
|
+
shared `FormAssociated` mixin — same reasoning as `lyra-combobox`/`lyra-select`: see the shared-foundation
|
|
3668
|
+
notes). Built on the same trigger-button/`aria-activedescendant` listbox technique `<lyra-select>` uses
|
|
3669
|
+
and the filter-as-you-type suggestion-popup technique `<lyra-combobox>` uses, without composing either
|
|
3670
|
+
element. First-party invention (no Web Awesome equivalent).
|
|
3671
|
+
|
|
3672
|
+
**Exported types:**
|
|
3673
|
+
- `LyraModelCatalogEntry { id: string; label: string }` — one catalog row.
|
|
3674
|
+
- `LyraModelCatalog = string[] | LyraModelCatalogEntry[]` — either every entry is a plain string (used
|
|
3675
|
+
as both id and label) or every entry is a full `{ id, label }` row; the two shapes are not meant to be
|
|
3676
|
+
mixed within one array.
|
|
3677
|
+
|
|
3678
|
+
**Properties:**
|
|
3679
|
+
- `provider: string = ''` — informational only (e.g. `'ollama'`); rendered as a small leading badge.
|
|
3680
|
+
- `catalog?: LyraModelCatalog` (attribute: false) — the full model list. Omit (or leave empty) to fall
|
|
3681
|
+
back to plain free-text entry.
|
|
3682
|
+
- `allowCustom: boolean = false` (attribute `allow-custom`, reflected) — let the user type/commit a
|
|
3683
|
+
value that isn't in `catalog`, even when `catalog` is non-empty.
|
|
3684
|
+
- `placeholder: string = ''`
|
|
3685
|
+
- `name: string = ''`
|
|
3686
|
+
- `disabled: boolean = false` (reflected)
|
|
3687
|
+
- `required: boolean = false` (reflected — enforced via `internals.setValidity()`)
|
|
3688
|
+
- `open: boolean = false` (reflected)
|
|
3689
|
+
- `value: string` — getter/setter (hand-rolled, not the `FormAssociated` mixin); the current model id,
|
|
3690
|
+
`''` when nothing is selected. Writing it calls `internals.setFormValue()` synchronously.
|
|
3691
|
+
|
|
3692
|
+
**Mode switching:** `closedMode` (private) is `true` whenever `normalizedCatalog.length > 0 &&
|
|
3693
|
+
!allowCustom` — a non-empty `catalog` with `allowCustom` left `false` renders the closed dropdown
|
|
3694
|
+
trigger-button UI (`[part="trigger"]`, `role="combobox"` on a `<button>`, no typing). Any other
|
|
3695
|
+
combination (`catalog` empty/unset, or `allowCustom` true) renders the free-text `<input>` UI
|
|
3696
|
+
(`[part="combobox"]`/`[part="combobox-input"]`) with live substring filtering against the catalog (id or
|
|
3697
|
+
label, case-insensitive). The mode is re-evaluated on every render, so toggling `allowCustom` or clearing
|
|
3698
|
+
`catalog` at runtime switches modes live, repositioning the shared `[part="listbox"]` popover against
|
|
3699
|
+
whichever element is the active anchor.
|
|
3700
|
+
|
|
3701
|
+
A `value` that isn't present in `catalog` (e.g. a model id saved from a provider whose live catalog has
|
|
3702
|
+
since changed) is never silently dropped: it's appended to the rendered option list as a synthetic,
|
|
3703
|
+
visually-distinct row (dashed border, italic label, "not in catalog" badge) computed fresh from
|
|
3704
|
+
`catalog` + `value` on every access, without ever mutating the `catalog` property itself.
|
|
3705
|
+
|
|
3706
|
+
**Events:** `lyra-change` (`detail: { value: string; inCatalog: boolean }` — fired when a value is
|
|
3707
|
+
selected from the listbox or committed in free-text mode; `inCatalog` reflects whether that value was
|
|
3708
|
+
actually present in `normalizedCatalog`, so a consumer can flag a freshly-typed custom value distinctly
|
|
3709
|
+
from a real catalog pick)
|
|
3710
|
+
|
|
3711
|
+
**Slots:** none.
|
|
3712
|
+
|
|
3713
|
+
**CSS parts:** `trigger`, `combobox`, `combobox-input`, `provider-badge`, `listbox`, `option`,
|
|
3714
|
+
`option-label`, `option-badge`
|
|
3715
|
+
|
|
3716
|
+
**Themeable custom properties:** shared tokens only — `--lyra-space-xs/-s`,
|
|
3717
|
+
`--lyra-color-border/-surface/-brand/-brand-quiet/-text-quiet`, `--lyra-radius`, `--lyra-shadow`,
|
|
3718
|
+
`--lyra-focus-ring-width/-color/-offset`, `--lyra-icon-button-size`, `--lyra-transition-fast`,
|
|
3719
|
+
`--lyra-opacity-disabled`.
|
|
3720
|
+
|
|
3721
|
+
**Optional peer deps:** none.
|
|
3722
|
+
|
|
3723
|
+
```html
|
|
3724
|
+
<lyra-model-select
|
|
3725
|
+
provider="openai"
|
|
3726
|
+
.catalog=${[
|
|
3727
|
+
{ id: 'gpt-4o', label: 'GPT-4o' },
|
|
3728
|
+
{ id: 'gpt-4o-mini', label: 'GPT-4o mini' },
|
|
3729
|
+
]}
|
|
3730
|
+
value="gpt-4o"
|
|
3731
|
+
placeholder="Choose a model…"
|
|
3732
|
+
@lyra-change=${(e) => setModel(e.detail.value, e.detail.inCatalog)}
|
|
3733
|
+
></lyra-model-select>
|
|
3734
|
+
|
|
3735
|
+
<!-- No fixed catalog yet: falls back to free-text entry -->
|
|
3736
|
+
<lyra-model-select provider="ollama" placeholder="Type a model id…" allow-custom></lyra-model-select>
|
|
3737
|
+
```
|
|
3738
|
+
|
|
3739
|
+
**Known gotchas:**
|
|
3740
|
+
- `catalog` must be homogeneous — an array of plain strings, or an array of `{ id, label }` objects, not
|
|
3741
|
+
a mix; `LyraModelCatalog` is a union of two array *types*, not an array of a union item type.
|
|
3742
|
+
- The synthetic "not in catalog" row only ever appears when `catalog` is non-empty and `value` isn't one
|
|
3743
|
+
of its ids — with no `catalog` at all, there's no catalog list to diff `value` against, so no badge.
|
|
3744
|
+
- `value`/form-association here is hand-rolled via `attachInternals()` directly, not the shared
|
|
3745
|
+
`FormAssociated` mixin — mirrors `lyra-combobox`/`lyra-select`'s identical divergence.
|
|
3746
|
+
- `aria-invalid`/`data-invalid` only reflect once the control has been blurred (touched) at least once,
|
|
3747
|
+
matching `lyra-select`'s identical pattern — validity styling never flashes on first render.
|
|
3748
|
+
- In free-text mode, the input's displayed text is `query` only while `open`; while closed it shows the
|
|
3749
|
+
committed value's label — so setting `.value` programmatically doesn't require also touching the live
|
|
3750
|
+
typed text.
|
|
3751
|
+
|
|
3752
|
+
---
|
|
3753
|
+
|
|
3754
|
+
## `lyra-slider`
|
|
3755
|
+
|
|
3756
|
+
A numeric range control (e.g. an LLM "temperature" setting). **Form-associated** via the shared
|
|
3757
|
+
`FormAssociated` mixin (`name`, `value`, `disabled`, `required` all inherited). Mirrors native `<input
|
|
3758
|
+
type="range">` semantics: `value` is the string form-submitted via the mixin, `valueAsNumber` is the
|
|
3759
|
+
ergonomic numeric accessor (matching native `<input type=range>`'s IDL attribute of the same name) kept
|
|
3760
|
+
in sync with it in both directions. First-party invention (no Web Awesome equivalent).
|
|
3761
|
+
|
|
3762
|
+
**Properties:**
|
|
3763
|
+
- `min: number = 0`
|
|
3764
|
+
- `max: number = 100`
|
|
3765
|
+
- `step: number = 1`
|
|
3766
|
+
- `label: string = ''` — accessible name set as `aria-label` on the `role="slider"` thumb; a plain
|
|
3767
|
+
`aria-label` attribute on the host itself is honored as a fallback when this is left unset (matching
|
|
3768
|
+
`<lyra-checkbox>`/`<lyra-switch>`).
|
|
3769
|
+
- `showValue: boolean = true` (attribute `show-value`) — whether to render the current numeric value as
|
|
3770
|
+
visible text next to the track. Not reflected; toggle it off via the `.showValue=${false}` property
|
|
3771
|
+
binding — a bare `show-value="false"` content attribute is still truthy, since presence is all Lit's
|
|
3772
|
+
default boolean converter checks.
|
|
3773
|
+
- Inherited from `FormAssociated`: `name: string = ''`, `value: string` (form-submitted string form),
|
|
3774
|
+
`disabled: boolean = false` (reflected), `required: boolean = false` (reflected).
|
|
3775
|
+
|
|
3776
|
+
**Accessor:** `valueAsNumber: number` — get/set. Reading always returns a finite, clamped, step-snapped
|
|
3777
|
+
number, even if `value` is momentarily `""` (e.g. right after `form.reset()`, before the mount-time
|
|
3778
|
+
default reseeds it), by falling back to the midpoint of `[min, max]`. Writing clamps/snaps the input and
|
|
3779
|
+
stringifies the result back into `value`.
|
|
3780
|
+
|
|
3781
|
+
**Events:** `lyra-input` (`detail: { value: number }` — fired continuously during an active drag or a
|
|
3782
|
+
keyboard step, including OS key-repeat while a key is held, mirroring native `<input type=range>`'s
|
|
3783
|
+
`input` event), `lyra-change` (`detail: { value: number }` — fired once an interaction commits: on
|
|
3784
|
+
pointerup for a drag, or on keyup for a keyboard step, so a single Arrow/Home/End/PageUp/PageDown press
|
|
3785
|
+
fires both `lyra-input` and `lyra-change`, mirroring native `<input type=range>`'s own `change`-on-every-
|
|
3786
|
+
committed-step behavior)
|
|
3787
|
+
|
|
3788
|
+
**Slots:** none.
|
|
3789
|
+
|
|
3790
|
+
**CSS parts:** `base` (row wrapping the track and optional value readout), `track`, `fill` (filled
|
|
3791
|
+
portion from `min` up to the current value), `thumb` (`role="slider"`), `value` (numeric readout, shown
|
|
3792
|
+
when `show-value` is true)
|
|
3793
|
+
|
|
3794
|
+
**Themeable custom properties:** shared tokens only — `--lyra-space-s`,
|
|
3795
|
+
`--lyra-color-border/-brand/-surface/-text-quiet`, `--lyra-shadow`,
|
|
3796
|
+
`--lyra-focus-ring-width/-color/-offset`, `--lyra-opacity-disabled`.
|
|
3797
|
+
|
|
3798
|
+
**Optional peer deps:** none.
|
|
3799
|
+
|
|
3800
|
+
```html
|
|
3801
|
+
<lyra-slider
|
|
3802
|
+
name="temperature"
|
|
3803
|
+
min="0"
|
|
3804
|
+
max="2"
|
|
3805
|
+
step="0.1"
|
|
3806
|
+
label="Temperature"
|
|
3807
|
+
.valueAsNumber=${0.7}
|
|
3808
|
+
@lyra-input=${(e) => setDraftTemperature(e.detail.value)}
|
|
3809
|
+
@lyra-change=${(e) => commitTemperature(e.detail.value)}
|
|
3810
|
+
></lyra-slider>
|
|
3811
|
+
```
|
|
3812
|
+
|
|
3813
|
+
An unset `value` is eagerly defaulted — on connect, and again after `form.reset()` — to the midpoint of
|
|
3814
|
+
`[min, max]` snapped to `step`, the same "range sanitization algorithm" default a native range input
|
|
3815
|
+
applies. A slider therefore always represents *some* number, so `required` only has a narrow window to
|
|
3816
|
+
block submission before that default lands, matching how `required` isn't a meaningful constraint on a
|
|
3817
|
+
native range input either.
|
|
3818
|
+
|
|
3819
|
+
**Known gotchas:**
|
|
3820
|
+
- `valueAsNumber` always returns a real, clamped number — never `NaN` or `""` — even reading it in the
|
|
3821
|
+
brief window right after a `form.reset()`.
|
|
3822
|
+
- Under `direction: rtl`, physical ArrowRight/ArrowLeft swap which one counts as "forward" (increasing
|
|
3823
|
+
value); ArrowUp/ArrowDown are never swapped, since direction only affects the horizontal inline axis.
|
|
3824
|
+
- Changing `min`/`max`/`step` after mount automatically re-clamps/re-snaps the current `value` in the
|
|
3825
|
+
next update — narrowing the domain can silently move the slider's value.
|
|
3826
|
+
- A pointer drag fires `lyra-input` continuously and a single `lyra-change` on release; a keyboard step
|
|
3827
|
+
fires exactly one of each per press, but OS key-repeat while a key is held re-fires `lyra-input` on
|
|
3828
|
+
every repeat while still only committing `lyra-change` once, on the eventual keyup.
|
|
3829
|
+
|
|
3830
|
+
---
|
|
3831
|
+
|
|
3832
|
+
## `lyra-tool-select-dialog`
|
|
3833
|
+
|
|
3834
|
+
A category-grouped, filterable, searchable tool-enablement dialog for picking which agent tools are
|
|
3835
|
+
available in a conversation. Its own standalone overlay implementation (`role="dialog"`, focus-trapped,
|
|
3836
|
+
Escape/backdrop-dismissible, scroll-locking) rather than nesting `<lyra-dialog>` in its shadow template —
|
|
3837
|
+
duplicated locally the same way `<lyra-tool-result-dialog>` does, so this component has no dependency on
|
|
3838
|
+
the general-purpose dialog. First-party invention (no Web Awesome equivalent).
|
|
3839
|
+
|
|
3840
|
+
**Exported types:**
|
|
3841
|
+
- `ToolSelectDialogTool { id: string; name: string; description?: string; category?: string; icon?:
|
|
3842
|
+
string; disabled?: boolean; disabledReason?: string }` — one selectable agent tool. `category` groups
|
|
3843
|
+
the row into a heading; tools with no `category` (or an empty/whitespace-only one) fall into a
|
|
3844
|
+
trailing "Other" bucket. `icon` is a literal glyph (e.g. an emoji) rendered next to `name` — an opaque
|
|
3845
|
+
string, not a registry lookup, the same convention `<lyra-tool-call-chip>`'s `icon` uses. `disabled`
|
|
3846
|
+
individually gates a tool regardless of `useDefaults`/`selected` (e.g. a tool requiring admin
|
|
3847
|
+
approval); `disabledReason` is supporting text shown under a disabled row, ignored when `disabled` is
|
|
3848
|
+
falsy.
|
|
3849
|
+
- `ToolSelectFilter = (tool: ToolSelectDialogTool, query: string) => boolean` — a predicate deciding
|
|
3850
|
+
whether `tool` matches an already-trimmed, already-lowercased `query`. Assign `filter` to replace the
|
|
3851
|
+
built-in case-insensitive name/description substring match entirely (mirrors `<lyra-combobox>`'s
|
|
3852
|
+
`OptionFilter` convention).
|
|
3853
|
+
- `ToolSelectionChangeDetail { selected: string[]; useDefaults: boolean }` — the `lyra-change` detail
|
|
3854
|
+
shape.
|
|
3855
|
+
- `ToolSelectDialogCloseReason = 'escape' | 'backdrop' | 'api' | string` — the `lyra-close` detail;
|
|
3856
|
+
`'escape'`/`'backdrop'` come from the dialog's own built-in dismiss triggers, any other string is
|
|
3857
|
+
whatever a caller passes to `close()` directly.
|
|
3858
|
+
|
|
3859
|
+
**Properties:**
|
|
3860
|
+
- `open: boolean = false` (reflected) — set this (or call `close()`) to dismiss; there is no separate
|
|
3861
|
+
`show()`/`hide()` pair.
|
|
3862
|
+
- `tools: ToolSelectDialogTool[] = []` (attribute: false) — the full set of tools a consumer offers,
|
|
3863
|
+
across all categories.
|
|
3864
|
+
- `selected: string[] = []` (attribute: false) — the currently-enabled tool ids.
|
|
3865
|
+
- `useDefaults: boolean = false` (attribute `use-defaults`, reflected) — whether the conversation is
|
|
3866
|
+
using the default tool set (`true`) or a custom selection (`false`).
|
|
3867
|
+
- `label: string = 'Select tools'` — the dialog's visible heading and accessible name.
|
|
3868
|
+
- `searchPlaceholder: string = 'Search tools…'` (attribute `search-placeholder`)
|
|
3869
|
+
- `filter: ToolSelectFilter | null = null` (attribute: false) — overrides the built-in
|
|
3870
|
+
case-insensitive name/description substring match.
|
|
3871
|
+
|
|
3872
|
+
**Methods:** `close(reason: ToolSelectDialogCloseReason = 'api'): void` — closes the dialog, emits
|
|
3873
|
+
`lyra-close` with `reason`, and returns focus to whatever had it before the dialog opened.
|
|
3874
|
+
|
|
3875
|
+
**Events:** `lyra-change` (`detail: ToolSelectionChangeDetail` — the enabled-tool selection or the
|
|
3876
|
+
`useDefaults` toggle changed), `lyra-close` (`detail: ToolSelectDialogCloseReason` — fired exactly once
|
|
3877
|
+
per dismissal, via Escape, a backdrop click, or a `close()` call)
|
|
3878
|
+
|
|
3879
|
+
**Slots:** `footer` — optional action buttons (e.g. a "Done" button), rendered in a bottom row. Changes
|
|
3880
|
+
already apply live via `lyra-change`, so this slot is purely optional; only visually shown once it has
|
|
3881
|
+
assigned elements.
|
|
3882
|
+
|
|
3883
|
+
**CSS parts:** `backdrop`, `panel`, `header`, `title`, `subtitle`, `search-row`, `search-input`,
|
|
3884
|
+
`defaults-row`, `defaults-toggle`, `defaults-hint`, `body`, `empty`, `category`, `category-heading`,
|
|
3885
|
+
`category-count`, `category-list`, `tool-row`, `tool-checkbox`, `tool-name`, `tool-icon`,
|
|
3886
|
+
`tool-description`, `tool-disabled-reason`, `footer`
|
|
3887
|
+
|
|
3888
|
+
**Themeable custom properties:** `--lyra-tool-select-dialog-overlay-color` (default `rgb(0 0 0 / 0.5)` —
|
|
3889
|
+
the backdrop scrim color; component-specific since no shared overlay token exists, mirrors
|
|
3890
|
+
`<lyra-dialog>`'s/`<lyra-tool-result-dialog>`'s identical pattern), plus shared `--lyra-space-*`,
|
|
3891
|
+
`--lyra-color-surface/-border/-text/-text-quiet/-warning`, `--lyra-radius`, `--lyra-shadow`,
|
|
3892
|
+
`--lyra-focus-ring-width/-color/-offset`, `--lyra-opacity-disabled`, `--lyra-icon-button-size`.
|
|
3893
|
+
|
|
3894
|
+
**Optional peer deps:** none — internally renders `<lyra-checkbox>` and `<lyra-switch>`, both bundled
|
|
3895
|
+
dependencies of this package imported directly, not optional peers.
|
|
3896
|
+
|
|
3897
|
+
```html
|
|
3898
|
+
<lyra-tool-select-dialog
|
|
3899
|
+
label="Select tools"
|
|
3900
|
+
.tools=${[
|
|
3901
|
+
{ id: 'search', name: 'Web search', category: 'Research' },
|
|
3902
|
+
{ id: 'python', name: 'Python', category: 'Code', description: 'Run sandboxed Python' },
|
|
3903
|
+
{ id: 'admin', name: 'Admin console', disabled: true, disabledReason: 'Requires admin approval' },
|
|
3904
|
+
]}
|
|
3905
|
+
.selected=${enabledToolIds}
|
|
3906
|
+
?use-defaults=${usingDefaults}
|
|
3907
|
+
?open=${dialogOpen}
|
|
3908
|
+
@lyra-change=${(e) => updateTools(e.detail.selected, e.detail.useDefaults)}
|
|
3909
|
+
@lyra-close=${() => (dialogOpen = false)}
|
|
3910
|
+
>
|
|
3911
|
+
<button slot="footer" @click=${(e) => e.target.closest('lyra-tool-select-dialog').close('done')}>
|
|
3912
|
+
Done
|
|
3913
|
+
</button>
|
|
3914
|
+
</lyra-tool-select-dialog>
|
|
3915
|
+
```
|
|
3916
|
+
|
|
3917
|
+
`useDefaults` is a single top-level switch: while `true`, every per-tool checkbox renders disabled
|
|
3918
|
+
(still reflecting whatever `selected` holds — populate that with the actual default tool set whenever
|
|
3919
|
+
`useDefaults` is true) alongside a hint explaining that turning the switch off is how to customize.
|
|
3920
|
+
Turning it off is the only control that both flips `useDefaults` to `false` *and* unlocks the per-tool
|
|
3921
|
+
checkboxes for editing.
|
|
3922
|
+
|
|
3923
|
+
**Known gotchas:**
|
|
3924
|
+
- No built-in footer/close button — dismissal happens via Escape, a backdrop click, or a consumer's own
|
|
3925
|
+
`footer`-slotted action calling `close()` directly.
|
|
3926
|
+
- A row is effectively disabled whenever *either* its own `tool.disabled` is true *or* the top-level
|
|
3927
|
+
`useDefaults` switch is on — a tool without `disabled` set can still render as a locked checkbox while
|
|
3928
|
+
`useDefaults` is true.
|
|
3929
|
+
- `disabledReason` text only renders when both `tool.disabled` and `tool.disabledReason` are set.
|
|
3930
|
+
- Categories are grouped in first-seen order across `tools`; an empty/whitespace-only `category` folds
|
|
3931
|
+
into a trailing "Other" bucket that's always rendered last. A category left with zero matches after
|
|
3932
|
+
filtering is dropped entirely, not rendered as an empty heading.
|
|
3933
|
+
- Reconnecting the element while still `open` (e.g. a drag-and-drop reparent that keeps the same
|
|
3934
|
+
instance) automatically re-acquires the scroll lock and keydown trap it dropped in
|
|
3935
|
+
`disconnectedCallback`.
|
|
3936
|
+
- The search input is the first focusable element in the panel and receives focus automatically on open.
|
|
3937
|
+
|
|
3938
|
+
---
|
|
3939
|
+
|
|
3940
|
+
## `lyra-citation-badge`
|
|
3941
|
+
|
|
3942
|
+
An inline `[n]` citation marker with a hover/focus preview popover and confidence/verification-status
|
|
3943
|
+
coloring. First-party invention (no Web Awesome equivalent). Meant to sit inline in a chat message's
|
|
3944
|
+
text, each badge carrying a `source-id` that matches a corresponding `<lyra-source-card>` shown
|
|
3945
|
+
elsewhere on the page (a sibling component in this family) — this component never imports or knows
|
|
3946
|
+
anything about `<lyra-source-card>`, it only carries the id through its event details.
|
|
3947
|
+
|
|
3948
|
+
**Properties:**
|
|
3949
|
+
- `index: number = 1` — the citation number shown, e.g. `3` renders as `[3]`.
|
|
3950
|
+
- `status: CitationBadgeStatus = 'default'` (reflected) — one of `'default' | 'high' | 'medium' |
|
|
3951
|
+
'low' | 'verified' | 'unverified'`; drives the badge's color and (unless `label` is set) part of
|
|
3952
|
+
its accessible name.
|
|
3953
|
+
- `sourceId: string = ''` (attribute `source-id`) — id of a corresponding `<lyra-source-card>`,
|
|
3954
|
+
echoed back verbatim in both events; never read or validated by this component.
|
|
3955
|
+
- `href: string = ''` — optional direct link target for the citation's source, carried into
|
|
3956
|
+
`lyra-citation-open`'s detail as-is; this component never navigates.
|
|
3957
|
+
- `label: string = ''` — overrides the computed accessible name (`"Citation {index}[, {status}]"`).
|
|
3958
|
+
|
|
3959
|
+
**Events:**
|
|
3960
|
+
- `lyra-citation-activate` (`detail: { sourceId: string; index: number }`) — fires on click, or on
|
|
3961
|
+
Enter while focused (native `<button>` behavior, no listener needed for the Enter case). The
|
|
3962
|
+
lightweight "jump to this source" signal.
|
|
3963
|
+
- `lyra-citation-open` (`detail: { sourceId: string; index: number; href?: string }`) — fires on
|
|
3964
|
+
dblclick, or on Space while focused. A distinct "full preview" signal; `href` is `undefined` when
|
|
3965
|
+
the `href` prop isn't set. A double-click also fires two `lyra-citation-activate` events (one per
|
|
3966
|
+
constituent click, standard browser `dblclick` behavior) in addition to the one `lyra-citation-open`.
|
|
3967
|
+
|
|
3968
|
+
**Slots:** default — rich preview/tooltip content (e.g. a filename + excerpt), shown in a floating
|
|
3969
|
+
popover on hover/focus. This is *not* the badge's visible content (the badge always renders
|
|
3970
|
+
`[index]`); nothing renders at all (no hover affordance) when this slot is empty.
|
|
3971
|
+
|
|
3972
|
+
**CSS parts:** `base` (the clickable `<button>`), `bracket` (each of the two literal `[`/`]` glyphs),
|
|
3973
|
+
`index` (the citation number), `popover` (the floating preview panel, only meaningful while open).
|
|
3974
|
+
|
|
3975
|
+
**Themeable custom properties:** `--lyra-citation-badge-accent` / `--lyra-citation-badge-bg` /
|
|
3976
|
+
`--lyra-citation-badge-border` (internal per-status accent variables, not typically overridden
|
|
3977
|
+
directly — set instead by the `:host([status=...])` rules), plus shared tokens
|
|
3978
|
+
`--lyra-color-text-quiet`, `--lyra-color-success` / `-success-quiet`, `--lyra-color-warning` /
|
|
3979
|
+
`-warning-quiet`, `--lyra-color-danger` / `-danger-quiet`, `--lyra-radius`, `--lyra-color-surface`,
|
|
3980
|
+
`--lyra-color-border`, `--lyra-shadow`, `--lyra-space-s`/`-m`, `--lyra-transition-fast`,
|
|
3981
|
+
`--lyra-focus-ring-*`.
|
|
3982
|
+
|
|
3983
|
+
**Optional peer deps:** none.
|
|
3984
|
+
|
|
3985
|
+
```html
|
|
3986
|
+
<p>
|
|
3987
|
+
Revenue grew 12% year over year
|
|
3988
|
+
<lyra-citation-badge index="1" status="verified" source-id="doc-1">
|
|
3989
|
+
<strong>annual_report.pdf</strong> — "Revenue grew 12% year over year, driven primarily by..."
|
|
3990
|
+
</lyra-citation-badge>.
|
|
3991
|
+
</p>
|
|
3992
|
+
<script type="module">
|
|
3993
|
+
document.addEventListener('lyra-citation-activate', (e) => {
|
|
3994
|
+
document.querySelector(`lyra-source-card[source-id="${e.detail.sourceId}"]`)
|
|
3995
|
+
?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
3996
|
+
});
|
|
3997
|
+
</script>
|
|
3998
|
+
```
|
|
3999
|
+
|
|
4000
|
+
The popover is positioned with the same internal `place()` helper (`top-start` placement) that
|
|
4001
|
+
`<lyra-tool-call-chip>` uses for its own detail tooltip, and never traps focus — it's supplementary
|
|
4002
|
+
preview content, not a modal, so Tab continues past the badge normally even while the popover happens
|
|
4003
|
+
to be visible from a mouse hover. Hovering and focus are tracked as independent "keep it open"
|
|
4004
|
+
reasons (mirroring `<lyra-toast-item>`'s hovering/focused pair), so the pointer leaving while the
|
|
4005
|
+
badge still holds keyboard focus doesn't schedule a hide the focus is still holding open. There's a
|
|
4006
|
+
200ms grace period before a hover/focus-out actually hides the popover, so moving the pointer from
|
|
4007
|
+
the badge into the popover itself (to select/copy its text) doesn't make it vanish mid-move; Escape
|
|
4008
|
+
and blur (Tab away) close it immediately instead, with no delay.
|
|
4009
|
+
|
|
4010
|
+
Status coloring follows a semantic scheme: `verified`/`high` use the success tones (a claim that's
|
|
4011
|
+
been checked, or the model is confident in); `medium`/`low` use warning tones; `unverified` uses the
|
|
4012
|
+
*danger* tone — deliberately distinct from `low`, since "hasn't been checked at all" is a different
|
|
4013
|
+
(arguably riskier) claim than "checked but uncertain". `default` renders as plain neutral text with
|
|
4014
|
+
no background tint, for citations that carry no confidence/verification signal at all.
|
|
4015
|
+
|
|
4016
|
+
**Known gotchas:**
|
|
4017
|
+
- Enter and Space are given distinct meanings (Enter = activate via native `<button>` click, Space =
|
|
4018
|
+
open) — Space's native click-on-keyup is pre-empted with `preventDefault()` on keydown so it fires
|
|
4019
|
+
`lyra-citation-open` instead of triggering a second `lyra-citation-activate`.
|
|
4020
|
+
- Escape closes the popover but calls `stopPropagation()`, so it won't also close a surrounding
|
|
4021
|
+
`<lyra-dialog>` that has its own Escape-to-close handler.
|
|
4022
|
+
- The preview slot's presence is tracked in JS (`hasPreviewSlot`), not via CSS `:empty` — the
|
|
4023
|
+
`[part="popover"]` always contains a literal `<slot>` child, so `:empty` would never match even
|
|
4024
|
+
with nothing assigned.
|
|
4025
|
+
|
|
4026
|
+
---
|
|
4027
|
+
|
|
4028
|
+
## `lyra-source-list` / `lyra-source-card`
|
|
4029
|
+
|
|
4030
|
+
A collapsible "Sources" panel for one chat message (`lyra-source-list`) that groups a set of
|
|
4031
|
+
`lyra-source-card` entries. First-party invention (no Web Awesome equivalent). Cards are meant to be
|
|
4032
|
+
direct light-DOM children of the list (plain composition — no `.items` array prop, the same shape
|
|
4033
|
+
`<lyra-split>`'s panels take), though `lyra-source-card` renders and functions fine standalone.
|
|
4034
|
+
|
|
4035
|
+
### `lyra-source-list`
|
|
4036
|
+
|
|
4037
|
+
**Properties:**
|
|
4038
|
+
- `expanded: boolean = false` (reflected) — whether the card list is currently shown. Starts
|
|
4039
|
+
collapsed by default so a message's sources don't eat vertical space until asked for.
|
|
4040
|
+
- `label: string = ''` — header text used when `label-plural` isn't set, e.g. `"Sources"`.
|
|
4041
|
+
- `labelPlural: string = ''` (attribute `label-plural`) — fully consumer-built, already-pluralized
|
|
4042
|
+
header summary, e.g. `"3 sources"` or `"1 source"`; this component never counts or pluralizes on
|
|
4043
|
+
its own. Takes precedence over `label` when both are set. If neither is set, the header falls back
|
|
4044
|
+
to the literal word `"Sources"`.
|
|
4045
|
+
|
|
4046
|
+
**Getters:** `sourceCount: number` — read-only, live-updated count of the currently-slotted children,
|
|
4047
|
+
handy for building a `label-plural` string reactively, e.g. `` list.labelPlural = `${list.sourceCount} sources` ``.
|
|
4048
|
+
|
|
4049
|
+
**Events:** `lyra-toggle` (`detail: { expanded: boolean }`) — the header was activated, expanding or
|
|
4050
|
+
collapsing the list.
|
|
4051
|
+
|
|
4052
|
+
**Slots:** default — `<lyra-source-card>` elements (or any content, though the card pairing is the
|
|
4053
|
+
intended usage).
|
|
4054
|
+
|
|
4055
|
+
**CSS parts:** `base` (outer container), `header` (the clickable `<button>` toggling `expanded`),
|
|
4056
|
+
`toggle` (the chevron indicator inside the header), `list` (wrapper around the default slot, `hidden`
|
|
4057
|
+
while collapsed).
|
|
4058
|
+
|
|
4059
|
+
**Themeable custom properties:** shared tokens only — `--lyra-color-border`, `--lyra-color-surface`,
|
|
4060
|
+
`--lyra-color-text`, `--lyra-color-brand` / `-brand-quiet`, `--lyra-radius`, `--lyra-space-xs`/`-s`/
|
|
4061
|
+
`-m`, `--lyra-transition-fast`, `--lyra-focus-ring-*`.
|
|
4062
|
+
|
|
4063
|
+
**Optional peer deps:** none.
|
|
4064
|
+
|
|
4065
|
+
### `lyra-source-card`
|
|
4066
|
+
|
|
4067
|
+
**Properties:**
|
|
4068
|
+
- `sourceId: string = ''` (attribute `source-id`) — stable identifier matching a
|
|
4069
|
+
`<lyra-citation-badge>` elsewhere on the page.
|
|
4070
|
+
- `title: string = ''` — the source's display title, e.g. a filename. Falls back to `"Untitled
|
|
4071
|
+
source"` when empty.
|
|
4072
|
+
- `page?: string | number` — optional page reference, e.g. `12` or `"iv"`, rendered as-is (never
|
|
4073
|
+
parsed/validated as a number), appended to the title as `" — p. {page}"`.
|
|
4074
|
+
- `href?: string` — optional URL, echoed back (unopened) in `lyra-open`'s detail.
|
|
4075
|
+
|
|
4076
|
+
**Events:**
|
|
4077
|
+
- `lyra-expand` (`detail: { sourceId: string; expanded: boolean }`) — the per-card "Show
|
|
4078
|
+
more"/"Show less" toggle was activated. Unrelated to the parent `lyra-source-list`'s own
|
|
4079
|
+
expand/collapse, which only ever hides/shows the *set* of cards, never a single card's own content.
|
|
4080
|
+
- `lyra-open` (`detail: { sourceId: string; href?: string }`) — the title was activated. This
|
|
4081
|
+
component never navigates on its own (a controlled component, the same convention
|
|
4082
|
+
`<lyra-tool-call-chip>`'s `lyra-tool-chip-select` follows); a listener decides what "open" means.
|
|
4083
|
+
|
|
4084
|
+
**Slots:** `excerpt` (a short preview, always visible), `full` (the complete source text/chunk,
|
|
4085
|
+
hidden behind the "Show more"/"Show less" toggle — when left empty, no toggle renders at all).
|
|
4086
|
+
|
|
4087
|
+
**CSS parts:** `base` (outer container), `title` (the clickable title/page heading, a `<button>`),
|
|
4088
|
+
`excerpt` (wrapper around the `excerpt` slot), `full` (wrapper around the `full` slot, `hidden` while
|
|
4089
|
+
collapsed), `toggle` (the "Show more"/"Show less" button — only rendered when the `full` slot has
|
|
4090
|
+
content).
|
|
4091
|
+
|
|
4092
|
+
**Themeable custom properties:** shared tokens only — `--lyra-color-border`, `--lyra-color-surface`,
|
|
4093
|
+
`--lyra-color-text` / `-text-quiet`, `--lyra-color-brand`, `--lyra-radius`, `--lyra-space-xs`/`-s`,
|
|
4094
|
+
`--lyra-focus-ring-*`.
|
|
4095
|
+
|
|
4096
|
+
**Optional peer deps:** none.
|
|
4097
|
+
|
|
4098
|
+
```html
|
|
4099
|
+
<lyra-source-list label-plural="2 sources">
|
|
4100
|
+
<lyra-source-card source-id="doc-1" title="annual_report.pdf" page="12">
|
|
4101
|
+
<span slot="excerpt">Revenue grew 12% year over year...</span>
|
|
4102
|
+
<span slot="full">Revenue grew 12% year over year, driven primarily by...</span>
|
|
4103
|
+
</lyra-source-card>
|
|
4104
|
+
<lyra-source-card source-id="doc-2" title="q3_notes.md">
|
|
4105
|
+
<span slot="excerpt">No matching full-text chunk for this source.</span>
|
|
4106
|
+
</lyra-source-card>
|
|
4107
|
+
</lyra-source-list>
|
|
4108
|
+
<script type="module">
|
|
4109
|
+
// Elsewhere, a <lyra-citation-badge>'s activation handler can scroll to and
|
|
4110
|
+
// highlight the matching card -- neither component needs extra API surface
|
|
4111
|
+
// for that, only source-id to be targeted by:
|
|
4112
|
+
document.addEventListener('lyra-citation-activate', (e) => {
|
|
4113
|
+
const card = document.querySelector(`lyra-source-card[source-id="${e.detail.sourceId}"]`);
|
|
4114
|
+
card?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
4115
|
+
});
|
|
4116
|
+
</script>
|
|
4117
|
+
```
|
|
4118
|
+
|
|
4119
|
+
`lyra-source-list` removes the card list from the accessibility tree (not just visually hides it)
|
|
4120
|
+
while collapsed, via the native `hidden` attribute on `[part="list"]` — a screen reader user tabbing
|
|
4121
|
+
past the header never lands on off-screen source cards they can't currently see. Both components
|
|
4122
|
+
track slot presence in JS state (`slottedCount` on the list, `hasFullSlot` on the card) rather than
|
|
4123
|
+
relying on CSS `:empty`, since a `[part]` wrapper always contains a literal `<slot>` child regardless
|
|
4124
|
+
of assigned content; both also reconcile that state in `firstUpdated()` as a fallback for slot-
|
|
4125
|
+
forwarding scenarios or engines that don't fire `slotchange` for content already present at parse
|
|
4126
|
+
time.
|
|
4127
|
+
|
|
4128
|
+
**Known gotchas:**
|
|
4129
|
+
- This library has no built-in i18n/pluralization (the same stance `<lyra-empty>`'s plain
|
|
4130
|
+
`description` prop takes) — `lyra-source-list`'s header text is entirely consumer-supplied via
|
|
4131
|
+
`label`/`label-plural`; there's no automatic "N sources" string generation beyond the literal
|
|
4132
|
+
`"Sources"` fallback.
|
|
4133
|
+
- `lyra-source-card`'s own expand/collapse (`full` slot, `lyra-expand` event) is completely
|
|
4134
|
+
independent of the parent list's `expanded`/`lyra-toggle` — collapsing the list doesn't reset an
|
|
4135
|
+
individual card's `fullExpanded` state, and there is no cross-talk between the two components at
|
|
4136
|
+
all beyond DOM nesting.
|
|
4137
|
+
|
|
4138
|
+
---
|
|
4139
|
+
|
|
4140
|
+
## `lyra-app-rail`
|
|
4141
|
+
|
|
4142
|
+
A responsive navigation rail that adapts across three presentations as the *viewport* narrows (not
|
|
4143
|
+
this element's own inline size): `'full'` (nav items show icon + label, inline), `'icon-only'` (a
|
|
4144
|
+
narrower inline rail, icons only), and `'mobile'` (hidden behind a toggle button; opening it shows a
|
|
4145
|
+
focus-trapped floating overlay over the page). First-party invention (no Web Awesome equivalent).
|
|
4146
|
+
Breakpoints are viewport-width `matchMedia()` queries against `icon-only-breakpoint`/
|
|
4147
|
+
`mobile-breakpoint`, not a `ResizeObserver` on this element — presentation tracks the actual device/
|
|
4148
|
+
window width the way a native OS shell's navigation does, not however much horizontal space a
|
|
4149
|
+
particular layout happens to give it. `[part="base"]` (the inline `'full'`/`'icon-only'`
|
|
4150
|
+
presentation) and `[part="panel"]` (the mobile overlay) are the *same* element promoted in place
|
|
4151
|
+
across modes (mirrors `<lyra-widget>`'s fullscreen mode) — never both at once, and slotted nav
|
|
4152
|
+
content is never duplicated.
|
|
4153
|
+
|
|
4154
|
+
**Properties:**
|
|
4155
|
+
- `mode: AppRailMode` (custom accessor, reflected) — the getter always returns one of the three real
|
|
4156
|
+
modes (`'full'|'icon-only'|'mobile'`), never `'auto'`. The setter accepts
|
|
4157
|
+
`AppRailModeInput` (`AppRailMode | 'auto'`): assigning `'full'`/`'icon-only'`/`'mobile'` forces
|
|
4158
|
+
that mode and stops the element responding to breakpoint changes; assigning the write-only
|
|
4159
|
+
sentinel `'auto'` releases the force and immediately re-syncs to the current viewport width,
|
|
4160
|
+
resuming automatic tracking. Settable via the `mode` attribute too (`mode="icon-only"`,
|
|
4161
|
+
`mode="auto"`).
|
|
4162
|
+
- `iconOnlyBreakpoint: string = '960px'` (attribute `icon-only-breakpoint`) — any valid CSS length,
|
|
4163
|
+
used directly in a `(max-width: ...)` media query; below it the rail switches from `'full'` to
|
|
4164
|
+
`'icon-only'`.
|
|
4165
|
+
- `mobileBreakpoint: string = '600px'` (attribute `mobile-breakpoint`) — same mechanism; below it the
|
|
4166
|
+
rail switches from `'icon-only'` to `'mobile'`. Should be smaller than `iconOnlyBreakpoint` to
|
|
4167
|
+
produce all three states as the viewport narrows.
|
|
4168
|
+
- `open: boolean = false` (reflected) — whether the mobile floating overlay is shown. Only meaningful
|
|
4169
|
+
while `mode` is `'mobile'` — the value is preserved (not reset) while another mode is active, but
|
|
4170
|
+
no overlay chrome renders until `mode` is `'mobile'` again. Set this directly, or use the built-in
|
|
4171
|
+
toggle button — there is no separate `show()`/`hide()` pair.
|
|
4172
|
+
- `label: string = 'Navigation'` — accessible name for the rail's navigation landmark, and for its
|
|
4173
|
+
dialog role while the mobile overlay is open.
|
|
4174
|
+
|
|
4175
|
+
**Events:** `lyra-mode-change` (`detail: AppRailModeChangeDetail` = `{ mode: AppRailMode }`; the
|
|
4176
|
+
effective mode changed, whether from a breakpoint crossing or an explicit `mode` assignment — not
|
|
4177
|
+
fired for a redundant reassignment to the mode already in effect), `lyra-toggle`
|
|
4178
|
+
(`detail: AppRailToggleDetail` = `{ open: boolean }`; the mobile overlay opened or closed — via the
|
|
4179
|
+
built-in toggle button, Escape, a backdrop click, a nav-item click while open, or a breakpoint/forced
|
|
4180
|
+
mode change leaving `'mobile'` while open — not fired when a consumer sets `open` directly).
|
|
4181
|
+
|
|
4182
|
+
**Slots:** default (nav items — generic slotted content, e.g. `<a>`/`<button>` elements the consumer
|
|
4183
|
+
builds with its own icon+label structure; clicking anywhere in this slot closes the mobile overlay if
|
|
4184
|
+
open), `header` (logo/brand content, shown above the nav items in every mode), `footer` (a trailing
|
|
4185
|
+
user/settings trigger, shown below the nav items).
|
|
4186
|
+
|
|
4187
|
+
**CSS parts:** `base`, `header`, `nav`, `footer`, `toggle`, `backdrop`, `panel` (`base`/`panel` are
|
|
4188
|
+
mutually exclusive on the same underlying element — see above).
|
|
4189
|
+
|
|
4190
|
+
**Themeable custom properties:** `--lyra-app-rail-width` (default `15rem` — the inline rail width in
|
|
4191
|
+
`'full'` mode), `--lyra-app-rail-icon-width` (default `4rem` — the inline rail width in `'icon-only'`
|
|
4192
|
+
mode), `--lyra-app-rail-mobile-width` (default `18rem`, capped at `85vw` — the mobile overlay panel
|
|
4193
|
+
width), `--lyra-app-rail-overlay-color` (default `rgb(0 0 0 / 0.5)` — the mobile backdrop scrim
|
|
4194
|
+
color; component-specific since no shared token exists), plus shared tokens (`--lyra-color-border`,
|
|
4195
|
+
`--lyra-color-surface`, `--lyra-color-text`, `--lyra-color-brand`, `--lyra-color-brand-quiet`,
|
|
4196
|
+
`--lyra-space-*`, `--lyra-radius`, `--lyra-shadow`, `--lyra-icon-button-size`,
|
|
4197
|
+
`--lyra-focus-ring-*`, `--lyra-transition-base`).
|
|
4198
|
+
|
|
4199
|
+
**Optional peer deps:** none.
|
|
4200
|
+
|
|
4201
|
+
```html
|
|
4202
|
+
<lyra-app-rail label="Main navigation" icon-only-breakpoint="960px" mobile-breakpoint="600px">
|
|
4203
|
+
<span slot="header"><img src="/logo.svg" alt="Acme" /></span>
|
|
4204
|
+
<a href="/inbox" aria-label="Inbox"><svg aria-hidden="true">...</svg><span>Inbox</span></a>
|
|
4205
|
+
<a href="/settings" aria-label="Settings"><svg aria-hidden="true">...</svg><span>Settings</span></a>
|
|
4206
|
+
<span slot="footer"><button>Profile</button></span>
|
|
4207
|
+
</lyra-app-rail>
|
|
4208
|
+
```
|
|
4209
|
+
```ts
|
|
4210
|
+
rail.mode = 'icon-only'; // force a presentation regardless of viewport width
|
|
4211
|
+
rail.mode = 'auto'; // release the force, resume live breakpoint tracking
|
|
4212
|
+
```
|
|
4213
|
+
|
|
4214
|
+
The package root also exports a pure `computeAppRailMode(iconOnlyMatches: boolean, mobileMatches:
|
|
4215
|
+
boolean): AppRailMode` resolver (plus the `AppRailMode`/`AppRailModeInput`/`AppRailModeChangeDetail`/
|
|
4216
|
+
`AppRailToggleDetail` types) — the same logic the element's internal `matchMedia` listeners call,
|
|
4217
|
+
exposed standalone so a consumer can compute or unit-test the same three-state resolution without a
|
|
4218
|
+
real browser window. `mobileMatches` wins over `iconOnlyMatches` when both are true (the viewport is
|
|
4219
|
+
narrower than both breakpoints at once).
|
|
4220
|
+
|
|
4221
|
+
This is its own standalone focus-trapped overlay implementation for the `'mobile'` state
|
|
4222
|
+
(`role="dialog"` while open, Escape/backdrop-dismissible, scroll-locking) rather than nesting a
|
|
4223
|
+
`<lyra-dialog>` in its shadow template. It's a plain `<div>` with an explicit `role="navigation"`
|
|
4224
|
+
(swapping to `role="dialog"` while the overlay is open) rather than a literal `<nav>` tag, since a
|
|
4225
|
+
`<nav>`'s implicit role can't be overridden to `role="dialog"` without an `aria-allowed-role`
|
|
4226
|
+
violation. In `'icon-only'` mode, slotted nav items lose their visible text label — give each one a
|
|
4227
|
+
real accessible name (`aria-label`, visually hidden text, or `title`) regardless, since this
|
|
4228
|
+
component only lays out whatever is slotted and can't inspect or fix up a consumer's own markup.
|
|
4229
|
+
|
|
4230
|
+
**Known gotchas:**
|
|
4231
|
+
- `mode`'s setter accepts the wider `AppRailModeInput` (including the `'auto'` sentinel) but the
|
|
4232
|
+
getter's return type is the narrower `AppRailMode` — assigning `'auto'` is a one-way instruction,
|
|
4233
|
+
not a value read back later; there is no `isForced`-style property to check whether the rail is
|
|
4234
|
+
currently locked to a mode or tracking the viewport.
|
|
4235
|
+
- reassigning `icon-only-breakpoint`/`mobile-breakpoint` after first render tears down and rebuilds
|
|
4236
|
+
the `matchMedia` listeners, but does not itself un-force a previously-forced `mode` — if a consumer
|
|
4237
|
+
set `mode = 'icon-only'`, changing the breakpoints won't resume auto-tracking until `mode = 'auto'`
|
|
4238
|
+
is set explicitly.
|
|
4239
|
+
- leaving `'mobile'` mode while `open` (via a breakpoint crossing or a forced `mode` reassignment)
|
|
4240
|
+
auto-closes the overlay through the same path as the toggle button, so `lyra-toggle` still fires
|
|
4241
|
+
and the scroll lock/focus trap still release normally — a consumer listening only for explicit
|
|
4242
|
+
toggle-button clicks would miss this closure.
|
|
4243
|
+
- the mobile panel is also given `inert` whenever `mode === 'mobile'` and `open` is `false` — it's
|
|
4244
|
+
removed from the accessibility tree and tab order via `inert` at the same time it's hidden visually
|
|
4245
|
+
via `transform: translateX(-100%)`, both applied simultaneously rather than one implying the other.
|
|
4246
|
+
- the offscreen slide direction for the mobile panel is flipped for RTL via a `:dir(rtl)` CSS
|
|
4247
|
+
selector (`translateX(100%)`), not through the shared `internal/rtl.ts` JS helper used for pointer/
|
|
4248
|
+
keyboard math elsewhere in this library — a physical `transform` isn't expressible with logical
|
|
4249
|
+
properties, so this one case is handled purely in CSS.
|
|
4250
|
+
- a reconnect that preserves the same element instance (e.g. a drag-and-drop reparent) restores the
|
|
4251
|
+
scroll lock and the Tab-trap `keydown` listener if the overlay was still active across the move —
|
|
4252
|
+
`disconnectedCallback`/`connectedCallback` fire back-to-back with no update in between, so
|
|
4253
|
+
`willUpdate()` alone wouldn't otherwise notice.
|
|
4254
|
+
|
|
4255
|
+
---
|
|
4256
|
+
|
|
4257
|
+
## `lyra-responsive-panel`
|
|
4258
|
+
|
|
4259
|
+
The same slotted content either docked inline in the page's normal layout flow (desktop) or
|
|
4260
|
+
presented as a full-screen/bottom-sheet overlay (mobile), depending on viewport width. First-party
|
|
4261
|
+
invention (no Web Awesome equivalent). Typical uses: a settings panel or a conversation-history
|
|
4262
|
+
sidebar that's a permanent docked pane on a wide screen but a modal on a phone.
|
|
4263
|
+
|
|
4264
|
+
**Properties:**
|
|
4265
|
+
- `open: boolean = false` (reflected) — in the inline presentation this just means visible/mounted;
|
|
4266
|
+
in the overlay presentation this is the actual modal open/closed state.
|
|
4267
|
+
- `mode: ResponsivePanelMode = 'auto'` (reflected) — `'auto'` tracks `mobile-breakpoint` live;
|
|
4268
|
+
`'inline'`/`'overlay'` force that presentation regardless of viewport width.
|
|
4269
|
+
- `variant: ResponsivePanelVariant = 'fullscreen'` (reflected) — only affects the overlay
|
|
4270
|
+
presentation's visual treatment: `'fullscreen'` covers the whole viewport; `'bottom-sheet'` slides
|
|
4271
|
+
up from the bottom and doesn't cover the full height. Has no visual effect while the effective
|
|
4272
|
+
presentation resolves to `'inline'`.
|
|
4273
|
+
- `label: string = ''` — accessible name for the overlay presentation's `role="dialog"`. Unused in
|
|
4274
|
+
the inline presentation, which has no dialog semantics to name.
|
|
4275
|
+
- `mobileBreakpoint: string = '768px'` (attribute `mobile-breakpoint`) — CSS length passed to
|
|
4276
|
+
`matchMedia` as `(max-width: <this>)` to decide, in `mode="auto"`, whether the effective
|
|
4277
|
+
presentation is `'overlay'` (below/at this width) or `'inline'` (above it).
|
|
4278
|
+
|
|
4279
|
+
**Methods:** `close(reason: ResponsivePanelCloseReason = 'api'): void` — closes the panel (sets
|
|
4280
|
+
`open = false`), emits `lyra-close` with `reason`, and — only in the overlay presentation — returns
|
|
4281
|
+
focus to whichever element triggered the open. No-op if already closed. Built-in overlay triggers
|
|
4282
|
+
call this with `'escape'`/`'backdrop'`; a consumer's own close affordance (a footer button, a docked
|
|
4283
|
+
panel's own toggle) should call it directly with its own reason string.
|
|
4284
|
+
|
|
4285
|
+
**Events:** `lyra-close` (`detail: ResponsivePanelCloseReason` = `'escape'|'backdrop'|'api'|string`;
|
|
4286
|
+
fired by the overlay presentation's built-in dismiss triggers — Escape, backdrop click — and by any
|
|
4287
|
+
`close()` call, in either presentation; a plain `open = false` property write does **not** fire it,
|
|
4288
|
+
only going through `close()` counts as a dismissal), `lyra-mode-change`
|
|
4289
|
+
(`detail: ResponsivePanelModeChangeDetail` = `{ mode: ResponsivePanelEffectiveMode }`; fired whenever
|
|
4290
|
+
the *effective* mode — not the `mode` prop's possibly-`'auto'` literal value — changes between
|
|
4291
|
+
`'inline'` and `'overlay'`; never fired on the initial render, only for a live change thereafter).
|
|
4292
|
+
|
|
4293
|
+
**Slots:** default (the panel body), `header` (optional header content, rendered above the body),
|
|
4294
|
+
`footer` (optional footer content, e.g. action buttons, rendered below the body).
|
|
4295
|
+
|
|
4296
|
+
**CSS parts:** `base`, `backdrop`, `panel`, `header`, `body`, `footer` (`backdrop` is only rendered
|
|
4297
|
+
in the overlay presentation).
|
|
4298
|
+
|
|
4299
|
+
**Themeable custom properties:** `--lyra-responsive-panel-overlay-color` (default
|
|
4300
|
+
`rgb(0 0 0 / 0.5)` — the overlay presentation's backdrop scrim color; component-specific since no
|
|
4301
|
+
shared token exists), plus shared tokens (`--lyra-color-border`, `--lyra-color-surface`,
|
|
4302
|
+
`--lyra-space-*`, `--lyra-radius`, `--lyra-shadow`).
|
|
4303
|
+
|
|
4304
|
+
**Optional peer deps:** none.
|
|
4305
|
+
|
|
4306
|
+
```html
|
|
4307
|
+
<lyra-responsive-panel id="settings-panel" label="Settings" variant="bottom-sheet" mobile-breakpoint="768px">
|
|
4308
|
+
<span slot="header"><h2>Settings</h2></span>
|
|
4309
|
+
<div>Panel body content — a form, a list, anything.</div>
|
|
4310
|
+
<span slot="footer"><button onclick="document.getElementById('settings-panel').close()">Done</button></span>
|
|
4311
|
+
</lyra-responsive-panel>
|
|
4312
|
+
```
|
|
4313
|
+
|
|
4314
|
+
Breakpoint detection uses `matchMedia('(max-width: ' + mobileBreakpoint + ')')`, re-evaluated live
|
|
4315
|
+
while connected — resizing/rotating a device that crosses the breakpoint while `mode="auto"` (the
|
|
4316
|
+
default) updates the effective presentation without unmounting or re-creating the slotted content:
|
|
4317
|
+
inline and overlay presentation share the exact same shadow DOM structure (only a CSS class, and the
|
|
4318
|
+
overlay-only `role`/`aria-modal` attributes and backdrop element, differ), so lit-html's diffing
|
|
4319
|
+
keeps `[part="body"]` and its `<slot>` as the same DOM node across the transition — scroll position
|
|
4320
|
+
and focus inside the slotted content survive it for free. The one exception is focus: crossing the
|
|
4321
|
+
breakpoint while already open does *not* forcibly move focus into the panel (unlike a genuine `open`
|
|
4322
|
+
transition, which does) — doing so on a resize could yank focus away from an input the user is
|
|
4323
|
+
actively typing into inside the now-newly-modal panel. The overlay presentation duplicates
|
|
4324
|
+
`<lyra-dialog>`'s `role="dialog"` + focus-trap + Escape/backdrop-dismiss + scroll-lock mechanics
|
|
4325
|
+
locally rather than nesting a `<lyra-dialog>` inside this component's shadow DOM.
|
|
4326
|
+
|
|
4327
|
+
The package root also exports the pure `resolveEffectiveMode(mode: ResponsivePanelMode,
|
|
4328
|
+
belowBreakpoint: boolean): ResponsivePanelEffectiveMode` resolver — renamed on export to
|
|
4329
|
+
`resolveResponsivePanelEffectiveMode` to avoid a name collision — alongside the
|
|
4330
|
+
`ResponsivePanelMode`/`ResponsivePanelEffectiveMode`/`ResponsivePanelVariant`/
|
|
4331
|
+
`ResponsivePanelCloseReason`/`ResponsivePanelModeChangeDetail` types. It's the same logic the
|
|
4332
|
+
element's internal `matchMedia` listener calls: `'inline'`/`'overlay'` pass straight through
|
|
4333
|
+
unchanged; `'auto'` resolves to `'overlay'` when `belowBreakpoint` is true, `'inline'` otherwise —
|
|
4334
|
+
exposed standalone so a consumer can compute or unit-test the same resolution without a real browser
|
|
4335
|
+
window.
|
|
4336
|
+
|
|
4337
|
+
**Known gotchas:**
|
|
4338
|
+
- setting `open = true`/`open = false` directly does not fire `lyra-close` and does not run the
|
|
4339
|
+
focus-return-to-trigger logic either — only calling `close()` does both; a consumer that toggles
|
|
4340
|
+
the property directly instead of calling `close()` silently loses both behaviors.
|
|
4341
|
+
- crossing the breakpoint while the panel is already open does not move focus into it, unlike a
|
|
4342
|
+
genuine `open` transition — see above; don't rely on a resize alone to place initial focus.
|
|
4343
|
+
- `variant="bottom-sheet"` has no visible effect at all while the effective presentation is
|
|
4344
|
+
`'inline'` — it only changes the overlay presentation's anchoring/height.
|
|
4345
|
+
- a reconnect that preserves the same element instance (e.g. a drag-and-drop reparent) restores the
|
|
4346
|
+
scroll lock and the Tab-trap `keydown` listener if overlay chrome was still active across the move
|
|
4347
|
+
— `disconnectedCallback`/`connectedCallback` fire back-to-back with no update in between, so
|
|
4348
|
+
`willUpdate()` alone wouldn't otherwise notice.
|
|
4349
|
+
- the exported pure function is named `resolveEffectiveMode` in the component module but re-exported
|
|
4350
|
+
from the package root as `resolveResponsivePanelEffectiveMode` — importing the un-prefixed name
|
|
4351
|
+
from `@aceshooting/lyra-ui` will fail.
|
|
4352
|
+
|
|
4353
|
+
---
|
|
4354
|
+
|
|
4355
|
+
## `lyra-mention-popover`
|
|
4356
|
+
|
|
4357
|
+
A caret-anchored, keyboard-navigable popover for `@`-mention and `/`-slash-command autocomplete
|
|
4358
|
+
inside a plain-text `<textarea>`/`<input>` the host owns. First-party invention (no Web Awesome
|
|
4359
|
+
equivalent). It never takes DOM focus itself — the host's own input keeps focus, and this
|
|
4360
|
+
component conveys the active row via `activeDescendantId` for the host to apply as its own
|
|
4361
|
+
input's `aria-activedescendant`, the same pattern `<lyra-select>`/`<lyra-combobox>` use for their
|
|
4362
|
+
own listbox.
|
|
4363
|
+
|
|
4364
|
+
**Properties:**
|
|
4365
|
+
- `anchor?: HTMLElement` (attribute: false) — the element to position the popup relative to. A
|
|
4366
|
+
plain `<textarea>` or single-line text `<input type="text"|"search">` gets caret-precise
|
|
4367
|
+
positioning; any other element anchors the whole popup under that element's own box.
|
|
4368
|
+
- `items: MentionItem[] = []` (attribute: false) — the full candidate set, pre-`query`-filtering.
|
|
4369
|
+
- `query: string = ''` — the text typed since the trigger character; drives the built-in filtering
|
|
4370
|
+
(see `filter`).
|
|
4371
|
+
- `open: boolean = false` (reflected)
|
|
4372
|
+
- `filter: MentionFilter | null = null` (attribute: false) — overrides the built-in
|
|
4373
|
+
case-insensitive `label`/`description` substring match entirely.
|
|
4374
|
+
- `emptyText: string = 'No matches'` (attribute `empty-text`)
|
|
4375
|
+
- `label: string = 'Suggestions'` — accessible name for the `role="listbox"` popup.
|
|
4376
|
+
- `filteredItems: MentionItem[]` — read-only getter; `items` filtered by `query` via `filter` (or
|
|
4377
|
+
the built-in default). Empty `query` returns `items` unfiltered.
|
|
4378
|
+
- `activeDescendantId: string | null` — read-only getter; the `id` of the currently-highlighted
|
|
4379
|
+
row, or `null` while closed or when `filteredItems` is empty.
|
|
4380
|
+
- `listboxId: string` — read-only getter; the `id` of the `role="listbox"` element, for a host that
|
|
4381
|
+
also wants to wire `aria-controls`.
|
|
4382
|
+
|
|
4383
|
+
**Methods:** `handleKeyDown(e: KeyboardEvent): boolean` — the host's own text-control `keydown`
|
|
4384
|
+
handler calls this while the popover is open. Handles `ArrowDown`/`ArrowUp` (moves the highlight),
|
|
4385
|
+
`Enter`/`Tab` (commits the highlighted row, no-op that returns `false` if nothing is highlighted),
|
|
4386
|
+
and `Escape` (closes with no selection). Returns `true` when the key was intercepted
|
|
4387
|
+
(`preventDefault()` already called) and the host should not also act on it.
|
|
4388
|
+
|
|
4389
|
+
**Exported types:** `MentionItem { id: string; label: string; description?: string; icon?: string
|
|
4390
|
+
}`; `MentionFilter = (item: MentionItem, query: string) => boolean`; `MentionSelectDetail { id:
|
|
4391
|
+
string; label: string }`.
|
|
4392
|
+
|
|
4393
|
+
**Events:** `lyra-mention-select` (`detail: MentionSelectDetail`, `{ id, label }` of the row that
|
|
4394
|
+
was committed via Enter/Tab/click), `lyra-mention-close` (no detail payload —
|
|
4395
|
+
`this.emit('lyra-mention-close')` is called with no second argument, so `event.detail` is `null`,
|
|
4396
|
+
not `undefined`; fires on Escape or any other `open: true -> false` transition, but never for the
|
|
4397
|
+
close that immediately follows a `lyra-mention-select` commit, and never for markup that simply
|
|
4398
|
+
renders `open="false"` on first paint)
|
|
4399
|
+
|
|
4400
|
+
**Slots:** none.
|
|
4401
|
+
|
|
4402
|
+
**CSS parts:** `listbox`, `option`, `option-icon` (when `icon` is set), `option-label`,
|
|
4403
|
+
`option-description` (when `description` is set), `empty`
|
|
4404
|
+
|
|
4405
|
+
**Themeable custom properties:** shared tokens only — `--lyra-space-xs`/`-s`/`-m` (popup padding,
|
|
4406
|
+
row padding/gap), `--lyra-color-surface`/`-border` (popup background/border), `--lyra-radius`
|
|
4407
|
+
(popup and row corners), `--lyra-shadow` (popup elevation), `--lyra-transition-fast` (open/close
|
|
4408
|
+
transition), `--lyra-color-brand-quiet` (active-row background), `--lyra-color-brand` (selected-row
|
|
4409
|
+
text), `--lyra-color-text-quiet`/`--lyra-color-text` (description text, full-contrast on the active
|
|
4410
|
+
row).
|
|
4411
|
+
|
|
4412
|
+
**Optional peer deps:** none.
|
|
4413
|
+
|
|
4414
|
+
```html
|
|
4415
|
+
<textarea id="composer"></textarea>
|
|
4416
|
+
<lyra-mention-popover id="mentions" label="People" empty-text="No matches"></lyra-mention-popover>
|
|
4417
|
+
<script type="module">
|
|
4418
|
+
const textarea = document.getElementById('composer');
|
|
4419
|
+
const popover = document.getElementById('mentions');
|
|
4420
|
+
|
|
4421
|
+
textarea.addEventListener('keydown', (e) => {
|
|
4422
|
+
if (popover.open && popover.handleKeyDown(e)) return; // consumed
|
|
4423
|
+
});
|
|
4424
|
+
textarea.addEventListener('input', () => {
|
|
4425
|
+
popover.anchor = textarea;
|
|
4426
|
+
popover.items = [
|
|
4427
|
+
{ id: 'ada', label: 'Ada Lovelace', description: 'Engineering', icon: '👩💻' },
|
|
4428
|
+
{ id: 'grace', label: 'Grace Hopper', description: 'Engineering' },
|
|
4429
|
+
];
|
|
4430
|
+
popover.query = 'a'; // detected since the trigger character
|
|
4431
|
+
popover.open = true;
|
|
4432
|
+
});
|
|
4433
|
+
|
|
4434
|
+
popover.addEventListener('lyra-mention-select', (e) => {
|
|
4435
|
+
// splice `${e.detail.label}` into the textarea at the trigger offset
|
|
4436
|
+
});
|
|
4437
|
+
</script>
|
|
4438
|
+
```
|
|
4439
|
+
|
|
4440
|
+
Integration is entirely the host's responsibility: detect a mention/command trigger in the host's
|
|
4441
|
+
own `input` handling, set `anchor`/`items`/`query` and flip `open = true`, forward every `keydown`
|
|
4442
|
+
through `handleKeyDown()` while open, and keep the host's own input's `aria-activedescendant` (and
|
|
4443
|
+
optionally `aria-controls`, via `listboxId`) in sync with `activeDescendantId`. Setting `open =
|
|
4444
|
+
false` whenever the query stops looking like an active mention context (a space typed, the trigger
|
|
4445
|
+
deleted, the input blurred, …) is also the host's job — `lyra-mention-close` fires automatically
|
|
4446
|
+
from that, there is no separate "tell it to close" call needed.
|
|
4447
|
+
|
|
4448
|
+
Positioning measures exactly where the caret currently paints via a hidden-mirror-element technique
|
|
4449
|
+
(`caretClientRect()`) and positions against that single point with `internal/positioner.js`'s
|
|
4450
|
+
`place()`, so the popup tracks the caret rather than sitting under the whole textarea. Re-measures
|
|
4451
|
+
automatically only on an `anchor` or `query` change while open (a keystroke moves the caret, so a
|
|
4452
|
+
fresh `query` is the proxy for "the caret may have moved").
|
|
4453
|
+
|
|
4454
|
+
**Known gotchas:**
|
|
4455
|
+
- The popover opens pre-highlighted on the top match (index 0), unlike `<lyra-combobox>`'s own
|
|
4456
|
+
listbox which opens with nothing highlighted (`-1`) — a bare Enter right after opening commits
|
|
4457
|
+
immediately.
|
|
4458
|
+
- Caret-precise positioning only applies to a plain `<textarea>` or single-line text
|
|
4459
|
+
`<input type="text"|"search">`; any other `anchor` element, or a text control whose caret rect
|
|
4460
|
+
can't be measured (e.g. `display: none`), silently falls back to whole-element anchoring against
|
|
4461
|
+
`anchor` itself.
|
|
4462
|
+
- A caret that moves for a reason other than typing (e.g. a mouse click elsewhere in the text while
|
|
4463
|
+
the popover happens to still be open) is not separately tracked — force a re-measure by toggling
|
|
4464
|
+
`open` or reassigning `anchor`.
|
|
4465
|
+
- `activeIndex` resets to `0` whenever `query` or `items` changes, but not when only `anchor`
|
|
4466
|
+
changes — reassigning `anchor` alone preserves whatever row was last highlighted.
|
|
4467
|
+
|
|
4468
|
+
---
|
|
4469
|
+
|
|
4470
|
+
## `lyra-streaming-text`
|
|
4471
|
+
|
|
4472
|
+
A token-coalescing incremental text renderer for streaming assistant output, with an optional
|
|
4473
|
+
blinking cursor and auto-detected Markdown rendering. First-party invention (no Web Awesome
|
|
4474
|
+
equivalent). The host is expected to assign the *entire* current text on every update to `content`,
|
|
4475
|
+
not a delta — this component does no accumulation or ordering of its own.
|
|
4476
|
+
|
|
4477
|
+
**Properties:**
|
|
4478
|
+
- `content: string = ''` — the full current text so far.
|
|
4479
|
+
- `streaming: boolean = false` (reflected) — shows the blinking cursor after the rendered text;
|
|
4480
|
+
reflects so a host can also target `lyra-streaming-text[streaming]` in CSS.
|
|
4481
|
+
- `coalesceMs: number = 50` (attribute `coalesce-ms`) — trailing-edge coalesce window, in ms, for
|
|
4482
|
+
`content` updates (see prose below).
|
|
4483
|
+
- `markdown?: boolean` (attribute `markdown`, tri-state via a custom `ComplexAttributeConverter`) —
|
|
4484
|
+
`undefined` (attribute absent, the default) auto-detects via `looksLikeMarkdown`; the attribute
|
|
4485
|
+
present with no value or `="true"` forces `true`; `markdown="false"` forces `false`. An explicit
|
|
4486
|
+
`true`/`false` always wins over the heuristic.
|
|
4487
|
+
|
|
4488
|
+
**Exported helper:** `looksLikeMarkdown(text: string): boolean` — runs a fixed, ordered list of
|
|
4489
|
+
lightweight regexes (ATX heading, fenced code block, `**bold**`, `_italic_`, inline code, bullet
|
|
4490
|
+
list item, numbered list item, `[text](url)` link, blockquote) against the whole string and returns
|
|
4491
|
+
`true` on the first match. Used internally whenever `markdown` is left unset; exported standalone
|
|
4492
|
+
so the heuristic is directly testable without going through the component's render cycle. None of
|
|
4493
|
+
the patterns need to be airtight — a false positive just routes ordinary prose harmlessly through
|
|
4494
|
+
`<lyra-markdown>`; a false negative just shows literal `**`/backticks/etc. as plain text until more
|
|
4495
|
+
of the stream arrives.
|
|
4496
|
+
|
|
4497
|
+
**Events:** none.
|
|
4498
|
+
|
|
4499
|
+
**Slots:** none — content renders from `content`, not a slot.
|
|
4500
|
+
|
|
4501
|
+
**CSS parts:** `base`, `cursor` (only rendered while `streaming` is `true`)
|
|
4502
|
+
|
|
4503
|
+
**Themeable custom properties:** `--lyra-streaming-text-cursor-width` (default `0.125rem` — the
|
|
4504
|
+
cursor bar's inline size; component-specific, since no shared "inline cursor bar" token exists,
|
|
4505
|
+
the same pattern `<lyra-typing-indicator>`'s own `--lyra-typing-cursor-width`/`-height` use), plus
|
|
4506
|
+
shared `--lyra-space-xs` (cursor's `margin-inline-start`) and `--lyra-transition-base` (blink
|
|
4507
|
+
animation cycle length).
|
|
4508
|
+
|
|
4509
|
+
**Optional peer deps:** none — internally imports and auto-registers `<lyra-markdown>` for
|
|
4510
|
+
Markdown-mode rendering (a side-effect import; the host never needs to import or register it
|
|
4511
|
+
itself).
|
|
4512
|
+
|
|
4513
|
+
```html
|
|
4514
|
+
<lyra-streaming-text id="out" coalesce-ms="80" streaming></lyra-streaming-text>
|
|
4515
|
+
<script type="module">
|
|
4516
|
+
const out = document.getElementById('out');
|
|
4517
|
+
let text = '';
|
|
4518
|
+
for await (const token of tokenStream) {
|
|
4519
|
+
text += token;
|
|
4520
|
+
out.content = text; // always the full string so far, never a delta
|
|
4521
|
+
}
|
|
4522
|
+
out.streaming = false; // forces the final chunk through immediately; cursor disappears
|
|
4523
|
+
</script>
|
|
4524
|
+
```
|
|
4525
|
+
|
|
4526
|
+
Token-by-token streaming can update `content` far faster than a human can usefully perceive a
|
|
4527
|
+
re-render, so updates funnel through `Announcer` (`../../internal/announcer.js`), reused here
|
|
4528
|
+
purely as a generic "coalesce rapid calls, flush the latest" timing primitive — with none of that
|
|
4529
|
+
class's usual DOM/ARIA plumbing. Within any `coalesce-ms` window, only the *last* `content` value
|
|
4530
|
+
assigned actually reaches the rendered DOM. Two cases always bypass the throttle and flush
|
|
4531
|
+
immediately: the very first `content` assignment after mount, and any transition of `streaming`
|
|
4532
|
+
from `true` to `false`, so the final chunk of a finished stream can never be left stranded mid-
|
|
4533
|
+
window.
|
|
4534
|
+
|
|
4535
|
+
Rendering itself is never reimplemented here: Markdown mode composes `<lyra-markdown>` directly,
|
|
4536
|
+
forwarding this component's own `streaming` through as that component's `streaming` hint prop;
|
|
4537
|
+
plain-text mode renders into a `white-space: pre-wrap` span instead. The blinking cursor degrades
|
|
4538
|
+
to a static, always-visible bar under `prefers-reduced-motion: reduce`. In plain-text mode it sits
|
|
4539
|
+
inline at the tail of the final character; in Markdown mode it renders as its own trailing block
|
|
4540
|
+
below the rendered content instead of attempting to splice into whatever nested block Markdown
|
|
4541
|
+
happens to end with.
|
|
4542
|
+
|
|
4543
|
+
**Known gotchas:**
|
|
4544
|
+
- `content` must always be the complete string so far, never a delta — this component does no
|
|
4545
|
+
accumulation of its own.
|
|
4546
|
+
- Only the very *first* `content` assignment after mount bypasses `coalesceMs` — every later
|
|
4547
|
+
assignment is throttled normally until `streaming` flips to `false`.
|
|
4548
|
+
- `markdown="false"` (any string value other than exactly `"false"` is treated as `true` by the
|
|
4549
|
+
converter) forces plain-text mode even if the text obviously contains Markdown syntax.
|
|
4550
|
+
- Purely presentational: no events, and it does not announce anything to assistive tech itself — a
|
|
4551
|
+
host that needs streamed text announced needs `<lyra-live-region>` for that (e.g. composed inside
|
|
4552
|
+
`<lyra-chat-message>`).
|
|
4553
|
+
|
|
4554
|
+
---
|
|
4555
|
+
|
|
4556
|
+
## `lyra-thinking-panel`
|
|
4557
|
+
|
|
4558
|
+
A collapsible panel for an AI agent's intermediate reasoning/"thinking" transcript, kept visually
|
|
4559
|
+
and semantically distinct from its final response. First-party invention (no Web Awesome
|
|
4560
|
+
equivalent). Same collapsible header-button-plus-region shape as `<lyra-source-list>`; the default
|
|
4561
|
+
slot is entirely free-form (a consumer-composed `<lyra-streaming-text>`, `<lyra-markdown>`, or
|
|
4562
|
+
plain text) — this component has no dependency on either.
|
|
4563
|
+
|
|
4564
|
+
**Properties:**
|
|
4565
|
+
- `label: string = 'Thinking'`
|
|
4566
|
+
- `expanded: boolean = false` (reflected) — starts collapsed, matching `<lyra-source-list>`'s
|
|
4567
|
+
default.
|
|
4568
|
+
- `mode: 'live' | 'post-hoc' = 'live'` (reflected) — `'live'` while reasoning is actively streaming
|
|
4569
|
+
in; `'post-hoc'` once it's complete and being reviewed after the fact. Drives two concrete
|
|
4570
|
+
behavior differences, see prose below.
|
|
4571
|
+
- `durationMs?: number` (attribute `duration-ms`) — how long the reasoning took. Omitted entirely
|
|
4572
|
+
(nothing rendered in `'post-hoc'`, a pulsing placeholder in `'live'`) while unset.
|
|
4573
|
+
|
|
4574
|
+
**Methods:** `scrollToBottom(): void` — scrolls `[part="body"]` to its current bottom immediately
|
|
4575
|
+
(no smooth-scroll animation). Safe to call directly, e.g. from a host that wants to force a
|
|
4576
|
+
jump-to-latest action of its own.
|
|
4577
|
+
|
|
4578
|
+
**Events:** `lyra-toggle` (`detail: { expanded: boolean }`, same event name and shape as
|
|
4579
|
+
`<lyra-source-list>`'s own `lyra-toggle`) — fired whenever the header button is activated.
|
|
4580
|
+
|
|
4581
|
+
**Slots:** default (the reasoning/thinking content; entirely free-form)
|
|
4582
|
+
|
|
4583
|
+
**CSS parts:** `base`, `header`, `label`, `duration`, `toggle`, `body`
|
|
4584
|
+
|
|
4585
|
+
**Themeable custom properties:** `--lyra-thinking-panel-max-block-size` (default `16rem` —
|
|
4586
|
+
consumer-overridable cap on how tall `[part="body"]` grows before it scrolls internally; not
|
|
4587
|
+
exposed as a component property since it's a pure layout knob, not something a template branches
|
|
4588
|
+
on), plus shared `--lyra-color-border`/`-surface`/`-text`/`-text-quiet`/`-brand`/`-brand-quiet`,
|
|
4589
|
+
`--lyra-space-xs`/`-s`/`-m`, `--lyra-radius`, `--lyra-focus-ring-width`/`-color`/`-offset`,
|
|
4590
|
+
`--lyra-transition-fast`/`-base`.
|
|
4591
|
+
|
|
4592
|
+
**Optional peer deps:** none.
|
|
4593
|
+
|
|
4594
|
+
```html
|
|
4595
|
+
<lyra-thinking-panel label="Reasoning" mode="live" expanded>
|
|
4596
|
+
<lyra-streaming-text content="Considering the user's constraints…" streaming></lyra-streaming-text>
|
|
4597
|
+
</lyra-thinking-panel>
|
|
4598
|
+
|
|
4599
|
+
<lyra-thinking-panel label="Reasoning" mode="post-hoc" duration-ms="4200">
|
|
4600
|
+
<p>Finished reasoning, collapsed by default.</p>
|
|
4601
|
+
</lyra-thinking-panel>
|
|
4602
|
+
```
|
|
4603
|
+
|
|
4604
|
+
`mode` drives two concrete behavior differences, not just a styling hook. **Header hint:** while
|
|
4605
|
+
`duration-ms` is unset, `'live'` shows a pulsing "Thinking…" placeholder in `[part="duration"]`;
|
|
4606
|
+
`'post-hoc'` shows nothing there. Once `duration-ms` is set, both modes show the same static
|
|
4607
|
+
"Thought for …" text. **Auto-scroll:** only `'live'` mode auto-follows new content appended to the
|
|
4608
|
+
default slot while `expanded`; `'post-hoc'` never scrolls on its own.
|
|
4609
|
+
|
|
4610
|
+
Live-mode auto-scroll ("stick to bottom") is the classic chat-transcript convention: while
|
|
4611
|
+
`mode="live"` and `expanded`, new content keeps the panel scrolled to its latest line — unless the
|
|
4612
|
+
user has manually scrolled up to re-read earlier content (tracked via a `scroll` listener on
|
|
4613
|
+
`[part="body"]`: every user-driven scroll records whether the body was left within 48px of its own
|
|
4614
|
+
max scroll position, and only a mutation that arrives while that's still true triggers a follow-up
|
|
4615
|
+
scroll). Opening an already-`'live'` panel always resets this to "anchored" and jumps to the latest
|
|
4616
|
+
content. New content is detected via a `MutationObserver` on this element's own light DOM
|
|
4617
|
+
(`childList`+`subtree`+`characterData`), not `slotchange`, since streamed reasoning typically
|
|
4618
|
+
appends chunks to an existing node's `textContent` rather than re-slotting a whole new element per
|
|
4619
|
+
token; scroll-to-bottom calls are coalesced to at most one per animation frame under a fast token
|
|
4620
|
+
stream.
|
|
4621
|
+
|
|
4622
|
+
**Known gotchas:**
|
|
4623
|
+
- The `MutationObserver` only watches this element's own light-DOM subtree — it cannot see a
|
|
4624
|
+
mutation that happens entirely inside a slotted custom element's own shadow root (e.g. a
|
|
4625
|
+
`<lyra-markdown>` re-rendering its shadow tree after a `content` change). A slotted element whose
|
|
4626
|
+
own internal updates should drive auto-scroll needs to append/mutate visible light-DOM text
|
|
4627
|
+
itself (as `<lyra-streaming-text>` does), or the host can call `scrollToBottom()` directly.
|
|
4628
|
+
- Switching `mode` from `'post-hoc'` to `'live'` while the panel is already `expanded` does not by
|
|
4629
|
+
itself jump to the bottom or reset stickiness — only an `expanded` transition to `true` while
|
|
4630
|
+
`mode === 'live'` does that.
|
|
4631
|
+
- The sticky-bottom flag starts `true` internally, so a panel that mounts already `expanded` and
|
|
4632
|
+
`mode="live"` follows its very first content mutation even before any `scroll` event has fired.
|
|
4633
|
+
|
|
4634
|
+
---
|
|
4635
|
+
|
|
4636
|
+
## `lyra-generation-status`
|
|
4637
|
+
|
|
4638
|
+
A compact, ticking status readout shown alongside an in-progress AI response: elapsed time, token
|
|
4639
|
+
count, and token-throughput, plus a built-in Stop button. First-party invention (no Web Awesome
|
|
4640
|
+
equivalent). Renders as e.g. `12.3s · 340 tokens · 27 tok/s [Stop]`.
|
|
4641
|
+
|
|
4642
|
+
**Properties:**
|
|
4643
|
+
- `active: boolean = false` (reflected) — whether generation is currently in progress. The
|
|
4644
|
+
elapsed-time ticker (a ~1s `setInterval`) runs only while this is `true`.
|
|
4645
|
+
- `startedAt?: number` (attribute `started-at`) — epoch-ms timestamp of when generation began.
|
|
4646
|
+
Optional — when unset while `active` is `true`, this component captures `Date.now()` itself the
|
|
4647
|
+
moment `active` becomes `true` and counts from there instead.
|
|
4648
|
+
- `tokenCount?: number` (attribute `token-count`) — running token count so far. Omitted from the
|
|
4649
|
+
readout entirely (no `tokens` segment) while unset.
|
|
4650
|
+
- `tokensPerSecond?: number` (attribute `tokens-per-second`) — host-computed tokens/sec figure,
|
|
4651
|
+
used as-is when set. When unset, derived from `token-count`/elapsed time instead (see prose).
|
|
4652
|
+
- `showStop: boolean = true` (attribute `show-stop`) — whether the built-in Stop button renders at
|
|
4653
|
+
all. **Not reflected**, and its class-field default is `true` — assign `false` via a PROPERTY
|
|
4654
|
+
binding, e.g. `.showStop=${false}` — a `?show-stop=${false}` boolean-attribute binding can never
|
|
4655
|
+
override a `true` default, since removing an attribute that was never present fires no
|
|
4656
|
+
`attributeChangedCallback` (see AGENTS.md).
|
|
4657
|
+
|
|
4658
|
+
**Events:** `lyra-stop` (no detail payload — `this.emit('lyra-stop')` is called with no second
|
|
4659
|
+
argument, so `event.detail` is `null`, not `undefined`) — fired when the built-in Stop button is
|
|
4660
|
+
clicked.
|
|
4661
|
+
|
|
4662
|
+
**Slots:** none.
|
|
4663
|
+
|
|
4664
|
+
**CSS parts:** `base`, `elapsed` (always rendered, reads `"0.0s"` before the component has ever
|
|
4665
|
+
been active), `tokens` (only rendered when `token-count` is set), `throughput` (only rendered when
|
|
4666
|
+
a value is available, host-supplied or derived), `stop-button` (only rendered while `show-stop` is
|
|
4667
|
+
`true`)
|
|
4668
|
+
|
|
4669
|
+
**Themeable custom properties:** shared tokens only — `--lyra-color-text-quiet` (base readout and
|
|
4670
|
+
tokens/throughput text color), `--lyra-color-text` (the elapsed segment's higher-contrast color,
|
|
4671
|
+
and the stop-button's icon color), `--lyra-space-s` (stop-button margin), `--lyra-icon-button-size`
|
|
4672
|
+
(stop-button min sizing, capped at `1.75rem`), `--lyra-color-border`/`-surface`/`-brand`
|
|
4673
|
+
(stop-button border/background/hover), `--lyra-focus-ring-width`/`-color`/`-offset`,
|
|
4674
|
+
`--lyra-transition-fast`.
|
|
4675
|
+
|
|
4676
|
+
**Optional peer deps:** none.
|
|
4677
|
+
|
|
4678
|
+
```html
|
|
4679
|
+
<lyra-generation-status active started-at="1732000000000" token-count="340" show-stop></lyra-generation-status>
|
|
4680
|
+
<script type="module">
|
|
4681
|
+
document.querySelector('lyra-generation-status').addEventListener('lyra-stop', () => {
|
|
4682
|
+
controller.abort(); // stop the host's own generation
|
|
4683
|
+
});
|
|
4684
|
+
</script>
|
|
4685
|
+
```
|
|
4686
|
+
|
|
4687
|
+
This is deliberately a *different* concern than `<lyra-stream-status>`: that component is about
|
|
4688
|
+
transport/connection health (idle/connecting/streaming/stalled, heartbeat-aware stall detection),
|
|
4689
|
+
while this one is a user-facing metrics readout for a generation both components' hosts typically
|
|
4690
|
+
already know is healthily in progress. Neither imports or depends on the other; compose both side
|
|
4691
|
+
by side rather than picking one. `tokens-per-second`, when supplied directly, is always used as-is;
|
|
4692
|
+
when omitted, this component derives a live figure from `token-count` divided by elapsed seconds,
|
|
4693
|
+
but only once at least one full second of elapsed time has accumulated (dividing by a sub-second
|
|
4694
|
+
window can produce wildly-swinging early readings, e.g. 3 tokens in 40ms reading as "75 tok/s").
|
|
4695
|
+
The elapsed clock is frozen, not reset, once `active` goes `false` — a static "Generated in 12.3s"
|
|
4696
|
+
reads better as a completed-state summary than the readout blanking out the instant generation
|
|
4697
|
+
ends.
|
|
4698
|
+
|
|
4699
|
+
This readout ticks roughly once per second while active, which is exactly the kind of
|
|
4700
|
+
high-frequency update `<lyra-live-region>`/`Announcer` exists to *prevent* from being read aloud
|
|
4701
|
+
verbatim — this component therefore carries no `role="status"`/`aria-live` of its own and never
|
|
4702
|
+
announces anything. A host that wants generation-start/-end announced should pair this with
|
|
4703
|
+
something that announces state *transitions* instead. The Stop button gets a normal, always-present
|
|
4704
|
+
`aria-label="Stop generating"`, no different from any other icon-only button in this library.
|
|
4705
|
+
|
|
4706
|
+
**Known gotchas:**
|
|
4707
|
+
- `showStop` defaults to `true` and is not a reflected property — see the property list above for
|
|
4708
|
+
the property-vs-attribute-binding footgun this creates.
|
|
4709
|
+
- The derived `tokens-per-second` figure only appears once `elapsedMs >= 1000`; before that, the
|
|
4710
|
+
`throughput` part simply doesn't render — supply `tokens-per-second` yourself for a stable figure
|
|
4711
|
+
from the very first tick.
|
|
4712
|
+
- The elapsed-time display is never reset to `"0.0s"` when `active` goes `false` — it freezes at
|
|
4713
|
+
its last value. A host that wants a blank readout between generations must reset `started-at`/
|
|
4714
|
+
`token-count` itself (or unmount/remount the element).
|
|
4715
|
+
- `started-at` only re-baselines the ticker at the moment it's read: mounting the component with
|
|
4716
|
+
`active` already `true` but no `started-at` captures `Date.now()` at that first update, not at
|
|
4717
|
+
whatever earlier instant generation may actually have begun.
|
|
4718
|
+
|
|
4719
|
+
---
|
|
4720
|
+
|
|
4721
|
+
## `lyra-code-block`
|
|
4722
|
+
|
|
4723
|
+
Fenced code display with optional lazy syntax highlighting and a copy button. First-party invention
|
|
4724
|
+
(no Web Awesome equivalent). No highlighting grammar ships in the component itself — it lazy-loads
|
|
4725
|
+
the optional peer dependency `shiki` (see `code-loader.ts`) for the actual tokenizing, and degrades
|
|
4726
|
+
to a plain `<pre><code>` when that peer isn't installed or `language` is unset/unrecognized. That
|
|
4727
|
+
fallback is the *default* rendering path, not a degraded one: unhighlighted code is perfectly usable,
|
|
4728
|
+
and it's what every instance renders at zero extra bytes until shiki resolves.
|
|
4729
|
+
|
|
4730
|
+
**Properties:**
|
|
4731
|
+
- `code: string = ''` — the raw source text
|
|
4732
|
+
- `language: string = ''` — a shiki-recognized language id or alias (e.g. `"javascript"`, `"python"`,
|
|
4733
|
+
`"json"`); when unset, or when shiki doesn't recognize it, the code renders as plain unhighlighted
|
|
4734
|
+
text regardless of whether shiki itself is available
|
|
4735
|
+
- `filename: string = ''` — shown in the header, when set
|
|
4736
|
+
- `collapsible: boolean = false` (reflected) — shows the collapse/expand chevron button
|
|
4737
|
+
- `collapsed: boolean = false` (reflected) — only has a visible effect while `collapsible` is also
|
|
4738
|
+
true
|
|
4739
|
+
- `copyable: boolean = true` (reflected) — shows the copy-to-clipboard button (assign `false` via a
|
|
4740
|
+
PROPERTY binding, e.g. `.copyable=${false}` — a `?copyable=${false}` boolean-attribute binding
|
|
4741
|
+
cannot override a true default)
|
|
4742
|
+
- `maxHeight: string = ''` (attribute `max-height`) — a CSS length (e.g. `"20rem"`); once set, the
|
|
4743
|
+
code scrolls internally past this height instead of growing the page
|
|
4744
|
+
|
|
4745
|
+
**Events:** `lyra-copy` (`detail: { text: string }` — always the raw `code` value, never the
|
|
4746
|
+
highlighted HTML, and always fires regardless of whether the actual OS clipboard write succeeded)
|
|
4747
|
+
|
|
4748
|
+
**Slots:** none.
|
|
4749
|
+
|
|
4750
|
+
**CSS parts:** `base`, `header`, `filename`, `language`, `copy-button`, `toggle`, `body`, `pre`,
|
|
4751
|
+
`code`
|
|
4752
|
+
|
|
4753
|
+
**Themeable custom properties:** `--lyra-code-block-max-height` (default `none` — the consumer-tunable
|
|
4754
|
+
scroll cap; only takes effect once `max-height` is set), `--lyra-code-block-font` (default
|
|
4755
|
+
`ui-monospace, SFMono-Regular, Menlo, Consolas, monospace` — no shared `--wa-*`/`--lyra-*` monospace
|
|
4756
|
+
token exists to resolve through), plus shared tokens `--lyra-color-border`, `--lyra-radius`,
|
|
4757
|
+
`--lyra-color-surface`, `--lyra-space-xs/-s/-m`, `--lyra-font`, `--lyra-color-text-quiet`,
|
|
4758
|
+
`--lyra-color-text`, `--lyra-color-brand`/`-brand-quiet`, `--lyra-transition-fast`,
|
|
4759
|
+
`--lyra-focus-ring-width/-color/-offset`.
|
|
4760
|
+
|
|
4761
|
+
**Optional peer deps:** `shiki` (lazy-loaded and cached once per page by `code-loader.ts`'s
|
|
4762
|
+
`loadShikiHighlighter()`, which builds a single `Highlighter` seeded with the bundled `github-light`/
|
|
4763
|
+
`github-dark` "dual themes" and *zero* language grammars up front; each `language` a
|
|
4764
|
+
`<lyra-code-block>` actually requests is loaded incrementally on first use via
|
|
4765
|
+
`loadShikiLanguage()`, and a language id that fails to load once is remembered and never retried. If
|
|
4766
|
+
`shiki` isn't installed, `loadShikiHighlighter()` resolves to `null` with a one-time `console.warn`
|
|
4767
|
+
and every instance falls back to plain text — install it with `pnpm add shiki` to enable
|
|
4768
|
+
highlighting).
|
|
4769
|
+
|
|
4770
|
+
```html
|
|
4771
|
+
<lyra-code-block
|
|
4772
|
+
language="typescript"
|
|
4773
|
+
filename="sum.ts"
|
|
4774
|
+
collapsible
|
|
4775
|
+
max-height="20rem"
|
|
4776
|
+
.code=${`export function sum(a: number, b: number) {\n return a + b;\n}`}
|
|
4777
|
+
@lyra-copy=${(e) => console.log('copied', e.detail.text)}
|
|
4778
|
+
></lyra-code-block>
|
|
4779
|
+
```
|
|
4780
|
+
|
|
4781
|
+
A `<lyra-skeleton variant="rect">` placeholder (with `aria-busy="true"` on the host) stands in only
|
|
4782
|
+
while shiki itself is loading for the very first time on the page and `language` is set — it is
|
|
4783
|
+
deliberately *not* shown again for a later per-language grammar fetch (that's typically fast, and the
|
|
4784
|
+
plain-text fallback already reads fine as a placeholder for it). Internally, a shiki `transformer`
|
|
4785
|
+
(`partTransformer`) rewrites shiki's generated `<pre>`/`<code>` nodes in a single pass to carry this
|
|
4786
|
+
component's own `part="pre"`/`part="code"` hooks and strips shiki's default `tabindex="0"` from
|
|
4787
|
+
`<pre>`, since `[part="body"]` is already the single scrollable/focusable region (`role="group"`,
|
|
4788
|
+
`tabindex="0"`) for the code area. Dark mode is handled via shiki's own "dual themes" feature: every
|
|
4789
|
+
token carries its light color as a plain inline `color`/`background-color` and its dark color in
|
|
4790
|
+
`--shiki-dark`/`--shiki-dark-bg` custom properties, which an `!important` media-query rule in
|
|
4791
|
+
`code-block.styles.ts` reassigns under `prefers-color-scheme: dark` — the one deliberate exception in
|
|
4792
|
+
this component to every other color being a `--lyra-*` token, since these values come from shiki's own
|
|
4793
|
+
theme data.
|
|
4794
|
+
|
|
4795
|
+
**Known gotchas:**
|
|
4796
|
+
- `copyable` defaults to `true` and reflects — see the property note above about overriding it with a
|
|
4797
|
+
property binding, not a boolean-attribute binding.
|
|
4798
|
+
- an in-flight per-language grammar load is guarded by an internal token so a `code`/`language` change
|
|
4799
|
+
that arrives before a previous load resolves never applies a stale result — only the load matching
|
|
4800
|
+
the *current* `language` is ever rendered.
|
|
4801
|
+
- a malformed `code`/`language` combination that makes shiki's `codeToHtml()` throw falls back to
|
|
4802
|
+
plain text silently, not a blank code block.
|
|
4803
|
+
- the "Copied!" label reverts to "Copy" after a fixed 1500ms, regardless of whether the clipboard
|
|
4804
|
+
write actually succeeded — `navigator.clipboard` is absent in insecure contexts/older browsers, and
|
|
4805
|
+
`lyra-copy` still fires either way.
|
|
4806
|
+
|
|
4807
|
+
---
|
|
4808
|
+
|
|
4809
|
+
## `lyra-tool-approval-dialog`
|
|
4810
|
+
|
|
4811
|
+
A human-in-the-loop gate: presents one proposed tool/function call (`toolName` + `args`) and blocks an
|
|
4812
|
+
agent from executing it until a person explicitly approves or denies it, with an optional inline
|
|
4813
|
+
"edit the arguments before approving" step. First-party invention (no Web Awesome equivalent). It is
|
|
4814
|
+
its own standalone overlay implementation (`role="dialog"`, focus-trapped, Escape/backdrop-dismissible,
|
|
4815
|
+
scroll-locking) rather than nesting `<lyra-dialog>` in its shadow template, so it has no dependency on
|
|
4816
|
+
the general-purpose dialog component.
|
|
4817
|
+
|
|
4818
|
+
Approve/Deny/Edit are built-in chrome, not a `footer` slot a consumer must assemble — there is exactly
|
|
4819
|
+
one correct action set for "approve this call". The `footer` slot is offered only for *supplementary*
|
|
4820
|
+
content alongside those buttons (e.g. a "remember this choice for this tool" checkbox); its content
|
|
4821
|
+
renders at the start of the action row, before Deny/Edit/Approve.
|
|
4822
|
+
|
|
4823
|
+
**Exported types:**
|
|
4824
|
+
- `ToolApprovalDialogCloseReason = 'escape' | 'backdrop' | 'approve' | 'deny' | 'api' | string` — the
|
|
4825
|
+
`lyra-close` detail; `'escape'`/`'backdrop'`/`'approve'`/`'deny'` come from the dialog's own built-in
|
|
4826
|
+
dismiss triggers, any other string is whatever a caller passes to `close()` directly.
|
|
4827
|
+
|
|
4828
|
+
**Properties:**
|
|
4829
|
+
- `open: boolean = false` (reflected) — set this (or call `close()`) to dismiss; there is no separate
|
|
4830
|
+
`show()`/`hide()` pair
|
|
4831
|
+
- `toolName: string = ''` (attribute `tool-name`) — the proposed call's name, e.g. `web_search`;
|
|
4832
|
+
drives the heading and the dialog's accessible name
|
|
4833
|
+
- `args: unknown = {}` (attribute: false) — the proposed call's arguments, rendered via
|
|
4834
|
+
`<lyra-json-viewer>` read-only, or stringified into a `<textarea>` while editing
|
|
4835
|
+
- `editable: boolean = true` (reflected) — whether an "Edit" affordance is offered at all (assign
|
|
4836
|
+
`false` via a PROPERTY binding, e.g. `.editable=${false}` — a `?editable=${false}`
|
|
4837
|
+
boolean-attribute binding cannot override a true default). When `false`, `args` is always shown
|
|
4838
|
+
read-only and can never be changed before approval.
|
|
4839
|
+
|
|
4840
|
+
**Methods:** `close(reason: ToolApprovalDialogCloseReason = 'api'): void` — closes the dialog, emits
|
|
4841
|
+
`lyra-close` with `reason`, and returns focus to whatever had it before the dialog opened; a no-op if
|
|
4842
|
+
already closed.
|
|
4843
|
+
|
|
4844
|
+
**Events:** `lyra-approve` (`detail: { args: unknown }` — the current, already-parsed arguments: the
|
|
4845
|
+
original `args` prop, or the user's edited-and-validated version if an edit was in progress; always
|
|
4846
|
+
followed by `lyra-close` with reason `'approve'`), `lyra-deny` (no detail — `this.emit('lyra-deny')` is
|
|
4847
|
+
called with no second argument, so per the DOM spec's `CustomEventInit` default, `event.detail` is
|
|
4848
|
+
`null`, not `undefined`; always followed by `lyra-close` with reason `'deny'`), `lyra-close`
|
|
4849
|
+
(`detail: ToolApprovalDialogCloseReason` — fired exactly once per dismissal, via Escape, a backdrop
|
|
4850
|
+
click, the Approve/Deny buttons, or a `close()` call)
|
|
4851
|
+
|
|
4852
|
+
**Slots:** `footer` — optional supplementary content (e.g. a "remember this choice" checkbox),
|
|
4853
|
+
rendered before the built-in Deny/Edit/Approve buttons.
|
|
4854
|
+
|
|
4855
|
+
**CSS parts:** `backdrop`, `panel`, `header`, `tool-name`, `body`, `args-view`, `args-editor`, `error`,
|
|
4856
|
+
`footer`, `deny-button`, `edit-button`, `approve-button`
|
|
4857
|
+
|
|
4858
|
+
**Themeable custom properties:** `--lyra-tool-approval-dialog-overlay-color` (default
|
|
4859
|
+
`rgb(0 0 0 / 0.5)` — the backdrop scrim color; component-specific since no shared overlay token
|
|
4860
|
+
exists), `--lyra-tool-approval-dialog-mono-font` (default `ui-monospace, SFMono-Regular, Menlo,
|
|
4861
|
+
Consolas, monospace` — used by both `tool-name` and the raw-JSON editor), plus shared tokens
|
|
4862
|
+
`--lyra-space-xs/-s/-m/-l`, `--lyra-color-surface`, `--lyra-color-border`, `--lyra-radius`,
|
|
4863
|
+
`--lyra-shadow`, `--lyra-color-brand`, `--lyra-color-on-brand`, `--lyra-color-danger`,
|
|
4864
|
+
`--lyra-color-text`, `--lyra-focus-ring-width/-color/-offset`, `--lyra-opacity-disabled`.
|
|
4865
|
+
|
|
4866
|
+
**Optional peer deps:** none — internally renders `<lyra-json-viewer>`, a bundled dependency of this
|
|
4867
|
+
package, not an optional peer.
|
|
4868
|
+
|
|
4869
|
+
```html
|
|
4870
|
+
<lyra-tool-approval-dialog
|
|
4871
|
+
tool-name="send_email"
|
|
4872
|
+
.args=${{ to: 'ops@example.com', subject: 'Deploy finished' }}
|
|
4873
|
+
@lyra-approve=${(e) => runTool(e.detail.args)}
|
|
4874
|
+
@lyra-deny=${() => console.log('denied')}
|
|
4875
|
+
@lyra-close=${(e) => console.log('closed:', e.detail)}
|
|
4876
|
+
></lyra-tool-approval-dialog>
|
|
4877
|
+
<script type="module">
|
|
4878
|
+
document.querySelector('lyra-tool-approval-dialog').open = true;
|
|
4879
|
+
</script>
|
|
4880
|
+
```
|
|
4881
|
+
|
|
4882
|
+
While `editable`, an Edit button swaps the read-only `<lyra-json-viewer>` for a plain `<textarea>`
|
|
4883
|
+
pre-filled with `JSON.stringify(args, null, 2)`. Every keystroke re-validates with `JSON.parse` — the
|
|
4884
|
+
Approve button is `disabled` for as long as the current textarea content fails to parse, so a
|
|
4885
|
+
malformed edit can never be silently approved as either the broken text or a stale copy of the
|
|
4886
|
+
original args. The same button relabels to "Cancel" while editing; clicking it discards the draft
|
|
4887
|
+
entirely and returns to the read-only view of the *original* `args` — there is no separate "save"
|
|
4888
|
+
step independent of Approve itself. Both `editing` and any in-progress draft reset back to the
|
|
4889
|
+
read-only view every time the dialog transitions from closed to open, so a reused instance never
|
|
4890
|
+
leaks one proposal's half-finished edit into the next.
|
|
4891
|
+
|
|
4892
|
+
Initial focus deliberately does *not* land on Approve: approving a tool call is a consequential,
|
|
4893
|
+
potentially irreversible action, so a user who opens the dialog and reflexively presses Enter/Space
|
|
4894
|
+
before reading anything should deny, not approve. Deny gets the initial focus instead — the same
|
|
4895
|
+
"focus the safe action" convention a native destructive-confirmation dialog typically follows for its
|
|
4896
|
+
own Cancel button. Tab/Shift+Tab are bounded to the panel's own focusable content (shadow-piercingly,
|
|
4897
|
+
so `<lyra-json-viewer>`'s internal toggle/copy buttons and any slotted custom element's real focusable
|
|
4898
|
+
target are found too) via a locally-duplicated focus-trap helper, the same shape `<lyra-dialog>`'s and
|
|
4899
|
+
`<lyra-tool-select-dialog>`'s own overlays use.
|
|
4900
|
+
|
|
4901
|
+
**Known gotchas:**
|
|
4902
|
+
- `editable` defaults to `true` and reflects — see the property note above about overriding it with a
|
|
4903
|
+
property binding, not a boolean-attribute binding.
|
|
4904
|
+
- `lyra-deny` has no detail payload: its `event.detail` is `null`, not `undefined`.
|
|
4905
|
+
- a consumer flipping `editable` off while an edit is already in progress automatically exits edit mode
|
|
4906
|
+
and discards the draft, so an unreachable "Cancel" affordance is never left stranded on screen.
|
|
4907
|
+
- reconnecting the element while still `open` (e.g. a drag-and-drop reparent that keeps the same
|
|
4908
|
+
instance) automatically re-acquires the ref-counted scroll lock and keydown trap it dropped in
|
|
4909
|
+
`disconnectedCallback` — `willUpdate()` alone wouldn't otherwise notice, since disconnect/reconnect
|
|
4910
|
+
fire back-to-back with no update in between.
|
|
4911
|
+
- the Approve button's native `disabled` attribute (while the draft is invalid JSON) automatically
|
|
4912
|
+
excludes it from the Tab trap, since the shared `FOCUSABLE_SELECTOR` used to compute the trap's
|
|
4913
|
+
focusable set already skips disabled controls.
|
|
4914
|
+
|
|
4915
|
+
---
|
|
4916
|
+
|
|
4917
|
+
## `lyra-tool-param-form`
|
|
4918
|
+
|
|
4919
|
+
Renders one form control per top-level property of a JSON Schema object, for ad hoc tool invocation or
|
|
4920
|
+
approval-editing UIs (e.g. "the agent wants to call `create_event(title, attendees, allDay)` — let the
|
|
4921
|
+
user tweak the arguments before running it"). First-party invention (no Web Awesome equivalent).
|
|
4922
|
+
|
|
4923
|
+
**Scope limitation (intentional, not accidental):** this component only understands a *flat* object
|
|
4924
|
+
schema — every `properties` entry must be `'string'`, `'number'`, `'integer'`, `'boolean'`, or a string
|
|
4925
|
+
`enum`. Nested objects, arrays, `oneOf`/`anyOf`/`allOf`, `$ref`, and any other JSON Schema keyword are
|
|
4926
|
+
not read. A property whose `type` isn't one of the four above renders a visible "Unsupported field
|
|
4927
|
+
type" note instead of silently dropping it or throwing.
|
|
4928
|
+
|
|
4929
|
+
**Exported types:**
|
|
4930
|
+
- `ToolParamFormPropertyType = 'string' | 'number' | 'integer' | 'boolean'` — the four leaf property
|
|
4931
|
+
types this renderer understands
|
|
4932
|
+
- `ToolParamFormProperty { type: ToolParamFormPropertyType; enum?: string[]; description?: string;
|
|
4933
|
+
title?: string; default?: unknown }` — one `schema.properties` entry. `enum` is only meaningful when
|
|
4934
|
+
`type` is `'string'` (rendered as a `<lyra-select>`); `title` is the display label, falling back to
|
|
4935
|
+
the property key itself; `description` renders as helper text under the control; `default` pre-fills
|
|
4936
|
+
a field whenever `value` doesn't already have that key.
|
|
4937
|
+
- `ToolParamFormSchema { type: 'object'; properties: Record<string, ToolParamFormProperty>; required?:
|
|
4938
|
+
string[] }` — the (intentionally flat) schema shape this component can render.
|
|
4939
|
+
|
|
4940
|
+
**Properties:**
|
|
4941
|
+
- `schema: ToolParamFormSchema = { type: 'object', properties: {} }` (attribute: false)
|
|
4942
|
+
- `value: Record<string, unknown> = {}` (attribute: false) — exactly what the consumer last set it to.
|
|
4943
|
+
A field with no entry in `value` but a schema `default` *displays* (and is *emitted*, via
|
|
4944
|
+
`lyra-input`) as that default, but the `value` property itself is left alone until the user actually
|
|
4945
|
+
edits that field — mirrors an uncontrolled `<input placeholder>` not writing to `.value`.
|
|
4946
|
+
- `name: string = ''` — submission key for optional native `<form>` participation
|
|
4947
|
+
- `disabled: boolean = false` (reflected)
|
|
4948
|
+
|
|
4949
|
+
**Getters:**
|
|
4950
|
+
- `effectiveValue: Record<string, unknown>` — `value` with every property missing from it filled in
|
|
4951
|
+
from `schema`'s own `default`; this is what actually renders and what `lyra-input`'s detail carries.
|
|
4952
|
+
A key the user has explicitly cleared (a real own property set to `undefined`) stays cleared rather
|
|
4953
|
+
than snapping back to its default — only a key genuinely absent from `value` falls back.
|
|
4954
|
+
- `errors: Record<string, string>` — the current per-field validation errors (`{ [propertyKey]:
|
|
4955
|
+
message }`), required-and-empty only; independent of which fields have been visited, so it doesn't
|
|
4956
|
+
need a prior `reportValidity()` call to be accurate.
|
|
4957
|
+
|
|
4958
|
+
**Methods:**
|
|
4959
|
+
- `checkValidity(): boolean` — non-mutating; delegates to the underlying `ElementInternals`, never
|
|
4960
|
+
reveals inline errors.
|
|
4961
|
+
- `reportValidity(): boolean` — reveals inline errors for every currently-invalid required field (as
|
|
4962
|
+
if each had been visited) and returns overall validity; the hook a consumer's own Submit/Approve
|
|
4963
|
+
button should call right before acting, mirroring a native `<form>`'s `reportValidity()`.
|
|
4964
|
+
|
|
4965
|
+
**Events:** `lyra-input` (`detail: { value: Record<string, unknown> }` — the full current value
|
|
4966
|
+
object, every property with defaults resolved, not just the field that changed), `lyra-validity-change`
|
|
4967
|
+
(`detail: { valid: boolean; errors: Record<string, string> }` — fired whenever overall validity
|
|
4968
|
+
changes, including once up front at connect time, so a consumer sees `valid: false` immediately if a
|
|
4969
|
+
required field starts out empty without waiting for user interaction)
|
|
4970
|
+
|
|
4971
|
+
**Slots:** none.
|
|
4972
|
+
|
|
4973
|
+
**CSS parts:** `base`, `field`, `label`, `description`, `error`
|
|
4974
|
+
|
|
4975
|
+
**Themeable custom properties:** no component-specific custom properties; shared tokens only —
|
|
4976
|
+
`--lyra-space-l/-xs/-s`, `--lyra-color-border`, `--lyra-radius`, `--lyra-color-surface`,
|
|
4977
|
+
`--lyra-color-danger`, `--lyra-color-text-quiet`, `--lyra-focus-ring-width/-color/-offset`,
|
|
4978
|
+
`--lyra-opacity-disabled`.
|
|
4979
|
+
|
|
4980
|
+
**Optional peer deps:** none — internally renders `<lyra-select>`, `<lyra-option>`, and
|
|
4981
|
+
`<lyra-checkbox>`, all bundled dependencies of this package imported directly, not optional peers.
|
|
4982
|
+
|
|
4983
|
+
```html
|
|
4984
|
+
<lyra-tool-param-form
|
|
4985
|
+
.schema=${{
|
|
4986
|
+
type: 'object',
|
|
4987
|
+
properties: {
|
|
4988
|
+
title: { type: 'string', title: 'Title' },
|
|
4989
|
+
attendees: { type: 'string', enum: ['team', 'everyone'], default: 'team' },
|
|
4990
|
+
allDay: { type: 'boolean', title: 'All day' },
|
|
4991
|
+
},
|
|
4992
|
+
required: ['title'],
|
|
4993
|
+
}}
|
|
4994
|
+
.value=${draftArgs}
|
|
4995
|
+
@lyra-input=${(e) => (draftArgs = e.detail.value)}
|
|
4996
|
+
@lyra-validity-change=${(e) => (formIsValid = e.detail.valid)}
|
|
4997
|
+
></lyra-tool-param-form>
|
|
4998
|
+
```
|
|
4999
|
+
|
|
5000
|
+
This component owns no Submit/Cancel/Approve chrome — a consumer composes it inside their own dialog
|
|
5001
|
+
(e.g. `<lyra-tool-approval-dialog>`) and reads `.value`/`.errors`/`checkValidity()` (or calls
|
|
5002
|
+
`reportValidity()` right before acting). Fields render in `Object.keys(schema.properties)` order
|
|
5003
|
+
(insertion order). A `'string'` property with a non-empty `enum` renders as a `<lyra-select>` of
|
|
5004
|
+
`<lyra-option>`s; a plain `'string'` renders a text `<input>`; `'number'`/`'integer'` render a numeric
|
|
5005
|
+
`<input type="number">` (`step="1"` for integer, `step="any"` for number); `'boolean'` renders a
|
|
5006
|
+
`<lyra-checkbox>` with the field's label projected into its default slot — real slotted content, since
|
|
5007
|
+
that's `<lyra-checkbox>`'s documented way to give itself an accessible name, unlike `aria-describedby`
|
|
5008
|
+
(neither `<lyra-select>` nor `<lyra-checkbox>` forward a host-level `aria-describedby` to their
|
|
5009
|
+
internal focusable element, so for the enum-select case the error message is folded into `aria-label`
|
|
5010
|
+
instead, alongside the always-visible adjacent `[part="description"]` text). A `required` boolean
|
|
5011
|
+
field means "must be checked" — there is no third "unset" visual state a rendered checkbox can
|
|
5012
|
+
represent, so `isFieldEmpty()` treats anything other than `true` as empty for that field's type.
|
|
5013
|
+
|
|
5014
|
+
Optional native `<form>` participation is implemented via `ElementInternals` attached directly in the
|
|
5015
|
+
constructor (`static formAssociated = true`) rather than a string-value mixin, since this component's
|
|
5016
|
+
value is a whole object: `formResetCallback()` clears `value`/touched state back to `{}`, and
|
|
5017
|
+
`formDisabledCallback(disabled)` mirrors the wrapping form's disabled state onto the `disabled`
|
|
5018
|
+
property. This is a nice-to-have layered on top of the primary `value` + `lyra-input`/
|
|
5019
|
+
`lyra-validity-change` contract, not a requirement — a consumer that never puts this inside a `<form>`
|
|
5020
|
+
loses nothing.
|
|
5021
|
+
|
|
5022
|
+
**Known gotchas:**
|
|
5023
|
+
- a schema property whose `type` isn't `'string'`/`'number'`/`'integer'`/`'boolean'` renders an inline
|
|
5024
|
+
"Unsupported field type" message (a plain `.unsupported`-classed element, not one of the documented
|
|
5025
|
+
CSS parts) instead of throwing or being silently dropped.
|
|
5026
|
+
- inline per-field errors only render once a field has been visited (`focusout`) at least once, or
|
|
5027
|
+
after an explicit `reportValidity()` call — `checkValidity()` alone never reveals them, matching
|
|
5028
|
+
every other form control in this library (`<lyra-select>`/`<lyra-combobox>`/`<lyra-model-select>`
|
|
5029
|
+
all avoid flashing red before the user has touched anything).
|
|
5030
|
+
- `effectiveValue` distinguishes "key absent from `value`" (falls back to `default`) from "key present
|
|
5031
|
+
but `undefined`" (stays cleared) via `hasOwnProperty`, not an `=== undefined` check — relevant if you
|
|
5032
|
+
ever construct `value` objects by hand rather than round-tripping `lyra-input`'s detail.
|
|
5033
|
+
- `lyra-validity-change` fires once immediately at connect time even before any user interaction, so a
|
|
5034
|
+
form with an unmet required field announces `valid: false` on mount, not only after the first edit.
|
|
5035
|
+
|
|
5036
|
+
---
|
|
5037
|
+
|
|
5038
|
+
## `lyra-menu` / `lyra-menu-item`
|
|
5039
|
+
|
|
5040
|
+
An anchored dropdown built around a consumer-supplied trigger element (typically an icon button)
|
|
5041
|
+
assigned to the `trigger` slot. First-party invention (no Web Awesome equivalent) — a close, drop-
|
|
5042
|
+
in-shaped replacement for reaching outside this library for a third-party dropdown to build a gear
|
|
5043
|
+
menu, an avatar menu, or a history row's overflow menu. Uses the WAI-ARIA "menu button" pattern —
|
|
5044
|
+
`role="menu"`/`role="menuitem"` with real roving DOM focus moving between actual focusable
|
|
5045
|
+
`<lyra-menu-item>` rows — deliberately not a `role="listbox"`/`aria-activedescendant` shape (that's
|
|
5046
|
+
`<lyra-select>`'s pattern instead).
|
|
5047
|
+
|
|
5048
|
+
### `lyra-menu`
|
|
5049
|
+
|
|
5050
|
+
**Properties:**
|
|
5051
|
+
- `open: boolean = false` (reflected)
|
|
5052
|
+
- `placement?: Placement` (reflected — resolved through `rtlAwarePlacement()` (`internal/rtl.ts`),
|
|
5053
|
+
then forwarded to `place()`; defaults to whatever `place()` itself defaults to. A `left`/`right`
|
|
5054
|
+
side is mirrored under `dir="rtl"`, so e.g. `placement="left-start"` still anchors to the menu's
|
|
5055
|
+
trailing edge instead of pinning to the physical left)
|
|
5056
|
+
- `label: string = 'Menu'` — accessible name for the `role="menu"` popup; override with something
|
|
5057
|
+
specific (e.g. "Row actions") when a page has more than one menu
|
|
5058
|
+
|
|
5059
|
+
**Events:** `lyra-show` (no detail — fires only when `open` transitions to `true`, not for markup
|
|
5060
|
+
that renders `open` true from the start), `lyra-hide` (same first-render guard, opposite
|
|
5061
|
+
transition), `lyra-menu-select` (`detail: { value }` — a consolidated re-fire of the activated
|
|
5062
|
+
`<lyra-menu-item>`'s own `lyra-menu-item-select`; always followed by the menu closing and focus
|
|
5063
|
+
returning to the trigger)
|
|
5064
|
+
|
|
5065
|
+
**Slots:** `trigger` (the consumer's own trigger element — first assigned element wins if several
|
|
5066
|
+
are assigned; enhanced imperatively with `aria-haspopup="menu"`/`aria-expanded`/`aria-controls`
|
|
5067
|
+
since those attributes belong on the actual interactive trigger, which lives outside this
|
|
5068
|
+
component's shadow root), default (`<lyra-menu-item>` elements, plus optionally plain `<hr>`
|
|
5069
|
+
dividers between groups — native `<hr>` already carries an implicit `separator` role)
|
|
5070
|
+
|
|
5071
|
+
**CSS parts:** `trigger` (wrapper around the `trigger` slot — the positioning anchor), `popup` (the
|
|
5072
|
+
positioned floating panel), `list` (the `role="menu"` container wrapping the default slot)
|
|
5073
|
+
|
|
5074
|
+
**Themeable custom properties:** shared tokens only (`--lyra-color-surface`, `--lyra-color-border`,
|
|
5075
|
+
`--lyra-radius`, `--lyra-shadow`, `--lyra-space-xs`, `--lyra-transition-fast`).
|
|
5076
|
+
|
|
5077
|
+
**Optional peer deps:** none.
|
|
5078
|
+
|
|
5079
|
+
### `lyra-menu-item`
|
|
5080
|
+
|
|
5081
|
+
Not meaningful standalone — it exists purely as `<lyra-menu>`'s light-DOM child, the same
|
|
5082
|
+
relationship `<lyra-option>` has to `<lyra-combobox>`/`<lyra-select>`. `role="menuitem"` and the
|
|
5083
|
+
roving `tabindex` both live on the host element itself (mirroring `<lyra-tree-node>`), not an
|
|
5084
|
+
internal shadow-DOM button; `<lyra-menu>` is the sole owner of this element's `tabIndex`.
|
|
5085
|
+
|
|
5086
|
+
**Properties:**
|
|
5087
|
+
- `value: string = ''` — an id/value echoed back in the parent `<lyra-menu>`'s `lyra-menu-select`
|
|
5088
|
+
detail
|
|
5089
|
+
- `disabled: boolean = false` (reflected — disables selection and excludes this item from
|
|
5090
|
+
`<lyra-menu>`'s roving-tabindex navigation entirely)
|
|
5091
|
+
- `destructive: boolean = false` (reflected — tints the row with `--lyra-color-danger`, for a
|
|
5092
|
+
dangerous action like "Delete")
|
|
5093
|
+
|
|
5094
|
+
**Events:** `lyra-menu-item-select` (no detail payload — `this.emit('lyra-menu-item-select')` is
|
|
5095
|
+
called with no second argument, so `event.detail` is `null`, not `undefined`; fires on click, or
|
|
5096
|
+
when the parent `<lyra-menu>`'s own Enter/Space keydown handling calls `select()` on the currently
|
|
5097
|
+
roving-focused item)
|
|
5098
|
+
|
|
5099
|
+
**Slots:** default (the item's label content), `icon` (optional leading icon)
|
|
5100
|
+
|
|
5101
|
+
**CSS parts:** `base` (the row — `role` lives on the host, not this part), `icon` (wrapper around
|
|
5102
|
+
the `icon` slot; not rendered/hidden entirely while the slot is empty), `label` (wrapper around the
|
|
5103
|
+
default slot)
|
|
5104
|
+
|
|
5105
|
+
**Themeable custom properties:** shared tokens only (`--lyra-radius`, `--lyra-focus-ring-width`,
|
|
5106
|
+
`--lyra-focus-ring-color`, `--lyra-space-xs`, `--lyra-space-s`, `--lyra-color-brand-quiet`,
|
|
5107
|
+
`--lyra-opacity-disabled`, `--lyra-color-danger`, `--lyra-color-danger-quiet`).
|
|
5108
|
+
|
|
5109
|
+
**Optional peer deps:** none.
|
|
5110
|
+
|
|
5111
|
+
```html
|
|
5112
|
+
<lyra-menu label="Row actions">
|
|
5113
|
+
<button slot="trigger" aria-label="More actions">⋮</button>
|
|
5114
|
+
<lyra-menu-item value="edit">Edit</lyra-menu-item>
|
|
5115
|
+
<lyra-menu-item value="duplicate">Duplicate</lyra-menu-item>
|
|
5116
|
+
<hr />
|
|
5117
|
+
<lyra-menu-item value="delete" destructive>Delete</lyra-menu-item>
|
|
5118
|
+
</lyra-menu>
|
|
5119
|
+
<script type="module">
|
|
5120
|
+
document.querySelector('lyra-menu').addEventListener('lyra-menu-select', (e) => console.log(e.detail.value));
|
|
5121
|
+
</script>
|
|
5122
|
+
```
|
|
5123
|
+
|
|
5124
|
+
The popup is always rendered in the DOM (never `display:none`) so `.focus()` calls on its content
|
|
5125
|
+
work synchronously the instant it opens — closed state is conveyed via `visibility`/`opacity`
|
|
5126
|
+
instead. `visibility` is an inherited CSS property that pierces the `<slot>` projection boundary, so
|
|
5127
|
+
every closed-state `<lyra-menu-item>` is automatically excluded from sequential (Tab-key)
|
|
5128
|
+
navigation with no separate JS bookkeeping. Once open, ArrowDown/ArrowUp move the roving focus among
|
|
5129
|
+
non-disabled items and wrap past either end; Home/End jump to the first/last non-disabled item;
|
|
5130
|
+
Enter/Space activate the focused item; Escape closes and refocuses the trigger. A click outside both
|
|
5131
|
+
the trigger and the open popup also closes it, but deliberately does *not* refocus the trigger — the
|
|
5132
|
+
outside click itself already moved focus somewhere the user chose.
|
|
5133
|
+
|
|
5134
|
+
**Known gotchas:**
|
|
5135
|
+
- `<lyra-menu-item>`'s click handler lives on an inner shadow-DOM element (`[part="base"]`), not the
|
|
5136
|
+
host — calling `.click()` directly on the `<lyra-menu-item>` host element in a test does **not**
|
|
5137
|
+
trigger selection; you must click (or dispatch on) the element returned by
|
|
5138
|
+
`menuItemEl.shadowRoot.querySelector('[part="base"]')`.
|
|
5139
|
+
- `lyra-show`/`lyra-hide` are suppressed on the very first render even if `open` is already `true`
|
|
5140
|
+
in markup — only later `open` transitions fire them.
|
|
5141
|
+
- `lyra-menu-item-select` carries no detail payload (`event.detail === null`); read
|
|
5142
|
+
`event.target.value` instead. `<lyra-menu>`'s own re-fired `lyra-menu-select` is the one that
|
|
5143
|
+
carries `detail: { value }`.
|
|
5144
|
+
- Tab closes the menu without `preventDefault()` or focus trapping — the browser's own default Tab
|
|
5145
|
+
navigation proceeds untouched, only the (now-stale) open state is cleared.
|
|
5146
|
+
- Only Escape and a committed selection refocus the trigger on close; a click outside does not.
|
|
5147
|
+
|
|
5148
|
+
---
|
|
5149
|
+
|
|
5150
|
+
## `lyra-chip` / `lyra-chip-group`
|
|
5151
|
+
|
|
5152
|
+
A small, content-agnostic pill for a short label: a tag, an active-filter/scope indicator, etc.
|
|
5153
|
+
Distinct from `<lyra-attachment-chip>` (specifically file-shaped, with a thumbnail/size/upload-
|
|
5154
|
+
progress) — this pair carries no domain assumptions at all. `<lyra-chip>` is a controlled component:
|
|
5155
|
+
clicking its remove (×) button only fires `lyra-remove` — the chip never removes itself from the DOM
|
|
5156
|
+
on its own interaction, the same contract `<lyra-attachment-chip>`/`<lyra-conversation-item>`
|
|
5157
|
+
already follow.
|
|
5158
|
+
|
|
5159
|
+
### `lyra-chip`
|
|
5160
|
+
|
|
5161
|
+
**Properties:**
|
|
5162
|
+
- `tone: 'neutral' | 'brand' | 'success' | 'warning' | 'danger' = 'neutral'` (reflected — tints the
|
|
5163
|
+
whole pill using a loud-color-on-quiet-tint convention; `neutral` has no dedicated token pair, so
|
|
5164
|
+
it falls back to a plain bordered-surface look)
|
|
5165
|
+
- `removable: boolean = false` (reflected — shows the remove (×) button)
|
|
5166
|
+
- `value?: string` — opaque consumer bookkeeping value, never read, validated, or rendered by this
|
|
5167
|
+
component itself, only ever echoed back verbatim (including `undefined` if never set) in
|
|
5168
|
+
`lyra-remove`'s detail
|
|
5169
|
+
|
|
5170
|
+
**Events:** `lyra-remove` (`detail: { value }` — the remove (×) button was activated via click or
|
|
5171
|
+
Enter/Space while focused; only rendered/reachable while `removable`)
|
|
5172
|
+
|
|
5173
|
+
**Slots:** default (the chip's label content), `icon` (optional leading icon or status dot; nothing
|
|
5174
|
+
reserved for it — no extra gap — when left empty)
|
|
5175
|
+
|
|
5176
|
+
**CSS parts:** `base` (the pill's root container), `icon` (wrapper around the `icon` slot; hidden
|
|
5177
|
+
entirely while empty), `label` (wrapper around the default slot), `remove-button` (the remove (×)
|
|
5178
|
+
affordance, only rendered while `removable`)
|
|
5179
|
+
|
|
5180
|
+
**Themeable custom properties:** `--lyra-chip-accent`, `--lyra-chip-bg`, `--lyra-chip-border`
|
|
5181
|
+
(component-local trio swapped per `tone` rather than repeating background/color/border per part per
|
|
5182
|
+
tone; default `var(--lyra-color-text)` / `var(--lyra-color-surface)` / `var(--lyra-color-border)` —
|
|
5183
|
+
mirrors the same accent/bg/border vocabulary `<lyra-tool-call-chip>`/`<lyra-attachment-chip>` use),
|
|
5184
|
+
plus shared tokens (`--lyra-space-xs`, `--lyra-space-s`, `--lyra-color-brand`/`-brand-quiet`,
|
|
5185
|
+
`--lyra-color-success`/`-success-quiet`, `--lyra-color-warning`/`-warning-quiet`,
|
|
5186
|
+
`--lyra-color-danger`/`-danger-quiet`, `--lyra-icon-button-size`, `--lyra-focus-ring-width`,
|
|
5187
|
+
`--lyra-focus-ring-color`, `--lyra-focus-ring-offset`, `--lyra-transition-fast`).
|
|
5188
|
+
|
|
5189
|
+
**Optional peer deps:** none.
|
|
5190
|
+
|
|
5191
|
+
### `lyra-chip-group`
|
|
5192
|
+
|
|
5193
|
+
A flex-wrap container for a set of `<lyra-chip>` children — plain light-DOM composition, direct
|
|
5194
|
+
children are the chips (the same shape `<lyra-split>`'s panels / `<lyra-source-list>`'s cards take,
|
|
5195
|
+
no `.items` array prop).
|
|
5196
|
+
|
|
5197
|
+
**Properties:**
|
|
5198
|
+
- `maxVisible?: number` (attribute `max-visible`) — maximum number of direct children shown before
|
|
5199
|
+
the rest collapse behind a "+N" indicator; unset (the default) means no limit, every child is
|
|
5200
|
+
always shown
|
|
5201
|
+
|
|
5202
|
+
**Events:** `lyra-overflow-toggle` (`detail: { expanded }` — the overflow indicator was activated,
|
|
5203
|
+
revealing or re-collapsing the excess children; fires only from that click, i.e. only when
|
|
5204
|
+
`max-visible` is actually causing an overflow state — never as a side effect of `max-visible`/
|
|
5205
|
+
children changing on their own)
|
|
5206
|
+
|
|
5207
|
+
**Slots:** default (`<lyra-chip>` elements, or any content, though the chip pairing is the intended
|
|
5208
|
+
usage)
|
|
5209
|
+
|
|
5210
|
+
**CSS parts:** `base` (the flex-wrap container, holds both the slot and the overflow indicator),
|
|
5211
|
+
`overflow-indicator` (the "+N" / "Show less" toggle button; only rendered while `max-visible` is
|
|
5212
|
+
actively causing an overflow — a locally-styled pill, not an instantiated real `<lyra-chip>`)
|
|
5213
|
+
|
|
5214
|
+
**Themeable custom properties:** shared tokens only (`--lyra-space-xs`, `--lyra-space-s`,
|
|
5215
|
+
`--lyra-color-border`, `--lyra-color-surface`, `--lyra-color-text-quiet`, `--lyra-color-text`,
|
|
5216
|
+
`--lyra-color-brand`, `--lyra-focus-ring-width`, `--lyra-focus-ring-color`,
|
|
5217
|
+
`--lyra-focus-ring-offset`, `--lyra-transition-fast`).
|
|
5218
|
+
|
|
5219
|
+
**Optional peer deps:** none.
|
|
5220
|
+
|
|
5221
|
+
```html
|
|
5222
|
+
<lyra-chip-group max-visible="3">
|
|
5223
|
+
<lyra-chip removable value="draft">Draft</lyra-chip>
|
|
5224
|
+
<lyra-chip tone="success" removable value="reviewed">Reviewed</lyra-chip>
|
|
5225
|
+
<lyra-chip tone="warning">Needs input</lyra-chip>
|
|
5226
|
+
<lyra-chip tone="danger">Blocked</lyra-chip>
|
|
5227
|
+
</lyra-chip-group>
|
|
5228
|
+
<script type="module">
|
|
5229
|
+
const group = document.querySelector('lyra-chip-group');
|
|
5230
|
+
group.addEventListener('lyra-overflow-toggle', (e) => console.log(e.detail.expanded));
|
|
5231
|
+
group.querySelectorAll('lyra-chip').forEach((chip) => chip.addEventListener('lyra-remove', (e) => console.log(e.detail.value)));
|
|
5232
|
+
</script>
|
|
5233
|
+
```
|
|
5234
|
+
|
|
5235
|
+
Since CSS alone can't parameterize `:nth-child` on a runtime prop, `<lyra-chip-group>` reaches
|
|
5236
|
+
directly into the light DOM and sets each excess child's own `hidden` property once `max-visible` is
|
|
5237
|
+
exceeded — the same approach `<lyra-split>` uses to set each panel's inline `flex`/`order`, rather
|
|
5238
|
+
than a stylesheet-only solution.
|
|
5239
|
+
|
|
5240
|
+
**Known gotchas:**
|
|
5241
|
+
- `<lyra-chip>`'s accessible remove-button label ("Remove {text}") is computed only from the default
|
|
5242
|
+
slot's own text content — text living inside the (decorative) `icon` slot doesn't leak into it.
|
|
5243
|
+
- `<lyra-chip-group>` silently un-expands (`expanded` resets to `false`, with no event firing) if a
|
|
5244
|
+
consumer raises `max-visible` past the current child count while already expanded — only an actual
|
|
5245
|
+
click on the overflow indicator fires `lyra-overflow-toggle`.
|
|
5246
|
+
- `<lyra-chip-group>`'s overflow indicator is its own locally-styled pill, not an instantiated
|
|
5247
|
+
`<lyra-chip>` in its shadow DOM — don't expect `<lyra-chip>`'s CSS parts or custom properties to
|
|
5248
|
+
reach it.
|
|
5249
|
+
|
|
5250
|
+
---
|
|
5251
|
+
|
|
5252
|
+
## `lyra-model-settings-panel`
|
|
5253
|
+
|
|
5254
|
+
A fixed composition of `<lyra-model-select>` and `<lyra-slider>` into one agent-configuration card:
|
|
5255
|
+
pick a provider's model, then tune its sampling temperature. First-party invention (no Web Awesome
|
|
5256
|
+
equivalent). Not a generic layout shell — it exists so a consumer doesn't have to re-wire the same
|
|
5257
|
+
two child `lyra-change` events into one combined settings object by hand every time this pairing
|
|
5258
|
+
comes up.
|
|
5259
|
+
|
|
5260
|
+
Every prop here is a plain pass-through to (or mirror of) the matching child control's own prop of
|
|
5261
|
+
the same/similar name — see `lyra-model-select` and `lyra-slider` themselves for the exact semantics
|
|
5262
|
+
of `catalog`/`allowCustom` and `temperatureMin`/`temperatureMax`/`temperatureStep`.
|
|
5263
|
+
|
|
5264
|
+
**Properties:**
|
|
5265
|
+
- `provider: string = ''` — informational provider badge, passed straight through to the internal
|
|
5266
|
+
`lyra-model-select`.
|
|
5267
|
+
- `catalog?: LyraModelCatalog` (attribute: false, JS-only) — `string[] | { id: string; label: string
|
|
5268
|
+
}[]` (every entry must be one shape or the other, never mixed); passed straight through to the
|
|
5269
|
+
internal `lyra-model-select`.
|
|
5270
|
+
- `modelValue: string = ''` (attribute `model-value`) — the current model id.
|
|
5271
|
+
- `allowCustom: boolean = false` (attribute `allow-custom`) — lets the model control accept a value
|
|
5272
|
+
outside `catalog`; passed straight through.
|
|
5273
|
+
- `temperature: number = 1` — the current sampling temperature. `1` is the midpoint of the default
|
|
5274
|
+
`[0, 2]` range and matches both OpenAI's and Anthropic's own provider default; reassign it yourself
|
|
5275
|
+
if your provider differs.
|
|
5276
|
+
- `temperatureMin: number = 0` (attribute `temperature-min`)
|
|
5277
|
+
- `temperatureMax: number = 2` (attribute `temperature-max`)
|
|
5278
|
+
- `temperatureStep: number = 0.1` (attribute `temperature-step`)
|
|
5279
|
+
- `layout: 'vertical' | 'compact' = 'vertical'` (reflected) — `vertical` stacks full-width rows with
|
|
5280
|
+
visible labels; `compact` runs the same two rows side by side with a smaller, uppercase temperature
|
|
5281
|
+
caption, for toolbars/sidebars where the vertical layout's height doesn't fit.
|
|
5282
|
+
|
|
5283
|
+
**Events:** `lyra-change` — `detail: { modelValue: string; inCatalog: boolean; temperature: number }`.
|
|
5284
|
+
Fires whenever *either* child control's own `lyra-change` fires, and always carries the full current
|
|
5285
|
+
settings snapshot, not just whichever field actually changed. `inCatalog` is recomputed fresh from
|
|
5286
|
+
`catalog`/`modelValue` on every emission (mirroring `lyra-model-select`'s own `effectiveEntries` logic)
|
|
5287
|
+
rather than cached from the last child event, so it's still correct even when `modelValue` was just
|
|
5288
|
+
assigned directly instead of via the child's own event.
|
|
5289
|
+
|
|
5290
|
+
**Slots:** none — this is a fixed two-control composition, not a generic layout shell.
|
|
5291
|
+
|
|
5292
|
+
**CSS parts:** `base`, `model-row`, `temperature-row`, `temperature-label`, `temperature-value`
|
|
5293
|
+
|
|
5294
|
+
**Themeable custom properties:** no component-specific custom properties; consumes shared tokens
|
|
5295
|
+
`--lyra-space-l/-m/-s/-xs`, `--lyra-color-border`, `--lyra-radius`, `--lyra-color-surface`,
|
|
5296
|
+
`--lyra-color-text`, `--lyra-color-text-quiet`.
|
|
5297
|
+
|
|
5298
|
+
**Optional peer deps:** none — it composes the library's own `<lyra-model-select>` and `<lyra-slider>`
|
|
5299
|
+
internally (both imported unconditionally as side effects, not optional).
|
|
5300
|
+
|
|
5301
|
+
```html
|
|
5302
|
+
<lyra-model-settings-panel
|
|
5303
|
+
provider="OpenAI"
|
|
5304
|
+
.catalog=${['gpt-4o', 'gpt-4o-mini', 'gpt-4.1']}
|
|
5305
|
+
model-value="gpt-4o"
|
|
5306
|
+
temperature="0.7"
|
|
5307
|
+
@lyra-change=${(e) => console.log(e.detail)}
|
|
5308
|
+
></lyra-model-settings-panel>
|
|
5309
|
+
|
|
5310
|
+
<lyra-model-settings-panel layout="compact" .catalog=${catalog}></lyra-model-settings-panel>
|
|
5311
|
+
```
|
|
5312
|
+
|
|
5313
|
+
The internal `lyra-slider` renders with its own value readout suppressed (`.showValue=${false}`);
|
|
5314
|
+
the current temperature is instead shown via this component's own `[part="temperature-value"]` span,
|
|
5315
|
+
which interpolates `temperature` verbatim with no `toFixed`/formatting applied — a value like `0.1`
|
|
5316
|
+
shows as `0.1`, and any floating-point noise a slider drag produces would render digit-for-digit.
|
|
5317
|
+
|
|
5318
|
+
**Known gotchas:**
|
|
5319
|
+
- `catalog` is JS-only (`attribute: false`) — set it via a property binding (`.catalog=`), never as
|
|
5320
|
+
an HTML attribute, same requirement as the underlying `lyra-model-select`.
|
|
5321
|
+
- `layout="compact"` removes the host's own `max-inline-size` cap (`28rem` in `vertical` layout)
|
|
5322
|
+
entirely, so a compact panel can grow as wide as its container/flex context allows.
|
|
5323
|
+
- The nested `lyra-model-select`'s own `max-inline-size` (sized for a standalone dropdown) is
|
|
5324
|
+
overridden to `100%`/`none` inside `[part="model-row"]` so it fills the card's full width — a
|
|
5325
|
+
detail only worth knowing if you're targeting `lyra-model-select` internals with your own CSS
|
|
5326
|
+
through this component.
|
|
5327
|
+
|
|
5328
|
+
---
|
|
5329
|
+
|
|
5330
|
+
## `lyra-context-meter`
|
|
5331
|
+
|
|
5332
|
+
A segmented occupancy meter (bar or ring) for showing how a fixed capacity — a model's context
|
|
5333
|
+
window, a token budget, any consumable quota — is divided across labeled categories. First-party
|
|
5334
|
+
invention (no Web Awesome equivalent). Pure data visualization: it renders `segments`/`total` exactly
|
|
5335
|
+
as given and never computes token counts, costs, or any other domain-specific estimate itself — the
|
|
5336
|
+
one exception is the plain arithmetic sum of segment values used to build the accessible "X of Y
|
|
5337
|
+
used" summary.
|
|
5338
|
+
|
|
5339
|
+
**Properties:**
|
|
5340
|
+
- `segments: ContextMeterSegment[] = []` (attribute: false, JS-only) — `{ label: string; value:
|
|
5341
|
+
number; tone?: 'brand' | 'success' | 'warning' | 'danger' | 'neutral' }[]`. `value` is an *absolute*
|
|
5342
|
+
quantity measured against `total`, never a pre-computed percentage.
|
|
5343
|
+
- `total: number = 0` — the full capacity segments are measured against (e.g. a model's context
|
|
5344
|
+
window size).
|
|
5345
|
+
- `variant: 'ring' | 'bar' = 'bar'` (reflected)
|
|
5346
|
+
- `label: string = ''` — overall accessible caption, e.g. `"128K context window"`. Also rendered
|
|
5347
|
+
visually (`[part="label"]`) when set.
|
|
5348
|
+
|
|
5349
|
+
**Events:** none.
|
|
5350
|
+
|
|
5351
|
+
**Slots:** none.
|
|
5352
|
+
|
|
5353
|
+
**CSS parts:** `base` (a `<div>` for `bar`, an `<svg>` for `ring`), `track` (the unfilled/empty
|
|
5354
|
+
capacity), `segment` (one occupied segment — carries `data-tone` for styling), `label`
|
|
5355
|
+
|
|
5356
|
+
**Themeable custom properties:** no component-specific custom properties; consumes shared tokens
|
|
5357
|
+
`--lyra-space-xs`, `--lyra-color-text-quiet`, `--lyra-font`, `--lyra-radius`, `--lyra-color-border`,
|
|
5358
|
+
`--lyra-color-brand`, `--lyra-color-success`, `--lyra-color-warning`, `--lyra-color-danger`,
|
|
5359
|
+
`--lyra-transition-base`.
|
|
5360
|
+
|
|
5361
|
+
**Optional peer deps:** none.
|
|
5362
|
+
|
|
5363
|
+
```html
|
|
5364
|
+
<lyra-context-meter
|
|
5365
|
+
label="128K context window"
|
|
5366
|
+
total="128000"
|
|
5367
|
+
.segments=${[
|
|
5368
|
+
{ label: 'System prompt', value: 2200, tone: 'neutral' },
|
|
5369
|
+
{ label: 'Conversation history', value: 61000, tone: 'brand' },
|
|
5370
|
+
{ label: 'Retrieved context', value: 30800, tone: 'warning' },
|
|
5371
|
+
]}
|
|
5372
|
+
></lyra-context-meter>
|
|
5373
|
+
|
|
5374
|
+
<lyra-context-meter variant="ring" total="128000" .segments=${segments}></lyra-context-meter>
|
|
5375
|
+
```
|
|
5376
|
+
|
|
5377
|
+
`role="img"` and a computed `aria-label` are set imperatively on the *host* element itself in
|
|
5378
|
+
`willUpdate` (mirroring `lyra-gauge`'s "meter" role convention) — every internal node (`track`,
|
|
5379
|
+
`segment`, the ring's `<svg>`, `label`) is `aria-hidden`, so a screen reader gets one meaningful
|
|
5380
|
+
summary string instead of the raw markup. That summary's "used" figure is the plain, unclamped sum of
|
|
5381
|
+
`segments[].value` (`"150,000 of 128,000 used"` is possible if segments overshoot `total`), even
|
|
5382
|
+
though the *visual* meter itself never overflows: each segment's ratio is clamped so the running
|
|
5383
|
+
cumulative fill across all segments can never exceed 100% of the bar/ring — an over-`total` `segments`
|
|
5384
|
+
array renders as a fully (not over-) filled meter, with later segments truncated or squeezed to zero
|
|
5385
|
+
width/arc-length as the budget runs out. `total <= 0` (or non-finite) renders zero segments — an empty
|
|
5386
|
+
track/ring — regardless of what's in `segments`. Ring geometry (a 40-radius circle, 12px stroke,
|
|
5387
|
+
centered at 50,50) intentionally matches `lyra-gauge`'s own radial numbers, so the two circular-meter
|
|
5388
|
+
components in the library share one visual scale.
|
|
5389
|
+
|
|
5390
|
+
**Known gotchas:**
|
|
5391
|
+
- The ring variant's per-segment `<title>` and the bar variant's per-segment `title=` attribute are
|
|
5392
|
+
native mouse-hover tooltips only — they sit inside `aria-hidden` markup, so screen readers never
|
|
5393
|
+
read them; only the host's own `role="img"`/`aria-label` carries accessible information.
|
|
5394
|
+
- `variant="ring"` fixes the host at `8em × 8em` (`:host([variant='ring'])`) — the bar variant's
|
|
5395
|
+
`inline-size: 100%` does not apply in ring mode; resize it via `font-size` or an explicit
|
|
5396
|
+
width/height override on the host instead.
|
|
5397
|
+
- Segment order is significant for the ring's cumulative `stroke-dashoffset` — later entries in
|
|
5398
|
+
`segments` render further around the circle (starting at 12 o'clock, going clockwise); there's no
|
|
5399
|
+
independent sort/z-order control.
|
|
5400
|
+
|
|
5401
|
+
---
|
|
5402
|
+
|
|
5403
|
+
## `lyra-dock-panel`
|
|
5404
|
+
|
|
5405
|
+
A single panel docked to one edge of whatever contains it, resizable by dragging its inner edge.
|
|
5406
|
+
First-party invention (no Web Awesome equivalent). Unlike `lyra-split` (which owns and lays out N
|
|
5407
|
+
sibling panels, and requires restructuring a layout so every panel becomes its direct child), this is
|
|
5408
|
+
one self-contained element you drop next to your existing content — typically as an absolutely-
|
|
5409
|
+
positioned child of a `position: relative` parent, or as a flex item alongside a main-content sibling.
|
|
5410
|
+
It deliberately imposes no `position`/`inset` of its own: it only manages its own size along the
|
|
5411
|
+
resize axis (`inline-size` for `start`/`end`, `block-size` for `top`/`bottom`) and fills 100% of the
|
|
5412
|
+
cross axis, leaving where it sits in the page entirely up to the consumer's own layout. `lyra-split`
|
|
5413
|
+
stays the right primitive for the multi-sibling-panel case; this is the primitive for the single-edge-
|
|
5414
|
+
docked case.
|
|
5415
|
+
|
|
5416
|
+
**Properties:**
|
|
5417
|
+
- `edge: 'start' | 'end' | 'top' | 'bottom' = 'end'` (reflected) — which edge of the panel's own
|
|
5418
|
+
container it's docked to. `start`/`end` are logical-inline (mirror left/right depending on writing
|
|
5419
|
+
direction); `top`/`bottom` are block-direction and unaffected by RTL.
|
|
5420
|
+
- `size: string = '280px'` — the current docked size along the resize axis, as a CSS length.
|
|
5421
|
+
- `minSize: string = '160px'` (attribute `min-size`) — minimum resize bound, as a CSS length.
|
|
5422
|
+
- `maxSize: string = ''` (attribute `max-size`) — maximum resize bound. Empty means "no explicit
|
|
5423
|
+
cap": the live extent of the containing element is used instead (falling back to the viewport if
|
|
5424
|
+
there's no parent, e.g. not yet connected), so the panel still can't be dragged wider/taller than
|
|
5425
|
+
its container.
|
|
5426
|
+
- `collapsible: boolean = false` (reflected)
|
|
5427
|
+
- `collapsed: boolean = false` (reflected)
|
|
5428
|
+
- `resizable: boolean = true` (reflected) — when `false`, no drag handle renders at all and the panel
|
|
5429
|
+
is a fixed size. (Assign `false` via a **property** binding, e.g. `.resizable=${false}` — a
|
|
5430
|
+
`?resizable=${false}` boolean-attribute binding cannot override a `true` default; see AGENTS.md.)
|
|
5431
|
+
|
|
5432
|
+
**Exported helper:** `parseLengthPx(length: string, containerPx: number, fontSizeEl: Element =
|
|
5433
|
+
document.documentElement): number | undefined` — resolves an arbitrary CSS length (`px`, `rem`, `em`,
|
|
5434
|
+
`vw`, `vh`, `%`, or a bare/unitless number treated as `px`) to a live pixel value without a DOM-probe
|
|
5435
|
+
measurement, since `min-size`/`max-size` are pure constraints that are never themselves rendered
|
|
5436
|
+
anywhere. `rem` resolves against the document root's font size; `em` resolves against `fontSizeEl`'s
|
|
5437
|
+
own computed font size; `%` resolves against `containerPx`. Returns `undefined` for an
|
|
5438
|
+
empty/unparseable string. Used internally to resolve `min-size`/`max-size`; the panel's *current* size
|
|
5439
|
+
is instead always read back live from `getBoundingClientRect()`, which handles any unit for free.
|
|
5440
|
+
|
|
5441
|
+
**Events:**
|
|
5442
|
+
- `lyra-resize` — `detail: { size }` (a `px` CSS length string), fired on every drag step, drag
|
|
5443
|
+
release, and keyboard step.
|
|
5444
|
+
- `lyra-collapse-change` — `detail: { collapsed }`, fired whenever the collapse toggle flips
|
|
5445
|
+
`collapsed`.
|
|
5446
|
+
|
|
5447
|
+
**Slots:** default — the panel's own content.
|
|
5448
|
+
|
|
5449
|
+
**CSS parts:** `base` (the panel root), `content` (wraps the default slot; hidden while `collapsed`),
|
|
5450
|
+
`handle` (the draggable resize edge; only rendered when `resizable` and not `collapsed`),
|
|
5451
|
+
`collapse-toggle` (only rendered when `collapsible`)
|
|
5452
|
+
|
|
5453
|
+
**Themeable custom properties:** `--lyra-dock-panel-collapsed-size` (default
|
|
5454
|
+
`var(--lyra-icon-button-size)`) — the persistent "rail" width/height the panel holds at while
|
|
5455
|
+
`collapsed`, rather than collapsing to zero (a zero-size collapsed panel would have nowhere to host
|
|
5456
|
+
the re-expand toggle); component-specific since collapse never zeroes the box. Plus shared tokens
|
|
5457
|
+
`--lyra-color-surface`, `--lyra-color-border`, `--lyra-color-brand`, `--lyra-color-brand-quiet`,
|
|
5458
|
+
`--lyra-color-text`, `--lyra-radius`, `--lyra-space-xs`, `--lyra-focus-ring-width/-color/-offset`,
|
|
5459
|
+
`--lyra-transition-fast`, `--lyra-icon-button-size`.
|
|
5460
|
+
|
|
5461
|
+
**Optional peer deps:** none.
|
|
5462
|
+
|
|
5463
|
+
```html
|
|
5464
|
+
<div style="position: relative; block-size: 100vh;">
|
|
5465
|
+
<lyra-dock-panel
|
|
5466
|
+
edge="end"
|
|
5467
|
+
size="320px"
|
|
5468
|
+
min-size="200px"
|
|
5469
|
+
max-size="480px"
|
|
5470
|
+
collapsible
|
|
5471
|
+
@lyra-resize=${(e) => console.log(e.detail.size)}
|
|
5472
|
+
@lyra-collapse-change=${(e) => console.log(e.detail.collapsed)}
|
|
5473
|
+
>
|
|
5474
|
+
<div>Sidebar content — a chat thread list, an inspector, anything.</div>
|
|
5475
|
+
</lyra-dock-panel>
|
|
5476
|
+
</div>
|
|
5477
|
+
```
|
|
5478
|
+
|
|
5479
|
+
Pointer-drag-resize mirrors `lyra-split`'s pointer-capture technique (`pointerdown` captures the
|
|
5480
|
+
pointer on the handle; `pointermove` computes a new size; `pointerup`/`pointercancel`/
|
|
5481
|
+
`lostpointercapture` all release it, since a drag can end without a clean `pointerup`) but reasons in
|
|
5482
|
+
raw pixels throughout rather than percent. Every resize — drag step, drag release, or a keyboard step
|
|
5483
|
+
(<kbd>ArrowLeft</kbd>/<kbd>ArrowRight</kbd> for the inline axis, <kbd>ArrowUp</kbd>/<kbd>ArrowDown</kbd>
|
|
5484
|
+
for the block axis, 16px per step) — always commits `size` as a rounded `px` string regardless of
|
|
5485
|
+
what unit `size`/`min-size`/`max-size` were originally expressed in.
|
|
5486
|
+
|
|
5487
|
+
**Known gotchas:**
|
|
5488
|
+
- `collapsed` doesn't zero the panel's box — it shrinks to the persistent rail size
|
|
5489
|
+
(`--lyra-dock-panel-collapsed-size`). `size` itself is left untouched while collapsed, so
|
|
5490
|
+
re-expanding restores exactly what it was.
|
|
5491
|
+
- `handle` only renders while `resizable && !collapsed`; `collapse-toggle` only renders while
|
|
5492
|
+
`collapsible` — a panel with both `false` renders neither control, just fixed-size slotted content.
|
|
5493
|
+
`resizable` and `collapsed` interact: dragging is disabled whenever `collapsed` is `true`, even if
|
|
5494
|
+
`resizable` is also `true`.
|
|
5495
|
+
- The collapse-toggle's chevron rotates to point toward the panel's pinned edge when expanded (the
|
|
5496
|
+
direction clicking it will shrink toward) and away from it when collapsed — this is folded through
|
|
5497
|
+
both `edge` and, for `start`/`end`, current RTL-ness, so the same markup visually flips correctly
|
|
5498
|
+
under `dir="rtl"` with no extra author work.
|
|
5499
|
+
|
|
5500
|
+
---
|
|
5501
|
+
|
|
5502
|
+
## `lyra-document-preview`
|
|
5503
|
+
|
|
5504
|
+
A format-dispatching viewer for one document/attachment, plus the visual state machine for an async
|
|
5505
|
+
server-side conversion a host app runs in front of it. First-party invention (no Web Awesome
|
|
5506
|
+
equivalent).
|
|
5507
|
+
|
|
5508
|
+
Format dispatch is intentionally minimal: only `text/*`/`application/json` (a plain, scrollable
|
|
5509
|
+
`<pre>` — no syntax highlighting; compose `<lyra-code-block>` yourself via the `unsupported` slot for
|
|
5510
|
+
that) and `image/*` (a contained `<img>`) render inline. Everything else — PDF, office documents,
|
|
5511
|
+
video, audio, or any unrecognized MIME type — falls back to a generic "can't preview this" state: a
|
|
5512
|
+
file glyph, a short message, and (when `src` is set) a native `<a download>` link. This is a
|
|
5513
|
+
deliberate ceiling, not a gap: the component ships a dispatch *shell*, not a format registry. The
|
|
5514
|
+
`unsupported` slot is the escape hatch for every format left out of the built-in three.
|
|
5515
|
+
|
|
5516
|
+
`status="converting"` is a second, independent axis from format dispatch. This component doesn't know
|
|
5517
|
+
your backend's conversion API shape and owns none of the actual polling/fetch — a host converting a
|
|
5518
|
+
non-natively-previewable format server-side (e.g. `.docx` → `.pdf`) polls its own backend and updates
|
|
5519
|
+
`status`/`progress`/`src` here as that proceeds; this component only *visualizes* that state (an
|
|
5520
|
+
indeterminate spinner, or a determinate one once `progress` is supplied). The one piece of async work
|
|
5521
|
+
this component *does* own is fetching a `text/*`/`application/json` `src` itself — there's no other
|
|
5522
|
+
way to get a `<pre>`'s text content from a URL — gated behind a generation-counter guard
|
|
5523
|
+
(`lyra-tool-result-view`'s `resolve()` uses the identical pattern) so a `src` reassigned mid-fetch
|
|
5524
|
+
can't have a stale response clobber a newer one.
|
|
5525
|
+
|
|
5526
|
+
**Properties:**
|
|
5527
|
+
- `src: string = ''` — URL to fetch (for `text`/`application/json`) or display (`image`, or as the
|
|
5528
|
+
generic fallback's download `href`). Optional — gracefully absent while, e.g., a conversion is
|
|
5529
|
+
still in progress.
|
|
5530
|
+
- `mimeType: string = ''` (attribute `mime-type`) — drives format dispatch (see above).
|
|
5531
|
+
- `filename: string = ''` — shown in the header and used as the download link's suggested filename.
|
|
5532
|
+
- `status: 'idle' | 'converting' | 'ready' | 'error' = 'idle'` (reflected) — host-owned lifecycle
|
|
5533
|
+
state. `"converting"` shows the spinner regardless of `mimeType`/`src`; `"error"` shows
|
|
5534
|
+
`errorMessage` regardless of either. `"idle"`/`"ready"` both resume normal format dispatch — a host
|
|
5535
|
+
with no conversion step never has to explicitly set `"ready"`.
|
|
5536
|
+
- `progress?: number` (type `Number`) — 0-100. Only consulted while `status="converting"`. Unset (the
|
|
5537
|
+
default) renders an indeterminate spinner instead of a determinate progress bar.
|
|
5538
|
+
- `errorMessage: string = ''` (attribute `error-message`) — shown via `[part="error"]` while
|
|
5539
|
+
`status="error"`.
|
|
5540
|
+
- `maxHeight: string = ''` (attribute `max-height`) — a CSS length (e.g. `"24rem"`); once set,
|
|
5541
|
+
`[part="body"]` scrolls internally past this height instead of growing the page — same contract as
|
|
5542
|
+
`lyra-json-viewer`'s identically-named prop.
|
|
5543
|
+
|
|
5544
|
+
**Events:**
|
|
5545
|
+
- `lyra-download` — `detail: { src, filename }` — fired when the generic-download fallback's link is
|
|
5546
|
+
activated. The browser download itself needs no JS (a plain `<a download>` handles it); this is
|
|
5547
|
+
purely for a host that wants to observe/log the download.
|
|
5548
|
+
- `lyra-render-error` — `detail: { error }` — fired when this component's own `text/*`/
|
|
5549
|
+
`application/json` `fetch(src)` fails (network error or non-2xx response). Distinct from
|
|
5550
|
+
`status="error"`, which is entirely host-driven.
|
|
5551
|
+
|
|
5552
|
+
**Slots:** `unsupported` — escape hatch: when populated, its content renders *instead of* the generic
|
|
5553
|
+
download fallback for any `mime-type` this component doesn't natively support. Ignored while
|
|
5554
|
+
`mime-type` resolves to `text`/`image` dispatch, or while `status` is `"converting"`/`"error"`.
|
|
5555
|
+
|
|
5556
|
+
**CSS parts:** `base`, `header` (hidden entirely when `filename` is unset), `filename`, `body`,
|
|
5557
|
+
`spinner` (indeterminate `role="status"`, or `role="progressbar"` once numeric progress is known —
|
|
5558
|
+
used both for `status="converting"` and this component's own in-flight text fetch), `error`
|
|
5559
|
+
(`role="alert"` — used both for `status="error"` and a failed text fetch), `download-link` (only
|
|
5560
|
+
rendered when `src` is set)
|
|
5561
|
+
|
|
5562
|
+
**Themeable custom properties:** `--lyra-document-preview-max-height` (default `none`) — the
|
|
5563
|
+
consumer-tunable scroll cap on `[part="body"]`, set from `max-height`; `none` means the preview grows
|
|
5564
|
+
with its content until a caller opts in. `--lyra-document-preview-font` (default `ui-monospace,
|
|
5565
|
+
SFMono-Regular, Menlo, Consolas, monospace`) — no shared `--wa-*`/`--lyra-*` monospace token exists yet
|
|
5566
|
+
(the same gap `lyra-json-viewer`'s own `--lyra-json-viewer-font` documents). Plus shared tokens
|
|
5567
|
+
`--lyra-color-border`, `--lyra-radius`, `--lyra-color-surface`, `--lyra-space-s/-m/-l/-xs`,
|
|
5568
|
+
`--lyra-color-text`, `--lyra-color-text-quiet`, `--lyra-color-danger`, `--lyra-color-brand`,
|
|
5569
|
+
`--lyra-color-on-brand`, `--lyra-focus-ring-width/-color/-offset`, `--lyra-transition-fast`.
|
|
5570
|
+
|
|
5571
|
+
**Optional peer deps:** none directly — the `unsupported` slot is commonly used to compose
|
|
5572
|
+
`<lyra-code-block>` (which has its own optional `shiki` peer dependency; see that component's own
|
|
5573
|
+
entry) or a third-party PDF/office-doc viewer, but neither is a dependency of this component itself.
|
|
5574
|
+
|
|
5575
|
+
```html
|
|
5576
|
+
<lyra-document-preview
|
|
5577
|
+
filename="board-notes.txt"
|
|
5578
|
+
mime-type="text/plain"
|
|
5579
|
+
src="/files/board-notes.txt"
|
|
5580
|
+
max-height="24rem"
|
|
5581
|
+
@lyra-render-error=${(e) => console.error(e.detail.error)}
|
|
5582
|
+
></lyra-document-preview>
|
|
5583
|
+
|
|
5584
|
+
<!-- A host driving its own server-side conversion -->
|
|
5585
|
+
<lyra-document-preview filename="deck.pptx" status="converting" progress="42"></lyra-document-preview>
|
|
5586
|
+
|
|
5587
|
+
<!-- Escape hatch for an unsupported format -->
|
|
5588
|
+
<lyra-document-preview filename="deck.pptx" mime-type="application/vnd.ms-powerpoint" src="/files/deck.pptx">
|
|
5589
|
+
<lyra-code-block slot="unsupported" language="text">Open in PowerPoint to preview.</lyra-code-block>
|
|
5590
|
+
</lyra-document-preview>
|
|
5591
|
+
```
|
|
5592
|
+
|
|
5593
|
+
Accessibility: the `"converting"` state without numeric `progress` is a `role="status"` region
|
|
5594
|
+
wrapping a visually-hidden "Converting document…" string — a *plain* static region, not routed
|
|
5595
|
+
through `<lyra-live-region>`/`Announcer`, since (like `<lyra-typing-indicator>`) it only ever has one
|
|
5596
|
+
thing to announce (entering the state), not a rapidly-repeating stream. Once real `progress` is
|
|
5597
|
+
available, the region becomes a standard `role="progressbar"` instead, self-describing via
|
|
5598
|
+
`aria-valuenow` with no extra live-region wiring. `status="error"` renders `[part="error"]` as
|
|
5599
|
+
`role="alert"` — a one-shot assertive notice, without needing the announcer machinery either.
|
|
5600
|
+
|
|
5601
|
+
**Known gotchas:**
|
|
5602
|
+
- `status="converting"`/`status="error"` always win over format dispatch, regardless of
|
|
5603
|
+
`mimeType`/`src` — a `text`/`image` source is not shown until `status` returns to `"idle"`/`"ready"`.
|
|
5604
|
+
- The component's own text/JSON `fetch(src)` is a *different* async operation from the host-driven
|
|
5605
|
+
`status="converting"` conversion. A failed fetch fires `lyra-render-error` and renders
|
|
5606
|
+
`[part="error"]` on its own; it never sets `status="error"` itself.
|
|
5607
|
+
- `progress` is only ever consulted for the host-driven `"converting"` state — this component's own
|
|
5608
|
+
in-flight text fetch always renders the indeterminate spinner, with no numeric-progress path.
|
|
5609
|
+
- The `unsupported` slot's initial presence is detected once, before the first render, by scanning
|
|
5610
|
+
light-DOM children directly (not the slot's `assignedElements()`); every later slot-content change
|
|
5611
|
+
is instead picked up via the slot's own `slotchange` listener. Both paths keep detection correct,
|
|
5612
|
+
just via two different mechanisms depending on timing.
|
|
5613
|
+
- `download-link` (and thus `lyra-download`) only renders/fires when `src` is set — a generic-fallback
|
|
5614
|
+
state with no `src` shows only the file glyph and message, with no download affordance at all.
|
|
5615
|
+
|
|
5616
|
+
---
|
|
5617
|
+
|
|
5618
|
+
## `lyra-media-card`
|
|
5619
|
+
|
|
5620
|
+
A lightweight inline preview for one already-sent, already-available attachment inside a rendered
|
|
5621
|
+
chat message body — distinct from `lyra-document-preview` (a fuller viewer with an async
|
|
5622
|
+
server-side-conversion state machine) and `lyra-attachment-chip` (a pre-send queued-file chip with
|
|
5623
|
+
upload progress). This component has neither concern; it only ever shows a `src` that's already
|
|
5624
|
+
final.
|
|
5625
|
+
|
|
5626
|
+
**Properties:**
|
|
5627
|
+
- `src: string = ''` — the media URL. Always re-validated against a safe-scheme allowlist before
|
|
5628
|
+
use (see below) — never trust it unsanitized even though it's typed as a plain string.
|
|
5629
|
+
- `kind?: 'image' | 'video' | 'file'` (reflected) — explicit format dispatch. Leave unset to
|
|
5630
|
+
auto-detect from `mimeType`.
|
|
5631
|
+
- `mimeType: string = ''` (attribute `mime-type`) — drives auto-detection when `kind` is unset.
|
|
5632
|
+
- `filename: string = ''` — shown in the file-chip fallback, used as the download link's suggested
|
|
5633
|
+
filename, and folded into the accessible name.
|
|
5634
|
+
- `alt: string = ''` — alt text for the image case (and reused as a video label fallback). Falls
|
|
5635
|
+
back to `filename`, then a generic per-kind description.
|
|
5636
|
+
|
|
5637
|
+
**Events:** `lyra-open` (`detail: { src: string; filename: string }`, cancelable) — fired when the
|
|
5638
|
+
card (or, for `kind="video"`, its separate `open-button`) is activated. This component never
|
|
5639
|
+
navigates on its own for `image`/`video` — a host decides what "open" means. The `file`-chip case is
|
|
5640
|
+
the exception: when `src` passes the stricter href safety check, the chip is a real `<a href
|
|
5641
|
+
download>` so a bare drop-in still does something useful, but `lyra-open` fires first — a host
|
|
5642
|
+
calling `preventDefault()` on it suppresses that default download/open so it can substitute its own
|
|
5643
|
+
handling.
|
|
5644
|
+
|
|
5645
|
+
**Slots:** none.
|
|
5646
|
+
|
|
5647
|
+
**CSS parts:** `base` (a `<button>` for `kind="image"`, a plain wrapper `<div>` for `kind="video"`,
|
|
5648
|
+
an `<a>` or `<span>` for the file-chip fallback depending on href safety), `media` (the `<img>`/
|
|
5649
|
+
`<video>`), `file-icon`, `filename` (file-chip fallback only), `open-button` (video only — see
|
|
5650
|
+
below).
|
|
5651
|
+
|
|
5652
|
+
**Themeable custom properties:** shared tokens only — `--lyra-space-xs`/`-s`, `--lyra-color-border`,
|
|
5653
|
+
`--lyra-color-surface`, `--lyra-color-text`/`-text-quiet`, `--lyra-radius`, `--lyra-focus-ring-*`.
|
|
5654
|
+
|
|
5655
|
+
**Optional peer deps:** none.
|
|
5656
|
+
|
|
5657
|
+
```html
|
|
5658
|
+
<lyra-media-card kind="image" src="https://example.com/photo.jpg" alt="Screenshot" filename="photo.jpg"
|
|
5659
|
+
@lyra-open=${(e) => openLightbox(e.detail.src)}
|
|
5660
|
+
></lyra-media-card>
|
|
5661
|
+
<lyra-media-card kind="file" src="https://example.com/report.pdf" filename="report.pdf"></lyra-media-card>
|
|
5662
|
+
```
|
|
5663
|
+
|
|
5664
|
+
**Safe-URL checking.** `src` is validated (exported as `safeMediaSrc()`/`safeLinkHref()`) before it's
|
|
5665
|
+
ever assigned to an `<img>`/`<video>` `src` or an `<a href>` — only `http:`/`https:`/`blob:` (plus
|
|
5666
|
+
`data:` for a *media* `src` only) or a scheme-relative/relative URL with no scheme at all pass;
|
|
5667
|
+
anything else (`javascript:`, `vbscript:`, and similarly suspicious schemes) is rejected. `data:` is
|
|
5668
|
+
allowed for `safeMediaSrc()` (a browser never executes script from a media element's `src`) but
|
|
5669
|
+
rejected by the stricter `safeLinkHref()` (a `data:text/html` URI navigated to via a clicked `<a
|
|
5670
|
+
href>` runs as a full document and can execute script) — the same scheme gets a different verdict
|
|
5671
|
+
depending on which DOM sink it's headed for. Both functions delegate to the platform's own `new
|
|
5672
|
+
URL()` parser rather than a hand-rolled scheme regex, specifically because `new URL()` already
|
|
5673
|
+
implements the WHATWG URL Standard's input normalization (stripping tab/newline/leading-trailing
|
|
5674
|
+
space before looking for a scheme) — a naive regex is vulnerable to exactly the kind of
|
|
5675
|
+
tab-injected-into-a-scheme bypass a browser attribute sink still normalizes and executes. An
|
|
5676
|
+
`image`/`video` `kind` whose `src` fails the media-src check falls back to the generic file-chip
|
|
5677
|
+
rendering, which then separately re-validates `src` against the stricter href allowlist for its own
|
|
5678
|
+
download affordance.
|
|
5679
|
+
|
|
5680
|
+
`kind="video"` renders its open affordance as a separate `[part="open-button"]` next to
|
|
5681
|
+
`[part="media"]` rather than wrapping the whole card in one `<button>`/`<a>` (the pattern
|
|
5682
|
+
`image`/`file` use) — a `<video controls>` element is itself interactive content, and HTML forbids
|
|
5683
|
+
nesting interactive content inside a `<button>`/`<a>`; doing so anyway would also make every click on
|
|
5684
|
+
the video's own native controls bubble up and spuriously fire `lyra-open`.
|
|
5685
|
+
|
|
5686
|
+
**Known gotchas:**
|
|
5687
|
+
- Calling the real `.click()` (or dispatching a `click`/`MouseEvent`) on the file-chip's `<a href>`
|
|
5688
|
+
in a test genuinely triggers real browser navigation — always `preventDefault()` on a `click`
|
|
5689
|
+
listener registered before triggering it, the same precaution `lyra-document-preview`'s own
|
|
5690
|
+
download-link tests already take. A synthetic `dispatchEvent(new MouseEvent('click', {cancelable:
|
|
5691
|
+
true}))` still invokes the anchor's native activation behavior if nothing calls
|
|
5692
|
+
`preventDefault()` during dispatch — it is not a safe no-op.
|
|
5693
|
+
- `kind` only reflects to the host attribute when explicitly set — CSS keying off the
|
|
5694
|
+
auto-detected resolved kind should target the rendered `[part]`/element (e.g. `video[part="media"]`),
|
|
5695
|
+
not `:host([kind=...])`, since the latter won't see an auto-detected kind.
|
|
5696
|
+
|
|
5697
|
+
---
|
|
5698
|
+
|
|
5699
|
+
## `lyra-attachment-trigger`
|
|
5700
|
+
|
|
5701
|
+
A compact attach affordance designed for a chat composer's leading slot (see `lyra-chat-composer`'s
|
|
5702
|
+
own `leading` slot, which this drops straight into, though it has no code dependency on it). First-
|
|
5703
|
+
party invention (no Web Awesome equivalent). Its shape adapts to how many attachment `capabilities`
|
|
5704
|
+
are configured: exactly one renders a single plain icon button; more than one renders a small
|
|
5705
|
+
anchored menu (composed from the already-landed `lyra-menu`/`lyra-menu-item`) listing each
|
|
5706
|
+
capability as a row.
|
|
5707
|
+
|
|
5708
|
+
**Properties:**
|
|
5709
|
+
- `capabilities: AttachmentCapability[] = ['files']` (property only, no attribute) — which
|
|
5710
|
+
capabilities to offer, in display order. `AttachmentCapability = 'files' | 'image' | 'camera'`.
|
|
5711
|
+
- `accept: string = ''` — a native-file-input-style accept string (e.g. `'image/*'` or
|
|
5712
|
+
`'.pdf,.docx'`), forwarded to the hidden file input for the `files`/`image` capabilities. `image`
|
|
5713
|
+
defaults it to `'image/*'` unless this prop overrides it; `files` always uses it as-is (empty
|
|
5714
|
+
means "any file type").
|
|
5715
|
+
- `multiple: boolean = true` (reflected) — forwarded to the hidden file input's own `multiple`
|
|
5716
|
+
attribute.
|
|
5717
|
+
- `disabled: boolean = false` (reflected)
|
|
5718
|
+
|
|
5719
|
+
**Events:** `lyra-pick` (`detail: { capability: 'files' | 'image'; files: FileList }`) — fired once a
|
|
5720
|
+
file-backed capability's hidden input produces a real selection. The `FileList` is an independent
|
|
5721
|
+
snapshot (rehomed into a fresh `DataTransfer`), not a live reference to the input's own `.files` —
|
|
5722
|
+
see the gotcha below for why that distinction matters. `lyra-camera-request` (no detail — `detail`
|
|
5723
|
+
is `null`, not `undefined`, per the DOM spec's `CustomEventInit` default) — fired when the `camera`
|
|
5724
|
+
capability is activated; this component implements no capture UI of its own, the host owns
|
|
5725
|
+
everything from here (there's no single right answer for `getUserMedia` vs. `<input capture>` vs. a
|
|
5726
|
+
native wrapper's own camera API).
|
|
5727
|
+
|
|
5728
|
+
**Slots:** none — capabilities are configured entirely via the `capabilities` prop.
|
|
5729
|
+
|
|
5730
|
+
**CSS parts:** `trigger` (the single-capability button, only rendered when
|
|
5731
|
+
`capabilities.length === 1`), `menu` (the `lyra-menu` wrapper, only rendered when
|
|
5732
|
+
`capabilities.length > 1`).
|
|
5733
|
+
|
|
5734
|
+
**Themeable custom properties:** shared tokens only — `--lyra-space-xs`, `--lyra-color-text`,
|
|
5735
|
+
`--lyra-icon-button-size`, `--lyra-focus-ring-*`, `--lyra-opacity-disabled`, `--lyra-radius`.
|
|
5736
|
+
|
|
5737
|
+
**Optional peer deps:** none.
|
|
5738
|
+
|
|
5739
|
+
```html
|
|
5740
|
+
<lyra-attachment-trigger .capabilities=${['files', 'image', 'camera']} accept=".pdf,.docx"
|
|
5741
|
+
@lyra-pick=${(e) => queueFiles(e.detail.capability, e.detail.files)}
|
|
5742
|
+
@lyra-camera-request=${openCameraFlow}
|
|
5743
|
+
></lyra-attachment-trigger>
|
|
5744
|
+
```
|
|
5745
|
+
|
|
5746
|
+
**Known gotchas:**
|
|
5747
|
+
- `HTMLInputElement.files` is a *live* view in most browsers — clearing `input.value` after reading
|
|
5748
|
+
`.files` (needed so re-picking the exact same file still fires another `change` event next time)
|
|
5749
|
+
mutates that exact `FileList` object back to empty in place, not just detaches a stale reference.
|
|
5750
|
+
A consumer reading `lyra-pick`'s `detail.files` even one microtask later (an `async` handler, a
|
|
5751
|
+
queued upload) would otherwise observe an empty list — this component avoids that by rehoming the
|
|
5752
|
+
selection into a fresh `DataTransfer` before emitting, but any other file-input-adjacent code
|
|
5753
|
+
emitting `input.files` directly without that rehoming step has the same latent bug.
|
|
5754
|
+
- The `camera` capability never touches the hidden `<input type="file">` at all — it's scope-
|
|
5755
|
+
limited by design to firing `lyra-camera-request` and nothing else.
|
|
5756
|
+
|
|
5757
|
+
---
|
|
5758
|
+
|
|
5759
|
+
## `lyra-kbd`
|
|
5760
|
+
|
|
5761
|
+
A small chip representing a keyboard shortcut, rendering the platform-appropriate glyph for
|
|
5762
|
+
cross-platform modifier keys (⌘ on macOS, "Ctrl" elsewhere) from a single platform-neutral `keys`
|
|
5763
|
+
string. First-party invention (no Web Awesome equivalent).
|
|
5764
|
+
|
|
5765
|
+
**Properties:**
|
|
5766
|
+
- `keys: string = ''` — a `+`-separated sequence of tokens, e.g. `"mod+k"` or `"mod+shift+p"`.
|
|
5767
|
+
Recognized modifier tokens: `mod` (platform-neutral primary modifier — ⌘/"Command" on macOS,
|
|
5768
|
+
"Ctrl"/"Control" elsewhere), `alt` (⌥/"Option" on macOS, "Alt" elsewhere), `shift` (⇧/"Shift"
|
|
5769
|
+
always), `ctrl`/`control` (always the literal Control key, distinct from `mod`, for a shortcut
|
|
5770
|
+
that's specifically Ctrl even on macOS). Anything else falls through to a small built-in map of
|
|
5771
|
+
friendly labels (`enter` → `↵`/"Enter", `esc`/`escape` → "Esc"/"Escape", `tab`, `space`,
|
|
5772
|
+
`backspace` → `⌫`/"Backspace", `delete` → "Del"/"Delete", `home`, `end`, `pageup` → "PgUp"/"Page
|
|
5773
|
+
Up", `pagedown` → "PgDn"/"Page Down", the four `arrowup`/`arrowdown`/`arrowleft`/`arrowright` →
|
|
5774
|
+
arrow glyphs, `plus`/`minus` → literal "+"/"−" as an escape hatch since `+` is the token
|
|
5775
|
+
delimiter and can't appear as a literal token itself), or, failing that, renders as typed
|
|
5776
|
+
(single letters/digits upper-cased).
|
|
5777
|
+
|
|
5778
|
+
**Exported types/functions (also directly usable standalone):** `KbdKeyLabel { visual: string;
|
|
5779
|
+
word: string }` — one resolved token's rendered glyph and spelled-out word.
|
|
5780
|
+
`shortcutTokenLabel(rawToken: string, isMac: boolean): KbdKeyLabel` — resolves a single token,
|
|
5781
|
+
parameterized on `isMac` so both platform branches are unit-testable without spoofing `navigator`.
|
|
5782
|
+
`parseShortcut(keys: string, isMac: boolean): KbdKeyLabel[]` — splits and resolves a full `keys`
|
|
5783
|
+
string.
|
|
5784
|
+
|
|
5785
|
+
**Events:** none — purely presentational.
|
|
5786
|
+
|
|
5787
|
+
**Slots:** default — an escape hatch for fully custom key-cap content (e.g. an icon instead of a
|
|
5788
|
+
text glyph). When it has any real (non-whitespace) content, it replaces the `keys`-driven rendering
|
|
5789
|
+
entirely and this component stops asserting its own `aria-label`, leaving the slotted content to
|
|
5790
|
+
carry its own accessible name.
|
|
5791
|
+
|
|
5792
|
+
**CSS parts:** `base` (the chip root), `key` (one per rendered token).
|
|
5793
|
+
|
|
5794
|
+
**Themeable custom properties:** shared tokens only — `--lyra-space-xs`, `--lyra-color-surface`/
|
|
5795
|
+
`-border`/`-text-quiet`, `--lyra-radius`, `--lyra-font`.
|
|
5796
|
+
|
|
5797
|
+
**Optional peer deps:** none.
|
|
5798
|
+
|
|
5799
|
+
```html
|
|
5800
|
+
<lyra-kbd keys="mod+k"></lyra-kbd>
|
|
5801
|
+
<lyra-kbd keys="mod+shift+p"></lyra-kbd>
|
|
5802
|
+
<lyra-kbd keys="esc"></lyra-kbd>
|
|
5803
|
+
```
|
|
5804
|
+
|
|
5805
|
+
Platform detection (`IS_MAC`, computed once at module scope, not per-instance/per-render, since a
|
|
5806
|
+
page's platform never changes mid-session) prefers `navigator.userAgentData` (Client Hints, so far
|
|
5807
|
+
Chromium-only) when available, falling back through `navigator.platform` (long-deprecated) and
|
|
5808
|
+
finally a `navigator.userAgent` substring check — all three are deprecated/non-standard to varying
|
|
5809
|
+
degrees but remain, in combination, the practical cross-browser way to answer "is this macOS" today.
|
|
5810
|
+
The rendered chip carries `role="img"` with a single spelled-out `aria-label` (e.g. "Command+K")
|
|
5811
|
+
rather than exposing each glyph/`+`-separator as separate accessible-tree text, since the individual
|
|
5812
|
+
pieces aren't real words and would read worse piecemeal than as one label — glyphs like ⌘/⇧/⌥ are
|
|
5813
|
+
not reliably announced by every screen reader/platform combination, which is exactly why the
|
|
5814
|
+
spelled-out word form exists at all.
|
|
5815
|
+
|
|
5816
|
+
---
|
|
5817
|
+
|
|
5818
|
+
## `lyra-result-card` / `lyra-result-field`
|
|
5819
|
+
|
|
5820
|
+
A small, tightly-coupled pair giving any custom `lyra-tool-result-view` renderer (registered via
|
|
5821
|
+
`registerToolRenderer()` in `../tool-result-view/registry.js`) a consistent "small bordered card +
|
|
5822
|
+
label/value row" visual language, without each one hand-rolling its own box. Neither component has
|
|
5823
|
+
any code dependency on the tool-result-view registry itself — they're generically usable anywhere a
|
|
5824
|
+
small card/field shell is useful.
|
|
5825
|
+
|
|
5826
|
+
### `lyra-result-card`
|
|
5827
|
+
|
|
5828
|
+
A small bordered card shell. Purely visual, with no state of its own beyond slot-presence tracking.
|
|
5829
|
+
|
|
5830
|
+
**Properties:**
|
|
5831
|
+
- `title: string = ''` — small heading for the card. Leave unset for an untitled card (e.g. a bare
|
|
5832
|
+
block of `lyra-result-field` rows with no natural heading).
|
|
5833
|
+
|
|
5834
|
+
**Events:** none.
|
|
5835
|
+
|
|
5836
|
+
**Slots:** default (the card body — typically one or more `lyra-result-field` rows, though any
|
|
5837
|
+
content is accepted), `actions` (small header controls, e.g. a copy button, rendered alongside the
|
|
5838
|
+
title).
|
|
5839
|
+
|
|
5840
|
+
**CSS parts:** `base` (outer bordered container), `header` (present in the DOM at all times so a
|
|
5841
|
+
later `slotchange` on `actions` is still observed, but `hidden` whenever there's no `title` and no
|
|
5842
|
+
`actions` content), `title`, `actions` (`hidden` whenever the slot has no assigned content), `body`.
|
|
5843
|
+
|
|
5844
|
+
**Themeable custom properties:** shared tokens only — `--lyra-space-xs`/`-s`, `--lyra-color-border`/
|
|
5845
|
+
`-surface`/`-text`, `--lyra-radius`.
|
|
5846
|
+
|
|
5847
|
+
### `lyra-result-field`
|
|
5848
|
+
|
|
5849
|
+
A single label/value row — e.g. "Status: 200 OK" or "Duration: 340ms" — rendered as a dense
|
|
5850
|
+
"label: value" line by default, matching the compact, small-card presentation this pair exists for.
|
|
5851
|
+
|
|
5852
|
+
**Properties:**
|
|
5853
|
+
- `label: string = ''` — the field name, e.g. "Status". Leave unset to render a value with no label.
|
|
5854
|
+
- `value: string = ''` — plain-text value, e.g. "200 OK". Ignored once the default slot carries real
|
|
5855
|
+
content.
|
|
5856
|
+
|
|
5857
|
+
**Events:** none.
|
|
5858
|
+
|
|
5859
|
+
**Slots:** default — rich value content (e.g. a `lyra-chip` status badge, or a plain text override),
|
|
5860
|
+
taking precedence over `value` whenever it has any assigned content. "Real content" means any
|
|
5861
|
+
assigned *element* (even one with no text of its own, like an attribute-driven status badge) or any
|
|
5862
|
+
non-whitespace text node — both a rich slotted badge and a plain-text override are caught.
|
|
5863
|
+
|
|
5864
|
+
**CSS parts:** `base` (row container), `label` (including its trailing colon), `value` (wrapper
|
|
5865
|
+
around either the slotted content or the plain `value` text).
|
|
5866
|
+
|
|
5867
|
+
**Themeable custom properties:** shared tokens only — `--lyra-space-xs`, `--lyra-color-text`/
|
|
5868
|
+
`-text-quiet`, `--lyra-font`.
|
|
5869
|
+
|
|
5870
|
+
**Optional peer deps:** none (either component).
|
|
5871
|
+
|
|
5872
|
+
```html
|
|
5873
|
+
<lyra-result-card title="Weather">
|
|
5874
|
+
<lyra-result-field label="Status" value="200 OK"></lyra-result-field>
|
|
5875
|
+
<lyra-result-field label="Duration" value="340ms"></lyra-result-field>
|
|
5876
|
+
<lyra-result-field label="Provider">
|
|
5877
|
+
<lyra-chip tone="success">OpenWeather</lyra-chip>
|
|
5878
|
+
</lyra-result-field>
|
|
5879
|
+
</lyra-result-card>
|
|
5880
|
+
```
|
|
5881
|
+
|
|
5882
|
+
**Known gotchas:**
|
|
5883
|
+
- `HTMLElement.textContent` read on a shadow-DOM wrapper containing a `<slot>` does NOT include the
|
|
5884
|
+
slot's assigned/projected light-DOM content — only literal fallback children of the `<slot>` tag
|
|
5885
|
+
itself (there are none here). Asserting against `[part="value"]`'s own `.textContent` to check
|
|
5886
|
+
rendered slotted content will read as empty even when the component is rendering correctly;
|
|
5887
|
+
assert against the slot's `assignedNodes()`/`assignedElements()` instead.
|
|
5888
|
+
|
|
5889
|
+
---
|
|
5890
|
+
|
|
2002
5891
|
## Cross-cutting notes
|
|
2003
5892
|
|
|
2004
5893
|
These apply across the whole library rather than to any one component:
|