@teamofsilicons/silicon-ui 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +81 -2
- package/check.mjs +236 -0
- package/components/accordion/accordion.d.ts +13 -0
- package/components/accordion/accordion.js +35 -0
- package/components/accordion/accordion.module.css +15 -0
- package/components/action-button/action-button.d.ts +11 -0
- package/components/action-button/action-button.js +57 -0
- package/components/action-button/action-button.module.css +15 -0
- package/components/activity-heatmap/activity-heatmap.d.ts +37 -0
- package/components/activity-heatmap/activity-heatmap.js +303 -0
- package/components/activity-heatmap/activity-heatmap.module.css +83 -0
- package/components/alert/alert.d.ts +13 -0
- package/components/alert/alert.js +79 -0
- package/components/alert/alert.module.css +15 -0
- package/components/animated-counter/animated-counter.d.ts +13 -0
- package/components/animated-counter/animated-counter.js +73 -0
- package/components/animated-counter/animated-counter.module.css +12 -0
- package/components/announcement-bar/announcement-bar.d.ts +53 -0
- package/components/announcement-bar/announcement-bar.js +187 -0
- package/components/announcement-bar/announcement-bar.module.css +60 -0
- package/components/avatar/avatar.d.ts +8 -0
- package/components/avatar/avatar.js +18 -0
- package/components/avatar/avatar.module.css +60 -0
- package/components/avatar-group/avatar-group.d.ts +13 -0
- package/components/avatar-group/avatar-group.js +24 -0
- package/components/avatar-group/avatar-group.module.css +27 -0
- package/components/badge/badge.d.ts +10 -0
- package/components/badge/badge.js +66 -0
- package/components/badge/badge.module.css +52 -0
- package/components/bar-chart/bar-chart.d.ts +32 -0
- package/components/bar-chart/bar-chart.js +327 -0
- package/components/bar-chart/bar-chart.module.css +49 -0
- package/components/billing-toggle/billing-toggle.d.ts +35 -0
- package/components/billing-toggle/billing-toggle.js +83 -0
- package/components/billing-toggle/billing-toggle.module.css +139 -0
- package/components/bottom-sheet/bottom-sheet.d.ts +27 -0
- package/components/bottom-sheet/bottom-sheet.js +386 -0
- package/components/bottom-sheet/bottom-sheet.module.css +69 -0
- package/components/breadcrumb/breadcrumb.d.ts +13 -0
- package/components/breadcrumb/breadcrumb.js +19 -0
- package/components/breadcrumb/breadcrumb.module.css +12 -0
- package/components/brush-chart/brush-chart.d.ts +45 -0
- package/components/brush-chart/brush-chart.js +426 -0
- package/components/brush-chart/brush-chart.module.css +69 -0
- package/components/button/button.d.ts +10 -0
- package/components/button/button.js +97 -0
- package/components/button/button.module.css +39 -0
- package/components/button-group/button-group.d.ts +59 -0
- package/components/button-group/button-group.js +280 -0
- package/components/button-group/button-group.module.css +87 -0
- package/components/button-link/button-link.d.ts +24 -0
- package/components/button-link/button-link.js +11 -0
- package/components/button-link/button-link.module.css +7 -0
- package/components/calendar/calendar.d.ts +22 -0
- package/components/calendar/calendar.js +253 -0
- package/components/calendar/calendar.module.css +51 -0
- package/components/card/card.d.ts +19 -0
- package/components/card/card.js +108 -0
- package/components/card/card.module.css +51 -0
- package/components/card-stack/card-stack.d.ts +34 -0
- package/components/card-stack/card-stack.js +291 -0
- package/components/card-stack/card-stack.module.css +63 -0
- package/components/carousel/carousel.d.ts +28 -0
- package/components/carousel/carousel.js +332 -0
- package/components/carousel/carousel.module.css +42 -0
- package/components/chat-thread/chat-thread.d.ts +135 -0
- package/components/chat-thread/chat-thread.js +406 -0
- package/components/chat-thread/chat-thread.module.css +242 -0
- package/components/checkbox/checkbox.d.ts +7 -0
- package/components/checkbox/checkbox.js +23 -0
- package/components/checkbox/checkbox.module.css +15 -0
- package/components/chip-group/chip-group.d.ts +24 -0
- package/components/chip-group/chip-group.js +196 -0
- package/components/chip-group/chip-group.module.css +38 -0
- package/components/code-block/code-block.d.ts +11 -0
- package/components/code-block/code-block.js +133 -0
- package/components/code-block/code-block.module.css +43 -0
- package/components/color-picker/color-picker.d.ts +40 -0
- package/components/color-picker/color-picker.js +449 -0
- package/components/color-picker/color-picker.module.css +111 -0
- package/components/combobox/combobox.d.ts +19 -0
- package/components/combobox/combobox.js +167 -0
- package/components/combobox/combobox.module.css +28 -0
- package/components/command-palette/command-palette.d.ts +20 -0
- package/components/command-palette/command-palette.js +151 -0
- package/components/command-palette/command-palette.module.css +94 -0
- package/components/comment-thread/comment-thread.d.ts +120 -0
- package/components/comment-thread/comment-thread.js +343 -0
- package/components/comment-thread/comment-thread.module.css +195 -0
- package/components/confirm-morph/confirm-morph.d.ts +65 -0
- package/components/confirm-morph/confirm-morph.js +355 -0
- package/components/confirm-morph/confirm-morph.module.css +146 -0
- package/components/context-menu/context-menu.d.ts +18 -0
- package/components/context-menu/context-menu.js +148 -0
- package/components/context-menu/context-menu.module.css +17 -0
- package/components/copy-button/copy-button.d.ts +10 -0
- package/components/copy-button/copy-button.js +69 -0
- package/components/copy-button/copy-button.module.css +21 -0
- package/components/countdown/countdown.d.ts +31 -0
- package/components/countdown/countdown.js +177 -0
- package/components/countdown/countdown.module.css +49 -0
- package/components/date-picker/date-picker.d.ts +17 -0
- package/components/date-picker/date-picker.js +100 -0
- package/components/date-picker/date-picker.module.css +34 -0
- package/components/date-range-picker/date-range-picker.d.ts +44 -0
- package/components/date-range-picker/date-range-picker.js +534 -0
- package/components/date-range-picker/date-range-picker.module.css +214 -0
- package/components/dialog/dialog.d.ts +11 -0
- package/components/dialog/dialog.js +52 -0
- package/components/dialog/dialog.module.css +36 -0
- package/components/donut-chart/donut-chart.d.ts +46 -0
- package/components/donut-chart/donut-chart.js +382 -0
- package/components/donut-chart/donut-chart.module.css +48 -0
- package/components/drawer/drawer.d.ts +20 -0
- package/components/drawer/drawer.js +111 -0
- package/components/drawer/drawer.module.css +97 -0
- package/components/dropdown-menu/dropdown-menu.d.ts +16 -0
- package/components/dropdown-menu/dropdown-menu.js +39 -0
- package/components/dropdown-menu/dropdown-menu.module.css +38 -0
- package/components/elastic-slider/elastic-slider.d.ts +39 -0
- package/components/elastic-slider/elastic-slider.js +261 -0
- package/components/elastic-slider/elastic-slider.module.css +40 -0
- package/components/empty-state/empty-state.d.ts +12 -0
- package/components/empty-state/empty-state.js +69 -0
- package/components/empty-state/empty-state.module.css +13 -0
- package/components/expandable-card/expandable-card.d.ts +13 -0
- package/components/expandable-card/expandable-card.js +89 -0
- package/components/expandable-card/expandable-card.module.css +22 -0
- package/components/expanding-button-group/expanding-button-group.d.ts +41 -0
- package/components/expanding-button-group/expanding-button-group.js +258 -0
- package/components/expanding-button-group/expanding-button-group.module.css +44 -0
- package/components/expanding-search/expanding-search.d.ts +38 -0
- package/components/expanding-search/expanding-search.js +242 -0
- package/components/expanding-search/expanding-search.module.css +52 -0
- package/components/file-dropzone/file-dropzone.d.ts +44 -0
- package/components/file-dropzone/file-dropzone.js +379 -0
- package/components/file-dropzone/file-dropzone.module.css +115 -0
- package/components/file-upload/file-upload.d.ts +22 -0
- package/components/file-upload/file-upload.js +192 -0
- package/components/file-upload/file-upload.module.css +49 -0
- package/components/filter-toolbar/filter-toolbar.d.ts +48 -0
- package/components/filter-toolbar/filter-toolbar.js +446 -0
- package/components/filter-toolbar/filter-toolbar.module.css +70 -0
- package/components/floating-button-group/floating-button-group.d.ts +51 -0
- package/components/floating-button-group/floating-button-group.js +215 -0
- package/components/floating-button-group/floating-button-group.module.css +74 -0
- package/components/gauge/gauge.d.ts +18 -0
- package/components/gauge/gauge.js +101 -0
- package/components/gauge/gauge.module.css +22 -0
- package/components/hold-to-confirm/hold-to-confirm.d.ts +25 -0
- package/components/hold-to-confirm/hold-to-confirm.js +165 -0
- package/components/hold-to-confirm/hold-to-confirm.module.css +20 -0
- package/components/hover-card/hover-card.d.ts +38 -0
- package/components/hover-card/hover-card.js +145 -0
- package/components/hover-card/hover-card.module.css +20 -0
- package/components/image-compare/image-compare.d.ts +27 -0
- package/components/image-compare/image-compare.js +267 -0
- package/components/image-compare/image-compare.module.css +25 -0
- package/components/in-view-title/in-view-title.d.ts +17 -0
- package/components/in-view-title/in-view-title.js +70 -0
- package/components/in-view-title/in-view-title.module.css +13 -0
- package/components/inline-edit/inline-edit.d.ts +27 -0
- package/components/inline-edit/inline-edit.js +315 -0
- package/components/inline-edit/inline-edit.module.css +73 -0
- package/components/input/input.d.ts +7 -0
- package/components/input/input.js +60 -0
- package/components/input/input.module.css +22 -0
- package/components/json-viewer/json-viewer.d.ts +48 -0
- package/components/json-viewer/json-viewer.js +463 -0
- package/components/json-viewer/json-viewer.module.css +92 -0
- package/components/line-chart/line-chart.d.ts +58 -0
- package/components/line-chart/line-chart.js +383 -0
- package/components/line-chart/line-chart.module.css +60 -0
- package/components/mention-input/mention-input.d.ts +72 -0
- package/components/mention-input/mention-input.js +417 -0
- package/components/mention-input/mention-input.module.css +134 -0
- package/components/metric-card/metric-card.d.ts +8 -0
- package/components/metric-card/metric-card.js +44 -0
- package/components/metric-card/metric-card.module.css +18 -0
- package/components/money-input/money-input.d.ts +48 -0
- package/components/money-input/money-input.js +503 -0
- package/components/money-input/money-input.module.css +77 -0
- package/components/morph-select/morph-select.d.ts +50 -0
- package/components/morph-select/morph-select.js +395 -0
- package/components/morph-select/morph-select.module.css +88 -0
- package/components/multi-select/multi-select.d.ts +18 -0
- package/components/multi-select/multi-select.js +105 -0
- package/components/multi-select/multi-select.module.css +37 -0
- package/components/notification-center/notification-center.d.ts +24 -0
- package/components/notification-center/notification-center.js +146 -0
- package/components/notification-center/notification-center.module.css +79 -0
- package/components/number-field/number-field.d.ts +41 -0
- package/components/number-field/number-field.js +455 -0
- package/components/number-field/number-field.module.css +71 -0
- package/components/otp-input/otp-input.d.ts +13 -0
- package/components/otp-input/otp-input.js +129 -0
- package/components/otp-input/otp-input.module.css +28 -0
- package/components/pagination/pagination.d.ts +7 -0
- package/components/pagination/pagination.js +60 -0
- package/components/pagination/pagination.module.css +16 -0
- package/components/password-field/password-field.d.ts +14 -0
- package/components/password-field/password-field.js +51 -0
- package/components/password-field/password-field.module.css +27 -0
- package/components/password-strength/password-strength.d.ts +107 -0
- package/components/password-strength/password-strength.js +161 -0
- package/components/password-strength/password-strength.module.css +57 -0
- package/components/phone-input/phone-input.d.ts +73 -0
- package/components/phone-input/phone-input.js +612 -0
- package/components/phone-input/phone-input.module.css +98 -0
- package/components/popover/popover.d.ts +6 -0
- package/components/popover/popover.js +19 -0
- package/components/popover/popover.module.css +32 -0
- package/components/progress/progress.d.ts +8 -0
- package/components/progress/progress.js +42 -0
- package/components/progress/progress.module.css +1 -0
- package/components/radio-cards/radio-cards.d.ts +35 -0
- package/components/radio-cards/radio-cards.js +110 -0
- package/components/radio-cards/radio-cards.module.css +84 -0
- package/components/radio-group/radio-group.d.ts +13 -0
- package/components/radio-group/radio-group.js +55 -0
- package/components/radio-group/radio-group.module.css +30 -0
- package/components/resizable-panels/resizable-panels.d.ts +37 -0
- package/components/resizable-panels/resizable-panels.js +338 -0
- package/components/resizable-panels/resizable-panels.module.css +40 -0
- package/components/rich-text-editor/rich-text-editor.d.ts +55 -0
- package/components/rich-text-editor/rich-text-editor.js +1733 -0
- package/components/rich-text-editor/rich-text-editor.module.css +201 -0
- package/components/ridgeline/ridgeline.d.ts +37 -0
- package/components/ridgeline/ridgeline.js +346 -0
- package/components/ridgeline/ridgeline.module.css +58 -0
- package/components/scroll-area/scroll-area.d.ts +40 -0
- package/components/scroll-area/scroll-area.js +153 -0
- package/components/scroll-area/scroll-area.module.css +79 -0
- package/components/search-field/search-field.d.ts +7 -0
- package/components/search-field/search-field.js +20 -0
- package/components/search-field/search-field.module.css +17 -0
- package/components/segmented-control/segmented-control.d.ts +16 -0
- package/components/segmented-control/segmented-control.js +56 -0
- package/components/segmented-control/segmented-control.module.css +15 -0
- package/components/select/select.d.ts +15 -0
- package/components/select/select.js +36 -0
- package/components/select/select.module.css +35 -0
- package/components/shortcut-recorder/shortcut-recorder.d.ts +108 -0
- package/components/shortcut-recorder/shortcut-recorder.js +380 -0
- package/components/shortcut-recorder/shortcut-recorder.module.css +85 -0
- package/components/signature-pad/signature-pad.d.ts +72 -0
- package/components/signature-pad/signature-pad.js +450 -0
- package/components/signature-pad/signature-pad.module.css +84 -0
- package/components/skeleton/skeleton.d.ts +13 -0
- package/components/skeleton/skeleton.js +53 -0
- package/components/skeleton/skeleton.module.css +11 -0
- package/components/slider/slider.d.ts +45 -0
- package/components/slider/slider.js +347 -0
- package/components/slider/slider.module.css +45 -0
- package/components/slope-chart/slope-chart.d.ts +38 -0
- package/components/slope-chart/slope-chart.js +183 -0
- package/components/slope-chart/slope-chart.module.css +51 -0
- package/components/slot-text/slot-text.d.ts +26 -0
- package/components/slot-text/slot-text.js +119 -0
- package/components/slot-text/slot-text.module.css +19 -0
- package/components/sortable-data-table/sortable-data-table.d.ts +38 -0
- package/components/sortable-data-table/sortable-data-table.js +240 -0
- package/components/sortable-data-table/sortable-data-table.module.css +116 -0
- package/components/sparkline/sparkline.d.ts +19 -0
- package/components/sparkline/sparkline.js +239 -0
- package/components/sparkline/sparkline.module.css +26 -0
- package/components/split-button/split-button.d.ts +19 -0
- package/components/split-button/split-button.js +33 -0
- package/components/split-button/split-button.module.css +51 -0
- package/components/stepper/stepper.d.ts +36 -0
- package/components/stepper/stepper.js +108 -0
- package/components/stepper/stepper.module.css +74 -0
- package/components/streamgraph/streamgraph.d.ts +51 -0
- package/components/streamgraph/streamgraph.js +376 -0
- package/components/streamgraph/streamgraph.module.css +55 -0
- package/components/swipe-actions/swipe-actions.d.ts +35 -0
- package/components/swipe-actions/swipe-actions.js +330 -0
- package/components/swipe-actions/swipe-actions.module.css +64 -0
- package/components/switch/switch.d.ts +7 -0
- package/components/switch/switch.js +45 -0
- package/components/switch/switch.module.css +12 -0
- package/components/tabs/tabs.d.ts +8 -0
- package/components/tabs/tabs.js +138 -0
- package/components/tabs/tabs.module.css +27 -0
- package/components/tag-input/tag-input.d.ts +15 -0
- package/components/tag-input/tag-input.js +150 -0
- package/components/tag-input/tag-input.module.css +34 -0
- package/components/text-morph/text-morph.d.ts +13 -0
- package/components/text-morph/text-morph.js +69 -0
- package/components/text-morph/text-morph.module.css +8 -0
- package/components/text-reveal/text-reveal.d.ts +14 -0
- package/components/text-reveal/text-reveal.js +20 -0
- package/components/text-reveal/text-reveal.module.css +13 -0
- package/components/text-shimmer/text-shimmer.d.ts +18 -0
- package/components/text-shimmer/text-shimmer.js +90 -0
- package/components/text-shimmer/text-shimmer.module.css +9 -0
- package/components/textarea/textarea.d.ts +7 -0
- package/components/textarea/textarea.js +56 -0
- package/components/textarea/textarea.module.css +22 -0
- package/components/theme-switch/theme-switch.d.ts +11 -0
- package/components/theme-switch/theme-switch.js +37 -0
- package/components/theme-switch/theme-switch.module.css +31 -0
- package/components/time-picker/time-picker.d.ts +13 -0
- package/components/time-picker/time-picker.js +95 -0
- package/components/time-picker/time-picker.module.css +23 -0
- package/components/timeline/timeline.d.ts +47 -0
- package/components/timeline/timeline.js +171 -0
- package/components/timeline/timeline.module.css +51 -0
- package/components/toast/toast.d.ts +8 -0
- package/components/toast/toast.js +118 -0
- package/components/toast/toast.module.css +100 -0
- package/components/toast-stack/toast-stack.d.ts +57 -0
- package/components/toast-stack/toast-stack.js +399 -0
- package/components/toast-stack/toast-stack.module.css +61 -0
- package/components/tooltip/tooltip.d.ts +8 -0
- package/components/tooltip/tooltip.js +62 -0
- package/components/tooltip/tooltip.module.css +18 -0
- package/components/tree-view/tree-view.d.ts +17 -0
- package/components/tree-view/tree-view.js +147 -0
- package/components/tree-view/tree-view.module.css +22 -0
- package/components/treemap/treemap.d.ts +35 -0
- package/components/treemap/treemap.js +375 -0
- package/components/treemap/treemap.module.css +59 -0
- package/components/usage-meter/usage-meter.d.ts +24 -0
- package/components/usage-meter/usage-meter.js +296 -0
- package/components/usage-meter/usage-meter.module.css +61 -0
- package/components/user-menu/user-menu.d.ts +62 -0
- package/components/user-menu/user-menu.js +316 -0
- package/components/user-menu/user-menu.module.css +97 -0
- package/components/waffle-chart/waffle-chart.d.ts +39 -0
- package/components/waffle-chart/waffle-chart.js +244 -0
- package/components/waffle-chart/waffle-chart.module.css +52 -0
- package/components.json +1330 -0
- package/eslint.js +53 -0
- package/lib/escape.d.ts +19 -0
- package/lib/escape.js +50 -0
- package/lib/media.d.ts +353 -0
- package/lib/media.js +65 -0
- package/lib/menu-highlight.d.ts +28 -0
- package/lib/menu-highlight.js +43 -0
- package/lib/motion-tokens.d.ts +60 -0
- package/lib/motion-tokens.js +25 -0
- package/lib/return-focus.d.ts +9 -0
- package/lib/return-focus.js +53 -0
- package/lib/silicon-provider.d.ts +60 -0
- package/lib/silicon-provider.js +47 -0
- package/lib/use-copy-feedback.d.ts +8 -0
- package/lib/use-copy-feedback.js +36 -0
- package/lib/use-today.d.ts +2 -0
- package/lib/use-today.js +29 -0
- package/package.json +76 -4
- package/styles.css +218 -0
- package/styles.css.d.ts +1 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useId, useRef, useState } from "react";
|
|
4
|
+
import { AnimatePresence, motion, useAnimate, useReducedMotion } from "motion/react";
|
|
5
|
+
import { motionTokens } from "../../lib/motion-tokens.js";
|
|
6
|
+
import styles from "./otp-input.module.css";
|
|
7
|
+
/** Changed words rise in and unblur while unchanged words hold still. Assistive tech reads the plain copy. */
|
|
8
|
+
function MotionText({ text }) {
|
|
9
|
+
const reduced = useReducedMotion();
|
|
10
|
+
const words = text.split(" ");
|
|
11
|
+
return _jsxs(_Fragment, { children: [_jsx("span", { className: styles.srOnly, children: text }), _jsx("span", { className: styles.words, "aria-hidden": "true", children: _jsx(AnimatePresence, { initial: false, mode: "popLayout", children: words.map((word, index) => _jsx(motion.span, { className: styles.word, initial: reduced ? { opacity: 0 } : { opacity: 0, y: "0.35em", filter: `blur(${motionTokens.blur.soft}px)` }, animate: { opacity: 1, y: 0, filter: "blur(0px)" }, exit: reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: "-0.35em", filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } }, transition: reduced ? { duration: motionTokens.duration.instant } : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }, children: index < words.length - 1 ? `${word} ` : word }, `${index}:${word}`)) }) })] });
|
|
12
|
+
}
|
|
13
|
+
/** Helper and error copy: the row opens its height on a spring, then the words settle in. */
|
|
14
|
+
function FieldMessage({ id, text, className, alert }) {
|
|
15
|
+
return _jsx(AnimatePresence, { initial: false, children: text ? _jsx(MessageRow, { id: id, text: text, className: className, alert: alert }, "message") : null });
|
|
16
|
+
}
|
|
17
|
+
/** The row tracks the measured copy, so a longer message that wraps opens its next line instead of snapping. */
|
|
18
|
+
function MessageRow({ id, text, className, alert }) {
|
|
19
|
+
const reduced = useReducedMotion();
|
|
20
|
+
const copyRef = useRef(null);
|
|
21
|
+
const [height, setHeight] = useState("auto");
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
const node = copyRef.current;
|
|
24
|
+
if (!node || typeof ResizeObserver === "undefined")
|
|
25
|
+
return;
|
|
26
|
+
const observer = new ResizeObserver(([entry]) => setHeight(entry?.borderBoxSize?.[0]?.blockSize ?? node.offsetHeight));
|
|
27
|
+
observer.observe(node);
|
|
28
|
+
return () => observer.disconnect();
|
|
29
|
+
}, []);
|
|
30
|
+
return _jsx(motion.span, { className: styles.messageSlot, initial: { height: 0, opacity: 0 }, animate: { height, opacity: 1 }, exit: { height: 0, opacity: 0, transition: reduced ? { duration: 0 } : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.instant } } }, transition: reduced ? { duration: 0 } : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.fast } }, children: _jsx(motion.span, { ref: copyRef, id: id, className: className, role: alert ? "alert" : undefined, initial: reduced ? false : { y: "0.35em", filter: `blur(${motionTokens.blur.soft}px)` }, animate: { y: 0, filter: "blur(0px)" }, transition: { duration: reduced ? 0 : motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }, children: _jsx(MotionText, { text: text }) }) });
|
|
31
|
+
}
|
|
32
|
+
export function OtpInput({ length = 6, value = "", onChange, label, description, error, autoFocus = false, disabled = false, inputMode = "numeric", className }) {
|
|
33
|
+
const generatedId = useId();
|
|
34
|
+
const reduced = useReducedMotion();
|
|
35
|
+
const [scope, animate] = useAnimate();
|
|
36
|
+
const inputRefs = useRef([]);
|
|
37
|
+
const values = Array.from({ length }, (_, index) => value[index] ?? "");
|
|
38
|
+
const hintId = description ? `${generatedId}-description` : undefined;
|
|
39
|
+
const errorId = error ? `${generatedId}-error` : undefined;
|
|
40
|
+
const describedBy = [hintId, errorId].filter(Boolean).join(" ") || undefined;
|
|
41
|
+
const focusAt = (index) => inputRefs.current[Math.max(0, Math.min(index, length - 1))]?.focus();
|
|
42
|
+
const sanitize = (next) => inputMode === "numeric" ? next.replace(/\D/g, "") : next;
|
|
43
|
+
// One ring for the whole row. It glides between slots while focus stays inside, and fades in where focus lands when it arrives from outside.
|
|
44
|
+
const [ring, setRing] = useState({ x: 0, width: 0, shown: false, glide: false });
|
|
45
|
+
// A paste fills several slots in one update; those digits land as a short left to right wave.
|
|
46
|
+
const [fill, setFill] = useState({ value, wave: false });
|
|
47
|
+
if (fill.value !== value)
|
|
48
|
+
setFill({ value, wave: Array.from({ length }, (_, index) => (fill.value[index] ?? "") !== (value[index] ?? "")).filter(Boolean).length > 1 });
|
|
49
|
+
const lastError = useRef(error);
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (autoFocus)
|
|
52
|
+
inputRefs.current[0]?.focus();
|
|
53
|
+
}, [autoFocus]);
|
|
54
|
+
// Slots shrink at narrow widths; keep the ring on the focused slot when the row resizes.
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
const row = scope.current;
|
|
57
|
+
if (!row || typeof ResizeObserver === "undefined")
|
|
58
|
+
return;
|
|
59
|
+
const observer = new ResizeObserver(() => {
|
|
60
|
+
const slot = (row.contains(document.activeElement) ? document.activeElement?.parentElement : null);
|
|
61
|
+
if (slot)
|
|
62
|
+
setRing(current => (current.x === slot.offsetLeft && current.width === slot.offsetWidth ? current : { ...current, x: slot.offsetLeft, width: slot.offsetWidth, glide: false }));
|
|
63
|
+
});
|
|
64
|
+
observer.observe(row);
|
|
65
|
+
return () => observer.disconnect();
|
|
66
|
+
}, [scope]);
|
|
67
|
+
// A new error nudges the row side to side once, so a rejected code reads as a response to the attempt.
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (error && error !== lastError.current && !reduced && scope.current)
|
|
70
|
+
animate(scope.current, { x: [0, -6, 5, -3, 2, 0] }, { duration: .36, ease: [...motionTokens.ease.standard] });
|
|
71
|
+
lastError.current = error;
|
|
72
|
+
}, [error, reduced, animate, scope]);
|
|
73
|
+
const updateAt = (index, raw) => {
|
|
74
|
+
const next = values.slice();
|
|
75
|
+
const clean = sanitize(raw).slice(-1);
|
|
76
|
+
next[index] = clean;
|
|
77
|
+
onChange?.(next.join(""));
|
|
78
|
+
if (clean && index < length - 1)
|
|
79
|
+
focusAt(index + 1);
|
|
80
|
+
};
|
|
81
|
+
const handleChange = (index, event) => {
|
|
82
|
+
const raw = sanitize(event.target.value);
|
|
83
|
+
if (raw.length <= 1) {
|
|
84
|
+
updateAt(index, raw);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const next = values.slice();
|
|
88
|
+
raw.slice(0, length - index).split("").forEach((character, offset) => { next[index + offset] = character; });
|
|
89
|
+
onChange?.(next.join(""));
|
|
90
|
+
focusAt(Math.min(index + raw.length, length - 1));
|
|
91
|
+
};
|
|
92
|
+
const handlePaste = (index, event) => {
|
|
93
|
+
event.preventDefault();
|
|
94
|
+
const pasted = sanitize(event.clipboardData.getData("text")).slice(0, length - index);
|
|
95
|
+
if (!pasted)
|
|
96
|
+
return;
|
|
97
|
+
const next = values.slice();
|
|
98
|
+
pasted.split("").forEach((character, offset) => { next[index + offset] = character; });
|
|
99
|
+
onChange?.(next.join(""));
|
|
100
|
+
focusAt(Math.min(index + pasted.length, length - 1));
|
|
101
|
+
};
|
|
102
|
+
const handleKeyDown = (index, event) => {
|
|
103
|
+
if (event.key === "ArrowLeft") {
|
|
104
|
+
event.preventDefault();
|
|
105
|
+
focusAt(index - 1);
|
|
106
|
+
}
|
|
107
|
+
if (event.key === "ArrowRight") {
|
|
108
|
+
event.preventDefault();
|
|
109
|
+
focusAt(index + 1);
|
|
110
|
+
}
|
|
111
|
+
if (event.key === "Backspace" && !values[index] && index > 0) {
|
|
112
|
+
event.preventDefault();
|
|
113
|
+
const next = values.slice();
|
|
114
|
+
next[index - 1] = "";
|
|
115
|
+
onChange?.(next.join(""));
|
|
116
|
+
focusAt(index - 1);
|
|
117
|
+
}
|
|
118
|
+
if (event.key === "Delete" && values[index]) {
|
|
119
|
+
const next = values.slice();
|
|
120
|
+
next[index] = "";
|
|
121
|
+
onChange?.(next.join(""));
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
const handleFocus = (event) => { const slot = event.currentTarget.parentElement; setRing(current => ({ x: slot.offsetLeft, width: slot.offsetWidth, shown: true, glide: current.shown })); };
|
|
125
|
+
const handleBlur = (event) => { if (!scope.current?.contains(event.relatedTarget))
|
|
126
|
+
setRing(current => ({ ...current, shown: false, glide: false })); };
|
|
127
|
+
const glyphTransition = (index) => reduced ? { duration: motionTokens.duration.instant } : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter], delay: fill.wave ? index * motionTokens.stagger.item : 0 };
|
|
128
|
+
return _jsxs("div", { className: [styles.field, className].filter(Boolean).join(" "), children: [_jsx("span", { className: styles.label, children: label }), _jsxs("div", { ref: scope, className: styles.inputs, role: "group", "aria-label": label, "data-invalid": error ? "true" : undefined, children: [values.map((character, index) => _jsxs("div", { className: styles.slot, children: [_jsx("input", { ref: (node) => { inputRefs.current[index] = node; }, className: styles.input, "aria-label": `${label}, digit ${index + 1} of ${length}`, "aria-describedby": describedBy, "aria-invalid": error ? true : undefined, value: character, "data-filled": character ? "true" : undefined, maxLength: 1, inputMode: inputMode, autoComplete: index === 0 ? "one-time-code" : "off", disabled: disabled, onChange: (event) => handleChange(index, event), onPaste: (event) => handlePaste(index, event), onKeyDown: (event) => handleKeyDown(index, event), onFocus: handleFocus, onBlur: handleBlur }), _jsx(AnimatePresence, { initial: false, children: character ? _jsx(motion.span, { className: styles.glyph, "aria-hidden": "true", initial: reduced ? { opacity: 0 } : { opacity: 0, y: "0.35em", filter: `blur(${motionTokens.blur.soft}px)` }, animate: { opacity: 1, y: 0, filter: "blur(0px)" }, exit: reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, scale: .9, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } }, transition: glyphTransition(index), children: character }, character) : null })] }, `${generatedId}-${index}`)), _jsx(motion.span, { className: styles.ring, "aria-hidden": "true", initial: false, animate: { x: ring.x, width: ring.width, opacity: ring.shown ? 1 : 0, scale: ring.shown ? 1 : .94 }, transition: reduced ? { duration: 0 } : { x: ring.glide ? motionTokens.spring.snappy : { duration: 0 }, width: { duration: 0 }, scale: motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast } } })] }), _jsx(FieldMessage, { id: hintId, text: description, className: styles.description }), _jsx(FieldMessage, { id: errorId, text: error, className: styles.error, alert: true })] });
|
|
129
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/* No row gap: grid tracks clamp a negative margin at zero, so a closed message row would still pay the gap. Each row carries its own space instead. */
|
|
2
|
+
.field { display: grid; min-width: 0; }
|
|
3
|
+
.label { margin-bottom: 8px; color: var(--foreground); font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); }
|
|
4
|
+
.inputs { position: relative; display: flex; width: fit-content; max-width: 100%; gap: 8px; }
|
|
5
|
+
.slot { position: relative; display: grid; width: 42px; height: 48px; min-width: 0; flex: 0 1 42px; }
|
|
6
|
+
/* The native text is transparent (caret stays visible); .glyph draws the digit so it can rise in. */
|
|
7
|
+
.input { width: 100%; height: 100%; min-width: 0; border: 1px solid var(--border); border-radius: var(--radius-control); background: var(--surface); color: transparent; caret-color: var(--accent); font: inherit; font-size: var(--text-lg); font-variant-numeric: tabular-nums; text-align: center; outline: none; transition: border-color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard); }
|
|
8
|
+
@media (hover: hover) and (pointer: fine) { .input:hover:not(:disabled, [data-filled="true"], [aria-invalid="true"]) { border-color: var(--border-strong); background: var(--surface-muted); } }
|
|
9
|
+
.input[data-filled="true"] { border-color: var(--accent); background: var(--accent-subtle); }
|
|
10
|
+
.input:focus-visible { outline: none; }
|
|
11
|
+
.input[aria-invalid="true"] { border-color: var(--danger); background: color-mix(in srgb, var(--danger) 7%, var(--surface)); }
|
|
12
|
+
.input:disabled { cursor: not-allowed; opacity: .48; background: var(--surface-muted); }
|
|
13
|
+
.glyph { position: absolute; inset: 0; display: grid; place-items: center; color: var(--foreground); font-size: var(--text-lg); font-variant-numeric: tabular-nums; pointer-events: none; }
|
|
14
|
+
.input:disabled ~ .glyph { opacity: .48; }
|
|
15
|
+
/* One ring per field. It glides to the focused slot and morphs to the danger color with the error state. */
|
|
16
|
+
.ring { position: absolute; top: 0; left: 0; height: 100%; z-index: 2; border: 1px solid var(--accent-strong); border-radius: var(--radius-control); pointer-events: none; transition: border-color var(--duration-fast) var(--ease-standard); }
|
|
17
|
+
.inputs[data-invalid="true"] .ring { border-color: var(--danger); }
|
|
18
|
+
/* The row's space is padding inside the clipped slot, so the height animates from a true zero. */
|
|
19
|
+
.messageSlot { display: block; overflow: hidden; }
|
|
20
|
+
.description, .error { display: block; padding-top: 8px; font-size: var(--text-xs); line-height: var(--leading-body); }
|
|
21
|
+
.description { color: var(--text-muted); }.error { color: var(--danger); }
|
|
22
|
+
/* Words are measured against this box when they pop out to leave, so it must be the positioned parent. */
|
|
23
|
+
.words { position: relative; display: block; }
|
|
24
|
+
.word { display: inline-block; white-space: pre; }
|
|
25
|
+
.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
|
26
|
+
@media (max-width: 360px) { .inputs { gap: 6px; }.slot { height: 44px; } }
|
|
27
|
+
@media (forced-colors: active) { .ring { display: none; } .input:focus-visible { outline: 2px solid CanvasText; outline-offset: 2px; } .input { color: CanvasText; } .glyph { display: none; } }
|
|
28
|
+
@media (prefers-reduced-motion: reduce) { .input, .ring { transition: none; } }
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useLayoutEffect, useRef, useState } from "react";
|
|
4
|
+
import { ChevronLeft as NavArrowLeft, ChevronRight as NavArrowRight } from "lucide-react";
|
|
5
|
+
import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from "motion/react";
|
|
6
|
+
import { motionTokens } from "../../lib/motion-tokens.js";
|
|
7
|
+
import styles from "./pagination.module.css";
|
|
8
|
+
/** When the five page window shifts, numbers travel like a belt: each one moves by the same number of slots. */
|
|
9
|
+
const slot = 1000 / 9; // One button plus the gap (36px + 4px), as a percentage of the button width.
|
|
10
|
+
const slide = {
|
|
11
|
+
enter: (shift) => ({ opacity: 0, x: `${slot * shift}%` }),
|
|
12
|
+
center: { opacity: 1, x: "0%" },
|
|
13
|
+
exit: (shift) => ({ opacity: 0, x: `${-slot * shift}%`, transition: { x: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } } }),
|
|
14
|
+
};
|
|
15
|
+
export function Pagination({ page, pageCount, onPageChange, label = "Pagination" }) {
|
|
16
|
+
const reduced = useReducedMotion() ?? false;
|
|
17
|
+
const navRef = useRef(null);
|
|
18
|
+
const markX = useMotionValue(0);
|
|
19
|
+
const markY = useMotionValue(0);
|
|
20
|
+
const safePageCount = Math.max(0, Math.floor(pageCount));
|
|
21
|
+
const currentPage = safePageCount > 0 ? Math.min(Math.max(1, Math.floor(page)), safePageCount) : 0;
|
|
22
|
+
const start = safePageCount > 0 ? Math.max(1, Math.min(safePageCount - 4, currentPage - 2)) : 1;
|
|
23
|
+
const [previousStart, setPreviousStart] = useState(start);
|
|
24
|
+
const [shift, setShift] = useState(0);
|
|
25
|
+
if (previousStart !== start) {
|
|
26
|
+
setShift(start - previousStart);
|
|
27
|
+
setPreviousStart(start);
|
|
28
|
+
}
|
|
29
|
+
const visible = Array.from({ length: Math.min(safePageCount, 5) }, (_, index) => start + index);
|
|
30
|
+
// One mark travels to the current page's final slot. Until it has measured once (and during server render),
|
|
31
|
+
// the current button draws its own border so nothing flashes before hydration.
|
|
32
|
+
useLayoutEffect(() => {
|
|
33
|
+
const nav = navRef.current;
|
|
34
|
+
const current = nav?.querySelector("[aria-current='page']");
|
|
35
|
+
if (!nav || !current)
|
|
36
|
+
return;
|
|
37
|
+
const place = (instant) => {
|
|
38
|
+
const x = current.offsetLeft, y = current.offsetTop;
|
|
39
|
+
if (instant || reduced) {
|
|
40
|
+
markX.jump(x);
|
|
41
|
+
markY.jump(y);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
animate(markX, x, motionTokens.spring.morph);
|
|
45
|
+
animate(markY, y, motionTokens.spring.morph);
|
|
46
|
+
};
|
|
47
|
+
place(!("markReady" in nav.dataset));
|
|
48
|
+
nav.dataset.markReady = "";
|
|
49
|
+
let size = `${nav.offsetWidth}x${nav.offsetHeight}`;
|
|
50
|
+
const observer = new ResizeObserver(() => { const next = `${nav.offsetWidth}x${nav.offsetHeight}`; if (next !== size) {
|
|
51
|
+
size = next;
|
|
52
|
+
place(true);
|
|
53
|
+
} });
|
|
54
|
+
observer.observe(nav);
|
|
55
|
+
return () => observer.disconnect();
|
|
56
|
+
}, [currentPage, start, safePageCount, reduced, markX, markY]);
|
|
57
|
+
if (!safePageCount)
|
|
58
|
+
return _jsx("nav", { className: styles.nav, "aria-label": label });
|
|
59
|
+
return _jsxs("nav", { ref: navRef, className: styles.nav, "aria-label": label, children: [_jsx("button", { type: "button", className: styles.step, onClick: () => onPageChange(currentPage - 1), disabled: currentPage <= 1, "aria-label": "Previous page", children: _jsx(NavArrowLeft, { width: 16, height: 16, "aria-hidden": "true" }) }), _jsx(motion.span, { "aria-hidden": "true", className: styles.mark, style: { x: markX, y: markY } }), _jsx(AnimatePresence, { mode: "popLayout", initial: false, custom: shift, children: visible.map(number => _jsx(motion.button, { type: "button", onClick: () => onPageChange(number), "aria-label": `Page ${number}`, "aria-current": currentPage === number ? "page" : undefined, layout: reduced ? false : "position", layoutDependency: start, custom: shift, variants: slide, initial: reduced ? false : "enter", animate: "center", exit: reduced ? undefined : "exit", transition: reduced ? { duration: 0 } : { x: motionTokens.spring.smooth, layout: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] } }, children: number }, number)) }), _jsx("button", { type: "button", className: styles.step, onClick: () => onPageChange(currentPage + 1), disabled: currentPage >= safePageCount, "aria-label": "Next page", children: _jsx(NavArrowRight, { width: 16, height: 16, "aria-hidden": "true" }) })] });
|
|
60
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
.nav { position: relative; display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-tight); }
|
|
2
|
+
.nav button { position: relative; display: grid; width: var(--control-height-sm); height: var(--control-height-sm); place-items: center; border: 1px solid transparent; border-radius: var(--radius-control); background: transparent; color: var(--text-secondary); font: inherit; font-size: var(--text-xs); font-variant-numeric: tabular-nums; cursor: pointer; transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard); }
|
|
3
|
+
.nav button[aria-current="page"] { color: var(--foreground); }
|
|
4
|
+
/* One shared mark travels between pages. Before it has measured (server render), the button draws its own. */
|
|
5
|
+
.mark { position: absolute; top: 0; left: 0; width: var(--control-height-sm); height: var(--control-height-sm); box-sizing: border-box; border: 1px solid var(--border); border-radius: var(--radius-control); background: var(--surface); box-shadow: var(--shadow-resting); opacity: 0; pointer-events: none; }
|
|
6
|
+
.nav[data-mark-ready] .mark { opacity: 1; }
|
|
7
|
+
.nav:not([data-mark-ready]) button[aria-current="page"] { border-color: var(--border); background: var(--surface); box-shadow: var(--shadow-resting); }
|
|
8
|
+
/* Only the arrows press in. Page numbers move with Motion, so they keep CSS off transform and opacity. */
|
|
9
|
+
.nav .step { transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), opacity var(--duration-fast) var(--ease-standard), transform var(--duration-spring) var(--ease-spring); }
|
|
10
|
+
.nav .step:active:not(:disabled) { transform: scale(.96); transition-duration: var(--duration-fast), var(--duration-fast), var(--duration-fast), 100ms; transition-timing-function: var(--ease-standard), var(--ease-standard), var(--ease-standard), ease-out; }
|
|
11
|
+
.nav button:disabled { opacity: .4; cursor: not-allowed; }
|
|
12
|
+
@media (hover: hover) and (pointer: fine) {
|
|
13
|
+
.nav button:hover:not(:disabled) { background: var(--surface-muted); color: var(--foreground); }
|
|
14
|
+
.nav[data-mark-ready] button[aria-current="page"]:hover { background: transparent; }
|
|
15
|
+
}
|
|
16
|
+
@media (prefers-reduced-motion: reduce) { .nav button, .nav .step { transition: none; } .nav .step:active:not(:disabled) { transform: none; } }
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { InputHTMLAttributes } from "react";
|
|
2
|
+
/** The toggle's names. Both have English defaults; pass the ones you translate. */
|
|
3
|
+
export interface PasswordFieldMessages {
|
|
4
|
+
showPassword: string;
|
|
5
|
+
hidePassword: string;
|
|
6
|
+
}
|
|
7
|
+
export declare const defaultPasswordFieldMessages: PasswordFieldMessages;
|
|
8
|
+
export interface PasswordFieldProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
|
|
9
|
+
label: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
/** Translated or reworded toggle names. */
|
|
12
|
+
messages?: Partial<PasswordFieldMessages>;
|
|
13
|
+
}
|
|
14
|
+
export declare const PasswordField: import("react").ForwardRefExoticComponent<PasswordFieldProps & import("react").RefAttributes<HTMLInputElement>>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { forwardRef, useEffect, useId, useRef, useState } from "react";
|
|
4
|
+
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
|
5
|
+
import { motionTokens } from "../../lib/motion-tokens.js";
|
|
6
|
+
import styles from "./password-field.module.css";
|
|
7
|
+
export const defaultPasswordFieldMessages = { showPassword: "Show password", hidePassword: "Hide password" };
|
|
8
|
+
/** One eye that a slash draws across, cutting the outline beneath it, instead of swapping two icons. */
|
|
9
|
+
function EyeMorph({ slashed }) {
|
|
10
|
+
const reduced = useReducedMotion();
|
|
11
|
+
const maskId = `eye-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
|
|
12
|
+
const slash = { pathLength: slashed ? 1 : 0, opacity: slashed ? 1 : 0 };
|
|
13
|
+
const transition = reduced ? { duration: 0 } : { pathLength: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.standard] }, opacity: { duration: motionTokens.duration.instant } };
|
|
14
|
+
return _jsxs("svg", { width: 18, height: 18, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.75, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [_jsxs("mask", { id: maskId, maskUnits: "userSpaceOnUse", x: "0", y: "0", width: "24", height: "24", children: [_jsx("rect", { width: "24", height: "24", fill: "white", stroke: "none" }), _jsx(motion.path, { d: "M2 2l20 20", stroke: "black", strokeWidth: 5, initial: false, animate: slash, transition: transition })] }), _jsxs("g", { mask: `url(#${maskId})`, children: [_jsx("path", { d: "M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" }), _jsx("circle", { cx: "12", cy: "12", r: "3" })] }), _jsx(motion.path, { d: "M2 2l20 20", initial: false, animate: slash, transition: transition })] });
|
|
15
|
+
}
|
|
16
|
+
/** Changed words rise in and unblur while unchanged words hold still. Assistive tech reads the plain copy. */
|
|
17
|
+
function MotionText({ text }) {
|
|
18
|
+
const reduced = useReducedMotion();
|
|
19
|
+
const words = text.split(" ");
|
|
20
|
+
return _jsxs(_Fragment, { children: [_jsx("span", { className: styles.srOnly, children: text }), _jsx("span", { className: styles.words, "aria-hidden": "true", children: _jsx(AnimatePresence, { initial: false, mode: "popLayout", children: words.map((word, index) => _jsx(motion.span, { className: styles.word, initial: reduced ? { opacity: 0 } : { opacity: 0, y: "0.35em", filter: `blur(${motionTokens.blur.soft}px)` }, animate: { opacity: 1, y: 0, filter: "blur(0px)" }, exit: reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: "-0.35em", filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.exit, ease: [...motionTokens.ease.exit] } }, transition: reduced ? { duration: motionTokens.duration.instant } : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }, children: index < words.length - 1 ? `${word} ` : word }, `${index}:${word}`)) }) })] });
|
|
21
|
+
}
|
|
22
|
+
/** Helper and error copy: the row opens its height on a spring, then the words settle in. */
|
|
23
|
+
function FieldMessage({ id, text, className, alert }) {
|
|
24
|
+
return _jsx(AnimatePresence, { initial: false, children: text ? _jsx(MessageRow, { id: id, text: text, className: className, alert: alert }, "message") : null });
|
|
25
|
+
}
|
|
26
|
+
/** The row tracks the measured copy, so a longer message that wraps opens its next line instead of snapping. */
|
|
27
|
+
function MessageRow({ id, text, className, alert }) {
|
|
28
|
+
const reduced = useReducedMotion();
|
|
29
|
+
const copyRef = useRef(null);
|
|
30
|
+
const [height, setHeight] = useState("auto");
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
const node = copyRef.current;
|
|
33
|
+
if (!node || typeof ResizeObserver === "undefined")
|
|
34
|
+
return;
|
|
35
|
+
const observer = new ResizeObserver(([entry]) => { if (entry)
|
|
36
|
+
setHeight(entry.borderBoxSize?.[0]?.blockSize ?? node.offsetHeight); });
|
|
37
|
+
observer.observe(node);
|
|
38
|
+
return () => observer.disconnect();
|
|
39
|
+
}, []);
|
|
40
|
+
return _jsx(motion.span, { className: styles.messageSlot, initial: { height: 0, opacity: 0 }, animate: { height, opacity: 1 }, exit: { height: 0, opacity: 0, transition: reduced ? { duration: 0 } : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.instant } } }, transition: reduced ? { duration: 0 } : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.fast } }, children: _jsx(motion.span, { ref: copyRef, id: id, className: className, role: alert ? "alert" : undefined, initial: reduced ? false : { y: "0.35em", filter: `blur(${motionTokens.blur.soft}px)` }, animate: { y: 0, filter: "blur(0px)" }, transition: { duration: reduced ? 0 : motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }, children: _jsx(MotionText, { text: text }) }) });
|
|
41
|
+
}
|
|
42
|
+
export const PasswordField = forwardRef(function PasswordField({ label, description, messages: messagesProp, id, className, ...props }, ref) {
|
|
43
|
+
const messages = { ...defaultPasswordFieldMessages, ...messagesProp };
|
|
44
|
+
const generated = useId();
|
|
45
|
+
const controlId = id ?? generated;
|
|
46
|
+
const [visible, setVisible] = useState(false);
|
|
47
|
+
const [toggled, setToggled] = useState(false);
|
|
48
|
+
const hintId = description ? `${controlId}-description` : undefined;
|
|
49
|
+
// data-reveal only appears after the first toggle, so the value resolves on each change but never on mount.
|
|
50
|
+
return _jsxs("div", { className: styles.field, children: [_jsx("label", { htmlFor: controlId, children: label }), _jsxs("div", { className: styles.shell, children: [_jsx("input", { ...props, ref: ref, id: controlId, type: visible ? "text" : "password", "data-reveal": toggled ? (visible ? "shown" : "hidden") : undefined, "aria-describedby": [props["aria-describedby"], hintId].filter(Boolean).join(" ") || undefined, className: [styles.input, className].filter(Boolean).join(" ") }), _jsx("button", { type: "button", onClick: () => { setVisible(current => !current); setToggled(true); }, "aria-label": visible ? messages.hidePassword : messages.showPassword, "aria-pressed": visible, children: _jsx(EyeMorph, { slashed: visible }) })] }), _jsx(FieldMessage, { id: hintId, text: description, className: styles.hint })] });
|
|
51
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/* No row gap: grid tracks clamp a negative margin at zero, so a closed message row would still pay the gap. Each row carries its own space instead. */
|
|
2
|
+
.field { display: grid; min-width: 0; }
|
|
3
|
+
.field label { margin-bottom: var(--space-related); font-size: var(--text-sm); font-weight: 500; }
|
|
4
|
+
/* Focus shows as a darker border only; the shell never changes size. The toggle sits with an even inset on top, bottom and right. */
|
|
5
|
+
.shell { display: flex; min-height: var(--control-height-md); align-items: center; border: 1px solid var(--border-strong); border-radius: var(--radius-control); padding: 0 calc((var(--control-height-md) - 2px - 30px) / 2) 0 var(--space-group); background: var(--surface); transition: border-color var(--duration-fast) var(--ease-standard); }
|
|
6
|
+
@media (hover: hover) and (pointer: fine) { .shell:hover:not(:focus-within) { border-color: var(--foreground); } }
|
|
7
|
+
.shell:focus-within { border-color: var(--foreground); }
|
|
8
|
+
.shell:has(.input[aria-invalid="true"]) { border-color: var(--danger); }
|
|
9
|
+
.input { width: 100%; min-width: 0; border: 0; background: transparent; color: var(--foreground); font: inherit; font-size: var(--text-sm); line-height: var(--leading-body); outline: 0; }
|
|
10
|
+
.input::placeholder { color: var(--text-muted); }
|
|
11
|
+
/* Switching between dots and characters resolves in place instead of snapping. Swapping the keyframe name restarts it. */
|
|
12
|
+
.input[data-reveal="shown"] { animation: resolve-shown var(--duration-standard) var(--ease-enter); }
|
|
13
|
+
.input[data-reveal="hidden"] { animation: resolve-hidden var(--duration-standard) var(--ease-enter); }
|
|
14
|
+
@keyframes resolve-shown { from { opacity: .35; filter: blur(2px); } }
|
|
15
|
+
@keyframes resolve-hidden { from { opacity: .35; filter: blur(2px); } }
|
|
16
|
+
.shell button { display: grid; width: 30px; height: 30px; flex: 0 0 auto; place-items: center; border: 0; border-radius: var(--radius-control); background: transparent; color: var(--text-muted); transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), transform var(--duration-spring) var(--ease-spring); }
|
|
17
|
+
@media (hover: hover) and (pointer: fine) { .shell button:hover { background: var(--surface-muted); color: var(--foreground); } }
|
|
18
|
+
.shell button:active { transform: scale(.96); transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), transform 100ms var(--ease-standard); }
|
|
19
|
+
.shell button[aria-pressed="true"] { color: var(--foreground); }
|
|
20
|
+
/* The row's space is padding inside the clipped slot, so the height animates from a true zero. */
|
|
21
|
+
.messageSlot { display: block; overflow: hidden; }
|
|
22
|
+
.hint { display: block; padding-top: var(--space-related); color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); }
|
|
23
|
+
/* Words are measured against this box when they pop out to leave, so it must be the positioned parent. */
|
|
24
|
+
.words { position: relative; display: block; }
|
|
25
|
+
.word { display: inline-block; white-space: pre; }
|
|
26
|
+
.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
|
27
|
+
@media (prefers-reduced-motion: reduce) { .shell, .shell button { transition: none; } .shell button:active { transform: none; } .input[data-reveal] { animation: none; } }
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { InputHTMLAttributes } from "react";
|
|
2
|
+
export interface PasswordRule {
|
|
3
|
+
id: string;
|
|
4
|
+
label: string;
|
|
5
|
+
test: (password: string) => boolean;
|
|
6
|
+
/** Characters still missing. Shown as a rolling count beside the rule while it is unmet. */
|
|
7
|
+
remaining?: (password: string) => number;
|
|
8
|
+
}
|
|
9
|
+
export interface PasswordStrengthResult {
|
|
10
|
+
level: 0 | 1 | 2 | 3 | 4;
|
|
11
|
+
label: string;
|
|
12
|
+
met: string[];
|
|
13
|
+
/** True while the password is shorter than the short length, so the meter holds at its first step. */
|
|
14
|
+
short?: boolean;
|
|
15
|
+
}
|
|
16
|
+
/** The strength words, one per meter step. Override them to translate the meter. */
|
|
17
|
+
export interface PasswordStrengthLevels {
|
|
18
|
+
tooShort: string;
|
|
19
|
+
weak: string;
|
|
20
|
+
fair: string;
|
|
21
|
+
good: string;
|
|
22
|
+
strong: string;
|
|
23
|
+
}
|
|
24
|
+
/** Every user-facing string in PasswordStrength. All have English defaults; pass only the ones you change. */
|
|
25
|
+
export interface PasswordStrengthMessages {
|
|
26
|
+
levels: PasswordStrengthLevels;
|
|
27
|
+
/** Name of the show password toggle. It is a pressed toggle (aria-pressed), so the name stays the same in both states. */
|
|
28
|
+
showPassword: string;
|
|
29
|
+
/** Accessible name of the strength meter. */
|
|
30
|
+
meterLabel: string;
|
|
31
|
+
/** Meter value text while the field is empty. */
|
|
32
|
+
noPassword: string;
|
|
33
|
+
/** Accessible name of the requirements list. */
|
|
34
|
+
rulesLabel: string;
|
|
35
|
+
/** Read after a rule's label for screen readers, including any leading punctuation. */
|
|
36
|
+
ruleMet: string;
|
|
37
|
+
ruleNotMet: string;
|
|
38
|
+
/** Words around the rolling count beside an unmet rule, as in "4 more". */
|
|
39
|
+
remainingPrefix: string;
|
|
40
|
+
remainingSuffix: string;
|
|
41
|
+
/** The polite live-region summary read after each change. */
|
|
42
|
+
summary: (result: {
|
|
43
|
+
label: string;
|
|
44
|
+
met: number;
|
|
45
|
+
total: number;
|
|
46
|
+
}) => string;
|
|
47
|
+
}
|
|
48
|
+
export declare const defaultPasswordStrengthMessages: PasswordStrengthMessages;
|
|
49
|
+
/** Partial messages, with partial strength words, for the `messages` prop. */
|
|
50
|
+
export type PasswordStrengthMessagesInput = Partial<Omit<PasswordStrengthMessages, "levels">> & {
|
|
51
|
+
levels?: Partial<PasswordStrengthLevels>;
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* A new password field that shows how strong the password is while it is typed: four segments fill and change tone,
|
|
55
|
+
* each rule checks off with a drawn tick, and the strength word morphs in place. Use it when creating or changing a password;
|
|
56
|
+
* use a plain password field for sign in. Scoring happens on the device, nothing is sent anywhere.
|
|
57
|
+
*/
|
|
58
|
+
export interface PasswordStrengthProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "defaultValue" | "children"> {
|
|
59
|
+
label: string;
|
|
60
|
+
value?: string;
|
|
61
|
+
defaultValue?: string;
|
|
62
|
+
onValueChange?: (value: string, strength: PasswordStrengthResult) => void;
|
|
63
|
+
/** Rules to check. Strength is the share of rules met, spread over four steps. */
|
|
64
|
+
rules?: PasswordRule[];
|
|
65
|
+
/** Error copy tied to the field. The field shakes once each time a new error appears. */
|
|
66
|
+
error?: string;
|
|
67
|
+
revealed?: boolean;
|
|
68
|
+
onRevealedChange?: (revealed: boolean) => void;
|
|
69
|
+
/** Translated or reworded copy. Every string has an English default; see PasswordStrengthMessages. */
|
|
70
|
+
messages?: PasswordStrengthMessagesInput;
|
|
71
|
+
/** Below this many characters the meter holds at its first step and reads "Too short". Defaults to 8. */
|
|
72
|
+
shortLength?: number;
|
|
73
|
+
/** How characters are counted for shortLength. Defaults to code points, so an emoji counts once; pass `password => password.length` to count UTF-16 units like a server might. */
|
|
74
|
+
countCharacters?: (password: string) => number;
|
|
75
|
+
}
|
|
76
|
+
/** Counts code points, so an emoji or other astral character counts as one. */
|
|
77
|
+
export declare const countCodePoints: (password: string) => number;
|
|
78
|
+
/** Labels for the built-in rules. `length` receives the minimum length. */
|
|
79
|
+
export interface PasswordRuleLabels {
|
|
80
|
+
length: (minLength: number) => string;
|
|
81
|
+
case: string;
|
|
82
|
+
number: string;
|
|
83
|
+
symbol: string;
|
|
84
|
+
}
|
|
85
|
+
export interface PasswordRuleOptions {
|
|
86
|
+
/** Characters the length rule asks for. Defaults to 12. */
|
|
87
|
+
minLength?: number;
|
|
88
|
+
/** Translated or reworded rule labels. */
|
|
89
|
+
labels?: Partial<PasswordRuleLabels>;
|
|
90
|
+
/** How the length rule counts characters. Defaults to code points. */
|
|
91
|
+
countCharacters?: (password: string) => number;
|
|
92
|
+
}
|
|
93
|
+
/** The built-in rules (length, mixed case, number, symbol) with your own minimum length, labels and character count. */
|
|
94
|
+
export declare function createPasswordRules({ minLength, labels, countCharacters }?: PasswordRuleOptions): PasswordRule[];
|
|
95
|
+
export declare const defaultPasswordRules: PasswordRule[];
|
|
96
|
+
export interface EstimateStrengthOptions {
|
|
97
|
+
/** Below this many characters a password stays at the first step, however varied it is. Defaults to 8. */
|
|
98
|
+
shortLength?: number;
|
|
99
|
+
/** Strength words for the result's label. */
|
|
100
|
+
levels?: Partial<PasswordStrengthLevels>;
|
|
101
|
+
/** How characters are counted for shortLength. Defaults to code points. */
|
|
102
|
+
countCharacters?: (password: string) => number;
|
|
103
|
+
}
|
|
104
|
+
/** Scores a password on the device: one step per share of rules met, capped at the first step while it is shorter than the short length (8 by default). */
|
|
105
|
+
export declare function estimateStrength(password: string, rules?: PasswordRule[], { shortLength, levels, countCharacters }?: EstimateStrengthOptions): PasswordStrengthResult;
|
|
106
|
+
export declare const PasswordStrength: import("react").ForwardRefExoticComponent<PasswordStrengthProps & import("react").RefAttributes<HTMLInputElement>>;
|
|
107
|
+
export default PasswordStrength;
|