@delacour/react-native-ui 0.1.0-alpha.4 → 0.1.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/README.md +10 -6
- package/package.json +48 -4
- package/src/components/accordion/AGENTS.md +21 -9
- package/src/components/accordion/accordion-content.tsx +7 -4
- package/src/components/accordion/accordion-item.tsx +15 -13
- package/src/components/accordion/accordion.context.tsx +11 -5
- package/src/components/accordion/accordion.variants.test.ts +64 -3
- package/src/components/accordion/accordion.variants.ts +33 -9
- package/src/components/alert/AGENTS.md +102 -0
- package/src/components/alert/alert-action.tsx +17 -0
- package/src/components/alert/alert-close-button.tsx +50 -0
- package/src/components/alert/alert-content.tsx +15 -0
- package/src/components/alert/alert-description.tsx +16 -0
- package/src/components/alert/alert-glyphs.ts +25 -0
- package/src/components/alert/alert-indicator.tsx +40 -0
- package/src/components/alert/alert-title.tsx +18 -0
- package/src/components/alert/alert.context.tsx +69 -0
- package/src/components/alert/alert.tsx +160 -0
- package/src/components/alert/alert.types.ts +10 -0
- package/src/components/alert/alert.variants.test.ts +218 -0
- package/src/components/alert/alert.variants.ts +180 -0
- package/src/components/alert/index.ts +23 -0
- package/src/components/avatar/AGENTS.md +106 -0
- package/src/components/avatar/avatar-badge.tsx +51 -0
- package/src/components/avatar/avatar-group.tsx +139 -0
- package/src/components/avatar/avatar.context.tsx +93 -0
- package/src/components/avatar/avatar.tsx +201 -0
- package/src/components/avatar/avatar.utils.test.ts +174 -0
- package/src/components/avatar/avatar.utils.ts +141 -0
- package/src/components/avatar/avatar.variants.test.ts +251 -0
- package/src/components/avatar/avatar.variants.ts +270 -0
- package/src/components/avatar/index.ts +39 -0
- package/src/components/badge/AGENTS.md +5 -0
- package/src/components/badge/badge.tsx +6 -1
- package/src/components/bottom-sheet/AGENTS.md +186 -231
- package/src/components/bottom-sheet/bottom-sheet-background.tsx +31 -22
- package/src/components/bottom-sheet/bottom-sheet-close.tsx +23 -30
- package/src/components/bottom-sheet/bottom-sheet-container.tsx +54 -208
- package/src/components/bottom-sheet/bottom-sheet-content.tsx +25 -48
- package/src/components/bottom-sheet/bottom-sheet-description.tsx +8 -2
- package/src/components/bottom-sheet/bottom-sheet-flat-list.tsx +65 -0
- package/src/components/bottom-sheet/bottom-sheet-footer.tsx +53 -122
- package/src/components/bottom-sheet/bottom-sheet-handle.tsx +31 -26
- package/src/components/bottom-sheet/bottom-sheet-legend-list.tsx +69 -0
- package/src/components/bottom-sheet/bottom-sheet-overlay.tsx +25 -147
- package/src/components/bottom-sheet/bottom-sheet-provider.tsx +42 -0
- package/src/components/bottom-sheet/bottom-sheet-scroll-view.tsx +38 -62
- package/src/components/bottom-sheet/bottom-sheet-section-list.tsx +58 -0
- package/src/components/bottom-sheet/bottom-sheet-steps.tsx +88 -0
- package/src/components/bottom-sheet/bottom-sheet-text-input.tsx +44 -0
- package/src/components/bottom-sheet/bottom-sheet-title.tsx +12 -8
- package/src/components/bottom-sheet/bottom-sheet-trigger.tsx +19 -37
- package/src/components/bottom-sheet/bottom-sheet.tsx +113 -60
- package/src/components/bottom-sheet/bottom-sheet.variants.test.ts +47 -87
- package/src/components/bottom-sheet/bottom-sheet.variants.ts +50 -126
- package/src/components/bottom-sheet/index.ts +39 -17
- package/src/components/bottom-sheet/use-bottom-sheet-input.ts +16 -115
- package/src/components/button/AGENTS.md +45 -11
- package/src/components/button/button-group.tsx +44 -8
- package/src/components/button/button.context.tsx +10 -0
- package/src/components/button/button.tsx +2 -1
- package/src/components/button/button.variants.test.ts +154 -0
- package/src/components/button/button.variants.ts +71 -6
- package/src/components/button/index.ts +2 -0
- package/src/components/calendar/AGENTS.md +142 -0
- package/src/components/calendar/calendar-caption.tsx +116 -0
- package/src/components/calendar/calendar-day.tsx +103 -0
- package/src/components/calendar/calendar-grid.tsx +183 -0
- package/src/components/calendar/calendar-header.tsx +34 -0
- package/src/components/calendar/calendar-nav.tsx +51 -0
- package/src/components/calendar/calendar-picker.tsx +128 -0
- package/src/components/calendar/calendar-weekdays.tsx +48 -0
- package/src/components/calendar/calendar.context.tsx +108 -0
- package/src/components/calendar/calendar.date.test.ts +456 -0
- package/src/components/calendar/calendar.date.ts +584 -0
- package/src/components/calendar/calendar.tsx +389 -0
- package/src/components/calendar/calendar.types.ts +87 -0
- package/src/components/calendar/calendar.variants.test.ts +363 -0
- package/src/components/calendar/calendar.variants.ts +426 -0
- package/src/components/calendar/index.ts +96 -0
- package/src/components/card/AGENTS.md +88 -0
- package/src/components/card/card-action.tsx +16 -0
- package/src/components/card/card-content.tsx +12 -0
- package/src/components/card/card-description.tsx +12 -0
- package/src/components/card/card-footer.tsx +30 -0
- package/src/components/card/card-header.tsx +45 -0
- package/src/components/card/card-title.tsx +25 -0
- package/src/components/card/card.context.tsx +64 -0
- package/src/components/card/card.tsx +94 -0
- package/src/components/card/card.types.ts +13 -0
- package/src/components/card/card.variants.test.ts +240 -0
- package/src/components/card/card.variants.ts +177 -0
- package/src/components/card/index.ts +15 -0
- package/src/components/chart/AGENTS.md +8 -0
- package/src/components/chart/chart.tsx +8 -1
- package/src/components/chip/AGENTS.md +113 -0
- package/src/components/chip/chip-close-button.tsx +48 -0
- package/src/components/chip/chip-end-content.tsx +15 -0
- package/src/components/chip/chip-label.tsx +21 -0
- package/src/components/chip/chip-start-content.tsx +16 -0
- package/src/components/chip/chip.context.tsx +66 -0
- package/src/components/chip/chip.tsx +268 -0
- package/src/components/chip/chip.types.ts +10 -0
- package/src/components/chip/chip.variants.test.ts +335 -0
- package/src/components/chip/chip.variants.ts +321 -0
- package/src/components/chip/index.ts +24 -0
- package/src/components/collapsible/AGENTS.md +97 -0
- package/src/components/collapsible/collapsible-content.tsx +97 -0
- package/src/components/collapsible/collapsible-description.tsx +17 -0
- package/src/components/collapsible/collapsible-indicator.tsx +92 -0
- package/src/components/collapsible/collapsible-title.tsx +17 -0
- package/src/components/collapsible/collapsible-trigger.tsx +133 -0
- package/src/components/collapsible/collapsible.context.tsx +91 -0
- package/src/components/collapsible/collapsible.tsx +167 -0
- package/src/components/collapsible/collapsible.types.ts +9 -0
- package/src/components/collapsible/collapsible.variants.test.ts +338 -0
- package/src/components/collapsible/collapsible.variants.ts +228 -0
- package/src/components/collapsible/index.ts +32 -0
- package/src/components/context-menu/AGENTS.md +103 -0
- package/src/components/context-menu/context-menu-content.tsx +64 -0
- package/src/components/context-menu/context-menu-item.tsx +22 -0
- package/src/components/context-menu/context-menu-preview.tsx +60 -0
- package/src/components/context-menu/context-menu-trigger.tsx +160 -0
- package/src/components/context-menu/context-menu.context.tsx +91 -0
- package/src/components/context-menu/context-menu.tsx +90 -0
- package/src/components/context-menu/context-menu.variants.test.ts +266 -0
- package/src/components/context-menu/context-menu.variants.ts +147 -0
- package/src/components/context-menu/index.ts +18 -0
- package/src/components/dialog/AGENTS.md +97 -0
- package/src/components/dialog/dialog-body.tsx +21 -0
- package/src/components/dialog/dialog-close.tsx +82 -0
- package/src/components/dialog/dialog-content.tsx +153 -0
- package/src/components/dialog/dialog-description.tsx +23 -0
- package/src/components/dialog/dialog-footer.tsx +32 -0
- package/src/components/dialog/dialog-header.tsx +20 -0
- package/src/components/dialog/dialog-title.tsx +42 -0
- package/src/components/dialog/dialog-trigger.tsx +54 -0
- package/src/components/dialog/dialog.context.tsx +90 -0
- package/src/components/dialog/dialog.tsx +119 -0
- package/src/components/dialog/dialog.variants.test.ts +224 -0
- package/src/components/dialog/dialog.variants.ts +173 -0
- package/src/components/dialog/index.ts +30 -0
- package/src/components/drawer/AGENTS.md +123 -0
- package/src/components/drawer/drawer-body.tsx +46 -0
- package/src/components/drawer/drawer-close.tsx +76 -0
- package/src/components/drawer/drawer-content.tsx +186 -0
- package/src/components/drawer/drawer-description.tsx +23 -0
- package/src/components/drawer/drawer-footer.tsx +21 -0
- package/src/components/drawer/drawer-header.tsx +42 -0
- package/src/components/drawer/drawer-title.tsx +35 -0
- package/src/components/drawer/drawer-trigger.tsx +54 -0
- package/src/components/drawer/drawer.context.tsx +114 -0
- package/src/components/drawer/drawer.tsx +119 -0
- package/src/components/drawer/drawer.variants.test.ts +392 -0
- package/src/components/drawer/drawer.variants.ts +244 -0
- package/src/components/drawer/index.ts +41 -0
- package/src/components/drawer/use-drawer-pan.ts +108 -0
- package/src/components/empty-state/AGENTS.md +90 -0
- package/src/components/empty-state/empty-state-content.tsx +17 -0
- package/src/components/empty-state/empty-state-description.tsx +12 -0
- package/src/components/empty-state/empty-state-header.tsx +17 -0
- package/src/components/empty-state/empty-state-media.tsx +56 -0
- package/src/components/empty-state/empty-state-title.tsx +18 -0
- package/src/components/empty-state/empty-state.context.tsx +65 -0
- package/src/components/empty-state/empty-state.tsx +84 -0
- package/src/components/empty-state/empty-state.types.ts +14 -0
- package/src/components/empty-state/empty-state.variants.test.ts +245 -0
- package/src/components/empty-state/empty-state.variants.ts +132 -0
- package/src/components/empty-state/index.ts +20 -0
- package/src/components/fab/AGENTS.md +119 -0
- package/src/components/fab/fab-action.tsx +118 -0
- package/src/components/fab/fab-group.tsx +214 -0
- package/src/components/fab/fab-label.tsx +18 -0
- package/src/components/fab/fab.context.tsx +125 -0
- package/src/components/fab/fab.tsx +156 -0
- package/src/components/fab/fab.types.ts +17 -0
- package/src/components/fab/fab.variants.test.ts +274 -0
- package/src/components/fab/fab.variants.ts +234 -0
- package/src/components/fab/index.ts +37 -0
- package/src/components/feedback/AGENTS.md +95 -0
- package/src/components/feedback/feedback-action.tsx +16 -0
- package/src/components/feedback/feedback-cancel.tsx +34 -0
- package/src/components/feedback/feedback-close.tsx +21 -0
- package/src/components/feedback/feedback-content.tsx +24 -0
- package/src/components/feedback/feedback-field.tsx +70 -0
- package/src/components/feedback/feedback-footer.tsx +21 -0
- package/src/components/feedback/feedback-panel.tsx +54 -0
- package/src/components/feedback/feedback-submit.tsx +74 -0
- package/src/components/feedback/feedback-title.tsx +34 -0
- package/src/components/feedback/feedback-trigger.tsx +20 -0
- package/src/components/feedback/feedback.context.tsx +73 -0
- package/src/components/feedback/feedback.tsx +135 -0
- package/src/components/feedback/feedback.variants.test.ts +185 -0
- package/src/components/feedback/feedback.variants.ts +128 -0
- package/src/components/feedback/index.ts +24 -0
- package/src/components/field/AGENTS.md +19 -0
- package/src/components/field/field-label.tsx +19 -5
- package/src/components/field/field.context.tsx +17 -0
- package/src/components/field/field.tsx +15 -2
- package/src/components/field/field.variants.test.ts +24 -0
- package/src/components/field/field.variants.ts +20 -0
- package/src/components/field/index.ts +1 -0
- package/src/components/item/AGENTS.md +92 -0
- package/src/components/item/index.ts +25 -0
- package/src/components/item/item-actions.tsx +28 -0
- package/src/components/item/item-content.tsx +15 -0
- package/src/components/item/item-description.tsx +12 -0
- package/src/components/item/item-footer.tsx +13 -0
- package/src/components/item/item-group.tsx +32 -0
- package/src/components/item/item-header.tsx +13 -0
- package/src/components/item/item-media.tsx +37 -0
- package/src/components/item/item-separator.tsx +13 -0
- package/src/components/item/item-title.tsx +17 -0
- package/src/components/item/item.context.tsx +66 -0
- package/src/components/item/item.tsx +218 -0
- package/src/components/item/item.types.ts +12 -0
- package/src/components/item/item.variants.test.ts +263 -0
- package/src/components/item/item.variants.ts +200 -0
- package/src/components/kpi/AGENTS.md +125 -0
- package/src/components/kpi/index.ts +45 -0
- package/src/components/kpi/kpi-action.tsx +12 -0
- package/src/components/kpi/kpi-content.tsx +35 -0
- package/src/components/kpi/kpi-footer.tsx +52 -0
- package/src/components/kpi/kpi-group.tsx +70 -0
- package/src/components/kpi/kpi-header.tsx +25 -0
- package/src/components/kpi/kpi-icon.tsx +28 -0
- package/src/components/kpi/kpi-sparkline.tsx +145 -0
- package/src/components/kpi/kpi-stat.tsx +20 -0
- package/src/components/kpi/kpi-title.tsx +19 -0
- package/src/components/kpi/kpi-trend.tsx +105 -0
- package/src/components/kpi/kpi-value.tsx +32 -0
- package/src/components/kpi/kpi.context.tsx +111 -0
- package/src/components/kpi/kpi.tsx +154 -0
- package/src/components/kpi/kpi.types.ts +9 -0
- package/src/components/kpi/kpi.variants.test.ts +428 -0
- package/src/components/kpi/kpi.variants.ts +347 -0
- package/src/components/label/AGENTS.md +89 -0
- package/src/components/label/index.ts +10 -0
- package/src/components/label/label.tsx +97 -0
- package/src/components/label/label.variants.test.ts +176 -0
- package/src/components/label/label.variants.ts +123 -0
- package/src/components/menu/AGENTS.md +176 -0
- package/src/components/menu/index.ts +44 -0
- package/src/components/menu/menu-background.tsx +17 -0
- package/src/components/menu/menu-checkbox-item.tsx +57 -0
- package/src/components/menu/menu-content.tsx +303 -0
- package/src/components/menu/menu-item.tsx +162 -0
- package/src/components/menu/menu-label.tsx +14 -0
- package/src/components/menu/menu-radio-group.tsx +50 -0
- package/src/components/menu/menu-radio-item.tsx +57 -0
- package/src/components/menu/menu-separator.tsx +11 -0
- package/src/components/menu/menu-sub-content.tsx +72 -0
- package/src/components/menu/menu-sub-trigger.tsx +50 -0
- package/src/components/menu/menu-sub.tsx +75 -0
- package/src/components/menu/menu-trigger.tsx +57 -0
- package/src/components/menu/menu.context.tsx +214 -0
- package/src/components/menu/menu.tsx +88 -0
- package/src/components/menu/menu.types.ts +33 -0
- package/src/components/menu/menu.variants.test.ts +400 -0
- package/src/components/menu/menu.variants.ts +353 -0
- package/src/components/meter/AGENTS.md +135 -0
- package/src/components/meter/index.ts +43 -0
- package/src/components/meter/meter-fill.tsx +19 -0
- package/src/components/meter/meter-header.tsx +17 -0
- package/src/components/meter/meter-label.tsx +18 -0
- package/src/components/meter/meter-output.tsx +42 -0
- package/src/components/meter/meter-segments.tsx +83 -0
- package/src/components/meter/meter-track.tsx +16 -0
- package/src/components/meter/meter.context.tsx +62 -0
- package/src/components/meter/meter.tsx +286 -0
- package/src/components/meter/meter.types.ts +30 -0
- package/src/components/meter/meter.variants.test.ts +430 -0
- package/src/components/meter/meter.variants.ts +347 -0
- package/src/components/overlay/AGENTS.md +89 -0
- package/src/components/overlay/index.ts +27 -0
- package/src/components/overlay/overlay-portal.tsx +84 -0
- package/src/components/overlay/overlay-presence.test.ts +46 -0
- package/src/components/overlay/overlay-presence.ts +49 -0
- package/src/components/overlay/overlay-registry.test.ts +96 -0
- package/src/components/overlay/overlay-registry.ts +99 -0
- package/src/components/overlay/overlay-scrim.tsx +49 -0
- package/src/components/overlay/overlay.context.tsx +48 -0
- package/src/components/overlay/overlay.tsx +118 -0
- package/src/components/overlay/overlay.variants.test.ts +37 -0
- package/src/components/overlay/overlay.variants.ts +33 -0
- package/src/components/overlay/use-overlay-back-handler.ts +34 -0
- package/src/components/overlay/use-overlay-presence.ts +87 -0
- package/src/components/popover/AGENTS.md +138 -0
- package/src/components/popover/index.ts +48 -0
- package/src/components/popover/popover-anchor.tsx +49 -0
- package/src/components/popover/popover-arrow.tsx +89 -0
- package/src/components/popover/popover-close.tsx +73 -0
- package/src/components/popover/popover-content.tsx +235 -0
- package/src/components/popover/popover-description.tsx +18 -0
- package/src/components/popover/popover-title.tsx +33 -0
- package/src/components/popover/popover-trigger.tsx +56 -0
- package/src/components/popover/popover.context.tsx +71 -0
- package/src/components/popover/popover.position.test.ts +354 -0
- package/src/components/popover/popover.position.ts +286 -0
- package/src/components/popover/popover.tsx +123 -0
- package/src/components/popover/popover.variants.test.ts +135 -0
- package/src/components/popover/popover.variants.ts +85 -0
- package/src/components/popover/use-anchor-measure.ts +76 -0
- package/src/components/popover/use-anchored-content.ts +219 -0
- package/src/components/pressable/AGENTS.md +11 -1
- package/src/components/pressable/pressable.tsx +7 -2
- package/src/components/pressable/pressable.variants.test.ts +29 -0
- package/src/components/pressable/pressable.variants.ts +17 -0
- package/src/components/progress/AGENTS.md +120 -0
- package/src/components/progress/index.ts +40 -0
- package/src/components/progress/progress-fill.tsx +152 -0
- package/src/components/progress/progress-header.tsx +19 -0
- package/src/components/progress/progress-label.tsx +27 -0
- package/src/components/progress/progress-output.tsx +47 -0
- package/src/components/progress/progress-track.tsx +38 -0
- package/src/components/progress/progress.context.tsx +79 -0
- package/src/components/progress/progress.tsx +171 -0
- package/src/components/progress/progress.types.ts +23 -0
- package/src/components/progress/progress.variants.test.ts +335 -0
- package/src/components/progress/progress.variants.ts +284 -0
- package/src/components/progress-button/AGENTS.md +116 -0
- package/src/components/progress-button/index.ts +36 -0
- package/src/components/progress-button/progress-button-done.tsx +33 -0
- package/src/components/progress-button/progress-button-label.tsx +27 -0
- package/src/components/progress-button/progress-button.context.tsx +93 -0
- package/src/components/progress-button/progress-button.tsx +414 -0
- package/src/components/progress-button/progress-button.variants.test.ts +314 -0
- package/src/components/progress-button/progress-button.variants.ts +236 -0
- package/src/components/provider/AGENTS.md +38 -25
- package/src/components/provider/provider.tsx +38 -11
- package/src/components/radio/AGENTS.md +8 -10
- package/src/components/radio/radio.variants.test.ts +5 -6
- package/src/components/radio/radio.variants.ts +6 -7
- package/src/components/rating/AGENTS.md +143 -0
- package/src/components/rating/index.ts +43 -0
- package/src/components/rating/rating-output.tsx +39 -0
- package/src/components/rating/rating-star.tsx +70 -0
- package/src/components/rating/rating-stars.tsx +219 -0
- package/src/components/rating/rating.context.tsx +79 -0
- package/src/components/rating/rating.tsx +163 -0
- package/src/components/rating/rating.variants.test.ts +347 -0
- package/src/components/rating/rating.variants.ts +354 -0
- package/src/components/selection-mode/AGENTS.md +155 -0
- package/src/components/selection-mode/index.ts +43 -0
- package/src/components/selection-mode/selection-mode-action.tsx +65 -0
- package/src/components/selection-mode/selection-mode-bar.tsx +79 -0
- package/src/components/selection-mode/selection-mode-group.tsx +123 -0
- package/src/components/selection-mode/selection-mode-header.tsx +85 -0
- package/src/components/selection-mode/selection-mode-indicator.tsx +118 -0
- package/src/components/selection-mode/selection-mode-item.tsx +182 -0
- package/src/components/selection-mode/selection-mode.context.tsx +89 -0
- package/src/components/selection-mode/selection-mode.tsx +207 -0
- package/src/components/selection-mode/selection-mode.types.ts +36 -0
- package/src/components/selection-mode/selection-mode.variants.test.ts +292 -0
- package/src/components/selection-mode/selection-mode.variants.ts +329 -0
- package/src/components/skeleton/AGENTS.md +120 -0
- package/src/components/skeleton/index.ts +15 -0
- package/src/components/skeleton/skeleton-clock.ts +48 -0
- package/src/components/skeleton/skeleton-group.tsx +64 -0
- package/src/components/skeleton/skeleton-shimmer.tsx +80 -0
- package/src/components/skeleton/skeleton.context.tsx +45 -0
- package/src/components/skeleton/skeleton.tsx +227 -0
- package/src/components/skeleton/skeleton.variants.test.ts +245 -0
- package/src/components/skeleton/skeleton.variants.ts +222 -0
- package/src/components/slide-button/AGENTS.md +122 -0
- package/src/components/slide-button/index.ts +43 -0
- package/src/components/slide-button/slide-button-label.tsx +70 -0
- package/src/components/slide-button/slide-button-thumb.tsx +93 -0
- package/src/components/slide-button/slide-button.context.tsx +76 -0
- package/src/components/slide-button/slide-button.tsx +457 -0
- package/src/components/slide-button/slide-button.variants.test.ts +288 -0
- package/src/components/slide-button/slide-button.variants.ts +324 -0
- package/src/components/slider/index.ts +2 -0
- package/src/components/slider/slider-thumb.tsx +33 -1
- package/src/components/slider/slider.variants.test.ts +52 -0
- package/src/components/slider/slider.variants.ts +56 -2
- package/src/components/stack-card/AGENTS.md +169 -0
- package/src/components/stack-card/index.ts +37 -0
- package/src/components/stack-card/stack-card-action.tsx +66 -0
- package/src/components/stack-card/stack-card-actions.tsx +18 -0
- package/src/components/stack-card/stack-card-card.tsx +71 -0
- package/src/components/stack-card/stack-card-empty.tsx +30 -0
- package/src/components/stack-card/stack-card-slot.tsx +90 -0
- package/src/components/stack-card/stack-card-stamp.tsx +76 -0
- package/src/components/stack-card/stack-card.context.tsx +117 -0
- package/src/components/stack-card/stack-card.tsx +557 -0
- package/src/components/stack-card/stack-card.types.ts +26 -0
- package/src/components/stack-card/stack-card.variants.test.ts +355 -0
- package/src/components/stack-card/stack-card.variants.ts +360 -0
- package/src/components/steps/AGENTS.md +153 -0
- package/src/components/steps/index.ts +45 -0
- package/src/components/steps/steps-connector.tsx +31 -0
- package/src/components/steps/steps-description.tsx +26 -0
- package/src/components/steps/steps-indicator.tsx +106 -0
- package/src/components/steps/steps-item.tsx +225 -0
- package/src/components/steps/steps-panel.tsx +41 -0
- package/src/components/steps/steps-title.tsx +32 -0
- package/src/components/steps/steps-track.tsx +59 -0
- package/src/components/steps/steps.context.tsx +123 -0
- package/src/components/steps/steps.tsx +166 -0
- package/src/components/steps/steps.variants.test.ts +388 -0
- package/src/components/steps/steps.variants.ts +468 -0
- package/src/components/surface/AGENTS.md +72 -0
- package/src/components/surface/index.ts +15 -0
- package/src/components/surface/surface.context.tsx +54 -0
- package/src/components/surface/surface.tsx +70 -0
- package/src/components/surface/surface.variants.test.ts +180 -0
- package/src/components/surface/surface.variants.ts +133 -0
- package/src/components/swipe/AGENTS.md +140 -0
- package/src/components/swipe/index.ts +31 -0
- package/src/components/swipe/swipe-action.tsx +99 -0
- package/src/components/swipe/swipe-end.tsx +14 -0
- package/src/components/swipe/swipe-group.tsx +55 -0
- package/src/components/swipe/swipe-panel.tsx +55 -0
- package/src/components/swipe/swipe-start.tsx +15 -0
- package/src/components/swipe/swipe.context.tsx +135 -0
- package/src/components/swipe/swipe.tsx +393 -0
- package/src/components/swipe/swipe.types.ts +7 -0
- package/src/components/swipe/swipe.variants.test.ts +337 -0
- package/src/components/swipe/swipe.variants.ts +290 -0
- package/src/components/switch/switch.variants.ts +2 -2
- package/src/components/tabs/AGENTS.md +4 -3
- package/src/components/tabs/tabs.variants.test.ts +1 -1
- package/src/components/tabs/tabs.variants.ts +4 -6
- package/src/components/textarea/AGENTS.md +86 -0
- package/src/components/textarea/index.ts +16 -0
- package/src/components/textarea/textarea.tsx +157 -0
- package/src/components/textarea/textarea.variants.test.ts +219 -0
- package/src/components/textarea/textarea.variants.ts +171 -0
- package/src/components/toast/AGENTS.md +140 -0
- package/src/components/toast/index.ts +74 -0
- package/src/components/toast/toast-action.tsx +59 -0
- package/src/components/toast/toast-api.ts +32 -0
- package/src/components/toast/toast-close.tsx +44 -0
- package/src/components/toast/toast-content.tsx +11 -0
- package/src/components/toast/toast-description.tsx +11 -0
- package/src/components/toast/toast-indicator.tsx +34 -0
- package/src/components/toast/toast-item.tsx +308 -0
- package/src/components/toast/toast-title.tsx +13 -0
- package/src/components/toast/toast-viewport.tsx +135 -0
- package/src/components/toast/toast.context.tsx +69 -0
- package/src/components/toast/toast.store.test.ts +259 -0
- package/src/components/toast/toast.store.ts +338 -0
- package/src/components/toast/toast.timer.test.ts +64 -0
- package/src/components/toast/toast.timer.ts +51 -0
- package/src/components/toast/toast.tsx +132 -0
- package/src/components/toast/toast.variants.test.ts +393 -0
- package/src/components/toast/toast.variants.ts +304 -0
- package/src/components/toggle-button/AGENTS.md +106 -0
- package/src/components/toggle-button/index.ts +30 -0
- package/src/components/toggle-button/toggle-button-group.tsx +156 -0
- package/src/components/toggle-button/toggle-button-label.tsx +19 -0
- package/src/components/toggle-button/toggle-button.context.tsx +130 -0
- package/src/components/toggle-button/toggle-button.tsx +172 -0
- package/src/components/toggle-button/toggle-button.variants.test.ts +189 -0
- package/src/components/toggle-button/toggle-button.variants.ts +160 -0
- package/src/components/tooltip/AGENTS.md +133 -0
- package/src/components/tooltip/index.ts +28 -0
- package/src/components/tooltip/tooltip-arrow.tsx +40 -0
- package/src/components/tooltip/tooltip-content.tsx +179 -0
- package/src/components/tooltip/tooltip-description.tsx +19 -0
- package/src/components/tooltip/tooltip-text.tsx +21 -0
- package/src/components/tooltip/tooltip-title.tsx +20 -0
- package/src/components/tooltip/tooltip-trigger.tsx +103 -0
- package/src/components/tooltip/tooltip.context.tsx +68 -0
- package/src/components/tooltip/tooltip.tsx +213 -0
- package/src/components/tooltip/tooltip.variants.test.ts +231 -0
- package/src/components/tooltip/tooltip.variants.ts +177 -0
- package/src/hooks/use-calm-motion.tsx +31 -0
- package/src/lib/calm-motion.test.ts +17 -0
- package/src/lib/calm-motion.ts +23 -0
- package/src/lib/cn.test.ts +14 -0
- package/src/lib/cn.ts +2 -2
- package/src/no-gorhom.test.ts +54 -0
- package/src/styles/geometry.test.ts +1 -0
- package/src/styles/theme.css +4 -0
- package/src/styles/tokens.css +11 -0
- package/src/styles/tokens.test.ts +22 -0
- package/src/styles/tokens.ts +20 -0
- package/src/components/bottom-sheet/bottom-sheet-portal.tsx +0 -72
- package/src/components/bottom-sheet/bottom-sheet.context.tsx +0 -135
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Textarea
|
|
2
|
+
|
|
3
|
+
A multiline text field, sized in rows. One component, no parts: it renders an
|
|
4
|
+
[`Input`](../input/AGENTS.md) and adds a height and an optional character count.
|
|
5
|
+
|
|
6
|
+
`import { Textarea } from "@delacour/react-native-ui/textarea";`
|
|
7
|
+
|
|
8
|
+
## Files
|
|
9
|
+
|
|
10
|
+
| File | What it holds |
|
|
11
|
+
| --- | --- |
|
|
12
|
+
| `index.ts` | → `@delacour/react-native-ui/textarea` |
|
|
13
|
+
| `textarea.tsx` | `Textarea` |
|
|
14
|
+
| `textarea.variants.ts` | Pure `tv()` slots, the line metrics and the height and count resolvers, no RN imports |
|
|
15
|
+
| `textarea.variants.test.ts` | |
|
|
16
|
+
|
|
17
|
+
## Design
|
|
18
|
+
|
|
19
|
+
- **The box is `Input`'s, and nothing here restates it.** Variants (`primary`,
|
|
20
|
+
`secondary`), sizes (`sm`, `md`, `lg`), the focus ring, the invalid border
|
|
21
|
+
that outranks it, the disabled fade, the `accent-*` caret and placeholder, and
|
|
22
|
+
the `Field` cascade all come from rendering an `Input` with `multiline`. A
|
|
23
|
+
second `tv()` for the same border would be a second thing that can drift, and
|
|
24
|
+
a textarea under a row of single-line fields has to read as the same control
|
|
25
|
+
at a different height — not as a close match.
|
|
26
|
+
- **`rows` is a line count, and it becomes points in JavaScript.** A row count
|
|
27
|
+
only turns into a height once the height of a line is a number, so
|
|
28
|
+
`TEXTAREA_LINE_HEIGHTS` holds it per size and `resolveTextareaRowsHeight` adds
|
|
29
|
+
the padding and the border either side. The result is a **style**, never a
|
|
30
|
+
class: a runtime `` `h-[${n}px]` `` is never compiled by Tailwind's static
|
|
31
|
+
scanner and would draw nothing — the rule in
|
|
32
|
+
[Sizing](../../../AGENTS.md#sizing). `size` moves the type and the leading, so
|
|
33
|
+
a small three-row field and a large one are both three rows.
|
|
34
|
+
- **Every number the height counts is pinned to the class that draws it.** The
|
|
35
|
+
line height is set by a `leading-*` class in `textareaVariants`' `field` slot,
|
|
36
|
+
and the padding is `Input`'s multiline `py-*`. `textarea.variants.test.ts`
|
|
37
|
+
resolves both classes and fails if either disagrees with its number — a
|
|
38
|
+
retune of `Input`'s padding would otherwise mis-size every textarea by a few
|
|
39
|
+
points, silently, with the last line clipped at the bottom edge.
|
|
40
|
+
- **A paragraph gets paragraph leading.** `Input` sets `leading-tight` because
|
|
41
|
+
it centres one line in a fixed box; four lines at 1.25× read as a wall. The
|
|
42
|
+
textarea's `leading-5`/`-6`/`-7` replace it through `Input`'s own className
|
|
43
|
+
merge rather than sitting beside it, and a test holds each between 1.2× and
|
|
44
|
+
1.6× the `--text-input-*` size so the type scale can be retuned without this
|
|
45
|
+
going stale.
|
|
46
|
+
- **`autoGrow` measures nothing.** It sets `minHeight` at `rows` and
|
|
47
|
+
`maxHeight` at `maxRows`, and between the two React Native's multiline
|
|
48
|
+
`TextInput` sizes itself to its content; past the cap it scrolls. An
|
|
49
|
+
`onContentSizeChange` loop would draw one frame at the old height after every
|
|
50
|
+
new line, which is exactly the jump a growing field exists to avoid. The field
|
|
51
|
+
never shrinks below `rows`, so a message typed and cleared goes back to where
|
|
52
|
+
it started rather than collapsing to one line.
|
|
53
|
+
- **`maxRows` only type-checks with `autoGrow`, and `showCount` only with
|
|
54
|
+
`maxLength`.** Both are unions in `TextareaProps`. A ceiling on a field that
|
|
55
|
+
does not grow is a number nothing reads, and a count with no limit to count
|
|
56
|
+
towards says nothing — a runtime warning would only find either out on a
|
|
57
|
+
device.
|
|
58
|
+
- **A bad row count degrades rather than throws.** `resolveTextareaRows` floors
|
|
59
|
+
to whole lines, clamps to at least one, falls back on `NaN` or `Infinity`, and
|
|
60
|
+
lifts a `maxRows` below `rows` up to it — otherwise a growing field would
|
|
61
|
+
shrink as it grew.
|
|
62
|
+
- **The count turns destructive at the limit, not past it.** Native `maxLength`
|
|
63
|
+
stops typing at the limit, so "over" is a state someone typing never sees;
|
|
64
|
+
the count has to change at the moment the keyboard stops working. It renders
|
|
65
|
+
`Text.Caption` and passes its colour through the preset's `color` prop, so the
|
|
66
|
+
`count` slot carries no colour class to fight it. It uses `tabular-nums` so it
|
|
67
|
+
does not jitter as it counts, and announces as "12 of 280 characters" rather
|
|
68
|
+
than as a fraction.
|
|
69
|
+
- **The count works controlled and uncontrolled.** A `value` is read directly;
|
|
70
|
+
without one, the last text the field reported is, seeded from `defaultValue`
|
|
71
|
+
so the first frame is already right.
|
|
72
|
+
- **There is always a wrapping `View`.** `className` reaches the field, as it
|
|
73
|
+
does on `Input`, and `containerClassName` reaches the wrapper that holds the
|
|
74
|
+
field and its count. The wrapper is there whether or not the count is, so
|
|
75
|
+
flipping `showCount` never remounts the field and drops focus.
|
|
76
|
+
- **`multiline` and `numberOfLines` are withheld.** It is always multiline, and
|
|
77
|
+
`rows` is its height; `numberOfLines` would be a second answer to the same
|
|
78
|
+
question, and on Android the one that wins.
|
|
79
|
+
- **There is no `label`, `description` or `errorMessage` prop.** A textarea goes
|
|
80
|
+
in a [`Field`](../field/AGENTS.md) the way an `Input` does — the reason
|
|
81
|
+
`Input` ships no label part holds here too. `apps/playground`'s
|
|
82
|
+
`/textarea` gallery ends with a composed form showing the trade at a call site.
|
|
83
|
+
- **Not for `Input.Group` or `Button.Group`.** An inner `Input` would read
|
|
84
|
+
either context and take its box or its corner, but a decorated or joined
|
|
85
|
+
paragraph is not a control this library draws, and the height math assumes
|
|
86
|
+
the box is the field's own.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export { Textarea, type TextareaProps } from "./textarea";
|
|
2
|
+
export {
|
|
3
|
+
resolveTextareaCount,
|
|
4
|
+
resolveTextareaFieldClass,
|
|
5
|
+
resolveTextareaHeightStyle,
|
|
6
|
+
resolveTextareaRows,
|
|
7
|
+
resolveTextareaRowsHeight,
|
|
8
|
+
TEXTAREA_BORDER_WIDTH,
|
|
9
|
+
TEXTAREA_DEFAULT_MAX_ROWS,
|
|
10
|
+
TEXTAREA_DEFAULT_ROWS,
|
|
11
|
+
TEXTAREA_LINE_HEIGHTS,
|
|
12
|
+
TEXTAREA_PADDING_Y,
|
|
13
|
+
type TextareaCount,
|
|
14
|
+
type TextareaVariantProps,
|
|
15
|
+
textareaVariants,
|
|
16
|
+
} from "./textarea.variants";
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { type ReactElement, useCallback, useState } from "react";
|
|
2
|
+
import { View } from "react-native";
|
|
3
|
+
import { Input, type InputProps } from "../input";
|
|
4
|
+
import { Text } from "../text";
|
|
5
|
+
import {
|
|
6
|
+
resolveTextareaCount,
|
|
7
|
+
resolveTextareaFieldClass,
|
|
8
|
+
resolveTextareaHeightStyle,
|
|
9
|
+
textareaVariants,
|
|
10
|
+
} from "./textarea.variants";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Whether the field grows with its text.
|
|
14
|
+
*
|
|
15
|
+
* A union so `maxRows` is only accepted where it means something: on a field
|
|
16
|
+
* that does not grow, a ceiling is a number nothing reads.
|
|
17
|
+
*/
|
|
18
|
+
type TextareaGrowProps =
|
|
19
|
+
| {
|
|
20
|
+
/** Stay `rows` tall and scroll past it. The default. */
|
|
21
|
+
autoGrow?: false;
|
|
22
|
+
maxRows?: never;
|
|
23
|
+
}
|
|
24
|
+
| {
|
|
25
|
+
/** Grow with the text, from `rows` up to `maxRows`, then scroll. */
|
|
26
|
+
autoGrow: true;
|
|
27
|
+
/** The tallest the field grows, in lines. Default 10. */
|
|
28
|
+
maxRows?: number;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Whether a character count sits under the field.
|
|
33
|
+
*
|
|
34
|
+
* A union so the count cannot be asked for without a limit: a count with
|
|
35
|
+
* nothing to count towards says nothing, and a runtime check would only find
|
|
36
|
+
* that out on a device.
|
|
37
|
+
*/
|
|
38
|
+
type TextareaCountProps =
|
|
39
|
+
| {
|
|
40
|
+
showCount?: false;
|
|
41
|
+
/** The most characters the field accepts. */
|
|
42
|
+
maxLength?: number;
|
|
43
|
+
}
|
|
44
|
+
| {
|
|
45
|
+
/** Show how much of `maxLength` is used, under the field's trailing edge. */
|
|
46
|
+
showCount: true;
|
|
47
|
+
maxLength: number;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type TextareaProps = Omit<InputProps, "multiline" | "numberOfLines" | "maxLength"> &
|
|
51
|
+
TextareaGrowProps &
|
|
52
|
+
TextareaCountProps & {
|
|
53
|
+
/** How many lines tall the field is — its height when fixed, its floor when growing. Default 4. */
|
|
54
|
+
rows?: number;
|
|
55
|
+
/** Class for the view holding the field and its count. `className` styles the field itself. */
|
|
56
|
+
containerClassName?: string;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A multiline text field, sized in rows.
|
|
61
|
+
*
|
|
62
|
+
* The box is `Input`'s — its variants, sizes, focus ring, invalid border,
|
|
63
|
+
* disabled fade and the `Field` cascade all come from there unchanged, so a
|
|
64
|
+
* textarea and the single-line fields above it in a form are the same control
|
|
65
|
+
* at a different height rather than two that happen to match.
|
|
66
|
+
*
|
|
67
|
+
* What this adds is the height. `rows` is a line count, and it becomes points
|
|
68
|
+
* through the paragraph leading and the box's own padding and border — see
|
|
69
|
+
* `resolveTextareaHeightStyle`. It is applied as a style, never a class,
|
|
70
|
+
* because a runtime number cannot be a Tailwind class. `size` changes the type
|
|
71
|
+
* and leading, so a small three-row field and a large one are both three rows.
|
|
72
|
+
*
|
|
73
|
+
* `autoGrow` floors the field at `rows` and caps it at `maxRows`; between the
|
|
74
|
+
* two, React Native's multiline `TextInput` sizes itself to its content, and
|
|
75
|
+
* past the cap it scrolls. `showCount` with `maxLength` puts a count under the
|
|
76
|
+
* trailing edge that turns destructive at the limit.
|
|
77
|
+
*
|
|
78
|
+
* `multiline` and `numberOfLines` are withheld: it is always multiline, and its
|
|
79
|
+
* height comes from `rows`.
|
|
80
|
+
*
|
|
81
|
+
* Label, description and error come from `Field`, exactly as they do for
|
|
82
|
+
* `Input` — there is no `label` prop.
|
|
83
|
+
*
|
|
84
|
+
* @example
|
|
85
|
+
* <Field>
|
|
86
|
+
* <Field.Label>Notes</Field.Label>
|
|
87
|
+
* <Textarea placeholder="Anything we should know?" />
|
|
88
|
+
* </Field>
|
|
89
|
+
*
|
|
90
|
+
* @example
|
|
91
|
+
* <Textarea autoGrow maxRows={8} rows={2} placeholder="Message" />
|
|
92
|
+
*
|
|
93
|
+
* @example
|
|
94
|
+
* <Textarea maxLength={280} onChangeText={setStatus} showCount value={status} />
|
|
95
|
+
*/
|
|
96
|
+
export function Textarea({
|
|
97
|
+
rows,
|
|
98
|
+
autoGrow,
|
|
99
|
+
maxRows,
|
|
100
|
+
showCount,
|
|
101
|
+
maxLength,
|
|
102
|
+
size,
|
|
103
|
+
className,
|
|
104
|
+
containerClassName,
|
|
105
|
+
style,
|
|
106
|
+
value,
|
|
107
|
+
defaultValue,
|
|
108
|
+
onChangeText,
|
|
109
|
+
...props
|
|
110
|
+
}: TextareaProps): ReactElement {
|
|
111
|
+
const resolvedSize = size ?? "md";
|
|
112
|
+
const slots = textareaVariants({ size: resolvedSize });
|
|
113
|
+
|
|
114
|
+
// The count reads the value's length whether or not the caller owns the
|
|
115
|
+
// value. Controlled, `value` is the truth; uncontrolled, the last text the
|
|
116
|
+
// field reported is — seeded from `defaultValue` so the count is right on
|
|
117
|
+
// the first frame rather than after the first keystroke.
|
|
118
|
+
const [ownLength, setOwnLength] = useState(() => defaultValue?.length ?? 0);
|
|
119
|
+
const length = value === undefined ? ownLength : value.length;
|
|
120
|
+
|
|
121
|
+
const handleChangeText = useCallback(
|
|
122
|
+
(text: string) => {
|
|
123
|
+
setOwnLength(text.length);
|
|
124
|
+
onChangeText?.(text);
|
|
125
|
+
},
|
|
126
|
+
[onChangeText]
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
const count = showCount && maxLength !== undefined ? resolveTextareaCount({ length, maxLength }) : null;
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<View className={slots.root({ className: containerClassName })}>
|
|
133
|
+
<Input
|
|
134
|
+
{...props}
|
|
135
|
+
className={resolveTextareaFieldClass({ className, size: resolvedSize })}
|
|
136
|
+
defaultValue={defaultValue}
|
|
137
|
+
maxLength={maxLength}
|
|
138
|
+
multiline
|
|
139
|
+
onChangeText={handleChangeText}
|
|
140
|
+
size={resolvedSize}
|
|
141
|
+
style={[resolveTextareaHeightStyle({ autoGrow, maxRows, rows, size: resolvedSize }), style]}
|
|
142
|
+
value={value}
|
|
143
|
+
/>
|
|
144
|
+
{count ? (
|
|
145
|
+
<Text.Caption
|
|
146
|
+
accessibilityLabel={count.accessibilityLabel}
|
|
147
|
+
accessibilityLiveRegion="polite"
|
|
148
|
+
className={slots.count()}
|
|
149
|
+
color={count.color}
|
|
150
|
+
>
|
|
151
|
+
{count.label}
|
|
152
|
+
</Text.Caption>
|
|
153
|
+
) : null}
|
|
154
|
+
</View>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
Textarea.displayName = "DelacourUI.Textarea";
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { INPUT_SIZES, inputVariants, resolveInputFieldClass } from "../input/input.variants";
|
|
5
|
+
import {
|
|
6
|
+
resolveTextareaCount,
|
|
7
|
+
resolveTextareaFieldClass,
|
|
8
|
+
resolveTextareaHeightStyle,
|
|
9
|
+
resolveTextareaRows,
|
|
10
|
+
resolveTextareaRowsHeight,
|
|
11
|
+
TEXTAREA_BORDER_WIDTH,
|
|
12
|
+
TEXTAREA_DEFAULT_MAX_ROWS,
|
|
13
|
+
TEXTAREA_DEFAULT_ROWS,
|
|
14
|
+
TEXTAREA_LINE_HEIGHTS,
|
|
15
|
+
TEXTAREA_PADDING_Y,
|
|
16
|
+
textareaVariants,
|
|
17
|
+
} from "./textarea.variants";
|
|
18
|
+
|
|
19
|
+
const TOKENS_CSS = readFileSync(join(import.meta.dirname, "../../styles/tokens.css"), "utf-8");
|
|
20
|
+
|
|
21
|
+
/** Points behind a `--text-input-*` token in `tokens.css`. */
|
|
22
|
+
function inputTextPoints(size: string): number {
|
|
23
|
+
const match = TOKENS_CSS.match(new RegExp(`--text-input-${size}:\\s*([\\d.]+)px;`));
|
|
24
|
+
if (!match?.[1]) throw new Error(`tokens.css defines no --text-input-${size}`);
|
|
25
|
+
return Number(match[1]);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Points behind the first `<prefix>-N` spacing utility in a class string, at Tailwind's 4pt step. */
|
|
29
|
+
function spacingPoints(cls: string, prefix: string): number | undefined {
|
|
30
|
+
const match = cls.match(new RegExp(`(?:^|\\s)${prefix}-(\\d+(?:\\.\\d+)?)(?:\\s|$)`));
|
|
31
|
+
return match?.[1] === undefined ? undefined : Number(match[1]) * 4;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("the metrics the height is derived from", () => {
|
|
35
|
+
// The height is a number, so the padding it counts has to be the padding
|
|
36
|
+
// the box actually wears. That padding is `Input`'s multiline `py-*`, and
|
|
37
|
+
// restating it here is only safe because this test reads it back.
|
|
38
|
+
test("count the vertical padding Input's multiline box wears", () => {
|
|
39
|
+
for (const size of INPUT_SIZES) {
|
|
40
|
+
const cls = resolveInputFieldClass({ isMultiline: true, size });
|
|
41
|
+
expect(spacingPoints(cls, "py")).toBe(TEXTAREA_PADDING_Y[size]);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("count the one-pixel border every variant draws", () => {
|
|
46
|
+
expect(inputVariants().root()).toMatch(/(?:^|\s)border(?:\s|$)/);
|
|
47
|
+
expect(TEXTAREA_BORDER_WIDTH).toBe(1);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("give each size the line height its field class sets", () => {
|
|
51
|
+
for (const size of INPUT_SIZES) {
|
|
52
|
+
const cls = resolveTextareaFieldClass({ size });
|
|
53
|
+
expect(spacingPoints(cls, "leading")).toBe(TEXTAREA_LINE_HEIGHTS[size]);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// A line shorter than its type clips descenders; one under 1.2× reads as
|
|
58
|
+
// a wall. The ratio is what is pinned, so the type scale can be retuned.
|
|
59
|
+
test("set every line between 1.2× and 1.6× the value's type size", () => {
|
|
60
|
+
for (const size of INPUT_SIZES) {
|
|
61
|
+
const ratio = TEXTAREA_LINE_HEIGHTS[size] / inputTextPoints(size);
|
|
62
|
+
expect(ratio).toBeGreaterThanOrEqual(1.2);
|
|
63
|
+
expect(ratio).toBeLessThanOrEqual(1.6);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("step the line height with the size", () => {
|
|
68
|
+
const heights = INPUT_SIZES.map((size) => TEXTAREA_LINE_HEIGHTS[size]);
|
|
69
|
+
expect([...heights].sort((a, b) => a - b)).toEqual(heights);
|
|
70
|
+
expect(new Set(heights).size).toBe(heights.length);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("resolveTextareaFieldClass", () => {
|
|
75
|
+
test("replaces Input's single-line leading rather than adding a second", () => {
|
|
76
|
+
for (const size of INPUT_SIZES) {
|
|
77
|
+
const cls = resolveInputFieldClass({
|
|
78
|
+
className: resolveTextareaFieldClass({ size }),
|
|
79
|
+
isMultiline: true,
|
|
80
|
+
size,
|
|
81
|
+
});
|
|
82
|
+
expect(cls).not.toContain("leading-tight");
|
|
83
|
+
expect(cls.match(/\bleading-/g)).toHaveLength(1);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("merges an incoming className last", () => {
|
|
88
|
+
expect(resolveTextareaFieldClass({ className: "leading-9", size: "md" })).toContain("leading-9");
|
|
89
|
+
expect(resolveTextareaFieldClass({ className: "leading-9", size: "md" })).not.toContain("leading-6");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("resolveTextareaRows", () => {
|
|
94
|
+
test("defaults to four rows and a ten-row ceiling", () => {
|
|
95
|
+
expect(TEXTAREA_DEFAULT_ROWS).toBe(4);
|
|
96
|
+
expect(TEXTAREA_DEFAULT_MAX_ROWS).toBe(10);
|
|
97
|
+
expect(resolveTextareaRows({})).toEqual({ maxRows: 10, rows: 4 });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("never goes below one row", () => {
|
|
101
|
+
expect(resolveTextareaRows({ rows: 0 }).rows).toBe(1);
|
|
102
|
+
expect(resolveTextareaRows({ rows: -3 }).rows).toBe(1);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("rounds a fractional row count down to whole lines", () => {
|
|
106
|
+
expect(resolveTextareaRows({ rows: 2.7 }).rows).toBe(2);
|
|
107
|
+
expect(resolveTextareaRows({ maxRows: 5.9, rows: 2 }).maxRows).toBe(5);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// A ceiling below the floor would make the field shrink as it grew.
|
|
111
|
+
test("lifts a ceiling below the floor up to it", () => {
|
|
112
|
+
expect(resolveTextareaRows({ maxRows: 2, rows: 6 })).toEqual({ maxRows: 6, rows: 6 });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("falls back on a non-finite count", () => {
|
|
116
|
+
expect(resolveTextareaRows({ rows: Number.NaN })).toEqual({ maxRows: 10, rows: 4 });
|
|
117
|
+
expect(resolveTextareaRows({ maxRows: Number.POSITIVE_INFINITY, rows: 3 }).maxRows).toBe(10);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("resolveTextareaRowsHeight", () => {
|
|
122
|
+
test("is the lines plus the padding and border either side", () => {
|
|
123
|
+
for (const size of INPUT_SIZES) {
|
|
124
|
+
const expected = 3 * TEXTAREA_LINE_HEIGHTS[size] + 2 * TEXTAREA_PADDING_Y[size] + 2 * TEXTAREA_BORDER_WIDTH;
|
|
125
|
+
expect(resolveTextareaRowsHeight({ rows: 3, size })).toBe(expected);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("grows by exactly one line per row", () => {
|
|
130
|
+
for (const size of INPUT_SIZES) {
|
|
131
|
+
const step = resolveTextareaRowsHeight({ rows: 5, size }) - resolveTextareaRowsHeight({ rows: 4, size });
|
|
132
|
+
expect(step).toBe(TEXTAREA_LINE_HEIGHTS[size]);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// A one-row textarea should not be shorter than the single-line field it
|
|
137
|
+
// sits beside in a form.
|
|
138
|
+
test("is never shorter than a single-line Input at one row", () => {
|
|
139
|
+
const INPUT_HEIGHTS = { lg: 52, md: 44, sm: 36 } as const;
|
|
140
|
+
for (const size of INPUT_SIZES) {
|
|
141
|
+
expect(resolveTextareaRowsHeight({ rows: 1, size })).toBeGreaterThanOrEqual(INPUT_HEIGHTS[size]);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("resolveTextareaHeightStyle", () => {
|
|
147
|
+
test("fixes the height at the row count when it does not grow", () => {
|
|
148
|
+
expect(resolveTextareaHeightStyle({ rows: 3, size: "md" })).toEqual({
|
|
149
|
+
height: resolveTextareaRowsHeight({ rows: 3, size: "md" }),
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("floors at rows and caps at maxRows when it grows", () => {
|
|
154
|
+
expect(resolveTextareaHeightStyle({ autoGrow: true, maxRows: 6, rows: 2, size: "sm" })).toEqual({
|
|
155
|
+
maxHeight: resolveTextareaRowsHeight({ rows: 6, size: "sm" }),
|
|
156
|
+
minHeight: resolveTextareaRowsHeight({ rows: 2, size: "sm" }),
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("caps a growing field at ten rows when maxRows is unset", () => {
|
|
161
|
+
expect(resolveTextareaHeightStyle({ autoGrow: true, size: "md" })).toEqual({
|
|
162
|
+
maxHeight: resolveTextareaRowsHeight({ rows: 10, size: "md" }),
|
|
163
|
+
minHeight: resolveTextareaRowsHeight({ rows: 4, size: "md" }),
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("ignores maxRows on a field that does not grow", () => {
|
|
168
|
+
expect(resolveTextareaHeightStyle({ maxRows: 8, rows: 2, size: "lg" })).toEqual({
|
|
169
|
+
height: resolveTextareaRowsHeight({ rows: 2, size: "lg" }),
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("resolveTextareaCount", () => {
|
|
175
|
+
test("reads as used over limit", () => {
|
|
176
|
+
expect(resolveTextareaCount({ length: 12, maxLength: 280 }).label).toBe("12/280");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("announces the count as a sentence", () => {
|
|
180
|
+
expect(resolveTextareaCount({ length: 1, maxLength: 280 }).accessibilityLabel).toBe("1 of 280 characters");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("reports the limit once it is reached, and not before", () => {
|
|
184
|
+
expect(resolveTextareaCount({ length: 279, maxLength: 280 }).isAtLimit).toBe(false);
|
|
185
|
+
expect(resolveTextareaCount({ length: 280, maxLength: 280 }).isAtLimit).toBe(true);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("is muted under the limit and destructive at it", () => {
|
|
189
|
+
expect(resolveTextareaCount({ length: 3, maxLength: 10 }).color).toBe("muted");
|
|
190
|
+
expect(resolveTextareaCount({ length: 10, maxLength: 10 }).color).toBe("destructive");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// A controlled value set past the limit in code is still over it; the
|
|
194
|
+
// native `maxLength` only stops typing.
|
|
195
|
+
test("still reports a value past the limit", () => {
|
|
196
|
+
expect(resolveTextareaCount({ length: 300, maxLength: 280 })).toEqual({
|
|
197
|
+
accessibilityLabel: "300 of 280 characters",
|
|
198
|
+
color: "destructive",
|
|
199
|
+
isAtLimit: true,
|
|
200
|
+
label: "300/280",
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe("the count slot", () => {
|
|
206
|
+
test("sits at the trailing edge", () => {
|
|
207
|
+
expect(textareaVariants().count()).toContain("self-end");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("uses tabular figures, so the count does not jitter as it changes", () => {
|
|
211
|
+
expect(textareaVariants().count()).toContain("tabular-nums");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// Colour belongs to the `Text` preset's `color` prop, not to a class that
|
|
215
|
+
// would fight it in the merge.
|
|
216
|
+
test("carries no text colour of its own", () => {
|
|
217
|
+
expect(textareaVariants().count()).not.toMatch(/\btext-(?:muted|destructive|foreground)/);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import type { VariantProps } from "tailwind-variants";
|
|
2
|
+
import { tv } from "../../lib/tv";
|
|
3
|
+
import type { InputSize } from "../input/input.variants";
|
|
4
|
+
|
|
5
|
+
/** How many lines tall a textarea is when `rows` is not named. */
|
|
6
|
+
export const TEXTAREA_DEFAULT_ROWS = 4;
|
|
7
|
+
|
|
8
|
+
/** The tallest a growing textarea goes when `maxRows` is not named. */
|
|
9
|
+
export const TEXTAREA_DEFAULT_MAX_ROWS = 10;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Points per line of text, by size.
|
|
13
|
+
*
|
|
14
|
+
* A paragraph needs more leading than the one line `Input` sets `leading-tight`
|
|
15
|
+
* for, and a row count only becomes a height once the height of a line is a
|
|
16
|
+
* number. So the number lives here, and the `leading-*` class that sets it lives
|
|
17
|
+
* in {@link textareaVariants} beside it; `textarea.variants.test.ts` reads the
|
|
18
|
+
* class back and fails if the two ever disagree.
|
|
19
|
+
*/
|
|
20
|
+
export const TEXTAREA_LINE_HEIGHTS: Record<InputSize, number> = { sm: 20, md: 24, lg: 28 };
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Vertical padding either side of the text, by size.
|
|
24
|
+
*
|
|
25
|
+
* A restatement of `Input`'s multiline `py-*` compounds — the box is `Input`'s,
|
|
26
|
+
* so the padding is too. The test resolves `Input`'s class and pins these
|
|
27
|
+
* against it, so a retune there fails here rather than silently mis-sizing
|
|
28
|
+
* every textarea by a few points.
|
|
29
|
+
*/
|
|
30
|
+
export const TEXTAREA_PADDING_Y: Record<InputSize, number> = { sm: 8, md: 10, lg: 12 };
|
|
31
|
+
|
|
32
|
+
/** The hairline `Input`'s box draws on every variant, counted top and bottom. */
|
|
33
|
+
export const TEXTAREA_BORDER_WIDTH = 1;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Styling for the parts a textarea adds around `Input`.
|
|
37
|
+
*
|
|
38
|
+
* Everything the box itself wears — border, fill, corner, focus and invalid —
|
|
39
|
+
* is `Input`'s, and is not restated here. What this adds is the paragraph
|
|
40
|
+
* leading, and the character count that sits under the box.
|
|
41
|
+
*
|
|
42
|
+
* `count` carries no colour class: it renders `Text.Caption`, whose `color`
|
|
43
|
+
* prop is the one place that decision is made — {@link resolveTextareaCount}.
|
|
44
|
+
*
|
|
45
|
+
* Free of React Native imports so it stays unit-testable. See AGENTS.md.
|
|
46
|
+
*/
|
|
47
|
+
export const textareaVariants = tv({
|
|
48
|
+
slots: {
|
|
49
|
+
root: "gap-1.5",
|
|
50
|
+
field: "",
|
|
51
|
+
count: "self-end tabular-nums",
|
|
52
|
+
},
|
|
53
|
+
variants: {
|
|
54
|
+
size: {
|
|
55
|
+
sm: { field: "leading-5" },
|
|
56
|
+
md: { field: "leading-6" },
|
|
57
|
+
lg: { field: "leading-7" },
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
defaultVariants: {
|
|
61
|
+
size: "md",
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
export type TextareaVariantProps = VariantProps<typeof textareaVariants>;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The class the textarea hands `Input` for its field.
|
|
69
|
+
*
|
|
70
|
+
* `Input` merges it after its own field slot, so the `leading-*` here replaces
|
|
71
|
+
* the `leading-tight` that slot sets rather than sitting beside it — and a
|
|
72
|
+
* caller's className, merged last here, still wins over both.
|
|
73
|
+
*
|
|
74
|
+
* Pure, so it is reachable from `bun test`. See AGENTS.md.
|
|
75
|
+
*/
|
|
76
|
+
export function resolveTextareaFieldClass({ size, className }: { size: InputSize; className?: string }): string {
|
|
77
|
+
return textareaVariants({ size }).field({ className });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A whole, positive line count, or the fallback when the value is not one. */
|
|
81
|
+
function wholeRows(value: number | undefined, fallback: number): number {
|
|
82
|
+
if (value === undefined || !Number.isFinite(value)) return fallback;
|
|
83
|
+
return Math.max(1, Math.floor(value));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The floor and ceiling a textarea is sized between, in lines.
|
|
88
|
+
*
|
|
89
|
+
* A ceiling below the floor is lifted up to it — otherwise a growing field
|
|
90
|
+
* would shrink as it grew.
|
|
91
|
+
*
|
|
92
|
+
* Pure, so it is reachable from `bun test`. See AGENTS.md.
|
|
93
|
+
*/
|
|
94
|
+
export function resolveTextareaRows({ rows, maxRows }: { rows?: number; maxRows?: number }): {
|
|
95
|
+
rows: number;
|
|
96
|
+
maxRows: number;
|
|
97
|
+
} {
|
|
98
|
+
const floor = wholeRows(rows, TEXTAREA_DEFAULT_ROWS);
|
|
99
|
+
return { maxRows: Math.max(floor, wholeRows(maxRows, TEXTAREA_DEFAULT_MAX_ROWS)), rows: floor };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Points tall a box of `rows` lines is: the lines, plus the padding and the
|
|
104
|
+
* border either side.
|
|
105
|
+
*
|
|
106
|
+
* Pure, so it is reachable from `bun test`. See AGENTS.md.
|
|
107
|
+
*/
|
|
108
|
+
export function resolveTextareaRowsHeight({ rows, size }: { rows: number; size: InputSize }): number {
|
|
109
|
+
return rows * TEXTAREA_LINE_HEIGHTS[size] + 2 * TEXTAREA_PADDING_Y[size] + 2 * TEXTAREA_BORDER_WIDTH;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The height a textarea is drawn at, as a style — never a class.
|
|
114
|
+
*
|
|
115
|
+
* A row count is a runtime number, and Tailwind's scanner is static: a
|
|
116
|
+
* `` `h-[${n}px]` `` is never compiled and would resolve to nothing. See
|
|
117
|
+
* AGENTS.md **Sizing**.
|
|
118
|
+
*
|
|
119
|
+
* A fixed field is exactly `rows` tall and scrolls past it. A growing one is
|
|
120
|
+
* floored at `rows` and capped at `maxRows`, and between the two React Native's
|
|
121
|
+
* own multiline `TextInput` sizes itself to its content — no measuring pass, so
|
|
122
|
+
* no frame drawn at the old height after a line is added.
|
|
123
|
+
*
|
|
124
|
+
* Pure, so it is reachable from `bun test`. See AGENTS.md.
|
|
125
|
+
*/
|
|
126
|
+
export function resolveTextareaHeightStyle({
|
|
127
|
+
size,
|
|
128
|
+
rows,
|
|
129
|
+
maxRows,
|
|
130
|
+
autoGrow = false,
|
|
131
|
+
}: {
|
|
132
|
+
size: InputSize;
|
|
133
|
+
rows?: number;
|
|
134
|
+
maxRows?: number;
|
|
135
|
+
autoGrow?: boolean;
|
|
136
|
+
}): { height: number } | { minHeight: number; maxHeight: number } {
|
|
137
|
+
const lines = resolveTextareaRows({ maxRows, rows });
|
|
138
|
+
if (!autoGrow) return { height: resolveTextareaRowsHeight({ rows: lines.rows, size }) };
|
|
139
|
+
return {
|
|
140
|
+
maxHeight: resolveTextareaRowsHeight({ rows: lines.maxRows, size }),
|
|
141
|
+
minHeight: resolveTextareaRowsHeight({ rows: lines.rows, size }),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** What the character count shows, announces and is coloured. */
|
|
146
|
+
export type TextareaCount = {
|
|
147
|
+
label: string;
|
|
148
|
+
accessibilityLabel: string;
|
|
149
|
+
isAtLimit: boolean;
|
|
150
|
+
color: "muted" | "destructive";
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The character count under a textarea.
|
|
155
|
+
*
|
|
156
|
+
* Turns destructive once the limit is reached, not after it: the native
|
|
157
|
+
* `maxLength` stops typing at the limit, so "over" is a state a person typing
|
|
158
|
+
* can never see, and the count has to say something at the moment typing stops
|
|
159
|
+
* working. A controlled value set past the limit in code still reads as at it.
|
|
160
|
+
*
|
|
161
|
+
* Pure, so it is reachable from `bun test`. See AGENTS.md.
|
|
162
|
+
*/
|
|
163
|
+
export function resolveTextareaCount({ length, maxLength }: { length: number; maxLength: number }): TextareaCount {
|
|
164
|
+
const isAtLimit = length >= maxLength;
|
|
165
|
+
return {
|
|
166
|
+
accessibilityLabel: `${length} of ${maxLength} characters`,
|
|
167
|
+
color: isAtLimit ? "destructive" : "muted",
|
|
168
|
+
isAtLimit,
|
|
169
|
+
label: `${length}/${maxLength}`,
|
|
170
|
+
};
|
|
171
|
+
}
|