@zubra/compiler 0.0.0-stage → 5.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/LICENSE +15 -0
  2. package/cli.js +175 -0
  3. package/index.d.ts +81 -0
  4. package/index.js +123 -0
  5. package/library.d.ts +20 -0
  6. package/library.js +224 -0
  7. package/package.json +41 -4
  8. package/registry/README.md +41 -0
  9. package/registry/build-index.mjs +58 -0
  10. package/registry/components/accordion-panel.zb +23 -0
  11. package/registry/components/chat-message.zb +65 -0
  12. package/registry/components/choice-cards.zb +47 -0
  13. package/registry/components/color-swatches.zb +30 -0
  14. package/registry/components/combo-box.zb +59 -0
  15. package/registry/components/date-range.zb +37 -0
  16. package/registry/components/donut-chart.zb +56 -0
  17. package/registry/components/dropdown-menu.zb +83 -0
  18. package/registry/components/flip-card.zb +29 -0
  19. package/registry/components/kanban-column.zb +65 -0
  20. package/registry/components/line-chart.zb +58 -0
  21. package/registry/components/message-composer.zb +80 -0
  22. package/registry/components/modal-dialog.zb +44 -0
  23. package/registry/components/money-input.zb +41 -0
  24. package/registry/components/progress-ring.zb +24 -0
  25. package/registry/components/quantity-stepper.zb +22 -0
  26. package/registry/components/segmented-control.zb +23 -0
  27. package/registry/components/spark-line.zb +19 -0
  28. package/registry/components/star-rating.zb +34 -0
  29. package/registry/components/step-indicator.zb +31 -0
  30. package/registry/components/streak-grid.zb +57 -0
  31. package/registry/components/tab-list.zb +59 -0
  32. package/registry/components/tag-input.zb +50 -0
  33. package/registry/components/toggle-switch.zb +27 -0
  34. package/registry/components/tool-tip.zb +68 -0
  35. package/registry/index.json +233 -0
  36. package/zubra.wasm +0 -0
  37. package/README.md +0 -3
@@ -0,0 +1,65 @@
1
+ <!--
2
+ A board column: a heading with a count and an optional work-in-progress limit, its cards (the
3
+ default slot) and a drop zone for cards dragged from anywhere on the board.
4
+ @slot - The cards: any elements. A dragged card sets its id as `application/x-card` drag data (`event.dataTransfer.setData("application/x-card", id)`), which the drop reports.
5
+ @slot actions - Buttons under the cards.
6
+ @fires carddrop - A card was dropped here; `detail` is `{ id, index }` (where among the cards).
7
+ -->
8
+ <script>
9
+ import { emit, hasSlot } from '@zubra/runtime'
10
+
11
+ const hasActions = hasSlot('actions')
12
+ let list = null
13
+ let over = false
14
+ const full = limit > 0 && count > limit
15
+
16
+ /** Where among this column's cards a drop at `y` lands. */
17
+ function indexAt(y) {
18
+ const cards = [...list.children].filter((child) => !child.classList.contains('empty'))
19
+ const after = cards.findIndex((card) => {
20
+ const box = card.getBoundingClientRect()
21
+ return y < box.top + box.height / 2
22
+ })
23
+ return after === -1 ? cards.length : after
24
+ }
25
+ function dragover(event) {
26
+ if (!event.dataTransfer.types.includes('application/x-card')) return
27
+ event.preventDefault()
28
+ event.dataTransfer.dropEffect = 'move'
29
+ over = true
30
+ }
31
+ function drop(event) {
32
+ const id = event.dataTransfer.getData('application/x-card')
33
+ over = false
34
+ if (!id) return
35
+ event.preventDefault()
36
+ emit('carddrop', { id, index: indexAt(event.clientY) })
37
+ }
38
+ </script>
39
+ <style>
40
+ :host { display: flex; flex-direction: column; min-width: 13rem; max-width: 20rem; flex: 1; }
41
+ section { display: flex; flex-direction: column; flex: 1; padding: 0.6rem; border-radius: 0.9rem; background: var(--column, #f3f4f7); transition: background 0.15s, box-shadow 0.15s; }
42
+ section.over { background: var(--column-over, #e8ebff); box-shadow: inset 0 0 0 2px var(--accent, #4f5df5); }
43
+ header { display: flex; align-items: center; gap: 0.5rem; padding: 0.25rem 0.35rem 0.6rem; }
44
+ h2 { margin: 0; font-size: 0.8125rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; }
45
+ .count { padding: 0 0.45rem; border-radius: 999px; background: var(--surface, #ffffff); font-size: 0.75rem; font-weight: 700; color: var(--muted, #667085); }
46
+ .full .count { background: var(--danger, #d92d20); color: white; }
47
+ .limit { margin-left: auto; font-size: 0.75rem; color: var(--muted, #667085); }
48
+ .full .limit { color: var(--danger, #d92d20); font-weight: 700; }
49
+ .cards { display: flex; flex-direction: column; gap: 0.5rem; flex: 1; min-height: 4rem; }
50
+ footer { display: flex; flex-direction: column; }
51
+ .empty { display: grid; place-items: center; flex: 1; min-height: 4rem; border: 2px dashed var(--line, #d9dce3); border-radius: 0.7rem; color: var(--muted, #667085); font-size: 0.8125rem; }
52
+ </style>
53
+ <kanban-column heading="" count={0} limit={0}>
54
+ <section class:over class:full aria-label={heading} ondragover={dragover} ondragleave={(event) => event.currentTarget.contains(event.relatedTarget) || (over = false)} ondrop={drop}>
55
+ <header>
56
+ <h2>{heading}</h2>
57
+ <span class="count">{count}</span>
58
+ <span zb-if={limit > 0} class="limit">{full ? `over the limit of ${limit}` : `limit ${limit}`}</span>
59
+ </header>
60
+ <div class="cards" ref={list}>
61
+ <zb-slot><div class="empty">Drop cards here</div></zb-slot>
62
+ </div>
63
+ <footer zb-if={hasActions}><zb-slot name="actions"></zb-slot></footer>
64
+ </section>
65
+ </kanban-column>
@@ -0,0 +1,58 @@
1
+ <!--
2
+ A price chart: an area under a line, with the high, low and a crosshair that follows the
3
+ pointer and shows the value under it.
4
+ @fires pointhover - The pointer is over a point; `detail` is its index, or -1 when it leaves.
5
+ -->
6
+ <script>
7
+ import { emit } from '@zubra/runtime'
8
+
9
+ const W = 600
10
+ const H = 220
11
+ let hover = -1
12
+ const low = Math.min(...points)
13
+ const high = Math.max(...points)
14
+ const span = high - low || 1
15
+ const x = (index) => (index / Math.max(1, points.length - 1)) * W
16
+ const y = (value) => H - 12 - ((value - low) / span) * (H - 24)
17
+ const line = points.map((value, index) => `${index ? "L" : "M"}${x(index).toFixed(1)},${y(value).toFixed(1)}`).join("")
18
+ const area = points.length ? `${line}L${W},${H}L0,${H}Z` : ""
19
+ const shown = hover >= 0 && hover < points.length ? hover : -1
20
+
21
+ function move(event) {
22
+ const box = event.currentTarget.getBoundingClientRect()
23
+ const index = Math.round(((event.clientX - box.left) / box.width) * (points.length - 1))
24
+ const next = Math.max(0, Math.min(points.length - 1, index))
25
+ if (next === hover) return
26
+ hover = next
27
+ emit('pointhover', next)
28
+ }
29
+ function leave() {
30
+ hover = -1
31
+ emit('pointhover', -1)
32
+ }
33
+ </script>
34
+ <style>
35
+ :host { display: block; position: relative; }
36
+ svg { display: block; width: 100%; height: 14rem; }
37
+ .area { fill: var(--accent, #4f5df5); opacity: 0.12; }
38
+ .line { fill: none; stroke: var(--accent, #4f5df5); stroke-width: 2; vector-effect: non-scaling-stroke; }
39
+ .grid { stroke: var(--line, #d9dce3); stroke-dasharray: 3 4; vector-effect: non-scaling-stroke; }
40
+ .cross { stroke: var(--muted, #667085); vector-effect: non-scaling-stroke; }
41
+ .dot { fill: var(--surface, #ffffff); stroke: var(--accent, #4f5df5); stroke-width: 2; vector-effect: non-scaling-stroke; }
42
+ .labels { position: absolute; inset: 0 auto 0 0; display: flex; flex-direction: column; justify-content: space-between; padding: 0.2rem 0; font-size: 0.7rem; color: var(--muted, #667085); pointer-events: none; font-variant-numeric: tabular-nums; }
43
+ .tip { position: absolute; top: 0.25rem; right: 0.25rem; padding: 0.2rem 0.5rem; border-radius: 0.4rem; background: var(--ink, #1d2130); color: var(--surface, #ffffff); font-size: 0.8125rem; font-weight: 700; font-variant-numeric: tabular-nums; }
44
+ </style>
45
+ <line-chart points={[]} label="Price">
46
+ <svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" role="img" aria-label={`${label}: ${points.length} points from ${low.toFixed(2)} to ${high.toFixed(2)}`} onpointermove={move} onpointerleave={leave}>
47
+ <line class="grid" x1="0" x2={W} y1="12" y2="12"></line>
48
+ <line class="grid" x1="0" x2={W} y1={H - 12} y2={H - 12}></line>
49
+ <path class="area" d={area}></path>
50
+ <path class="line" d={line}></path>
51
+ <zb-if test={shown >= 0}>
52
+ <line class="cross" x1={x(shown)} x2={x(shown)} y1="0" y2={H}></line>
53
+ <circle class="dot" cx={x(shown)} cy={y(points[shown])} r="4"></circle>
54
+ </zb-if>
55
+ </svg>
56
+ <div class="labels" aria-hidden="true"><span class="high">{high.toFixed(2)}</span><span class="low">{low.toFixed(2)}</span></div>
57
+ <span zb-if={shown >= 0} class="tip">{points[shown].toFixed(2)}</span>
58
+ </line-chart>
@@ -0,0 +1,80 @@
1
+ <!--
2
+ Writing a message: Enter sends, Shift+Enter adds a line, the box grows with the text, an
3
+ emoji menu inserts at the caret, and a message being replied to shows above.
4
+ @fires send - Send was chosen; `detail` is the trimmed text.
5
+ @fires typing - The user typed (at most every two seconds).
6
+ @fires replycancel - The reply was cancelled.
7
+ -->
8
+ <script>
9
+ import { emit } from '@zubra/runtime'
10
+
11
+ const EMOJI = ["😀", "😂", "😍", "🤔", "👍", "🙏", "🎉", "🔥", "🚀", "✅", "👀", "☕"]
12
+ let box = null
13
+ let text = ""
14
+ let picking = false
15
+ let lastTyping = 0
16
+ const empty = text.trim() === ""
17
+
18
+ function grow() {
19
+ box.style.height = "auto"
20
+ box.style.height = `${Math.min(box.scrollHeight, 160)}px`
21
+ }
22
+ function input(event) {
23
+ text = event.target.value
24
+ grow()
25
+ if (Date.now() - lastTyping > 2000) {
26
+ lastTyping = Date.now()
27
+ emit('typing')
28
+ }
29
+ }
30
+ function send() {
31
+ if (empty || disabled) return
32
+ emit('send', text.trim())
33
+ text = ""
34
+ lastTyping = 0
35
+ queueMicrotask(grow)
36
+ }
37
+ function enter(event) {
38
+ // (While an input method is composing, Enter belongs to it.)
39
+ if (event.isComposing) return
40
+ event.preventDefault()
41
+ send()
42
+ }
43
+ function insert(emoji) {
44
+ const at = box.selectionStart ?? text.length
45
+ text = text.slice(0, at) + emoji + text.slice(box.selectionEnd ?? at)
46
+ picking = false
47
+ queueMicrotask(() => {
48
+ box.focus()
49
+ box.setSelectionRange(at + emoji.length, at + emoji.length)
50
+ })
51
+ }
52
+ </script>
53
+ <style>
54
+ :host { display: block; }
55
+ .reply { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.75rem; border: 1px solid var(--line, #d9dce3); border-bottom: 0; border-radius: 0.75rem 0.75rem 0 0; background: var(--hover, #f2f4f8); font-size: 0.8125rem; color: var(--muted, #667085); }
56
+ .reply b { color: var(--ink, #1d2130); }
57
+ .reply span { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
58
+ .reply button { border: 0; background: none; color: inherit; cursor: pointer; font: inherit; }
59
+ .box { position: relative; display: flex; align-items: flex-end; gap: 0.25rem; padding: 0.4rem; border: 1px solid var(--line, #d9dce3); border-radius: 0.75rem; background: var(--surface, #ffffff); }
60
+ .reply + .box { border-radius: 0 0 0.75rem 0.75rem; }
61
+ .box:focus-within { border-color: var(--link, #2563eb); }
62
+ textarea { flex: 1; min-height: 2.25rem; max-height: 10rem; padding: 0.45rem 0.5rem; border: 0; outline: 0; resize: none; background: none; color: inherit; font: inherit; line-height: 1.4; }
63
+ button.icon, button.send { flex: none; width: 2.25rem; height: 2.25rem; border: 0; border-radius: 0.55rem; background: none; color: var(--muted, #667085); font-size: 1.15rem; cursor: pointer; }
64
+ button.send { background: var(--link, #2563eb); color: white; font-size: 1rem; }
65
+ button.send:disabled { background: var(--line, #d9dce3); color: var(--muted, #667085); cursor: default; }
66
+ .picker { position: absolute; bottom: calc(100% + 0.4rem); right: 0; display: grid; grid-template-columns: repeat(6, 2rem); gap: 0.15rem; padding: 0.4rem; border: 1px solid var(--line, #d9dce3); border-radius: 0.75rem; background: var(--surface, #ffffff); box-shadow: 0 8px 24px #0003; }
67
+ .picker button { border: 0; background: none; border-radius: 0.4rem; font-size: 1.2rem; cursor: pointer; }
68
+ .picker button:hover { background: var(--hover, #f2f4f8); }
69
+ .hint { margin: 0.3rem 0.25rem 0; font-size: 0.7rem; color: var(--muted, #667085); }
70
+ </style>
71
+ <message-composer placeholder="Message" disabled={false} replyto={null}>
72
+ <div zb-if={replyto} class="reply"><span>Replying to <b>{replyto?.author}</b>: {replyto?.text}</span><button type="button" class="cancel-reply" aria-label="Cancel reply" onclick={() => emit('replycancel')}>×</button></div>
73
+ <div class="box">
74
+ <textarea rows="1" aria-label={placeholder} placeholder={placeholder} disabled={disabled} value={text} ref={box} oninput={input} onkeydown.enter={enter} onkeydown.escape={() => replyto && emit('replycancel')}></textarea>
75
+ <button type="button" class="icon emoji" aria-label="Insert emoji" aria-expanded={picking ? "true" : "false"} onclick={() => (picking = !picking)}>☺</button>
76
+ <button type="button" class="send" aria-label="Send" disabled={empty || disabled} onclick={send}>➤</button>
77
+ <div zb-if={picking} class="picker" role="group" aria-label="Emoji" onkeydown.escape={() => (picking = false)}><zb-each items={EMOJI} as="emoji" key={emoji}><button type="button" onclick={() => insert(emoji)}>{emoji}</button></zb-each></div>
78
+ </div>
79
+ <p class="hint">Enter to send · Shift+Enter for a new line</p>
80
+ </message-composer>
@@ -0,0 +1,44 @@
1
+ <!--
2
+ A modal dialog on the native <dialog>: the page behind is inert, focus moves into the dialog
3
+ and back to where it was, and Escape, the close button or a click on the backdrop closes it.
4
+ `open` is two-way.
5
+ @slot - The dialog's content.
6
+ @slot heading - Its title, which labels the dialog (defaults to `label`).
7
+ @slot actions - Buttons at the bottom, shown only when given.
8
+ -->
9
+ <script>
10
+ import { effect, hasSlot, id } from '@zubra/runtime'
11
+
12
+ const headingId = id()
13
+ const hasActions = hasSlot('actions')
14
+ let dialog = null
15
+
16
+ // Follow `open` (from outside or inside): show the dialog modally, or close it.
17
+ effect(() => {
18
+ if (!dialog) return
19
+ if (open && !dialog.open) dialog.showModal()
20
+ else if (!open && dialog.open) dialog.close()
21
+ })
22
+ </script>
23
+ <style>
24
+ dialog { padding: 0; border: 0; border-radius: 0.9rem; width: min(32rem, calc(100vw - 2rem)); background: var(--surface, #ffffff); color: var(--ink, #1d2130); box-shadow: 0 24px 64px #0d122040; }
25
+ dialog::backdrop { background: #0d122066; }
26
+ .panel { display: grid; gap: 1rem; padding: 1.1rem 1.25rem 1.25rem; }
27
+ header { display: flex; align-items: center; gap: 1rem; }
28
+ h2 { flex: 1; margin: 0; font-size: 1.1rem; }
29
+ .close { width: 2rem; height: 2rem; border: 0; border-radius: 50%; background: none; color: inherit; font-size: 1.4rem; line-height: 1; cursor: pointer; }
30
+ .close:hover { background: var(--hover, #f2f4f8); }
31
+ footer { display: flex; justify-content: flex-end; gap: 0.5rem; }
32
+ </style>
33
+ <modal-dialog open:model={false} label="Dialog">
34
+ <dialog ref={dialog} aria-labelledby={headingId} onclose={() => (open = false)} onclick.self={() => (open = false)}>
35
+ <div class="panel">
36
+ <header>
37
+ <h2 id={headingId}><zb-slot name="heading">{label}</zb-slot></h2>
38
+ <button type="button" class="close" aria-label="Close" onclick={() => (open = false)}>×</button>
39
+ </header>
40
+ <div class="body"><zb-slot></zb-slot></div>
41
+ <footer zb-if={hasActions}><zb-slot name="actions"></zb-slot></footer>
42
+ </div>
43
+ </dialog>
44
+ </modal-dialog>
@@ -0,0 +1,41 @@
1
+ <!--
2
+ An amount of money. Typing is free-form; on blur it's formatted (`1234.5` → `1,234.50`). The
3
+ value is a number (or null when empty). A form control: `required` and `min` validate it.
4
+ @fires valuechange - The amount changed; `detail` is the number, or null.
5
+ -->
6
+ <script>
7
+ import { validity } from '@zubra/runtime'
8
+
9
+ const format = (amount) => (amount == null ? "" : amount.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }))
10
+ let text = format(value)
11
+ let focused = false
12
+ const shown = focused ? text : format(value)
13
+
14
+ validity(() => (required && value == null ? "Enter an amount" : value != null && value < min ? `At least ${format(min)}` : ""))
15
+
16
+ function input(event) {
17
+ text = event.target.value
18
+ const cleaned = text.replace(/[^0-9.]/g, "")
19
+ const next = cleaned === "" || isNaN(Number(cleaned)) ? null : Math.round(Number(cleaned) * 100) / 100
20
+ if (next === value) return
21
+ value = next
22
+ }
23
+ // While focused the text is left as typed; on blur it's formatted again.
24
+ function focus() {
25
+ text = format(value)
26
+ focused = true
27
+ }
28
+ </script>
29
+ <style>
30
+ :host { display: block; }
31
+ .box { display: flex; align-items: center; border: 1px solid var(--line, #d9dce3); border-radius: 0.6rem; background: var(--surface, #ffffff); }
32
+ .box:focus-within { outline: 2px solid var(--accent, #4f5df5); outline-offset: 1px; }
33
+ span { padding-left: 0.7rem; color: var(--muted, #667085); font-weight: 600; }
34
+ input { flex: 1; min-width: 0; padding: 0.55rem 0.7rem 0.55rem 0.3rem; border: 0; outline: 0; background: none; font: inherit; font-variant-numeric: tabular-nums; color: inherit; text-align: right; }
35
+ </style>
36
+ <money-input form-associated value:model={null} symbol="$" required={false} min={0} label="Amount">
37
+ <div class="box">
38
+ <span aria-hidden="true">{symbol}</span>
39
+ <input inputmode="decimal" autocomplete="off" aria-label={label} placeholder="0.00" value={shown} oninput={input} onfocus={focus} onblur={() => (focused = false)}>
40
+ </div>
41
+ </money-input>
@@ -0,0 +1,24 @@
1
+ <!--
2
+ A ring that fills to `value` of `max`, with what's in the middle from the default slot (the
3
+ percentage if empty).
4
+ @slot - The middle.
5
+ -->
6
+ <script>
7
+ const R = 15.915494309189533
8
+ const percent = max > 0 ? Math.min(100, Math.round((value / max) * 100)) : 0
9
+ </script>
10
+ <style>
11
+ :host { display: inline-grid; place-items: center; width: var(--size, 7rem); aspect-ratio: 1; position: relative; }
12
+ svg { position: absolute; inset: 0; width: 100%; transform: rotate(-90deg); }
13
+ circle { fill: none; stroke-width: 3.2; }
14
+ .track { stroke: var(--line, #ddd); }
15
+ .fill { stroke: var(--accent, #36c); stroke-linecap: round; transition: stroke-dasharray 0.4s; }
16
+ .middle { position: relative; text-align: center; line-height: 1.15; }
17
+ </style>
18
+ <progress-ring value={0} max={100} label="Progress">
19
+ <svg viewBox="0 0 42 42" role="img" aria-label={`${label}: ${percent}%`}>
20
+ <circle class="track" cx="21" cy="21" r={R}></circle>
21
+ <circle zb-if={percent > 0} class="fill" cx="21" cy="21" r={R} stroke-dasharray={`${percent} ${100 - percent}`}></circle>
22
+ </svg>
23
+ <div class="middle"><zb-slot><strong>{percent}%</strong></zb-slot></div>
24
+ </progress-ring>
@@ -0,0 +1,22 @@
1
+ <!--
2
+ A number with − and + buttons, between `min` and `max`. A form control.
3
+ @fires valuechange - The value changed; `detail` is the new value.
4
+ -->
5
+ <script>
6
+ function step(by) {
7
+ const next = Math.min(max, Math.max(min, value + by))
8
+ if (next === value) return
9
+ value = next
10
+ }
11
+ </script>
12
+ <style>
13
+ :host { display: inline-flex; align-items: center; gap: 0.5rem; }
14
+ button { width: 2.25rem; height: 2.25rem; border-radius: 50%; border: 1px solid var(--line, #ccd); background: var(--surface, #fff); font: inherit; font-size: 1.125rem; cursor: pointer; }
15
+ button:disabled { opacity: 0.35; cursor: default; }
16
+ output { min-width: 2ch; text-align: center; font-weight: 700; font-variant-numeric: tabular-nums; }
17
+ </style>
18
+ <quantity-stepper form-associated value:model={1} min={0} max={10} label="Quantity">
19
+ <button type="button" class="less" aria-label={`Fewer ${label}`} disabled={value <= min} onclick={() => step(-1)}>−</button>
20
+ <output aria-live="polite" aria-label={label}>{value}</output>
21
+ <button type="button" class="more" aria-label={`More ${label}`} disabled={value >= max} onclick={() => step(1)}>+</button>
22
+ </quantity-stepper>
@@ -0,0 +1,23 @@
1
+ <!--
2
+ A row of mutually exclusive options. `options` is `[{ value, label }]`.
3
+ @fires valuechange - The choice changed; `detail` is the option's value.
4
+ -->
5
+ <script>
6
+ function pick(option) {
7
+ if (option.value === value) return
8
+ value = option.value
9
+ }
10
+ </script>
11
+ <style>
12
+ :host { display: inline-block; }
13
+ div { display: inline-flex; padding: 0.2rem; border-radius: 0.7rem; background: var(--sunken, #f3f4f7); gap: 0.2rem; }
14
+ button { padding: 0.35rem 0.8rem; border: 0; border-radius: 0.5rem; background: none; color: var(--muted, #667085); font: inherit; font-weight: 600; cursor: pointer; }
15
+ button[aria-pressed=true] { background: var(--surface, #ffffff); color: var(--ink, #1d2130); box-shadow: 0 1px 2px #0002; }
16
+ </style>
17
+ <segmented-control value:model="" options={[]} label="">
18
+ <div role="group" aria-label={label}>
19
+ <zb-each items={options} as="option" key={option.value}>
20
+ <button type="button" aria-pressed={option.value === value ? "true" : "false"} data-value={option.value} onclick={() => pick(option)}>{option.label}</button>
21
+ </zb-each>
22
+ </div>
23
+ </segmented-control>
@@ -0,0 +1,19 @@
1
+ <!--
2
+ A small line of recent values, green when it ends higher than it started, red when lower.
3
+ -->
4
+ <script>
5
+ const low = Math.min(...points)
6
+ const high = Math.max(...points)
7
+ const span = high - low || 1
8
+ const path = points.map((value, index) => `${(index / Math.max(1, points.length - 1)) * 100},${28 - ((value - low) / span) * 26}`).join(" ")
9
+ const trend = points.length < 2 ? "flat" : points[points.length - 1] >= points[0] ? "up" : "down"
10
+ </script>
11
+ <style>
12
+ :host { display: block; height: 2rem; }
13
+ svg { display: block; width: 100%; height: 100%; overflow: visible; }
14
+ polyline { fill: none; stroke-width: 1.6; vector-effect: non-scaling-stroke; stroke-linejoin: round; }
15
+ .up { stroke: var(--up, #12805c); } .down { stroke: var(--down, #c4320a); } .flat { stroke: var(--muted, #667085); }
16
+ </style>
17
+ <spark-line points={[]} label="Trend">
18
+ <svg viewBox="0 0 100 30" preserveAspectRatio="none" role="img" aria-label={`${label}: ${trend}`}><polyline class={trend} points={path}></polyline></svg>
19
+ </spark-line>
@@ -0,0 +1,34 @@
1
+ <!--
2
+ A star rating that is a form control: it submits its value under its `name`, is invalid while
3
+ `required` and unset, and goes back to 0 when its form resets. `readonly` shows a rating.
4
+ @fires valuechange - The user picked a rating; `detail` is the number of stars.
5
+ -->
6
+ <script>
7
+ import { formValue, validity } from '@zubra/runtime'
8
+
9
+ let hovered = 0
10
+ const shown = hovered || value
11
+ const stars = Array.from({ length: max }, (_, index) => index + 1)
12
+
13
+ formValue(() => (value ? value : null))
14
+ validity(() => (required && !value ? "Pick a rating" : ""))
15
+
16
+ function rate(stars) {
17
+ if (readonly) return
18
+ value = stars
19
+ }
20
+ </script>
21
+ <style>
22
+ :host { display: inline-flex; }
23
+ [role=group] { display: inline-flex; gap: 0.1rem; }
24
+ button { font: inherit; font-size: 1.25rem; line-height: 1; padding: 0; border: 0; background: none; color: #c9c2b8; cursor: pointer; }
25
+ button.on { color: var(--accent, #e07a2f); }
26
+ :host([readonly]) button { cursor: default; font-size: 1rem; }
27
+ </style>
28
+ <star-rating form-associated value:model={0} max={5} required={false} readonly={false} label="Rating">
29
+ <span role="group" aria-label={label}>
30
+ <zb-each items={stars} as="n" key={n}>
31
+ <button type="button" class:on={n <= shown} aria-label={`${n} of ${max}`} aria-pressed={n === value ? "true" : "false"} onclick={() => rate(n)} onmouseenter={() => (hovered = readonly ? 0 : n)} onmouseleave={() => (hovered = 0)}>★</button>
32
+ </zb-each>
33
+ </span>
34
+ </star-rating>
@@ -0,0 +1,31 @@
1
+ <!--
2
+ Where a multi-step flow is: steps done (which can be revisited), the current one, and those
3
+ ahead.
4
+ @fires stepselect - A finished step was chosen; `detail` is its index.
5
+ -->
6
+ <script>
7
+ import { emit } from '@zubra/runtime'
8
+ </script>
9
+ <style>
10
+ ol { display: flex; gap: 0.5rem; margin: 0; padding: 0; list-style: none; counter-reset: step; }
11
+ li { flex: 1; }
12
+ button { display: flex; align-items: center; gap: 0.5rem; width: 100%; padding: 0.6rem 0.25rem 0.5rem; border: 0; border-top: 3px solid var(--line, #dde); background: none; color: var(--muted, #667); font: inherit; font-size: 0.875rem; font-weight: 600; text-align: left; }
13
+ button::before { counter-increment: step; content: counter(step); display: grid; place-items: center; flex: none; width: 1.5rem; height: 1.5rem; border-radius: 50%; background: var(--line, #dde); color: var(--ink, #111); font-size: 0.75rem; }
14
+ .done button { border-top-color: var(--accent, #36c); color: var(--ink, #111); cursor: pointer; }
15
+ .done button::before { content: "✓"; background: var(--accent, #36c); color: white; }
16
+ .current button { border-top-color: var(--accent, #36c); color: var(--accent, #36c); }
17
+ .current button::before { background: var(--accent, #36c); color: white; }
18
+ button:disabled { cursor: default; }
19
+ @media (max-width: 36rem) { .label { display: none; } }
20
+ </style>
21
+ <step-indicator steps={[]} current={0}>
22
+ <nav aria-label="Progress">
23
+ <ol>
24
+ <zb-each items={steps} as="step" index="index" key={step}>
25
+ <li class:done={index < current} class:current={index === current}>
26
+ <button type="button" disabled={index >= current} aria-current={index === current ? "step" : "false"} onclick={() => emit('stepselect', index)}><span class="label">{step}</span></button>
27
+ </li>
28
+ </zb-each>
29
+ </ol>
30
+ </nav>
31
+ </step-indicator>
@@ -0,0 +1,57 @@
1
+ <!--
2
+ A habit's last weeks as a grid of days (a column per week, Monday at the top), shaded by how
3
+ much of the goal was done. Choosing a day toggles it; arrow keys move between days.
4
+ `log` maps "YYYY-MM-DD" to a count; `today` is "YYYY-MM-DD".
5
+ @fires daytoggle - A day was chosen; `detail` is the day.
6
+ -->
7
+ <script>
8
+ import { emit } from '@zubra/runtime'
9
+
10
+ let grid = null
11
+ const day = (date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`
12
+ const parse = (text) => new Date(`${text}T12:00`)
13
+ const names = new Intl.DateTimeFormat("en-US", { weekday: "short", month: "short", day: "numeric" })
14
+ const end = parse(today)
15
+ // Start on the Monday `weeks` weeks before this week's.
16
+ const start = new Date(end)
17
+ start.setDate(start.getDate() - ((start.getDay() + 6) % 7) - (weeks - 1) * 7)
18
+ const columns = Array.from({ length: weeks }, (_, week) => ({
19
+ week,
20
+ days: Array.from({ length: 7 }, (_, weekday) => {
21
+ const date = new Date(start)
22
+ date.setDate(start.getDate() + week * 7 + weekday)
23
+ const key = day(date)
24
+ const count = log[key] ?? 0
25
+ const level = date > end ? -1 : count === 0 ? 0 : Math.min(4, Math.ceil((count / goal) * 4))
26
+ return { key, level, count, label: `${names.format(date)}: ${count}/${goal}` }
27
+ }),
28
+ }))
29
+
30
+ // Arrow keys: a day up or down, a week left or right.
31
+ function move(key, step) {
32
+ const date = parse(key)
33
+ date.setDate(date.getDate() + step)
34
+ grid.querySelector(`[data-day="${day(date)}"]:not([disabled])`)?.focus()
35
+ }
36
+ </script>
37
+ <style>
38
+ :host { display: block; overflow-x: auto; }
39
+ .grid { display: grid; grid-auto-flow: column; grid-template-rows: repeat(7, var(--cell, 0.85rem)); grid-auto-columns: var(--cell, 0.85rem); gap: 3px; width: max-content; }
40
+ button { width: 100%; height: 100%; padding: 0; border: 0; border-radius: 3px; background: var(--level-0, #ebedf0); cursor: pointer; }
41
+ button:focus-visible { outline: 2px solid var(--ink, #1d2130); outline-offset: 1px; }
42
+ button[data-level="1"] { background: color-mix(in srgb, var(--habit, #16a34a) 30%, var(--level-0, #ebedf0)); }
43
+ button[data-level="2"] { background: color-mix(in srgb, var(--habit, #16a34a) 55%, var(--level-0, #ebedf0)); }
44
+ button[data-level="3"] { background: color-mix(in srgb, var(--habit, #16a34a) 78%, var(--level-0, #ebedf0)); }
45
+ button[data-level="4"] { background: var(--habit, #16a34a); }
46
+ button[data-today] { box-shadow: 0 0 0 1.5px var(--ink, #1d2130); }
47
+ button:disabled { visibility: hidden; }
48
+ </style>
49
+ <streak-grid log={{}} goal={1} today="" weeks={12} label="History">
50
+ <div class="grid" role="group" aria-label={label} ref={grid}>
51
+ <zb-each items={columns} as="column" key={column.week}>
52
+ <zb-each items={column.days} as="cell" key={cell.key}>
53
+ <button type="button" data-day={cell.key} data-level={cell.level} data-today={cell.key === today ? "" : null} disabled={cell.level < 0} title={cell.label} aria-label={cell.label} aria-pressed={cell.count >= goal ? "true" : "false"} tabindex={cell.key === today ? "0" : "-1"} onclick={() => emit('daytoggle', cell.key)} onkeydown.up.prevent={() => move(cell.key, -1)} onkeydown.down.prevent={() => move(cell.key, 1)} onkeydown.left.prevent={() => move(cell.key, -7)} onkeydown.right.prevent={() => move(cell.key, 7)}></button>
54
+ </zb-each>
55
+ </zb-each>
56
+ </div>
57
+ </streak-grid>
@@ -0,0 +1,59 @@
1
+ <!--
2
+ Tabs: a tab list and the panels it switches between. The panels are the slotted elements with a
3
+ `data-tab` attribute matching a tab's value; they get role=tabpanel, are labelled by their tab,
4
+ and are hidden unless chosen. Arrow keys, Home and End move between the tabs (one tab stop), and
5
+ the focused tab is chosen as it moves, unless `manual`, where Enter or Space chooses it.
6
+ `tabs` is `[{ value, label }]`; `value` is two-way and defaults to the first tab.
7
+ @slot - The panels.
8
+ @fires valuechange - Another tab was chosen; `detail` is its value.
9
+ -->
10
+ <script>
11
+ import { id, host, effect, onMount } from '@zubra/runtime'
12
+ import { rovingFocus } from '@zubra/runtime/primitives'
13
+
14
+ const uid = id()
15
+ const element = host()
16
+ const current = value || tabs[0]?.value || ""
17
+ let list = null
18
+
19
+ const tabId = (tab) => `${uid}-tab-${tab}`
20
+ const panels = () => [...element.querySelectorAll("[data-tab]")].filter((panel) => panel.closest("tab-list") === element)
21
+
22
+ // The panels are the page's elements, not this template's: set them up here.
23
+ effect(() => {
24
+ if (!list) return
25
+ const chosen = current
26
+ tabs.length // and again when the tabs change
27
+ for (const panel of panels()) {
28
+ panel.id ||= `${uid}-panel-${panel.dataset.tab}`
29
+ panel.setAttribute("role", "tabpanel")
30
+ panel.setAttribute("aria-labelledby", tabId(panel.dataset.tab))
31
+ if (!panel.hasAttribute("tabindex")) panel.tabIndex = 0
32
+ panel.hidden = panel.dataset.tab !== chosen
33
+ list.querySelector(`[data-value="${CSS.escape(panel.dataset.tab)}"]`)?.setAttribute("aria-controls", panel.id)
34
+ }
35
+ })
36
+
37
+ onMount(() => rovingFocus(list, {
38
+ selector: "[role=tab]",
39
+ orientation: "horizontal",
40
+ onMove: (tab) => { if (!manual) value = tab.dataset.value },
41
+ }))
42
+ </script>
43
+ <style>
44
+ :host { display: block; }
45
+ [role=tablist] { display: flex; gap: 0.25rem; border-bottom: 1px solid var(--line, #dde); }
46
+ [role=tab] { padding: 0.55rem 0.9rem; border: 0; border-bottom: 2px solid transparent; margin-bottom: -1px; background: none; color: var(--muted, #667085); font: inherit; font-weight: 600; cursor: pointer; }
47
+ [role=tab][aria-selected=true] { color: var(--ink, #1d2130); border-bottom-color: var(--accent, #36c); }
48
+ [role=tab]:focus-visible { outline: 2px solid var(--accent, #36c); outline-offset: -2px; border-radius: 0.3rem; }
49
+ [role=tabpanel] { padding: 1rem 0.25rem; }
50
+ [role=tabpanel]:focus-visible { outline: 2px solid var(--accent, #36c); outline-offset: 2px; }
51
+ </style>
52
+ <tab-list value:model="" tabs={[]} label="" manual={false}>
53
+ <div role="tablist" aria-label={label || null} ref={list}>
54
+ <zb-each items={tabs} as="tab" key={tab.value}>
55
+ <button type="button" role="tab" id={tabId(tab.value)} data-value={tab.value} aria-selected={tab.value === current ? "true" : "false"} tabindex={tab.value === current ? 0 : -1} onclick={() => (value = tab.value)}>{tab.label}</button>
56
+ </zb-each>
57
+ </div>
58
+ <zb-slot></zb-slot>
59
+ </tab-list>
@@ -0,0 +1,50 @@
1
+ <!--
2
+ A list of tags you type: Enter or comma adds one, Backspace in the empty field removes the
3
+ last, × removes any. Suggestions come from `suggestions`. A form control.
4
+ @fires valuechange - The tags changed; `detail` is the new array.
5
+ -->
6
+ <script>
7
+ import { formValue, id } from '@zubra/runtime'
8
+
9
+ const listId = id()
10
+ let draft = ""
11
+ const offered = suggestions.filter((tag) => !value.includes(tag))
12
+
13
+ formValue(() => value.join(","))
14
+
15
+ function set(next) {
16
+ value = next
17
+ }
18
+ function add(text) {
19
+ const tag = text.trim().toLowerCase().replace(/\s+/g, "-")
20
+ draft = ""
21
+ if (tag && !value.includes(tag) && value.length < max) set([...value, tag])
22
+ }
23
+ function key(event) {
24
+ if (event.key === 'Enter' || event.key === ',') {
25
+ event.preventDefault()
26
+ add(draft)
27
+ } else if (event.key === 'Backspace' && !draft && value.length) {
28
+ set(value.slice(0, -1))
29
+ }
30
+ }
31
+ </script>
32
+ <style>
33
+ :host { display: block; }
34
+ .box { display: flex; flex-wrap: wrap; align-items: center; gap: 0.3rem; padding: 0.35rem 0.4rem; border: 1px solid var(--line, #d9dce3); border-radius: 0.6rem; background: var(--surface, #ffffff); }
35
+ .box:focus-within { outline: 2px solid var(--accent, #4f5df5); outline-offset: 1px; }
36
+ .tag { display: inline-flex; align-items: center; gap: 0.15rem; padding: 0.1rem 0.2rem 0.1rem 0.5rem; border-radius: 0.4rem; background: var(--chip, #eceefe); color: var(--chip-ink, #3a3f9e); font-size: 0.8125rem; font-weight: 600; }
37
+ .tag button { border: 0; background: none; color: inherit; padding: 0 0.25rem; cursor: pointer; font: inherit; }
38
+ input { flex: 1; min-width: 7rem; border: 0; outline: 0; padding: 0.3rem; font: inherit; background: none; color: inherit; }
39
+ .hint { margin: 0.3rem 0 0; font-size: 0.75rem; color: var(--muted, #667085); }
40
+ </style>
41
+ <tag-input form-associated value:model={[]} suggestions={[]} max={6} label="Tags">
42
+ <div class="box">
43
+ <zb-each items={value} as="tag" key={tag}>
44
+ <span class="tag">{tag}<button type="button" aria-label={`Remove ${tag}`} onclick={() => set(value.filter((item) => item !== tag))}>×</button></span>
45
+ </zb-each>
46
+ <input aria-label={label} list={listId} placeholder={value.length >= max ? "" : "Add a tag…"} disabled={value.length >= max} value={draft} oninput={(event) => (draft = event.target.value)} onkeydown={key} onchange={(event) => offered.includes(event.target.value) && add(event.target.value)}>
47
+ <datalist id={listId}><zb-each items={offered} as="tag" key={tag}><option value={tag}></option></zb-each></datalist>
48
+ </div>
49
+ <p class="hint">{value.length}/{max} · Enter or comma to add</p>
50
+ </tag-input>