@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.
- package/LICENSE +15 -0
- package/cli.js +175 -0
- package/index.d.ts +81 -0
- package/index.js +123 -0
- package/library.d.ts +20 -0
- package/library.js +224 -0
- package/package.json +41 -4
- package/registry/README.md +41 -0
- package/registry/build-index.mjs +58 -0
- package/registry/components/accordion-panel.zb +23 -0
- package/registry/components/chat-message.zb +65 -0
- package/registry/components/choice-cards.zb +47 -0
- package/registry/components/color-swatches.zb +30 -0
- package/registry/components/combo-box.zb +59 -0
- package/registry/components/date-range.zb +37 -0
- package/registry/components/donut-chart.zb +56 -0
- package/registry/components/dropdown-menu.zb +83 -0
- package/registry/components/flip-card.zb +29 -0
- package/registry/components/kanban-column.zb +65 -0
- package/registry/components/line-chart.zb +58 -0
- package/registry/components/message-composer.zb +80 -0
- package/registry/components/modal-dialog.zb +44 -0
- package/registry/components/money-input.zb +41 -0
- package/registry/components/progress-ring.zb +24 -0
- package/registry/components/quantity-stepper.zb +22 -0
- package/registry/components/segmented-control.zb +23 -0
- package/registry/components/spark-line.zb +19 -0
- package/registry/components/star-rating.zb +34 -0
- package/registry/components/step-indicator.zb +31 -0
- package/registry/components/streak-grid.zb +57 -0
- package/registry/components/tab-list.zb +59 -0
- package/registry/components/tag-input.zb +50 -0
- package/registry/components/toggle-switch.zb +27 -0
- package/registry/components/tool-tip.zb +68 -0
- package/registry/index.json +233 -0
- package/zubra.wasm +0 -0
- 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>
|