@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
package/package.json
CHANGED
|
@@ -1,6 +1,43 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zubra/compiler",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "5.0.3",
|
|
4
|
+
"description": "The Zubra compiler as WebAssembly: compile .zb components from Node without installing Rust",
|
|
5
|
+
"license": "ISC",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/journey-to-code-org/zubra.git",
|
|
9
|
+
"directory": "compiler"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./index.d.ts",
|
|
15
|
+
"default": "./index.js"
|
|
16
|
+
},
|
|
17
|
+
"./library": {
|
|
18
|
+
"types": "./library.d.ts",
|
|
19
|
+
"default": "./library.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"index.js",
|
|
24
|
+
"index.d.ts",
|
|
25
|
+
"zubra.wasm",
|
|
26
|
+
"cli.js",
|
|
27
|
+
"library.js",
|
|
28
|
+
"library.d.ts",
|
|
29
|
+
"registry",
|
|
30
|
+
"!registry/test"
|
|
31
|
+
],
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "cargo build -p zubra-wasm --target wasm32-unknown-unknown --release && cp ../target/wasm32-unknown-unknown/release/zubra_wasm.wasm zubra.wasm",
|
|
34
|
+
"test": "node --test \"test/*.test.js\"",
|
|
35
|
+
"prepack": "node scripts/prepack.mjs"
|
|
36
|
+
},
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=20"
|
|
39
|
+
},
|
|
40
|
+
"bin": {
|
|
41
|
+
"zubra": "cli.js"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# The Zubra component registry
|
|
2
|
+
|
|
3
|
+
Twenty-five accessible components to copy into your project:
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npx zubra add tag-input modal-dialog # into src/components/ (--dir elsewhere)
|
|
7
|
+
npx zubra add --list
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
`zubra` comes with `@zubra/compiler`. The files are copied, not installed: they're yours to edit, and `zubra add` never overwrites a file you have (`--force` does).
|
|
11
|
+
|
|
12
|
+
| Inputs | Display | App pieces | Widgets |
|
|
13
|
+
|---|---|---|---|
|
|
14
|
+
| `tag-input`, `date-range`, `money-input`, `star-rating`, `toggle-switch`, `choice-cards`, `color-swatches`, `quantity-stepper`, `segmented-control` | `donut-chart`, `spark-line`, `line-chart`, `progress-ring`, `streak-grid`, `flip-card`, `step-indicator` | `kanban-column`, `chat-message`, `message-composer`, `modal-dialog` | `accordion-panel`, `tool-tip`, `tab-list`, `dropdown-menu`, `combo-box` |
|
|
15
|
+
|
|
16
|
+
The widgets are the pieces Radix and shadcn/ui give React apps, built on the platform (`<details>`, `popover`, anchor positioning) and `@zubra/runtime/primitives`. `combo-box` uses Zag.js through `@zubra/runtime/zag`. It imports `@zag-js/vanilla` and `@zag-js/combobox`, and `zubra add` says to install them (`index.json` lists them under `packages`). [Accessible widgets without a headless library](../docs/guide/accessible-widgets.md) maps the rest.
|
|
17
|
+
|
|
18
|
+
Each component's comment says what it does, its props, slots and events (`index.json` collects them). The inputs are form controls with a two-way `value` (or `checked`), so `v-model`, React's controlled pattern and Angular's `ZubraModel` work with them as they are.
|
|
19
|
+
|
|
20
|
+
## Accessibility
|
|
21
|
+
|
|
22
|
+
Every component is tested in Chromium (`test/components.test.js`) the way assistive technology meets it: found by its role and accessible name, used from the keyboard (radio groups, tab lists and menus with arrow keys and one tab stop, typing to jump in a menu, Space and Enter on switches and toggle buttons, a dialog and a menu that move focus in and back, Escape to close), and checked for the state it announces (`aria-checked`, `aria-pressed`, `aria-current`, `aria-expanded`).
|
|
23
|
+
|
|
24
|
+
## Theming
|
|
25
|
+
|
|
26
|
+
Colours come from CSS custom properties, each with a default, so the components look right on their own and take your theme when you set them on a parent:
|
|
27
|
+
|
|
28
|
+
| Property | Used for | Default |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| `--ink`, `--muted` | text | `#1d2130`, `#667085` |
|
|
31
|
+
| `--surface`, `--sunken`, `--hover` | backgrounds | `#ffffff`, `#f3f4f7`, `#f2f4f8` |
|
|
32
|
+
| `--line` | borders | `#d9dce3` |
|
|
33
|
+
| `--accent`, `--link`, `--danger` | emphasis | `#4f5df5`, `#2563eb`, `#d92d20` |
|
|
34
|
+
| `--up`, `--down` | chart trends | `#12805c`, `#c4320a` |
|
|
35
|
+
| `--habit`, `--level-0` | `streak-grid` | `#16a34a`, `#ebedf0` |
|
|
36
|
+
| `--chip`, `--chip-ink`, `--column`, `--column-over` | `kanban-column` | `#eceefe`, `#3a3f9e`, `#f3f4f7`, `#e8ebff` |
|
|
37
|
+
| `--mention`, `--mention-wash` | `chat-message` | `#8a5a00`, `#fff4cc` |
|
|
38
|
+
|
|
39
|
+
## Adding one
|
|
40
|
+
|
|
41
|
+
Put the `.zb` file in `components/`, give it a description comment, add it to a category in `build-index.mjs`, run `node registry/build-index.mjs`, and add its keyboard and screen-reader test.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Writes index.json, the registry's catalogue, from the components' own description comments:
|
|
2
|
+
// node registry/build-index.mjs [--check]
|
|
3
|
+
// With --check it fails if index.json is out of date (CI runs that).
|
|
4
|
+
import { copyFileSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const CATEGORIES = {
|
|
10
|
+
input: ['tag-input', 'date-range', 'money-input', 'star-rating', 'toggle-switch', 'choice-cards', 'color-swatches', 'quantity-stepper', 'segmented-control'],
|
|
11
|
+
display: ['donut-chart', 'spark-line', 'line-chart', 'progress-ring', 'streak-grid', 'flip-card', 'step-indicator'],
|
|
12
|
+
app: ['kanban-column', 'chat-message', 'message-composer', 'modal-dialog'],
|
|
13
|
+
widget: ['accordion-panel', 'tool-tip', 'tab-list', 'dropdown-menu', 'combo-box'],
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function catalogue() {
|
|
17
|
+
const files = readdirSync(join(here, 'components')).filter((file) => file.endsWith('.zb')).sort();
|
|
18
|
+
const components = files.map((file) => {
|
|
19
|
+
const name = file.replace(/\.zb$/, '');
|
|
20
|
+
const source = readFileSync(join(here, 'components', file), 'utf8');
|
|
21
|
+
const comment = source.match(/^<!--([\s\S]*?)-->/)?.[1] ?? '';
|
|
22
|
+
const description = comment.split(/\n\s*@/)[0].split(/\n\s*\n/)[0].replace(/\s+/g, ' ').trim();
|
|
23
|
+
const category = Object.keys(CATEGORIES).find((key) => CATEGORIES[key].includes(name));
|
|
24
|
+
if (!category) throw new Error(`registry: ${name} has no category in build-index.mjs`);
|
|
25
|
+
if (!description) throw new Error(`registry: ${name} has no description comment`);
|
|
26
|
+
const script = source.match(/<script[^>]*>([\s\S]*?)<\/script>/)?.[1] ?? '';
|
|
27
|
+
const dependencies = [...script.matchAll(/import\s+['"]\.\/([a-z0-9-]+)\.zb['"]/g)].map((match) => match[1]);
|
|
28
|
+
// npm packages it imports, beyond the runtime: `zubra add` says to install them. The Zag adapter
|
|
29
|
+
// needs @zag-js/vanilla, which it imports itself.
|
|
30
|
+
const packages = [...new Set([...script.matchAll(/from\s+['"]((?:@[a-z0-9-]+\/)?[a-z0-9-]+)[^'"]*['"]/g)].map((match) => match[1]).filter((name) => name !== '@zubra/runtime'))];
|
|
31
|
+
if (/from\s+['"]@zubra\/runtime\/zag['"]/.test(script)) packages.unshift('@zag-js/vanilla');
|
|
32
|
+
return { name, category, description, files: [file], dependencies, ...(packages.length ? { packages } : {}) };
|
|
33
|
+
});
|
|
34
|
+
return { components };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Copies the components that import no npm packages into `into`, for the libraries the tests and
|
|
39
|
+
* examples build from the registry: a plain page, or an app that hasn't installed Zag, can use them
|
|
40
|
+
* all. Returns `into`.
|
|
41
|
+
*/
|
|
42
|
+
export function standalone(into) {
|
|
43
|
+
mkdirSync(into, { recursive: true });
|
|
44
|
+
for (const component of catalogue().components) {
|
|
45
|
+
if (!component.packages) for (const file of component.files) copyFileSync(join(here, 'components', file), join(into, file));
|
|
46
|
+
}
|
|
47
|
+
return into;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const text = JSON.stringify(catalogue(), null, 2) + '\n';
|
|
51
|
+
if (process.argv.includes('--check')) {
|
|
52
|
+
if (readFileSync(join(here, 'index.json'), 'utf8') !== text) {
|
|
53
|
+
console.error('registry/index.json is out of date: run node registry/build-index.mjs');
|
|
54
|
+
process.exitCode = 1;
|
|
55
|
+
}
|
|
56
|
+
} else if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
57
|
+
writeFileSync(join(here, 'index.json'), text);
|
|
58
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
A section that opens and closes, on the native <details>: the browser does the keyboard (Enter
|
|
3
|
+
and Space on the heading), the find-in-page opening and the announcements. Panels with the same
|
|
4
|
+
`group` form an accordion where one is open at a time (<details name>). `open` is two-way.
|
|
5
|
+
@slot - The panel's content.
|
|
6
|
+
@slot heading - Its heading (defaults to `heading`).
|
|
7
|
+
-->
|
|
8
|
+
<style>
|
|
9
|
+
:host { display: block; }
|
|
10
|
+
details { border-bottom: 1px solid var(--line, #dde); }
|
|
11
|
+
summary { display: flex; align-items: center; gap: 0.75rem; padding: 0.8rem 0.25rem; font-weight: 600; cursor: pointer; list-style: none; }
|
|
12
|
+
summary::-webkit-details-marker { display: none; }
|
|
13
|
+
summary::after { content: ""; margin-left: auto; width: 0.5rem; height: 0.5rem; border: solid currentColor; border-width: 0 2px 2px 0; transform: rotate(45deg); transition: transform 0.2s; }
|
|
14
|
+
details[open] > summary::after { transform: rotate(-135deg); }
|
|
15
|
+
summary:focus-visible { outline: 2px solid var(--accent, #36c); outline-offset: 2px; border-radius: 0.3rem; }
|
|
16
|
+
.body { padding: 0 0.25rem 1rem; }
|
|
17
|
+
</style>
|
|
18
|
+
<accordion-panel open:model={false} heading="" group="">
|
|
19
|
+
<details name={group || null} open={open} ontoggle={(event) => (open = event.target.open)}>
|
|
20
|
+
<summary><zb-slot name="heading">{heading}</zb-slot></summary>
|
|
21
|
+
<div class="body"><zb-slot></zb-slot></div>
|
|
22
|
+
</details>
|
|
23
|
+
</accordion-panel>
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
One message: avatar, author, time, text (with @mentions and links picked out), the message it
|
|
3
|
+
replies to, and reactions. `me` is the signed-in user's name: their messages, mentions of them and
|
|
4
|
+
their reactions are marked.
|
|
5
|
+
@fires reacttoggle - A reaction was toggled; `detail` is the emoji.
|
|
6
|
+
@fires reply - Reply was chosen.
|
|
7
|
+
-->
|
|
8
|
+
<script>
|
|
9
|
+
import { emit } from '@zubra/runtime'
|
|
10
|
+
|
|
11
|
+
const QUICK = ["👍", "🎉", "❤️", "😂"]
|
|
12
|
+
const mine = message.author === me
|
|
13
|
+
const initials = message.author.split(/\s+/).map((part) => part[0]).join("").slice(0, 2)
|
|
14
|
+
const hue = [...message.author].reduce((sum, char) => sum + char.charCodeAt(0) * 47, 0) % 360
|
|
15
|
+
const time = new Date(message.time).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })
|
|
16
|
+
const reactions = Object.entries(message.reactions ?? {}).filter(([, people]) => people.length > 0)
|
|
17
|
+
// Text as pieces, so mentions and links are elements, not HTML strings.
|
|
18
|
+
const pieces = message.text.split(/(@[A-Za-z]+|https?:\/\/\S+)/g).filter(Boolean).map((text, index) => ({
|
|
19
|
+
index,
|
|
20
|
+
text,
|
|
21
|
+
kind: text.startsWith("@") ? "mention" : /^https?:/.test(text) ? "link" : "text",
|
|
22
|
+
me: text.startsWith("@") && me.toLowerCase().startsWith(text.slice(1).toLowerCase()),
|
|
23
|
+
}))
|
|
24
|
+
</script>
|
|
25
|
+
<style>
|
|
26
|
+
:host { display: block; }
|
|
27
|
+
article { position: relative; display: grid; grid-template-columns: 2.25rem 1fr; gap: 0 0.7rem; padding: 0.45rem 1.25rem; }
|
|
28
|
+
article:hover { background: var(--hover, #f2f4f8); }
|
|
29
|
+
article.mentioned { background: var(--mention-wash, #fff4cc); box-shadow: inset 3px 0 var(--mention, #8a5a00); }
|
|
30
|
+
.avatar { grid-row: span 3; display: grid; place-items: center; width: 2.25rem; height: 2.25rem; border-radius: 0.6rem; color: white; font-weight: 800; font-size: 0.8rem; }
|
|
31
|
+
header { display: flex; align-items: baseline; gap: 0.5rem; }
|
|
32
|
+
.author { font-weight: 700; }
|
|
33
|
+
.you { font-size: 0.7rem; font-weight: 700; color: var(--muted, #667085); }
|
|
34
|
+
time { font-size: 0.75rem; color: var(--muted, #667085); }
|
|
35
|
+
.quote { margin: 0.15rem 0 0.25rem; padding-left: 0.6rem; border-left: 3px solid var(--line, #d9dce3); color: var(--muted, #667085); font-size: 0.875rem; }
|
|
36
|
+
p { margin: 0; white-space: pre-wrap; word-break: break-word; }
|
|
37
|
+
.mention { padding: 0 0.15rem; border-radius: 0.3rem; background: var(--mention-wash, #fff4cc); color: var(--mention, #8a5a00); font-weight: 600; }
|
|
38
|
+
.mention.me { background: var(--mention, #8a5a00); color: white; }
|
|
39
|
+
a { color: var(--link, #2563eb); }
|
|
40
|
+
.reactions { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-top: 0.35rem; }
|
|
41
|
+
.reactions button { display: inline-flex; gap: 0.3rem; align-items: center; padding: 0.05rem 0.5rem; border: 1px solid var(--line, #d9dce3); border-radius: 999px; background: var(--surface, #ffffff); color: inherit; font: inherit; font-size: 0.8125rem; cursor: pointer; }
|
|
42
|
+
.reactions button[aria-pressed=true] { border-color: var(--link, #2563eb); background: var(--mention-wash, #fff4cc); }
|
|
43
|
+
.tools { position: absolute; top: -0.9rem; right: 1rem; display: flex; opacity: 0; gap: 0.1rem; padding: 0.15rem; border: 1px solid var(--line, #d9dce3); border-radius: 0.55rem; background: var(--surface, #ffffff); box-shadow: 0 2px 8px #0002; }
|
|
44
|
+
/* Transparent rather than hidden, so the keyboard can reach it; it shows on hover or focus. */
|
|
45
|
+
article:hover .tools, .tools:focus-within { opacity: 1; }
|
|
46
|
+
.tools button { border: 0; background: none; padding: 0.15rem 0.35rem; border-radius: 0.35rem; cursor: pointer; font: inherit; color: inherit; }
|
|
47
|
+
.tools button:hover { background: var(--hover, #f2f4f8); }
|
|
48
|
+
</style>
|
|
49
|
+
<chat-message me="" message={{ id: "", author: "", text: "", time: 0, reactions: {}, replyTo: null }}>
|
|
50
|
+
<article class:mentioned={pieces.some((piece) => piece.me)} data-mine={mine ? "true" : "false"}>
|
|
51
|
+
<span class="avatar" style:background={`hsl(${hue} 55% 40%)`} aria-hidden="true">{initials}</span>
|
|
52
|
+
<header><span class="author">{message.author}</span><zb-if test={mine}><span class="you">YOU</span></zb-if><time>{time}</time></header>
|
|
53
|
+
<div zb-if={message.replyTo} class="quote">{message.replyTo?.author}: {message.replyTo?.text}</div>
|
|
54
|
+
<p><zb-each items={pieces} as="piece" key={piece.index}><zb-if test={piece.kind === "mention"}><span class="mention" class:me={piece.me}>{piece.text}</span><zb-else-if test={piece.kind === "link"}><a href={piece.text} target="_blank" rel="noopener">{piece.text}</a></zb-else-if><zb-else>{piece.text}</zb-else></zb-if></zb-each></p>
|
|
55
|
+
<div zb-if={reactions.length > 0} class="reactions">
|
|
56
|
+
<zb-each items={reactions} as="reaction" key={reaction[0]}>
|
|
57
|
+
<button type="button" aria-pressed={reaction[1].includes(me) ? "true" : "false"} title={reaction[1].join(", ")} onclick={() => emit('reacttoggle', reaction[0])}>{reaction[0]} <b>{reaction[1].length}</b></button>
|
|
58
|
+
</zb-each>
|
|
59
|
+
</div>
|
|
60
|
+
<div class="tools" role="toolbar" aria-label="Message actions">
|
|
61
|
+
<zb-each items={QUICK} as="emoji" key={emoji}><button type="button" aria-label={`React ${emoji}`} onclick={() => emit('reacttoggle', emoji)}>{emoji}</button></zb-each>
|
|
62
|
+
<button type="button" class="reply" onclick={() => emit('reply')}>Reply</button>
|
|
63
|
+
</div>
|
|
64
|
+
</article>
|
|
65
|
+
</chat-message>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Pick one of several cards: a radio group. A form control; arrow keys move the choice.
|
|
3
|
+
Each item is `{ id, title, detail, price, note }` (`note` and `price` optional).
|
|
4
|
+
@fires valuechange - The choice changed; `detail` is the chosen id.
|
|
5
|
+
-->
|
|
6
|
+
<script>
|
|
7
|
+
import { validity } from '@zubra/runtime'
|
|
8
|
+
|
|
9
|
+
let group = null
|
|
10
|
+
validity(() => (required && !value ? "Choose one" : ""))
|
|
11
|
+
|
|
12
|
+
function choose(item) {
|
|
13
|
+
if (item.disabled || item.id === value) return
|
|
14
|
+
value = item.id
|
|
15
|
+
}
|
|
16
|
+
function move(index, step) {
|
|
17
|
+
const enabled = items.filter((item) => !item.disabled)
|
|
18
|
+
const at = enabled.findIndex((item) => item.id === items[index].id)
|
|
19
|
+
const next = enabled[(at + step + enabled.length) % enabled.length]
|
|
20
|
+
choose(next)
|
|
21
|
+
queueMicrotask(() => group.querySelector(`[data-id="${next.id}"]`)?.focus())
|
|
22
|
+
}
|
|
23
|
+
</script>
|
|
24
|
+
<style>
|
|
25
|
+
:host { display: block; }
|
|
26
|
+
[role=radiogroup] { display: grid; grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr)); gap: 0.75rem; }
|
|
27
|
+
button { display: grid; gap: 0.25rem; text-align: left; padding: 0.9rem 1rem; border: 2px solid var(--line, #dde); border-radius: 0.9rem; background: var(--surface, #fff); color: inherit; font: inherit; cursor: pointer; transition: border-color 0.15s, box-shadow 0.15s; }
|
|
28
|
+
button:hover:not(:disabled) { border-color: color-mix(in srgb, var(--accent, #36c) 50%, transparent); }
|
|
29
|
+
button[aria-checked=true] { border-color: var(--accent, #36c); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent, #36c) 20%, transparent); }
|
|
30
|
+
button:disabled { opacity: 0.45; cursor: not-allowed; }
|
|
31
|
+
strong { font-size: 1rem; }
|
|
32
|
+
.detail { font-size: 0.8125rem; color: var(--muted, #667); }
|
|
33
|
+
.row { display: flex; justify-content: space-between; align-items: baseline; margin-top: 0.35rem; }
|
|
34
|
+
.price { font-weight: 700; }
|
|
35
|
+
.note { font-size: 0.75rem; font-weight: 600; color: var(--accent, #36c); }
|
|
36
|
+
</style>
|
|
37
|
+
<choice-cards form-associated value:model="" items={[]} required={false} label="">
|
|
38
|
+
<div role="radiogroup" aria-label={label} ref={group}>
|
|
39
|
+
<zb-each items={items} as="item" index="index" key={item.id}>
|
|
40
|
+
<button type="button" role="radio" data-id={item.id} aria-checked={item.id === value ? "true" : "false"} tabindex={item.id === value || (!value && index === 0) ? "0" : "-1"} disabled={item.disabled ?? false} onclick={() => choose(item)} onkeydown.down.right.prevent={() => move(index, 1)} onkeydown.up.left.prevent={() => move(index, -1)}>
|
|
41
|
+
<strong>{item.title}</strong>
|
|
42
|
+
<span class="detail">{item.detail}</span>
|
|
43
|
+
<span class="row"><span class="note">{item.note ?? ""}</span><span class="price">{item.price ?? ""}</span></span>
|
|
44
|
+
</button>
|
|
45
|
+
</zb-each>
|
|
46
|
+
</div>
|
|
47
|
+
</choice-cards>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Pick a colour from `colors`. A form control (a radio group): arrow keys move the choice.
|
|
3
|
+
@fires valuechange - The colour changed; `detail` is the colour.
|
|
4
|
+
-->
|
|
5
|
+
<script>
|
|
6
|
+
let group = null
|
|
7
|
+
|
|
8
|
+
function pick(color) {
|
|
9
|
+
if (color === value) return
|
|
10
|
+
value = color
|
|
11
|
+
}
|
|
12
|
+
function move(index, step) {
|
|
13
|
+
const next = colors[(index + step + colors.length) % colors.length]
|
|
14
|
+
pick(next)
|
|
15
|
+
queueMicrotask(() => group.querySelector(`[data-color="${next}"]`)?.focus())
|
|
16
|
+
}
|
|
17
|
+
</script>
|
|
18
|
+
<style>
|
|
19
|
+
:host { display: block; }
|
|
20
|
+
div { display: flex; flex-wrap: wrap; gap: 0.4rem; }
|
|
21
|
+
button { width: 1.75rem; height: 1.75rem; padding: 0; border: 2px solid transparent; border-radius: 50%; cursor: pointer; box-shadow: inset 0 0 0 2px var(--surface, #ffffff); }
|
|
22
|
+
button[aria-checked=true] { border-color: var(--ink, #1d2130); }
|
|
23
|
+
</style>
|
|
24
|
+
<color-swatches form-associated value:model="#16a34a" colors={["#16a34a", "#2563eb", "#db2777", "#ea580c", "#7c3aed", "#0891b2"]} label="Colour">
|
|
25
|
+
<div role="radiogroup" aria-label={label} ref={group}>
|
|
26
|
+
<zb-each items={colors} as="color" index="index" key={color}>
|
|
27
|
+
<button type="button" role="radio" style:background={color} data-color={color} aria-label={color} aria-checked={color === value ? "true" : "false"} tabindex={color === value ? "0" : "-1"} onclick={() => pick(color)} onkeydown.right.down.prevent={() => move(index, 1)} onkeydown.left.up.prevent={() => move(index, -1)}></button>
|
|
28
|
+
</zb-each>
|
|
29
|
+
</div>
|
|
30
|
+
</color-swatches>
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
A combobox: type to filter, arrow keys to move, Enter to pick, Escape to close. A form control.
|
|
3
|
+
Its behaviour is Zag.js's combobox machine (through @zubra/runtime/zag); its markup and styles are
|
|
4
|
+
this file's. It needs `npm install @zag-js/vanilla @zag-js/combobox`. `items` is
|
|
5
|
+
`[{ value, label }]`; `value` is two-way.
|
|
6
|
+
@fires valuechange - An item was picked; `detail` is its value.
|
|
7
|
+
-->
|
|
8
|
+
<script>
|
|
9
|
+
import * as combobox from '@zag-js/combobox'
|
|
10
|
+
import { id, computed } from '@zubra/runtime'
|
|
11
|
+
import { useMachine, normalizeProps } from '@zubra/runtime/zag'
|
|
12
|
+
|
|
13
|
+
let query = ""
|
|
14
|
+
const shown = items.filter((item) => item.label.toLowerCase().includes(query.toLowerCase()))
|
|
15
|
+
const collection = combobox.collection({ items: shown, itemToValue: (item) => item.value, itemToString: (item) => item.label })
|
|
16
|
+
|
|
17
|
+
const uid = id() // once: Zag's ids must be stable
|
|
18
|
+
const machine = useMachine(combobox.machine, () => ({
|
|
19
|
+
id: uid,
|
|
20
|
+
collection,
|
|
21
|
+
value: value ? [value] : [],
|
|
22
|
+
placeholder,
|
|
23
|
+
onInputValueChange: (details) => { query = details.inputValue },
|
|
24
|
+
// Opened from the button or the arrow keys: show everything, as Zag's own examples do.
|
|
25
|
+
onOpenChange: (details) => { if (details.open && details.reason !== "input-change") query = "" },
|
|
26
|
+
onValueChange: (details) => { value = details.value[0] ?? "" },
|
|
27
|
+
}))
|
|
28
|
+
const api = computed(() => combobox.connect(machine.current, normalizeProps))
|
|
29
|
+
</script>
|
|
30
|
+
<style>
|
|
31
|
+
:host { display: inline-block; font: inherit; }
|
|
32
|
+
[data-part=root] { display: grid; gap: 0.3rem; }
|
|
33
|
+
[data-part=label] { font-weight: 600; }
|
|
34
|
+
[data-part=control] { display: flex; border: 1px solid var(--line, #dde); border-radius: 0.5rem; background: var(--surface, #fff); }
|
|
35
|
+
[data-part=control]:focus-within { outline: 2px solid var(--accent, #36c); outline-offset: 1px; }
|
|
36
|
+
input { flex: 1; min-width: 0; padding: 0.45rem 0.6rem; border: 0; background: transparent; color: inherit; font: inherit; outline: none; }
|
|
37
|
+
[data-part=trigger] { padding: 0 0.6rem; border: 0; background: none; color: inherit; font: inherit; cursor: pointer; }
|
|
38
|
+
ul { list-style: none; margin: 0; padding: 0.3rem; min-width: 12rem; max-height: 16rem; overflow: auto; border: 1px solid var(--line, #dde); border-radius: 0.6rem; background: var(--surface, #fff); box-shadow: 0 8px 24px #0d122026; }
|
|
39
|
+
ul:not([data-state=open]) { display: none; }
|
|
40
|
+
li { padding: 0.4rem 0.6rem; border-radius: 0.4rem; cursor: pointer; }
|
|
41
|
+
li[data-highlighted] { background: var(--hover, #f2f4f8); }
|
|
42
|
+
li[data-state=checked] { font-weight: 700; }
|
|
43
|
+
</style>
|
|
44
|
+
<combo-box form-associated value:model="" label="" placeholder="" items={[]}>
|
|
45
|
+
<div {...api.getRootProps()}>
|
|
46
|
+
<label {...api.getLabelProps()}>{label}</label>
|
|
47
|
+
<div {...api.getControlProps()}>
|
|
48
|
+
<input {...api.getInputProps()}>
|
|
49
|
+
<button type="button" {...api.getTriggerProps()}>▾</button>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
<div {...api.getPositionerProps()}>
|
|
53
|
+
<ul {...api.getContentProps()}>
|
|
54
|
+
<zb-each items={shown} as="item" key={item.value}>
|
|
55
|
+
<li {...api.getItemProps({ item })}>{item.label}</li>
|
|
56
|
+
</zb-each>
|
|
57
|
+
</ul>
|
|
58
|
+
</div>
|
|
59
|
+
</combo-box>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
A check-in and check-out date, as one value `{ start, end }` ("YYYY-MM-DD" strings). A form
|
|
3
|
+
control: invalid until both are set and `end` is after `start`.
|
|
4
|
+
@fires valuechange - Either date changed; `detail` is the new `{ start, end }`.
|
|
5
|
+
-->
|
|
6
|
+
<script>
|
|
7
|
+
import { formValue, validity, id } from '@zubra/runtime'
|
|
8
|
+
|
|
9
|
+
const startId = id()
|
|
10
|
+
const endId = id()
|
|
11
|
+
const nights = value.start && value.end ? Math.round((Date.parse(value.end) - Date.parse(value.start)) / 86400000) : 0
|
|
12
|
+
const nextDay = (day) => (day ? new Date(Date.parse(day) + 86400000).toISOString().slice(0, 10) : min)
|
|
13
|
+
|
|
14
|
+
formValue(() => (value.start && value.end ? `${value.start}/${value.end}` : null))
|
|
15
|
+
validity(() => (!value.start || !value.end ? "Choose both dates" : nights < 1 ? "Check-out must be after check-in" : ""))
|
|
16
|
+
|
|
17
|
+
function set(part, day) {
|
|
18
|
+
const next = { ...value, [part]: day }
|
|
19
|
+
if (part === "start" && next.end && next.end <= day) next.end = nextDay(day)
|
|
20
|
+
value = next
|
|
21
|
+
}
|
|
22
|
+
</script>
|
|
23
|
+
<style>
|
|
24
|
+
:host { display: grid; grid-template-columns: 1fr 1fr auto; gap: 0.75rem; align-items: end; }
|
|
25
|
+
label { display: grid; gap: 0.3rem; font-size: 0.8125rem; font-weight: 600; color: var(--muted, #667); }
|
|
26
|
+
input { font: inherit; padding: 0.55rem 0.65rem; border: 1px solid var(--line, #ccd); border-radius: 0.6rem; background: var(--surface, #fff); color: var(--ink, #111); }
|
|
27
|
+
.nights { padding: 0.55rem 0.8rem; border-radius: 999px; background: var(--chip, #eef); font-weight: 700; white-space: nowrap; }
|
|
28
|
+
</style>
|
|
29
|
+
<date-range form-associated value:model={{ start: "", end: "" }} min="">
|
|
30
|
+
<label for={startId}>Check-in
|
|
31
|
+
<input id={startId} type="date" class="start" min={min} value={value.start} onchange={(event) => set("start", event.target.value)}>
|
|
32
|
+
</label>
|
|
33
|
+
<label for={endId}>Check-out
|
|
34
|
+
<input id={endId} type="date" class="end" min={nextDay(value.start)} value={value.end} onchange={(event) => set("end", event.target.value)}>
|
|
35
|
+
</label>
|
|
36
|
+
<span class="nights" aria-live="polite">{nights > 0 ? `${nights} night${nights === 1 ? "" : "s"}` : "—"}</span>
|
|
37
|
+
</date-range>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
A donut chart of `segments` (`[{ label, value, tone }]`, `tone` 0–7), with the total in the
|
|
3
|
+
middle and a legend. Choosing a segment (or its legend entry) selects it; again clears it.
|
|
4
|
+
@slot - What's in the middle; the total by default.
|
|
5
|
+
@fires valuechange - The selected label changed; `detail` is the label, or "" for none.
|
|
6
|
+
-->
|
|
7
|
+
<script>
|
|
8
|
+
const R = 15.915494309189533 // circumference 100, so lengths are percentages
|
|
9
|
+
const total = segments.reduce((sum, segment) => sum + segment.value, 0)
|
|
10
|
+
const arcs = segments.reduce((list, segment) => {
|
|
11
|
+
const start = list.length ? list[list.length - 1].end : 0
|
|
12
|
+
const length = total ? (segment.value / total) * 100 : 0
|
|
13
|
+
return [...list, { ...segment, start, end: start + length, length }]
|
|
14
|
+
}, []).filter((arc) => arc.length > 0)
|
|
15
|
+
const percent = (arc) => `${Math.round(arc.length)}%`
|
|
16
|
+
|
|
17
|
+
function select(label) {
|
|
18
|
+
value = value === label ? "" : label
|
|
19
|
+
}
|
|
20
|
+
</script>
|
|
21
|
+
<style>
|
|
22
|
+
:host { display: grid; grid-template-columns: 11rem 1fr; gap: 1.25rem; align-items: center; }
|
|
23
|
+
.chart { position: relative; }
|
|
24
|
+
svg { display: block; width: 100%; transform: rotate(-90deg); }
|
|
25
|
+
circle { fill: none; stroke-width: 5; cursor: pointer; transition: stroke-width 0.15s, opacity 0.15s; }
|
|
26
|
+
circle.track { stroke: var(--sunken, #f3f4f7); cursor: default; }
|
|
27
|
+
circle.dim { opacity: 0.3; }
|
|
28
|
+
circle.on { stroke-width: 6.5; }
|
|
29
|
+
.middle { position: absolute; inset: 0; display: grid; place-content: center; text-align: center; pointer-events: none; }
|
|
30
|
+
ul { display: grid; gap: 0.25rem; margin: 0; padding: 0; list-style: none; }
|
|
31
|
+
button { display: grid; grid-template-columns: 0.75rem 1fr auto; gap: 0.5rem; align-items: center; width: 100%; padding: 0.3rem 0.45rem; border: 0; border-radius: 0.45rem; background: none; color: inherit; font: inherit; font-size: 0.875rem; text-align: left; cursor: pointer; }
|
|
32
|
+
button:hover, button[aria-pressed=true] { background: var(--sunken, #f3f4f7); }
|
|
33
|
+
.swatch { width: 0.75rem; height: 0.75rem; border-radius: 0.2rem; }
|
|
34
|
+
.pct { color: var(--muted, #667085); font-variant-numeric: tabular-nums; }
|
|
35
|
+
.t0 { stroke: #4f46e5; background: #4f46e5; } .t1 { stroke: #0891b2; background: #0891b2; }
|
|
36
|
+
.t2 { stroke: #16a34a; background: #16a34a; } .t3 { stroke: #ca8a04; background: #ca8a04; }
|
|
37
|
+
.t4 { stroke: #ea580c; background: #ea580c; } .t5 { stroke: #db2777; background: #db2777; }
|
|
38
|
+
.t6 { stroke: #7c3aed; background: #7c3aed; } .t7 { stroke: #64748b; background: #64748b; }
|
|
39
|
+
@media (max-width: 34rem) { :host { grid-template-columns: 1fr; } .chart { max-width: 12rem; margin: 0 auto; } }
|
|
40
|
+
</style>
|
|
41
|
+
<donut-chart segments={[]} value:model="" label="Chart">
|
|
42
|
+
<div class="chart">
|
|
43
|
+
<svg viewBox="0 0 42 42" role="img" aria-label={label}>
|
|
44
|
+
<circle class="track" cx="21" cy="21" r={R}></circle>
|
|
45
|
+
<zb-each items={arcs} as="arc" key={arc.label}>
|
|
46
|
+
<circle class={`t${arc.tone}`} class:dim={value && value !== arc.label} class:on={value === arc.label} cx="21" cy="21" r={R} stroke-dasharray={`${arc.length} ${100 - arc.length}`} stroke-dashoffset={-arc.start} data-label={arc.label} onclick={() => select(arc.label)}><title>{arc.label}: {percent(arc)}</title></circle>
|
|
47
|
+
</zb-each>
|
|
48
|
+
</svg>
|
|
49
|
+
<div class="middle"><zb-slot><strong>{total}</strong></zb-slot></div>
|
|
50
|
+
</div>
|
|
51
|
+
<ul>
|
|
52
|
+
<zb-each items={arcs} as="arc" key={arc.label}>
|
|
53
|
+
<li><button type="button" aria-pressed={value === arc.label ? "true" : "false"} data-label={arc.label} onclick={() => select(arc.label)}><span class={`swatch t${arc.tone}`}></span><span>{arc.label}</span><span class="pct">{percent(arc)}</span></button></li>
|
|
54
|
+
</zb-each>
|
|
55
|
+
</ul>
|
|
56
|
+
</donut-chart>
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
A menu of actions behind a button, on the platform's `popover`: the browser puts it in the top
|
|
3
|
+
layer and closes it on Escape or a click outside. Down or Up on the button opens it; arrow keys,
|
|
4
|
+
Home, End and typing a letter move among the items; Enter or Space picks one, and focus goes
|
|
5
|
+
back to the button. It's placed below the button by CSS anchor positioning, or by script where
|
|
6
|
+
that isn't supported. `items` is `[{ value, label, disabled? }]`.
|
|
7
|
+
@slot label - The button's content (defaults to `label`).
|
|
8
|
+
@fires itemselect - An item was picked; `detail` is its value.
|
|
9
|
+
-->
|
|
10
|
+
<script>
|
|
11
|
+
import { id, emit, onMount } from '@zubra/runtime'
|
|
12
|
+
import { rovingFocus, typeahead } from '@zubra/runtime/primitives'
|
|
13
|
+
|
|
14
|
+
const menuId = id()
|
|
15
|
+
const buttonId = id()
|
|
16
|
+
const anchored = typeof CSS !== "undefined" && CSS.supports("position-area: bottom")
|
|
17
|
+
let button = null
|
|
18
|
+
let menu = null
|
|
19
|
+
let open = false
|
|
20
|
+
let last = false // open at the last item (Up on the button)
|
|
21
|
+
|
|
22
|
+
const entries = () => [...menu.querySelectorAll("[role=menuitem]:not(:disabled)")]
|
|
23
|
+
|
|
24
|
+
function toggled(event) {
|
|
25
|
+
open = event.newState === "open"
|
|
26
|
+
if (open) {
|
|
27
|
+
if (!anchored) place()
|
|
28
|
+
const all = entries()
|
|
29
|
+
;(last ? all.at(-1) : all[0])?.focus()
|
|
30
|
+
last = false
|
|
31
|
+
} else if (menu.contains(document.activeElement) || document.activeElement === document.body) {
|
|
32
|
+
button.focus()
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function keys(event) {
|
|
36
|
+
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return
|
|
37
|
+
event.preventDefault()
|
|
38
|
+
last = event.key === "ArrowUp"
|
|
39
|
+
menu.showPopover()
|
|
40
|
+
}
|
|
41
|
+
function choose(item) {
|
|
42
|
+
menu.hidePopover()
|
|
43
|
+
button.focus()
|
|
44
|
+
emit("itemselect", item.value)
|
|
45
|
+
}
|
|
46
|
+
// Tab out of the menu closes it, as menus do.
|
|
47
|
+
function left(event) {
|
|
48
|
+
if (open && !menu.contains(event.relatedTarget) && event.relatedTarget !== button) menu.hidePopover()
|
|
49
|
+
}
|
|
50
|
+
// Without anchor positioning: below the button, or above it if there's no room.
|
|
51
|
+
function place() {
|
|
52
|
+
const at = button.getBoundingClientRect()
|
|
53
|
+
const own = menu.getBoundingClientRect()
|
|
54
|
+
menu.style.top = `${at.bottom + 4 + own.height <= innerHeight ? at.bottom + 4 : Math.max(4, at.top - own.height - 4)}px`
|
|
55
|
+
menu.style.left = `${Math.min(at.left, innerWidth - own.width - 4)}px`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
onMount(() => {
|
|
59
|
+
const roving = rovingFocus(menu, { selector: "[role=menuitem]", orientation: "vertical" })
|
|
60
|
+
const typing = typeahead(menu, { selector: "[role=menuitem]" })
|
|
61
|
+
return () => { roving(); typing() }
|
|
62
|
+
})
|
|
63
|
+
</script>
|
|
64
|
+
<style>
|
|
65
|
+
:host { display: inline-block; }
|
|
66
|
+
.trigger { display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.45rem 0.8rem; border: 1px solid var(--line, #dde); border-radius: 0.5rem; background: var(--surface, #fff); color: inherit; font: inherit; cursor: pointer; }
|
|
67
|
+
.trigger::after { content: ""; width: 0.35rem; height: 0.35rem; border: solid currentColor; border-width: 0 2px 2px 0; transform: translateY(-2px) rotate(45deg); }
|
|
68
|
+
[popover] { position: fixed; inset: auto; margin: 0; min-width: 11rem; padding: 0.3rem; border: 1px solid var(--line, #dde); border-radius: 0.6rem; background: var(--surface, #fff); color: var(--ink, #1d2130); box-shadow: 0 8px 24px #0d122026; }
|
|
69
|
+
@supports (position-area: bottom) {
|
|
70
|
+
[popover] { position-area: bottom span-right; position-try-fallbacks: flip-block; margin-top: 4px; }
|
|
71
|
+
}
|
|
72
|
+
[role=menuitem] { display: block; width: 100%; padding: 0.4rem 0.6rem; border: 0; border-radius: 0.4rem; background: none; color: inherit; font: inherit; text-align: left; cursor: pointer; }
|
|
73
|
+
[role=menuitem]:hover, [role=menuitem]:focus { background: var(--hover, #f2f4f8); outline: none; }
|
|
74
|
+
[role=menuitem]:disabled { color: var(--muted, #98a2b3); cursor: default; }
|
|
75
|
+
</style>
|
|
76
|
+
<dropdown-menu label="Options" items={[]}>
|
|
77
|
+
<button type="button" class="trigger" id={buttonId} ref={button} popovertarget={menuId} aria-haspopup="menu" aria-expanded={open ? "true" : "false"} style:anchor-name={`--${menuId}`} onkeydown={keys}><zb-slot name="label">{label}</zb-slot></button>
|
|
78
|
+
<div popover id={menuId} role="menu" aria-labelledby={buttonId} ref={menu} style:position-anchor={`--${menuId}`} ontoggle={toggled} onfocusout={left}>
|
|
79
|
+
<zb-each items={items} as="item" key={item.value}>
|
|
80
|
+
<button type="button" role="menuitem" tabindex="-1" disabled={!!item.disabled} onclick={() => choose(item)}>{item.label}</button>
|
|
81
|
+
</zb-each>
|
|
82
|
+
</div>
|
|
83
|
+
</dropdown-menu>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
A card with two faces that flips in 3D. Click, Space or Enter flips it. Shadow DOM: the faces
|
|
3
|
+
are slotted, and the page's CSS styles them, not the card's.
|
|
4
|
+
@slot front - The question side.
|
|
5
|
+
@slot back - The answer side.
|
|
6
|
+
@fires flippedchange - It was flipped; `detail` is whether the back is showing.
|
|
7
|
+
-->
|
|
8
|
+
<script>
|
|
9
|
+
function flip() {
|
|
10
|
+
flipped = !flipped
|
|
11
|
+
}
|
|
12
|
+
</script>
|
|
13
|
+
<style>
|
|
14
|
+
:host { display: block; perspective: 1400px; }
|
|
15
|
+
.card { position: relative; display: grid; min-height: 15rem; transform-style: preserve-3d; transition: transform 0.5s cubic-bezier(0.2, 0.7, 0.2, 1); cursor: pointer; border-radius: 1.25rem; outline: none; }
|
|
16
|
+
.card:focus-visible { box-shadow: 0 0 0 3px var(--accent, #36c); }
|
|
17
|
+
.flipped { transform: rotateY(180deg); }
|
|
18
|
+
.face { grid-area: 1 / 1; display: grid; place-items: center; padding: 2rem; border-radius: 1.25rem; backface-visibility: hidden; background: var(--card-front, #fff); box-shadow: 0 2px 4px #0001, 0 16px 40px #00000014; text-align: center; }
|
|
19
|
+
.back { transform: rotateY(180deg); background: var(--card-back, #fdf8ee); }
|
|
20
|
+
.hint { position: absolute; bottom: 0.75rem; left: 0; right: 0; text-align: center; font-size: 0.75rem; color: var(--muted, #888); backface-visibility: hidden; }
|
|
21
|
+
@media (prefers-reduced-motion: reduce) { .card { transition: none; } }
|
|
22
|
+
</style>
|
|
23
|
+
<flip-card shadow flipped:model={false} hint="Click or press Space to flip">
|
|
24
|
+
<div class="card" class:flipped role="button" tabindex="0" aria-pressed={flipped ? "true" : "false"} onclick={flip} onkeydown.space.enter.prevent={flip}>
|
|
25
|
+
<div class="face front" aria-hidden={flipped ? "true" : "false"}><zb-slot name="front"></zb-slot></div>
|
|
26
|
+
<div class="face back" aria-hidden={flipped ? "false" : "true"}><zb-slot name="back"></zb-slot></div>
|
|
27
|
+
<span class="hint">{hint}</span>
|
|
28
|
+
</div>
|
|
29
|
+
</flip-card>
|