@invisibleloop/pulse 0.1.21
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/.claude/commands/build-page.md +59 -0
- package/.claude/commands/new-doc-page.md +45 -0
- package/.claude/commands/verify.md +52 -0
- package/.claude/pulse-checklist.md +111 -0
- package/.claude/settings.local.json +102 -0
- package/.github/workflows/ci.yml +22 -0
- package/.github/workflows/publish.yml +41 -0
- package/.pulse/load-reports/home/1773432711417.json +22 -0
- package/CLAUDE.md +383 -0
- package/README.md +95 -0
- package/docs/.claude/pulse-checklist.md +111 -0
- package/docs/public/.pulse-ui-version +1 -0
- package/docs/public/dist/accessibility.boot-5DVTARJU.js +115 -0
- package/docs/public/dist/actions.boot-P66HKQEM.js +164 -0
- package/docs/public/dist/auth.boot-IMAJAUPH.js +140 -0
- package/docs/public/dist/caching.boot-DVR6KDE7.js +53 -0
- package/docs/public/dist/components--accordion.boot-3HVKMNWC.js +11 -0
- package/docs/public/dist/components--alert.boot-GCEXOZAC.js +6 -0
- package/docs/public/dist/components--app-badge.boot-DVT3GCHJ.js +6 -0
- package/docs/public/dist/components--avatar.boot-PSW24EVA.js +5 -0
- package/docs/public/dist/components--badge.boot-TYDY2RMK.js +7 -0
- package/docs/public/dist/components--banner.boot-EI5PZSZK.js +7 -0
- package/docs/public/dist/components--breadcrumbs.boot-SMA2E2GO.js +34 -0
- package/docs/public/dist/components--button.boot-J54BQM2E.js +23 -0
- package/docs/public/dist/components--card.boot-PZGNDIB6.js +138 -0
- package/docs/public/dist/components--carousel.boot-TP6LPFZZ.js +12 -0
- package/docs/public/dist/components--charts.boot-2EOYQWKL.js +108 -0
- package/docs/public/dist/components--checkbox.boot-DS5BSL6T.js +54 -0
- package/docs/public/dist/components--cluster.boot-HHVIBBJG.js +9 -0
- package/docs/public/dist/components--code-window.boot-2GR2DV33.js +20 -0
- package/docs/public/dist/components--container.boot-7LOOGK2K.js +5 -0
- package/docs/public/dist/components--cta.boot-FSNZ5YRT.js +11 -0
- package/docs/public/dist/components--divider.boot-3NI2C3QG.js +6 -0
- package/docs/public/dist/components--empty.boot-YX2UR3PV.js +7 -0
- package/docs/public/dist/components--feature.boot-MUD7NSUO.js +13 -0
- package/docs/public/dist/components--fieldset.boot-J7BYHMKF.js +19 -0
- package/docs/public/dist/components--fileupload.boot-NIKVTTPD.js +52 -0
- package/docs/public/dist/components--footer.boot-EYUK5FRG.js +14 -0
- package/docs/public/dist/components--grid.boot-URDQVDDR.js +59 -0
- package/docs/public/dist/components--heading.boot-BPQKU43E.js +44 -0
- package/docs/public/dist/components--hero.boot-4RAPRGAB.js +17 -0
- package/docs/public/dist/components--icons.boot-ZITNU5JP.js +68 -0
- package/docs/public/dist/components--image.boot-XEEGHQZF.js +19 -0
- package/docs/public/dist/components--input.boot-SGASZG5K.js +7 -0
- package/docs/public/dist/components--list.boot-W3XC5MHD.js +55 -0
- package/docs/public/dist/components--media.boot-5VFIETZO.js +13 -0
- package/docs/public/dist/components--modal.boot-RZUYXBN2.js +47 -0
- package/docs/public/dist/components--nav.boot-ODBOHU7O.js +33 -0
- package/docs/public/dist/components--pricing.boot-4AQ4ZVBY.js +21 -0
- package/docs/public/dist/components--progress.boot-GHAGYZOK.js +30 -0
- package/docs/public/dist/components--prose.boot-QANJL6JI.js +67 -0
- package/docs/public/dist/components--pullquote.boot-Q2WMNAZU.js +22 -0
- package/docs/public/dist/components--radio.boot-TJRDQ2OL.js +75 -0
- package/docs/public/dist/components--rating.boot-QBAN6DEL.js +38 -0
- package/docs/public/dist/components--search.boot-PXH5O5AG.js +17 -0
- package/docs/public/dist/components--section.boot-AQGIYHWW.js +12 -0
- package/docs/public/dist/components--segmented.boot-BEVTKEJO.js +33 -0
- package/docs/public/dist/components--select.boot-47X5RHOC.js +10 -0
- package/docs/public/dist/components--slider.boot-PSRRX7XL.js +47 -0
- package/docs/public/dist/components--spinner.boot-MZ5MO2OH.js +22 -0
- package/docs/public/dist/components--stack.boot-DI4NJXBF.js +9 -0
- package/docs/public/dist/components--stat.boot-QMFUWBQT.js +9 -0
- package/docs/public/dist/components--stepper.boot-34PP2NEV.js +22 -0
- package/docs/public/dist/components--table.boot-FCQGSFIQ.js +11 -0
- package/docs/public/dist/components--testimonial.boot-DWQPDKYG.js +11 -0
- package/docs/public/dist/components--textarea.boot-QVXLBOJ5.js +4 -0
- package/docs/public/dist/components--timeline.boot-26LN52P2.js +95 -0
- package/docs/public/dist/components--toggle.boot-IQQEI76S.js +29 -0
- package/docs/public/dist/components--tooltip.boot-LGHCO6NN.js +9 -0
- package/docs/public/dist/components.boot-SE6PQ4P7.js +103 -0
- package/docs/public/dist/config.boot-DTRRWUE6.js +126 -0
- package/docs/public/dist/constraints.boot-DUHDZBMC.js +71 -0
- package/docs/public/dist/deploy.boot-SLAD3NI2.js +163 -0
- package/docs/public/dist/docs-8e3d4b5c.css +1 -0
- package/docs/public/dist/extending.boot-UA3CN243.js +159 -0
- package/docs/public/dist/faq.boot-6EQAWLQR.js +43 -0
- package/docs/public/dist/getting-started.boot-TDKIFL5U.js +86 -0
- package/docs/public/dist/guard.boot-AUHAWTG4.js +80 -0
- package/docs/public/dist/home.boot-BVQXRH32.js +383 -0
- package/docs/public/dist/how-it-works.boot-LTWAKWKW.js +104 -0
- package/docs/public/dist/hydration.boot-JRM6IPJL.js +78 -0
- package/docs/public/dist/images.boot-M6ZVKTZS.js +80 -0
- package/docs/public/dist/manifest.json +94 -0
- package/docs/public/dist/meta.boot-7NXGPHR4.js +79 -0
- package/docs/public/dist/mutations.boot-F6F43UDX.js +79 -0
- package/docs/public/dist/navigation.boot-AOXWS3ZF.js +57 -0
- package/docs/public/dist/performance.boot-C3UPCOBK.js +98 -0
- package/docs/public/dist/persist.boot-WT32PQOQ.js +61 -0
- package/docs/public/dist/project-structure.boot-FB3LRVJ4.js +63 -0
- package/docs/public/dist/prompt-examples.boot-YKR4VDK4.js +31 -0
- package/docs/public/dist/pulse-ui-81a85c03.css +1 -0
- package/docs/public/dist/raw-responses.boot-M4KA5YXL.js +104 -0
- package/docs/public/dist/routing.boot-FNX5FDGH.js +70 -0
- package/docs/public/dist/runtime-B73WLANC.js +1 -0
- package/docs/public/dist/runtime-KO4BHUQ3.js +49 -0
- package/docs/public/dist/runtime-L2HNXIHW.js +59 -0
- package/docs/public/dist/runtime-QFURDKA2.js +5 -0
- package/docs/public/dist/runtime-UVPXO4IR.js +375 -0
- package/docs/public/dist/runtime-VMJA3Z4N.js +10 -0
- package/docs/public/dist/runtime-ZJ4FXT5O.js +11 -0
- package/docs/public/dist/server-api.boot-K7X3LCFB.js +219 -0
- package/docs/public/dist/server-data.boot-Y7HQYC4R.js +157 -0
- package/docs/public/dist/slash-commands.boot-V2UV7OW2.js +26 -0
- package/docs/public/dist/spec.boot-2WU7ZHCV.js +159 -0
- package/docs/public/dist/state.boot-B24GUE3R.js +73 -0
- package/docs/public/dist/store.boot-TLIB4XHH.js +150 -0
- package/docs/public/dist/streaming.boot-W2DZSMW4.js +80 -0
- package/docs/public/dist/stripe.boot-QN3C2GEL.js +164 -0
- package/docs/public/dist/supabase.boot-BG4XXLZE.js +303 -0
- package/docs/public/dist/testing.boot-6U4WKMTE.js +130 -0
- package/docs/public/dist/validation.boot-PQHYGW5B.js +100 -0
- package/docs/public/docs.css +2020 -0
- package/docs/public/menu.js +83 -0
- package/docs/public/pulse-ui.css +2739 -0
- package/docs/public/pulse-ui.js +236 -0
- package/docs/server.js +192 -0
- package/docs/src/lib/component-page.js +47 -0
- package/docs/src/lib/highlight.js +255 -0
- package/docs/src/lib/layout.js +131 -0
- package/docs/src/lib/metrics-store.js +6 -0
- package/docs/src/lib/nav.js +159 -0
- package/docs/src/lib/stats.js +81 -0
- package/docs/src/pages/accessibility.js +157 -0
- package/docs/src/pages/actions.js +191 -0
- package/docs/src/pages/auth.js +177 -0
- package/docs/src/pages/caching.js +95 -0
- package/docs/src/pages/components/accordion.js +48 -0
- package/docs/src/pages/components/alert.js +35 -0
- package/docs/src/pages/components/app-badge.js +41 -0
- package/docs/src/pages/components/avatar.js +35 -0
- package/docs/src/pages/components/badge.js +36 -0
- package/docs/src/pages/components/banner.js +45 -0
- package/docs/src/pages/components/breadcrumbs.js +94 -0
- package/docs/src/pages/components/button.js +84 -0
- package/docs/src/pages/components/card.js +225 -0
- package/docs/src/pages/components/carousel.js +72 -0
- package/docs/src/pages/components/charts.js +278 -0
- package/docs/src/pages/components/checkbox.js +129 -0
- package/docs/src/pages/components/cluster.js +47 -0
- package/docs/src/pages/components/code-window.js +57 -0
- package/docs/src/pages/components/container.js +40 -0
- package/docs/src/pages/components/cta.js +53 -0
- package/docs/src/pages/components/divider.js +37 -0
- package/docs/src/pages/components/empty.js +36 -0
- package/docs/src/pages/components/feature.js +60 -0
- package/docs/src/pages/components/fieldset.js +65 -0
- package/docs/src/pages/components/fileupload.js +127 -0
- package/docs/src/pages/components/footer.js +58 -0
- package/docs/src/pages/components/grid.js +165 -0
- package/docs/src/pages/components/heading.js +107 -0
- package/docs/src/pages/components/hero.js +65 -0
- package/docs/src/pages/components/icons.js +285 -0
- package/docs/src/pages/components/image.js +71 -0
- package/docs/src/pages/components/input.js +51 -0
- package/docs/src/pages/components/list.js +112 -0
- package/docs/src/pages/components/media.js +51 -0
- package/docs/src/pages/components/modal.js +111 -0
- package/docs/src/pages/components/nav.js +86 -0
- package/docs/src/pages/components/pricing.js +68 -0
- package/docs/src/pages/components/progress.js +102 -0
- package/docs/src/pages/components/prose.js +111 -0
- package/docs/src/pages/components/pullquote.js +71 -0
- package/docs/src/pages/components/radio.js +194 -0
- package/docs/src/pages/components/rating.js +106 -0
- package/docs/src/pages/components/search.js +61 -0
- package/docs/src/pages/components/section.js +59 -0
- package/docs/src/pages/components/segmented.js +121 -0
- package/docs/src/pages/components/select.js +45 -0
- package/docs/src/pages/components/slider.js +114 -0
- package/docs/src/pages/components/spinner.js +73 -0
- package/docs/src/pages/components/stack.js +48 -0
- package/docs/src/pages/components/stat.js +55 -0
- package/docs/src/pages/components/stepper.js +66 -0
- package/docs/src/pages/components/table.js +45 -0
- package/docs/src/pages/components/testimonial.js +49 -0
- package/docs/src/pages/components/textarea.js +31 -0
- package/docs/src/pages/components/timeline.js +227 -0
- package/docs/src/pages/components/toggle.js +84 -0
- package/docs/src/pages/components/tooltip.js +48 -0
- package/docs/src/pages/components.js +204 -0
- package/docs/src/pages/config.js +193 -0
- package/docs/src/pages/constraints.js +99 -0
- package/docs/src/pages/deploy.js +233 -0
- package/docs/src/pages/extending.js +198 -0
- package/docs/src/pages/faq.js +96 -0
- package/docs/src/pages/getting-started.js +106 -0
- package/docs/src/pages/guard.js +121 -0
- package/docs/src/pages/home.js +401 -0
- package/docs/src/pages/how-it-works.js +183 -0
- package/docs/src/pages/hydration.js +98 -0
- package/docs/src/pages/images.js +121 -0
- package/docs/src/pages/meta.js +120 -0
- package/docs/src/pages/mutations.js +106 -0
- package/docs/src/pages/navigation.js +85 -0
- package/docs/src/pages/performance.js +157 -0
- package/docs/src/pages/persist.js +88 -0
- package/docs/src/pages/project-structure.js +90 -0
- package/docs/src/pages/prompt-examples.js +186 -0
- package/docs/src/pages/raw-responses.js +124 -0
- package/docs/src/pages/routing.js +99 -0
- package/docs/src/pages/server-api.js +281 -0
- package/docs/src/pages/server-data.js +185 -0
- package/docs/src/pages/slash-commands.js +55 -0
- package/docs/src/pages/spec.js +207 -0
- package/docs/src/pages/state.js +101 -0
- package/docs/src/pages/store.js +181 -0
- package/docs/src/pages/streaming.js +108 -0
- package/docs/src/pages/stripe.js +193 -0
- package/docs/src/pages/supabase.js +323 -0
- package/docs/src/pages/testing.js +198 -0
- package/docs/src/pages/validation.js +138 -0
- package/examples/contact.js +166 -0
- package/examples/counter.js +94 -0
- package/examples/dev.server.js +91 -0
- package/examples/examples.test.js +394 -0
- package/examples/pricing.js +244 -0
- package/examples/products.js +191 -0
- package/examples/quiz.js +208 -0
- package/examples/shared.js +78 -0
- package/examples/todos.js +162 -0
- package/package.json +75 -0
- package/public/.pulse-ui-version +1 -0
- package/public/chippy-bird.css +246 -0
- package/public/examples/contact.css +119 -0
- package/public/examples/counter.css +79 -0
- package/public/examples/pricing.css +132 -0
- package/public/examples/products.css +100 -0
- package/public/examples/quiz.css +200 -0
- package/public/examples/todos.css +137 -0
- package/public/favicon.ico +0 -0
- package/public/log-dashboard.css +383 -0
- package/public/pulse-ui.css +2740 -0
- package/public/pulse-ui.js +236 -0
- package/public/pulse.css +149 -0
- package/scripts/build.js +411 -0
- package/src/agent/checklist.md +111 -0
- package/src/agent/coverage-check.js +66 -0
- package/src/agent/guide-components.md +274 -0
- package/src/agent/guide-examples.md +54 -0
- package/src/agent/guide-routing.md +36 -0
- package/src/agent/guide-server.md +258 -0
- package/src/agent/guide-spec.md +103 -0
- package/src/agent/guide-styles.md +191 -0
- package/src/agent/guide.md +979 -0
- package/src/agent/identity.md +106 -0
- package/src/agent/workflow.md +108 -0
- package/src/cli/cli.test.js +82 -0
- package/src/cli/dev.js +195 -0
- package/src/cli/discover.js +113 -0
- package/src/cli/index.js +361 -0
- package/src/cli/load-report.js +91 -0
- package/src/cli/load-runner.js +121 -0
- package/src/cli/report-server.js +723 -0
- package/src/cli/report.js +116 -0
- package/src/cli/scaffold.archive.js +1371 -0
- package/src/cli/scaffold.js +349 -0
- package/src/cli/start.js +74 -0
- package/src/html.js +19 -0
- package/src/mcp/server.js +884 -0
- package/src/mcp/validate-worker.js +110 -0
- package/src/runtime/image.js +74 -0
- package/src/runtime/image.test.js +111 -0
- package/src/runtime/index.js +621 -0
- package/src/runtime/navigate.js +146 -0
- package/src/runtime/runtime.test.js +773 -0
- package/src/runtime/ssr.js +464 -0
- package/src/runtime/ssr.test.js +421 -0
- package/src/runtime/store.js +92 -0
- package/src/runtime/toast.js +163 -0
- package/src/server/index.js +1386 -0
- package/src/server/server.test.js +1248 -0
- package/src/spec/schema.js +428 -0
- package/src/spec/schema.test.js +291 -0
- package/src/store/index.js +102 -0
- package/src/store/store.test.js +210 -0
- package/src/testing/html.js +283 -0
- package/src/testing/index.js +249 -0
- package/src/testing/testing.test.js +450 -0
- package/src/ui/accordion.js +28 -0
- package/src/ui/alert.js +43 -0
- package/src/ui/app-badge.js +48 -0
- package/src/ui/avatar.js +47 -0
- package/src/ui/badge.js +24 -0
- package/src/ui/banner.js +26 -0
- package/src/ui/breadcrumbs.js +38 -0
- package/src/ui/button.js +66 -0
- package/src/ui/card.js +34 -0
- package/src/ui/carousel.js +59 -0
- package/src/ui/charts.js +321 -0
- package/src/ui/checkbox.js +65 -0
- package/src/ui/cluster.js +44 -0
- package/src/ui/code-window.js +39 -0
- package/src/ui/container.js +24 -0
- package/src/ui/cta.js +37 -0
- package/src/ui/divider.js +29 -0
- package/src/ui/empty.js +33 -0
- package/src/ui/feature.js +33 -0
- package/src/ui/fieldset.js +37 -0
- package/src/ui/fileupload.js +89 -0
- package/src/ui/footer.js +38 -0
- package/src/ui/grid.js +36 -0
- package/src/ui/heading.js +45 -0
- package/src/ui/hero.js +37 -0
- package/src/ui/icons.js +161 -0
- package/src/ui/index.js +89 -0
- package/src/ui/input.js +74 -0
- package/src/ui/list.js +36 -0
- package/src/ui/media.js +44 -0
- package/src/ui/modal.js +80 -0
- package/src/ui/nav.js +61 -0
- package/src/ui/pricing.js +56 -0
- package/src/ui/progress.js +62 -0
- package/src/ui/prose.js +29 -0
- package/src/ui/pullquote.js +34 -0
- package/src/ui/radio.js +102 -0
- package/src/ui/rating.js +93 -0
- package/src/ui/search.js +77 -0
- package/src/ui/section.js +69 -0
- package/src/ui/segmented.js +50 -0
- package/src/ui/select.js +77 -0
- package/src/ui/slider.js +84 -0
- package/src/ui/spinner.js +34 -0
- package/src/ui/stack.js +36 -0
- package/src/ui/stat.js +52 -0
- package/src/ui/stepper.js +46 -0
- package/src/ui/switch.js +57 -0
- package/src/ui/table.js +45 -0
- package/src/ui/testimonial.js +48 -0
- package/src/ui/textarea.js +72 -0
- package/src/ui/timeline.js +72 -0
- package/src/ui/tooltip.js +28 -0
- package/src/ui/ui.test.js +1241 -0
- package/src/ui/uiimage.js +65 -0
- package/tsconfig.json +13 -0
- package/types/html.d.ts +17 -0
- package/types/image.d.ts +70 -0
- package/types/index.d.ts +7 -0
- package/types/navigate.d.ts +38 -0
- package/types/runtime.d.ts +63 -0
- package/types/schema.d.ts +243 -0
- package/types/server.d.ts +145 -0
- package/types/ssr.d.ts +110 -0
- package/types/testing.d.ts +154 -0
- package/types/ui.d.ts +704 -0
package/src/ui/rating.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse UI — Rating
|
|
3
|
+
*
|
|
4
|
+
* Star rating display and interactive input.
|
|
5
|
+
*
|
|
6
|
+
* Without `name`: renders a read-only star display (role="img").
|
|
7
|
+
* With `name`: renders radio inputs — submits the numeric value in FormData.
|
|
8
|
+
*
|
|
9
|
+
* The interactive version uses a pure-CSS technique:
|
|
10
|
+
* - Stars are rendered in reverse DOM order inside a row-reverse flex container
|
|
11
|
+
* so they appear left-to-right (★1 … ★5) visually.
|
|
12
|
+
* - The hidden radio inputs sit between each label. Because they are
|
|
13
|
+
* position:absolute they are removed from the flex layout but remain in the
|
|
14
|
+
* DOM so CSS sibling selectors can reach from input → subsequent labels.
|
|
15
|
+
* - `input:checked ~ label` fills all labels (= lower-numbered stars) after
|
|
16
|
+
* the checked input in DOM, which appear to its left visually.
|
|
17
|
+
* - `label:hover ~ label` fills all subsequent labels (lower stars) on hover.
|
|
18
|
+
*
|
|
19
|
+
* @param {object} opts
|
|
20
|
+
* @param {number} opts.value - Current rating (0–max). Supports 0.5 steps for display.
|
|
21
|
+
* @param {number} opts.max - Total stars (default: 5)
|
|
22
|
+
* @param {string} opts.name - Field name — enables interactive radio mode
|
|
23
|
+
* @param {string} opts.label - Accessible group label (interactive mode)
|
|
24
|
+
* @param {'sm'|'md'|'lg'} opts.size
|
|
25
|
+
* @param {boolean} opts.disabled
|
|
26
|
+
* @param {string} opts.class
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { escHtml as e } from '../html.js'
|
|
30
|
+
|
|
31
|
+
const SIZES = { sm: '1rem', md: '1.5rem', lg: '2rem' }
|
|
32
|
+
|
|
33
|
+
const starFilled = `<svg width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>`
|
|
34
|
+
const starEmpty = `<svg width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" aria-hidden="true"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>`
|
|
35
|
+
const starHalf = `<svg width="1em" height="1em" viewBox="0 0 24 24" aria-hidden="true"><defs><linearGradient id="half"><stop offset="50%" stop-color="currentColor"/><stop offset="50%" stop-color="transparent"/></linearGradient></defs><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#half)" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></svg>`
|
|
36
|
+
|
|
37
|
+
export function rating({
|
|
38
|
+
value = 0,
|
|
39
|
+
max = 5,
|
|
40
|
+
name = '',
|
|
41
|
+
label = '',
|
|
42
|
+
size = 'md',
|
|
43
|
+
disabled = false,
|
|
44
|
+
class: cls = '',
|
|
45
|
+
} = {}) {
|
|
46
|
+
const fontSize = SIZES[size] ?? SIZES.md
|
|
47
|
+
const classes = ['ui-rating', cls].filter(Boolean).join(' ')
|
|
48
|
+
|
|
49
|
+
// ── Read-only display ──────────────────────────────────────────────────────
|
|
50
|
+
if (!name) {
|
|
51
|
+
const stars = Array.from({ length: max }, (_, i) => {
|
|
52
|
+
const pos = i + 1
|
|
53
|
+
if (value >= pos) return `<span class="ui-rating-star ui-rating-star--filled">${starFilled}</span>`
|
|
54
|
+
if (value >= pos - 0.5) return `<span class="ui-rating-star ui-rating-star--half">${starHalf}</span>`
|
|
55
|
+
return `<span class="ui-rating-star">${starEmpty}</span>`
|
|
56
|
+
}).join('')
|
|
57
|
+
|
|
58
|
+
return `<div
|
|
59
|
+
class="${e(classes)}"
|
|
60
|
+
style="--rating-size:${fontSize}"
|
|
61
|
+
role="img"
|
|
62
|
+
aria-label="${e(value)} out of ${e(String(max))} stars"
|
|
63
|
+
>${stars}</div>`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── Interactive (radio) ────────────────────────────────────────────────────
|
|
67
|
+
// Each input lives INSIDE its label — labels are the only flex items so no
|
|
68
|
+
// hidden elements can intercept pointer events between stars.
|
|
69
|
+
// Stars rendered in reverse DOM order (max → 1) inside a row-reverse flex
|
|
70
|
+
// container so they appear visually as ★1 … ★max left-to-right.
|
|
71
|
+
// CSS :has() drives the checked + hover highlight via sibling combinators.
|
|
72
|
+
const items = Array.from({ length: max }, (_, i) => {
|
|
73
|
+
const v = max - i // counts down: max, max-1, …, 1
|
|
74
|
+
const checked = v === Math.round(value)
|
|
75
|
+
const title = `${v} out of ${max}`
|
|
76
|
+
return `<label class="ui-rating-star" title="${title}" aria-label="${title} stars"><input
|
|
77
|
+
type="radio"
|
|
78
|
+
name="${e(name)}"
|
|
79
|
+
value="${v}"
|
|
80
|
+
class="ui-rating-input"
|
|
81
|
+
${checked ? 'checked' : ''}
|
|
82
|
+
${disabled ? 'disabled' : ''}
|
|
83
|
+
>★</label>`
|
|
84
|
+
}).join('')
|
|
85
|
+
|
|
86
|
+
const srOnly = 'position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0'
|
|
87
|
+
const groupLabel = label ? `<legend style="${srOnly}">${e(label)}</legend>` : ''
|
|
88
|
+
|
|
89
|
+
return `<fieldset class="${e(classes)}" style="--rating-size:${fontSize};border:0;padding:0;margin:0"${disabled ? ' disabled' : ''}>
|
|
90
|
+
${groupLabel}
|
|
91
|
+
<div class="ui-rating-stars">${items}</div>
|
|
92
|
+
</fieldset>`
|
|
93
|
+
}
|
package/src/ui/search.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse UI — Search
|
|
3
|
+
*
|
|
4
|
+
* Search input with icon, optional clear button, and label.
|
|
5
|
+
* Handles the native browser cancel button, debounce binding,
|
|
6
|
+
* and the clear event all in one component.
|
|
7
|
+
*
|
|
8
|
+
* @param {object} opts
|
|
9
|
+
* @param {string} opts.name - Field name (also used as id base)
|
|
10
|
+
* @param {string} opts.label - Label text (visually shown or screen-reader only)
|
|
11
|
+
* @param {boolean} opts.labelHidden - Hide label visually but keep for screen readers
|
|
12
|
+
* @param {string} opts.placeholder
|
|
13
|
+
* @param {string} opts.value - Current search value (for re-renders)
|
|
14
|
+
* @param {string} opts.event - data-event binding, e.g. 'input:setSearch'
|
|
15
|
+
* @param {number} opts.debounce - Debounce delay in ms (default: 200)
|
|
16
|
+
* @param {string} opts.clearEvent - Click event fired by the clear button (shown when value is non-empty)
|
|
17
|
+
* @param {boolean} opts.disabled
|
|
18
|
+
* @param {string} opts.id - Override generated id
|
|
19
|
+
* @param {string} opts.class
|
|
20
|
+
* @param {object} opts.attrs - Extra HTML attributes for the <input>
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { escHtml as e } from '../html.js'
|
|
24
|
+
import { iconSearch, iconX } from './icons.js'
|
|
25
|
+
|
|
26
|
+
export function search({
|
|
27
|
+
name = '',
|
|
28
|
+
label = '',
|
|
29
|
+
labelHidden = false,
|
|
30
|
+
placeholder = '',
|
|
31
|
+
value = '',
|
|
32
|
+
event = '',
|
|
33
|
+
debounce = 200,
|
|
34
|
+
clearEvent = '',
|
|
35
|
+
disabled = false,
|
|
36
|
+
id = '',
|
|
37
|
+
class: cls = '',
|
|
38
|
+
attrs = {},
|
|
39
|
+
} = {}) {
|
|
40
|
+
const fieldId = e(id || `field-${name}`)
|
|
41
|
+
|
|
42
|
+
const wrapClasses = ['ui-field', 'ui-search', cls].filter(Boolean).join(' ')
|
|
43
|
+
|
|
44
|
+
const labelClasses = ['ui-label', labelHidden ? 'ui-sr-only' : ''].filter(Boolean).join(' ')
|
|
45
|
+
|
|
46
|
+
const labelHtml = label
|
|
47
|
+
? `<label for="${fieldId}" class="${labelClasses}">${e(label)}</label>`
|
|
48
|
+
: ''
|
|
49
|
+
|
|
50
|
+
const attrsStr = Object.entries(attrs)
|
|
51
|
+
.map(([k, v]) => ` ${e(k)}="${e(String(v))}"`)
|
|
52
|
+
.join('')
|
|
53
|
+
|
|
54
|
+
const clearBtn = clearEvent && value
|
|
55
|
+
? `<button class="ui-search-clear" data-event="${e(clearEvent)}" type="button" aria-label="Clear search">${iconX({ size: 14 })}</button>`
|
|
56
|
+
: ''
|
|
57
|
+
|
|
58
|
+
return `<div class="${e(wrapClasses)}">
|
|
59
|
+
${labelHtml}
|
|
60
|
+
<div class="ui-search-wrap">
|
|
61
|
+
<span class="ui-search-icon" aria-hidden="true">${iconSearch({ size: 16 })}</span>
|
|
62
|
+
<input
|
|
63
|
+
id="${fieldId}"
|
|
64
|
+
name="${e(name)}"
|
|
65
|
+
type="search"
|
|
66
|
+
class="ui-search-input"
|
|
67
|
+
${placeholder ? `placeholder="${e(placeholder)}"` : ''}
|
|
68
|
+
${value ? `value="${e(value)}"` : ''}
|
|
69
|
+
${disabled ? 'disabled' : ''}
|
|
70
|
+
${event ? `data-event="${e(event)}"` : ''}
|
|
71
|
+
${event && debounce > 0 ? `data-debounce="${debounce}"` : ''}
|
|
72
|
+
${attrsStr}
|
|
73
|
+
>
|
|
74
|
+
${clearBtn}
|
|
75
|
+
</div>
|
|
76
|
+
</div>`
|
|
77
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse UI — Section
|
|
3
|
+
*
|
|
4
|
+
* Vertical padding wrapper with optional background variant and built-in
|
|
5
|
+
* section header (eyebrow + title + subtitle) above the content slot.
|
|
6
|
+
* Compose with container() to get a constrained-width section.
|
|
7
|
+
*
|
|
8
|
+
* @param {object} opts
|
|
9
|
+
* @param {string} opts.content - Raw HTML slot
|
|
10
|
+
* @param {'default'|'alt'|'dark'} opts.variant - Background variant (default: 'default')
|
|
11
|
+
* @param {'sm'|'md'|'lg'} opts.padding - Vertical padding size (default: 'md')
|
|
12
|
+
* @param {string} opts.id - Section id for anchor links
|
|
13
|
+
* @param {string} opts.eyebrow - Small label above the title
|
|
14
|
+
* @param {string} opts.title - Section heading
|
|
15
|
+
* @param {number} opts.level - Heading level 1–6 (default 2). Visual style is always ui-section-title.
|
|
16
|
+
* @param {string} opts.subtitle - Supporting text beneath the heading
|
|
17
|
+
* @param {'left'|'center'} opts.align - Header text alignment (default: 'left')
|
|
18
|
+
* @param {'sm'|'md'|'lg'|'none'} opts.gap - Gap between header and content (default: 'md' = 2.5rem)
|
|
19
|
+
* @param {string} opts.class
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { escHtml as e } from '../html.js'
|
|
23
|
+
|
|
24
|
+
const VARIANTS = new Set(['default', 'alt', 'dark'])
|
|
25
|
+
const PADDINGS = new Set(['sm', 'md', 'lg'])
|
|
26
|
+
const GAPS = new Set(['none', 'sm', 'md', 'lg'])
|
|
27
|
+
|
|
28
|
+
export function section({
|
|
29
|
+
content = '',
|
|
30
|
+
variant = 'default',
|
|
31
|
+
padding = 'md',
|
|
32
|
+
gap = 'md',
|
|
33
|
+
id = '',
|
|
34
|
+
eyebrow = '',
|
|
35
|
+
title = '',
|
|
36
|
+
level = 2,
|
|
37
|
+
subtitle = '',
|
|
38
|
+
align = 'left',
|
|
39
|
+
class: cls = '',
|
|
40
|
+
} = {}) {
|
|
41
|
+
if (!VARIANTS.has(variant)) variant = 'default'
|
|
42
|
+
if (!PADDINGS.has(padding)) padding = 'md'
|
|
43
|
+
if (!GAPS.has(gap)) gap = 'md'
|
|
44
|
+
|
|
45
|
+
const classes = [
|
|
46
|
+
'ui-section',
|
|
47
|
+
variant !== 'default' && `ui-section--${variant}`,
|
|
48
|
+
padding !== 'md' && `ui-section--${padding}`,
|
|
49
|
+
cls,
|
|
50
|
+
].filter(Boolean).join(' ')
|
|
51
|
+
|
|
52
|
+
const idAttr = id ? ` id="${e(id)}"` : ''
|
|
53
|
+
const tag = `h${Math.min(Math.max(Math.floor(level), 1), 6)}`
|
|
54
|
+
|
|
55
|
+
const headerClasses = [
|
|
56
|
+
'ui-section-header',
|
|
57
|
+
align === 'center' && 'ui-section-header--center',
|
|
58
|
+
gap !== 'md' && `ui-section-header--gap-${gap}`,
|
|
59
|
+
].filter(Boolean).join(' ')
|
|
60
|
+
|
|
61
|
+
const header = (eyebrow || title || subtitle) ? `
|
|
62
|
+
<div class="${headerClasses}">
|
|
63
|
+
${eyebrow ? `<p class="ui-section-eyebrow">${e(eyebrow)}</p>` : ''}
|
|
64
|
+
${title ? `<${tag} class="ui-section-title">${e(title)}</${tag}>` : ''}
|
|
65
|
+
${subtitle ? `<p class="ui-section-subtitle">${e(subtitle)}</p>` : ''}
|
|
66
|
+
</div>` : ''
|
|
67
|
+
|
|
68
|
+
return `<section class="${e(classes)}"${idAttr}>${header}${content}</section>`
|
|
69
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse UI — Segmented Control
|
|
3
|
+
*
|
|
4
|
+
* iOS-style segmented control implemented with hidden radio inputs.
|
|
5
|
+
* The selected option's label is highlighted via input:checked + label CSS.
|
|
6
|
+
*
|
|
7
|
+
* @param {object} opts
|
|
8
|
+
* @param {string} opts.name - Field name (submitted in FormData)
|
|
9
|
+
* @param {Array} opts.options - Array of { value, label }
|
|
10
|
+
* @param {string} opts.value - Currently selected value
|
|
11
|
+
* @param {boolean} opts.disabled
|
|
12
|
+
* @param {'sm'|'md'|'lg'} opts.size - Size variant (default: 'md')
|
|
13
|
+
* @param {string} opts.event - data-event binding, e.g. 'change:setTab'
|
|
14
|
+
* @param {string} opts.class
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { escHtml as e } from '../html.js'
|
|
18
|
+
|
|
19
|
+
export function segmented({
|
|
20
|
+
name = '',
|
|
21
|
+
options = [],
|
|
22
|
+
value = '',
|
|
23
|
+
disabled = false,
|
|
24
|
+
size = 'md',
|
|
25
|
+
event = '',
|
|
26
|
+
class: cls = '',
|
|
27
|
+
} = {}) {
|
|
28
|
+
const sizeClass = size === 'sm' ? 'ui-segmented--sm'
|
|
29
|
+
: size === 'lg' ? 'ui-segmented--lg'
|
|
30
|
+
: ''
|
|
31
|
+
|
|
32
|
+
const wrapClasses = ['ui-segmented', sizeClass, cls].filter(Boolean).join(' ')
|
|
33
|
+
|
|
34
|
+
const items = options.map((opt, i) => {
|
|
35
|
+
const optId = e(`seg-${name}-${i}`)
|
|
36
|
+
const checked = String(opt.value) === String(value)
|
|
37
|
+
return `<input
|
|
38
|
+
type="radio"
|
|
39
|
+
class="ui-segmented-input"
|
|
40
|
+
id="${optId}"
|
|
41
|
+
name="${e(name)}"
|
|
42
|
+
value="${e(String(opt.value))}"
|
|
43
|
+
${checked ? 'checked' : ''}
|
|
44
|
+
${disabled ? 'disabled' : ''}
|
|
45
|
+
${event ? `data-event="${e(event)}"` : ''}
|
|
46
|
+
><label class="ui-segmented-label" for="${optId}">${e(String(opt.label))}</label>`
|
|
47
|
+
}).join('')
|
|
48
|
+
|
|
49
|
+
return `<div class="${e(wrapClasses)}" role="group">${items}</div>`
|
|
50
|
+
}
|
package/src/ui/select.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse UI — Select
|
|
3
|
+
*
|
|
4
|
+
* Dropdown with label, hint, and error message.
|
|
5
|
+
* Options accept { value, label } objects or plain strings.
|
|
6
|
+
*
|
|
7
|
+
* @param {object} opts
|
|
8
|
+
* @param {string} opts.name
|
|
9
|
+
* @param {string} opts.label
|
|
10
|
+
* @param {Array} opts.options - Array of strings or { value, label } objects
|
|
11
|
+
* @param {string} opts.value - Currently selected value
|
|
12
|
+
* @param {string} opts.error
|
|
13
|
+
* @param {string} opts.hint
|
|
14
|
+
* @param {boolean} opts.required
|
|
15
|
+
* @param {boolean} opts.disabled
|
|
16
|
+
* @param {string} opts.id
|
|
17
|
+
* @param {string} opts.event - data-event binding, e.g. 'change:setCategory'
|
|
18
|
+
* @param {string} opts.class
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { escHtml as e } from '../html.js'
|
|
22
|
+
import { iconChevronDown } from './icons.js'
|
|
23
|
+
|
|
24
|
+
export function select({
|
|
25
|
+
name = '',
|
|
26
|
+
label = '',
|
|
27
|
+
options = [],
|
|
28
|
+
value = '',
|
|
29
|
+
error = '',
|
|
30
|
+
hint = '',
|
|
31
|
+
required = false,
|
|
32
|
+
disabled = false,
|
|
33
|
+
id = '',
|
|
34
|
+
event = '',
|
|
35
|
+
class: cls = '',
|
|
36
|
+
} = {}) {
|
|
37
|
+
const fieldId = e(id || `field-${name}`)
|
|
38
|
+
const errorId = `${fieldId}-error`
|
|
39
|
+
const hintId = `${fieldId}-hint`
|
|
40
|
+
const described = [error ? errorId : '', hint ? hintId : ''].filter(Boolean).join(' ')
|
|
41
|
+
|
|
42
|
+
const wrapClasses = ['ui-field', error ? 'ui-field--error' : '', cls].filter(Boolean).join(' ')
|
|
43
|
+
|
|
44
|
+
const labelHtml = label
|
|
45
|
+
? `<label for="${fieldId}" class="ui-label">${e(label)}${required ? ' <span class="ui-required" aria-hidden="true">*</span>' : ''}</label>`
|
|
46
|
+
: ''
|
|
47
|
+
|
|
48
|
+
const optionsHtml = options.map(opt => {
|
|
49
|
+
const v = typeof opt === 'string' ? opt : opt.value
|
|
50
|
+
const l = typeof opt === 'string' ? opt : opt.label
|
|
51
|
+
return `<option value="${e(v)}"${v === value ? ' selected' : ''}>${e(l)}</option>`
|
|
52
|
+
}).join('')
|
|
53
|
+
|
|
54
|
+
const chevron = iconChevronDown({ size: 12 })
|
|
55
|
+
|
|
56
|
+
const hintHtml = hint ? `<p id="${hintId}" class="ui-hint">${e(hint)}</p>` : ''
|
|
57
|
+
const errorHtml = error ? `<p id="${errorId}" class="ui-error" role="alert">${e(error)}</p>` : ''
|
|
58
|
+
|
|
59
|
+
return `<div class="${e(wrapClasses)}">
|
|
60
|
+
${labelHtml}
|
|
61
|
+
<div class="ui-select-wrap">
|
|
62
|
+
<select
|
|
63
|
+
id="${fieldId}"
|
|
64
|
+
name="${e(name)}"
|
|
65
|
+
class="ui-select"
|
|
66
|
+
${required ? 'required aria-required="true"' : ''}
|
|
67
|
+
${disabled ? 'disabled' : ''}
|
|
68
|
+
${event ? `data-event="${e(event)}"` : ''}
|
|
69
|
+
${described ? `aria-describedby="${described}"` : ''}
|
|
70
|
+
${error ? 'aria-invalid="true"' : ''}
|
|
71
|
+
>${optionsHtml}</select>
|
|
72
|
+
<span class="ui-select-chevron" aria-hidden="true">${chevron}</span>
|
|
73
|
+
</div>
|
|
74
|
+
${hintHtml}
|
|
75
|
+
${errorHtml}
|
|
76
|
+
</div>`
|
|
77
|
+
}
|
package/src/ui/slider.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse UI — Slider
|
|
3
|
+
*
|
|
4
|
+
* Styled range input with label and hint.
|
|
5
|
+
* The fill gradient is driven by --slider-fill CSS custom property.
|
|
6
|
+
*
|
|
7
|
+
* @param {object} opts
|
|
8
|
+
* @param {string} opts.name - Field name (submitted in FormData as a number string)
|
|
9
|
+
* @param {string} opts.label - Visible label text
|
|
10
|
+
* @param {number} opts.min - Minimum value (default: 0)
|
|
11
|
+
* @param {number} opts.max - Maximum value (default: 100)
|
|
12
|
+
* @param {number} opts.step - Step increment (default: 1)
|
|
13
|
+
* @param {number} opts.value - Current value (default: 50)
|
|
14
|
+
* @param {boolean} opts.disabled
|
|
15
|
+
* @param {string} opts.hint - Helper text below the slider
|
|
16
|
+
* @param {boolean} opts.showValue - Show the current value live beside the label
|
|
17
|
+
* @param {string} opts.id - Override generated id
|
|
18
|
+
* @param {string} opts.event - data-event binding, e.g. 'change:setBrightness'
|
|
19
|
+
* @param {string} opts.class
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { escHtml as e } from '../html.js'
|
|
23
|
+
|
|
24
|
+
export function slider({
|
|
25
|
+
name = '',
|
|
26
|
+
label = '',
|
|
27
|
+
min = 0,
|
|
28
|
+
max = 100,
|
|
29
|
+
step = 1,
|
|
30
|
+
value = 50,
|
|
31
|
+
disabled = false,
|
|
32
|
+
hint = '',
|
|
33
|
+
showValue = false,
|
|
34
|
+
id = '',
|
|
35
|
+
event = '',
|
|
36
|
+
class: cls = '',
|
|
37
|
+
} = {}) {
|
|
38
|
+
const fieldId = e(id || `slider-${name}`)
|
|
39
|
+
const hintId = `${fieldId}-hint`
|
|
40
|
+
const described = hint ? hintId : ''
|
|
41
|
+
|
|
42
|
+
const minN = Number(min)
|
|
43
|
+
const maxN = Number(max)
|
|
44
|
+
const valN = Math.min(Math.max(Number(value), minN), maxN)
|
|
45
|
+
const fillPct = maxN > minN
|
|
46
|
+
? (((valN - minN) / (maxN - minN)) * 100).toFixed(2) + '%'
|
|
47
|
+
: '0%'
|
|
48
|
+
|
|
49
|
+
const wrapClasses = ['ui-field', cls].filter(Boolean).join(' ')
|
|
50
|
+
|
|
51
|
+
const outputId = `${fieldId}-output`
|
|
52
|
+
|
|
53
|
+
const labelHtml = label
|
|
54
|
+
? `<label for="${fieldId}" class="ui-label${showValue ? ' ui-label--row' : ''}">
|
|
55
|
+
${e(label)}
|
|
56
|
+
${showValue ? `<output id="${outputId}" class="ui-slider-output" for="${fieldId}">${valN}</output>` : ''}
|
|
57
|
+
</label>`
|
|
58
|
+
: ''
|
|
59
|
+
|
|
60
|
+
const hintHtml = hint
|
|
61
|
+
? `<p id="${hintId}" class="ui-hint">${e(hint)}</p>`
|
|
62
|
+
: ''
|
|
63
|
+
|
|
64
|
+
return `<div class="${e(wrapClasses)}" style="--slider-fill:${fillPct}">
|
|
65
|
+
${labelHtml}
|
|
66
|
+
<input
|
|
67
|
+
type="range"
|
|
68
|
+
id="${fieldId}"
|
|
69
|
+
name="${e(name)}"
|
|
70
|
+
class="ui-slider"
|
|
71
|
+
min="${minN}"
|
|
72
|
+
max="${maxN}"
|
|
73
|
+
step="${e(String(step))}"
|
|
74
|
+
value="${valN}"
|
|
75
|
+
aria-valuemin="${minN}"
|
|
76
|
+
aria-valuemax="${maxN}"
|
|
77
|
+
aria-valuenow="${valN}"
|
|
78
|
+
${disabled ? 'disabled' : ''}
|
|
79
|
+
${event ? `data-event="${e(event)}"` : ''}
|
|
80
|
+
${described ? `aria-describedby="${described}"` : ''}
|
|
81
|
+
>
|
|
82
|
+
${hintHtml}
|
|
83
|
+
</div>`
|
|
84
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse UI — Spinner
|
|
3
|
+
*
|
|
4
|
+
* CSS-animated loading indicator. No JavaScript required.
|
|
5
|
+
*
|
|
6
|
+
* @param {object} opts
|
|
7
|
+
* @param {'sm'|'md'|'lg'} opts.size
|
|
8
|
+
* @param {'accent'|'muted'|'white'} opts.color
|
|
9
|
+
* @param {string} opts.label - Accessible label (default: 'Loading…')
|
|
10
|
+
* @param {string} opts.class
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { escHtml as e } from '../html.js'
|
|
14
|
+
|
|
15
|
+
const SIZES = { sm: '1rem', md: '1.5rem', lg: '2.5rem' }
|
|
16
|
+
const COLORS = { accent: 'var(--ui-accent)', muted: 'var(--ui-muted)', white: '#fff' }
|
|
17
|
+
|
|
18
|
+
export function spinner({
|
|
19
|
+
size = 'md',
|
|
20
|
+
color = 'accent',
|
|
21
|
+
label = 'Loading…',
|
|
22
|
+
class: cls = '',
|
|
23
|
+
} = {}) {
|
|
24
|
+
const sz = SIZES[size] ?? SIZES.md
|
|
25
|
+
const clr = COLORS[color] ?? COLORS.accent
|
|
26
|
+
const classes = ['ui-spinner', cls].filter(Boolean).join(' ')
|
|
27
|
+
|
|
28
|
+
return `<span
|
|
29
|
+
class="${e(classes)}"
|
|
30
|
+
role="status"
|
|
31
|
+
aria-label="${e(label)}"
|
|
32
|
+
style="--spinner-size:${sz};--spinner-color:${clr}"
|
|
33
|
+
></span>`
|
|
34
|
+
}
|
package/src/ui/stack.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse UI — Stack
|
|
3
|
+
*
|
|
4
|
+
* Flex column with consistent vertical gap. The simplest way to space
|
|
5
|
+
* a sequence of elements vertically without writing custom CSS.
|
|
6
|
+
*
|
|
7
|
+
* @param {object} opts
|
|
8
|
+
* @param {string} opts.content - Raw HTML slot
|
|
9
|
+
* @param {'xs'|'sm'|'md'|'lg'|'xl'} opts.gap - Gap between children (default: 'md')
|
|
10
|
+
* @param {'stretch'|'start'|'center'|'end'} opts.align - align-items (default: 'stretch')
|
|
11
|
+
* @param {string} opts.class
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { escHtml as e } from '../html.js'
|
|
15
|
+
|
|
16
|
+
const GAPS = new Set(['xs', 'sm', 'md', 'lg', 'xl'])
|
|
17
|
+
const ALIGNS = new Set(['stretch', 'start', 'center', 'end'])
|
|
18
|
+
|
|
19
|
+
export function stack({
|
|
20
|
+
content = '',
|
|
21
|
+
gap = 'md',
|
|
22
|
+
align = 'stretch',
|
|
23
|
+
class: cls = '',
|
|
24
|
+
} = {}) {
|
|
25
|
+
if (!GAPS.has(gap)) gap = 'md'
|
|
26
|
+
if (!ALIGNS.has(align)) align = 'stretch'
|
|
27
|
+
|
|
28
|
+
const classes = [
|
|
29
|
+
'ui-stack',
|
|
30
|
+
gap !== 'md' && `ui-stack--gap-${gap}`,
|
|
31
|
+
align !== 'stretch' && `ui-stack--align-${align}`,
|
|
32
|
+
cls,
|
|
33
|
+
].filter(Boolean).join(' ')
|
|
34
|
+
|
|
35
|
+
return `<div class="${e(classes)}">${content}</div>`
|
|
36
|
+
}
|
package/src/ui/stat.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse UI — Stat
|
|
3
|
+
*
|
|
4
|
+
* A single metric with label, value, and optional trend indicator.
|
|
5
|
+
*
|
|
6
|
+
* @param {object} opts
|
|
7
|
+
* @param {string} opts.label
|
|
8
|
+
* @param {string} opts.value - Formatted value string (e.g. "2.4k", "98%")
|
|
9
|
+
* @param {string} opts.change - Change label (e.g. "+12%", "−3")
|
|
10
|
+
* @param {'up'|'down'|'neutral'} opts.trend
|
|
11
|
+
* @param {boolean} opts.center - Centre-align all text
|
|
12
|
+
* @param {string} opts.class
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { escHtml as e } from '../html.js'
|
|
16
|
+
import { iconTrendingUp, iconTrendingDown, iconMinus } from './icons.js'
|
|
17
|
+
|
|
18
|
+
const TRENDS = new Set(['up', 'down', 'neutral'])
|
|
19
|
+
|
|
20
|
+
const TREND_ICONS = {
|
|
21
|
+
up: iconTrendingUp({ size: 13 }),
|
|
22
|
+
down: iconTrendingDown({ size: 13 }),
|
|
23
|
+
neutral: iconMinus({ size: 13 }),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const TREND_LABELS = { up: 'increase', down: 'decrease', neutral: 'no change' }
|
|
27
|
+
|
|
28
|
+
export function stat({
|
|
29
|
+
label = '',
|
|
30
|
+
value = '',
|
|
31
|
+
change = '',
|
|
32
|
+
trend = 'neutral',
|
|
33
|
+
center = false,
|
|
34
|
+
class: cls = '',
|
|
35
|
+
} = {}) {
|
|
36
|
+
if (!TRENDS.has(trend)) trend = 'neutral'
|
|
37
|
+
|
|
38
|
+
const classes = ['ui-stat', center && 'ui-stat--center', cls].filter(Boolean).join(' ')
|
|
39
|
+
|
|
40
|
+
const changeHtml = change
|
|
41
|
+
? `<p class="ui-stat-change ui-stat-change--${e(trend)}">
|
|
42
|
+
<span aria-label="${e(TREND_LABELS[trend])}">${TREND_ICONS[trend]}</span>
|
|
43
|
+
${e(change)}
|
|
44
|
+
</p>`
|
|
45
|
+
: ''
|
|
46
|
+
|
|
47
|
+
return `<div class="${e(classes)}">
|
|
48
|
+
<p class="ui-stat-label">${e(label)}</p>
|
|
49
|
+
<p class="ui-stat-value">${e(value)}</p>
|
|
50
|
+
${changeHtml}
|
|
51
|
+
</div>`
|
|
52
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse UI — Stepper
|
|
3
|
+
*
|
|
4
|
+
* Horizontal step progress indicator.
|
|
5
|
+
* Steps before `current` are complete (filled accent dot with check icon).
|
|
6
|
+
* The step at `current` is active (accent border + accent number).
|
|
7
|
+
* Steps after `current` are upcoming (muted border + muted number).
|
|
8
|
+
*
|
|
9
|
+
* @param {object} opts
|
|
10
|
+
* @param {Array} opts.steps - Array of step label strings
|
|
11
|
+
* @param {number} opts.current - 0-based index of the active step
|
|
12
|
+
* @param {string} opts.class
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { escHtml as e } from '../html.js'
|
|
16
|
+
import { iconCheck } from './icons.js'
|
|
17
|
+
|
|
18
|
+
const CHECK_SVG = iconCheck({ size: 12 })
|
|
19
|
+
|
|
20
|
+
export function stepper({
|
|
21
|
+
steps = [],
|
|
22
|
+
current = 0,
|
|
23
|
+
class: cls = '',
|
|
24
|
+
} = {}) {
|
|
25
|
+
const wrapClasses = ['ui-stepper', cls].filter(Boolean).join(' ')
|
|
26
|
+
|
|
27
|
+
const items = steps.map((label, i) => {
|
|
28
|
+
const isComplete = i < current
|
|
29
|
+
const isActive = i === current
|
|
30
|
+
|
|
31
|
+
const modClass = isComplete ? 'ui-stepper-item--complete'
|
|
32
|
+
: isActive ? 'ui-stepper-item--active'
|
|
33
|
+
: ''
|
|
34
|
+
|
|
35
|
+
const dot = isComplete
|
|
36
|
+
? `<div class="ui-stepper-dot">${CHECK_SVG}</div>`
|
|
37
|
+
: `<div class="ui-stepper-dot">${i + 1}</div>`
|
|
38
|
+
|
|
39
|
+
return `<div class="ui-stepper-item${modClass ? ` ${modClass}` : ''}">
|
|
40
|
+
${dot}
|
|
41
|
+
<span class="ui-stepper-label">${e(label)}</span>
|
|
42
|
+
</div>`
|
|
43
|
+
}).join('')
|
|
44
|
+
|
|
45
|
+
return `<div class="${e(wrapClasses)}">${items}</div>`
|
|
46
|
+
}
|