@sableui/react 0.6.2 → 0.7.1
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/AGENTS.md +2 -2
- package/CHANGELOG.md +315 -0
- package/CONTRIBUTING.md +16 -10
- package/DESIGN.md +3 -3
- package/README.md +3 -3
- package/bin/sable-lint.mjs +18 -0
- package/bin/sable.mjs +470 -62
- package/dist/blocks/api-keys-block.js +44 -0
- package/dist/blocks/api-keys-block.meta.js +21 -0
- package/dist/blocks/chat-thread-block.js +17 -0
- package/dist/blocks/chat-thread-block.meta.js +20 -0
- package/dist/blocks/command-palette-block.js +24 -0
- package/dist/blocks/command-palette-block.meta.js +21 -0
- package/dist/blocks/cta-band-block.js +27 -0
- package/dist/blocks/cta-band-block.meta.js +20 -0
- package/dist/blocks/danger-zone-block.js +39 -0
- package/dist/blocks/danger-zone-block.meta.js +21 -0
- package/dist/blocks/device-comparison-block.js +69 -0
- package/dist/blocks/device-comparison-block.meta.js +21 -0
- package/dist/blocks/event-card-block.js +40 -0
- package/dist/blocks/event-card-block.meta.js +21 -0
- package/dist/blocks/faq-block.js +38 -0
- package/dist/blocks/faq-block.meta.js +20 -0
- package/dist/blocks/feature-grid-block.js +38 -0
- package/dist/blocks/feature-grid-block.meta.js +21 -0
- package/dist/blocks/feedback-form-block.js +30 -0
- package/dist/blocks/feedback-form-block.meta.js +21 -0
- package/dist/blocks/goal-progress-block.js +24 -0
- package/dist/blocks/goal-progress-block.meta.js +21 -0
- package/dist/blocks/hero-block.js +33 -0
- package/dist/blocks/hero-block.meta.js +21 -0
- package/dist/blocks/index.js +162 -1964
- package/dist/blocks/invite-team-block.js +36 -0
- package/dist/blocks/invite-team-block.meta.js +20 -0
- package/dist/blocks/invoice-history-block.js +41 -0
- package/dist/blocks/invoice-history-block.meta.js +21 -0
- package/dist/blocks/kpi-sparkline-block.js +29 -0
- package/dist/blocks/kpi-sparkline-block.meta.js +20 -0
- package/dist/blocks/media-player-block.js +68 -0
- package/dist/blocks/media-player-block.meta.js +21 -0
- package/dist/blocks/newsletter-block.js +35 -0
- package/dist/blocks/newsletter-block.meta.js +21 -0
- package/dist/blocks/not-found-block.js +27 -0
- package/dist/blocks/not-found-block.meta.js +20 -0
- package/dist/blocks/notification-list-block.js +32 -0
- package/dist/blocks/notification-list-block.meta.js +21 -0
- package/dist/blocks/notification-settings-block.js +25 -0
- package/dist/blocks/notification-settings-block.meta.js +20 -0
- package/dist/blocks/order-tracking-block.js +26 -0
- package/dist/blocks/order-tracking-block.meta.js +20 -0
- package/dist/blocks/pricing-card-block.js +46 -0
- package/dist/blocks/pricing-card-block.meta.js +21 -0
- package/dist/blocks/pricing-table-block.js +88 -0
- package/dist/blocks/pricing-table-block.meta.js +20 -0
- package/dist/blocks/product-card-block.js +26 -0
- package/dist/blocks/product-card-block.meta.js +21 -0
- package/dist/blocks/profile-card-block.js +58 -0
- package/dist/blocks/profile-card-block.meta.js +21 -0
- package/dist/blocks/profile-form-block.js +70 -0
- package/dist/blocks/profile-form-block.meta.js +21 -0
- package/dist/blocks/revenue-breakdown-block.js +38 -0
- package/dist/blocks/revenue-breakdown-block.meta.js +21 -0
- package/dist/blocks/sign-in-block.js +38 -0
- package/dist/blocks/sign-in-block.meta.js +21 -0
- package/dist/blocks/sign-up-block.js +48 -0
- package/dist/blocks/sign-up-block.meta.js +21 -0
- package/dist/blocks/site-footer-block.js +54 -0
- package/dist/blocks/site-footer-block.meta.js +20 -0
- package/dist/blocks/stat-row-block.js +28 -0
- package/dist/blocks/stat-row-block.meta.js +21 -0
- package/dist/blocks/steps-block.js +37 -0
- package/dist/blocks/steps-block.meta.js +20 -0
- package/dist/blocks/task-list-block.js +52 -0
- package/dist/blocks/task-list-block.meta.js +21 -0
- package/dist/blocks/team-members-block.js +49 -0
- package/dist/blocks/team-members-block.meta.js +21 -0
- package/dist/blocks/testimonial-block.js +49 -0
- package/dist/blocks/testimonial-block.meta.js +20 -0
- package/dist/blocks/traffic-sources-block.js +39 -0
- package/dist/blocks/traffic-sources-block.meta.js +21 -0
- package/dist/blocks/traffic-trend-block.js +61 -0
- package/dist/blocks/traffic-trend-block.meta.js +21 -0
- package/dist/blocks/upload-list-block.js +39 -0
- package/dist/blocks/upload-list-block.meta.js +21 -0
- package/dist/blocks/verify-code-block.js +28 -0
- package/dist/blocks/verify-code-block.meta.js +21 -0
- package/dist/blocks/wallet-block.js +48 -0
- package/dist/blocks/wallet-block.meta.js +21 -0
- package/dist/components/accordion/accordion.js +63 -0
- package/dist/components/accordion/accordion.meta.js +155 -0
- package/dist/components/accordion/index.js +15 -0
- package/dist/components/app-header/app-header.js +30 -0
- package/dist/components/app-header/app-header.meta.js +88 -0
- package/dist/components/app-header/index.js +7 -0
- package/dist/components/app-shell/app-shell-dock.js +37 -0
- package/dist/components/app-shell/app-shell.js +67 -0
- package/dist/components/app-shell/app-shell.meta.js +176 -0
- package/dist/components/app-shell/index.js +7 -0
- package/dist/components/area-chart/area-chart.js +10 -0
- package/dist/components/area-chart/area-chart.meta.js +218 -0
- package/dist/components/area-chart/index.js +7 -0
- package/dist/components/attachment/attachment.js +176 -0
- package/dist/components/attachment/attachment.meta.js +179 -0
- package/dist/components/attachment/attachment.variants.js +47 -0
- package/dist/components/attachment/index.js +7 -0
- package/dist/components/avatar/avatar.js +192 -0
- package/dist/components/avatar/avatar.meta.js +233 -0
- package/dist/components/avatar/avatar.variants.js +44 -0
- package/dist/components/avatar/index.js +22 -0
- package/dist/components/badge/badge.js +26 -0
- package/dist/components/badge/badge.meta.js +171 -0
- package/dist/components/badge/badge.variants.js +103 -0
- package/dist/components/badge/index.js +11 -0
- package/dist/components/banner/banner.js +76 -0
- package/dist/components/banner/banner.meta.js +172 -0
- package/dist/components/banner/banner.variants.js +44 -0
- package/dist/components/banner/index.js +9 -0
- package/dist/components/bar-chart/bar-chart.js +10 -0
- package/dist/components/bar-chart/bar-chart.meta.js +245 -0
- package/dist/components/bar-chart/index.js +7 -0
- package/dist/components/bottom-nav/bottom-nav.js +63 -0
- package/dist/components/bottom-nav/bottom-nav.meta.js +95 -0
- package/dist/components/bottom-nav/index.js +11 -0
- package/dist/components/breadcrumbs/breadcrumbs.js +90 -0
- package/dist/components/breadcrumbs/breadcrumbs.meta.js +111 -0
- package/dist/components/breadcrumbs/index.js +17 -0
- package/dist/components/browser/browser.js +39 -0
- package/dist/components/browser/browser.meta.js +80 -0
- package/dist/components/browser/index.js +7 -0
- package/dist/components/bubble/bubble.js +110 -0
- package/dist/components/bubble/bubble.meta.js +158 -0
- package/dist/components/bubble/bubble.variants.js +90 -0
- package/dist/components/bubble/index.js +13 -0
- package/dist/components/button/button.js +87 -0
- package/dist/components/button/button.meta.js +302 -0
- package/dist/components/button/button.variants.js +137 -0
- package/dist/components/button/index.js +9 -0
- package/dist/components/calendar/calendar.js +89 -0
- package/dist/components/calendar/calendar.meta.js +136 -0
- package/dist/components/calendar/index.js +7 -0
- package/dist/components/card/card.js +203 -0
- package/dist/components/card/card.meta.js +212 -0
- package/dist/components/card/index.js +23 -0
- package/dist/components/carousel/carousel.js +428 -0
- package/dist/components/carousel/carousel.meta.js +100 -0
- package/dist/components/carousel/index.js +7 -0
- package/dist/components/chat-composer/chat-composer.js +174 -0
- package/dist/components/chat-composer/chat-composer.meta.js +241 -0
- package/dist/components/chat-composer/chat-composer.variants.js +17 -0
- package/dist/components/chat-composer/index.js +7 -0
- package/dist/components/checkbox/checkbox-mark.js +28 -0
- package/dist/components/checkbox/checkbox.js +30 -0
- package/dist/components/checkbox/checkbox.meta.js +127 -0
- package/dist/components/checkbox/index.js +7 -0
- package/dist/components/checkbox-card/checkbox-card.js +94 -0
- package/dist/components/checkbox-card/checkbox-card.meta.js +154 -0
- package/dist/components/checkbox-card/checkbox-card.variants.js +43 -0
- package/dist/components/checkbox-card/index.js +11 -0
- package/dist/components/code/code.js +19 -0
- package/dist/components/code/code.meta.js +71 -0
- package/dist/components/code/index.js +7 -0
- package/dist/components/code-block/code-block.js +84 -0
- package/dist/components/code-block/code-block.meta.js +103 -0
- package/dist/components/code-block/index.js +8 -0
- package/dist/components/collapsible/collapsible.js +38 -0
- package/dist/components/collapsible/collapsible.meta.js +117 -0
- package/dist/components/collapsible/index.js +15 -0
- package/dist/components/color-field/color-field.js +293 -0
- package/dist/components/color-field/color-field.meta.js +142 -0
- package/dist/components/color-field/color.js +69 -0
- package/dist/components/color-field/index.js +15 -0
- package/dist/components/combobox/combobox.js +486 -0
- package/dist/components/combobox/combobox.meta.js +217 -0
- package/dist/components/combobox/index.js +37 -0
- package/dist/components/command-menu/command-menu-trigger.js +122 -0
- package/dist/components/command-menu/command-menu.js +433 -0
- package/dist/components/command-menu/command-menu.meta.js +279 -0
- package/dist/components/command-menu/index.js +21 -0
- package/dist/components/context-card/context-card.js +62 -0
- package/dist/components/context-card/context-card.meta.js +106 -0
- package/dist/components/context-card/index.js +17 -0
- package/dist/components/context-menu/context-menu.js +92 -0
- package/dist/components/context-menu/context-menu.meta.js +138 -0
- package/dist/components/context-menu/index.js +23 -0
- package/dist/components/data-table/data-table.js +142 -0
- package/dist/components/data-table/data-table.meta.js +146 -0
- package/dist/components/data-table/index.js +9 -0
- package/dist/components/date-picker/date-picker.js +196 -0
- package/dist/components/date-picker/date-picker.meta.js +164 -0
- package/dist/components/date-picker/index.js +7 -0
- package/dist/components/date-tile/date-tile.js +29 -0
- package/dist/components/date-tile/date-tile.meta.js +72 -0
- package/dist/components/date-tile/index.js +7 -0
- package/dist/components/destructive-action-modal/destructive-action-modal.js +130 -0
- package/dist/components/destructive-action-modal/destructive-action-modal.meta.js +136 -0
- package/dist/components/destructive-action-modal/index.js +9 -0
- package/dist/components/dialog/dialog.js +207 -0
- package/dist/components/dialog/dialog.meta.js +239 -0
- package/dist/components/dialog/index.js +45 -0
- package/dist/components/disclosure-picker/disclosure-picker.js +417 -0
- package/dist/components/disclosure-picker/disclosure-picker.meta.js +213 -0
- package/dist/components/disclosure-picker/index.js +21 -0
- package/dist/components/empty-state/empty-state.js +52 -0
- package/dist/components/empty-state/empty-state.meta.js +158 -0
- package/dist/components/empty-state/index.js +7 -0
- package/dist/components/entity/entity.js +70 -0
- package/dist/components/entity/entity.meta.js +108 -0
- package/dist/components/entity/entity.variants.js +16 -0
- package/dist/components/entity/index.js +7 -0
- package/dist/components/feedback/feedback.js +93 -0
- package/dist/components/feedback/feedback.meta.js +109 -0
- package/dist/components/feedback/index.js +7 -0
- package/dist/components/fieldset/fieldset.js +80 -0
- package/dist/components/fieldset/fieldset.meta.js +160 -0
- package/dist/components/fieldset/index.js +17 -0
- package/dist/components/file-dropzone/file-dropzone.js +193 -0
- package/dist/components/file-dropzone/file-dropzone.meta.js +210 -0
- package/dist/components/file-dropzone/file-dropzone.variants.js +26 -0
- package/dist/components/file-dropzone/index.js +13 -0
- package/dist/components/font-picker/font-picker.js +77 -0
- package/dist/components/font-picker/font-picker.meta.js +119 -0
- package/dist/components/font-picker/index.js +7 -0
- package/dist/components/form/form.js +145 -0
- package/dist/components/form/form.meta.js +142 -0
- package/dist/components/form/index.js +11 -0
- package/dist/components/gliding-highlight/gliding-highlight.js +50 -0
- package/dist/components/gliding-highlight/gliding-highlight.meta.js +135 -0
- package/dist/components/gliding-highlight/index.js +9 -0
- package/dist/components/gradient-art/gradient-art.js +168 -0
- package/dist/components/gradient-art/gradient-art.meta.js +81 -0
- package/dist/components/gradient-art/index.js +7 -0
- package/dist/components/gradient-art/renderer.js +336 -0
- package/dist/components/inline-status/index.js +7 -0
- package/dist/components/inline-status/inline-status.js +33 -0
- package/dist/components/inline-status/inline-status.meta.js +83 -0
- package/dist/components/inline-status/inline-status.variants.js +18 -0
- package/dist/components/input/index.js +7 -0
- package/dist/components/input/input.js +33 -0
- package/dist/components/input/input.meta.js +155 -0
- package/dist/components/kbd/index.js +8 -0
- package/dist/components/kbd/kbd.js +54 -0
- package/dist/components/kbd/kbd.meta.js +95 -0
- package/dist/components/label/index.js +7 -0
- package/dist/components/label/label.js +51 -0
- package/dist/components/label/label.meta.js +98 -0
- package/dist/components/line-chart/index.js +7 -0
- package/dist/components/line-chart/line-chart.js +10 -0
- package/dist/components/line-chart/line-chart.meta.js +222 -0
- package/dist/components/marker/index.js +7 -0
- package/dist/components/marker/marker.js +39 -0
- package/dist/components/marker/marker.meta.js +132 -0
- package/dist/components/marker/marker.variants.js +25 -0
- package/dist/components/marquee/index.js +7 -0
- package/dist/components/marquee/marquee.js +146 -0
- package/dist/components/marquee/marquee.meta.js +106 -0
- package/dist/components/media-image/index.js +7 -0
- package/dist/components/media-image/media-image.js +56 -0
- package/dist/components/media-image/media-image.meta.js +92 -0
- package/dist/components/media-tile/index.js +7 -0
- package/dist/components/media-tile/media-tile.js +135 -0
- package/dist/components/media-tile/media-tile.meta.js +133 -0
- package/dist/components/media-tile/media-tile.variants.js +48 -0
- package/dist/components/menu/index.js +33 -0
- package/dist/components/menu/menu.js +168 -0
- package/dist/components/menu/menu.meta.js +176 -0
- package/dist/components/message/index.js +7 -0
- package/dist/components/message/message.js +83 -0
- package/dist/components/message/message.meta.js +150 -0
- package/dist/components/message/message.variants.js +41 -0
- package/dist/components/message-scroller/index.js +9 -0
- package/dist/components/message-scroller/message-scroller.js +300 -0
- package/dist/components/message-scroller/message-scroller.meta.js +172 -0
- package/dist/components/meter/index.js +20 -0
- package/dist/components/meter/meter.js +112 -0
- package/dist/components/meter/meter.meta.js +227 -0
- package/dist/components/meter/meter.variants.js +61 -0
- package/dist/components/middle-truncate/index.js +7 -0
- package/dist/components/middle-truncate/middle-truncate.js +28 -0
- package/dist/components/middle-truncate/middle-truncate.meta.js +77 -0
- package/dist/components/note/index.js +9 -0
- package/dist/components/note/note.js +73 -0
- package/dist/components/note/note.meta.js +112 -0
- package/dist/components/note/note.variants.js +22 -0
- package/dist/components/notification-badge/index.js +15 -0
- package/dist/components/notification-badge/notification-badge.js +48 -0
- package/dist/components/notification-badge/notification-badge.meta.js +107 -0
- package/dist/components/notification-badge/notification-badge.variants.js +57 -0
- package/dist/components/number-field/index.js +7 -0
- package/dist/components/number-field/number-field.js +70 -0
- package/dist/components/number-field/number-field.meta.js +109 -0
- package/dist/components/otp-field/index.js +7 -0
- package/dist/components/otp-field/otp-field.js +70 -0
- package/dist/components/otp-field/otp-field.meta.js +197 -0
- package/dist/components/overflow-menu/index.js +7 -0
- package/dist/components/overflow-menu/overflow-menu.js +26 -0
- package/dist/components/overflow-menu/overflow-menu.meta.js +100 -0
- package/dist/components/page-header/index.js +7 -0
- package/dist/components/page-header/page-header.js +34 -0
- package/dist/components/page-header/page-header.meta.js +104 -0
- package/dist/components/page-loader/index.js +7 -0
- package/dist/components/page-loader/page-loader.js +37 -0
- package/dist/components/page-loader/page-loader.meta.js +85 -0
- package/dist/components/pagination/index.js +7 -0
- package/dist/components/pagination/pagination.js +105 -0
- package/dist/components/pagination/pagination.meta.js +141 -0
- package/dist/components/panel-group/index.js +10 -0
- package/dist/components/panel-group/panel-group.js +168 -0
- package/dist/components/panel-group/panel-group.meta.js +153 -0
- package/dist/components/panel-section/index.js +9 -0
- package/dist/components/panel-section/panel-section.js +115 -0
- package/dist/components/panel-section/panel-section.meta.js +156 -0
- package/dist/components/phone/index.js +7 -0
- package/dist/components/phone/phone.js +50 -0
- package/dist/{chunk-KFZ43RKU.js → components/phone/phone.meta.js} +3 -50
- package/dist/{chunk-NEQNXI2G.js → components/phone/status-icons.js} +4 -4
- package/dist/components/pie-chart/index.js +7 -0
- package/dist/components/pie-chart/pie-chart.js +191 -0
- package/dist/components/pie-chart/pie-chart.meta.js +210 -0
- package/dist/components/pill/index.js +9 -0
- package/dist/components/pill/pill.js +24 -0
- package/dist/components/pill/pill.meta.js +108 -0
- package/dist/components/pill/pill.variants.js +65 -0
- package/dist/components/popover/index.js +23 -0
- package/dist/components/popover/popover.js +100 -0
- package/dist/components/popover/popover.meta.js +136 -0
- package/dist/components/progress/index.js +19 -0
- package/dist/components/progress/progress.js +70 -0
- package/dist/components/progress/progress.meta.js +171 -0
- package/dist/components/progress/progress.variants.js +17 -0
- package/dist/components/radar-chart/index.js +7 -0
- package/dist/components/radar-chart/radar-chart.js +161 -0
- package/dist/components/radar-chart/radar-chart.meta.js +204 -0
- package/dist/components/radial-chart/index.js +7 -0
- package/dist/components/radial-chart/radial-chart.js +175 -0
- package/dist/components/radial-chart/radial-chart.meta.js +216 -0
- package/dist/components/radio/index.js +8 -0
- package/dist/components/radio/radio.js +39 -0
- package/dist/components/radio/radio.meta.js +104 -0
- package/dist/components/radio-card/index.js +7 -0
- package/dist/components/radio-card/radio-card.js +81 -0
- package/dist/components/radio-card/radio-card.meta.js +144 -0
- package/dist/components/radio-card/radio-card.variants.js +43 -0
- package/dist/components/relative-time/index.js +7 -0
- package/dist/components/relative-time/relative-time.js +47 -0
- package/dist/components/relative-time/relative-time.meta.js +77 -0
- package/dist/components/scroller/index.js +7 -0
- package/dist/components/scroller/scroller.js +111 -0
- package/dist/components/scroller/scroller.meta.js +118 -0
- package/dist/components/scrub-field/index.js +7 -0
- package/dist/components/scrub-field/scrub-field.js +124 -0
- package/dist/components/scrub-field/scrub-field.meta.js +139 -0
- package/dist/components/search-input/index.js +7 -0
- package/dist/components/search-input/search-input.js +91 -0
- package/dist/components/search-input/search-input.meta.js +129 -0
- package/dist/components/section-header/index.js +7 -0
- package/dist/components/section-header/section-header.js +39 -0
- package/dist/components/section-header/section-header.meta.js +174 -0
- package/dist/components/segmented-bar/index.js +14 -0
- package/dist/components/segmented-bar/segmented-bar.js +26 -0
- package/dist/components/segmented-bar/segmented-bar.meta.js +108 -0
- package/dist/components/segmented-bar/segmented-bar.variants.js +39 -0
- package/dist/components/select/index.js +25 -0
- package/dist/components/select/select.js +191 -0
- package/dist/components/select/select.meta.js +189 -0
- package/dist/components/separator/index.js +7 -0
- package/dist/components/separator/separator.js +42 -0
- package/dist/components/separator/separator.meta.js +84 -0
- package/dist/components/settings-menu/index.js +11 -0
- package/dist/components/settings-menu/settings-menu.js +101 -0
- package/dist/components/settings-menu/settings-menu.meta.js +177 -0
- package/dist/components/settings-menu/settings-menu.variants.js +15 -0
- package/dist/components/sheet/index.js +29 -0
- package/dist/components/sheet/sheet.js +121 -0
- package/dist/components/sheet/sheet.meta.js +167 -0
- package/dist/components/sheet/sheet.variants.js +21 -0
- package/dist/components/show-more/index.js +7 -0
- package/dist/components/show-more/show-more.js +87 -0
- package/dist/components/show-more/show-more.meta.js +79 -0
- package/dist/components/side-nav/index.js +13 -0
- package/dist/components/side-nav/side-nav.js +75 -0
- package/dist/components/side-nav/side-nav.meta.js +139 -0
- package/dist/components/side-nav/side-nav.variants.js +34 -0
- package/dist/components/sidebar/index.js +21 -0
- package/dist/components/sidebar/sidebar.js +196 -0
- package/dist/components/sidebar/sidebar.meta.js +118 -0
- package/dist/components/skeleton/index.js +7 -0
- package/dist/components/skeleton/skeleton.js +16 -0
- package/dist/components/skeleton/skeleton.meta.js +90 -0
- package/dist/components/slider/index.js +21 -0
- package/dist/components/slider/slider.js +115 -0
- package/dist/components/slider/slider.meta.js +214 -0
- package/dist/components/sparkline/index.js +7 -0
- package/dist/components/sparkline/sparkline.js +84 -0
- package/dist/components/sparkline/sparkline.meta.js +147 -0
- package/dist/components/spinner/index.js +7 -0
- package/dist/components/spinner/spinner.js +59 -0
- package/dist/components/spinner/spinner.meta.js +118 -0
- package/dist/components/status-bar/index.js +9 -0
- package/dist/components/status-bar/status-bar.js +40 -0
- package/dist/components/status-bar/status-bar.meta.js +117 -0
- package/dist/components/status-dot/index.js +9 -0
- package/dist/components/status-dot/status-dot.js +40 -0
- package/dist/components/status-dot/status-dot.meta.js +113 -0
- package/dist/components/status-dot/status-dot.variants.js +28 -0
- package/dist/components/stepper/index.js +17 -0
- package/dist/components/stepper/stepper.js +161 -0
- package/dist/components/stepper/stepper.meta.js +190 -0
- package/dist/components/stepper/stepper.variants.js +72 -0
- package/dist/components/streaming-text/index.js +7 -0
- package/dist/components/streaming-text/streaming-text.js +55 -0
- package/dist/components/streaming-text/streaming-text.meta.js +147 -0
- package/dist/components/switch/index.js +7 -0
- package/dist/components/switch/switch.js +26 -0
- package/dist/components/switch/switch.meta.js +133 -0
- package/dist/components/table/index.js +23 -0
- package/dist/components/table/table.js +117 -0
- package/dist/components/table/table.meta.js +143 -0
- package/dist/components/tabs/index.js +17 -0
- package/dist/components/tabs/tabs.js +58 -0
- package/dist/components/tabs/tabs.meta.js +120 -0
- package/dist/components/textarea/index.js +7 -0
- package/dist/components/textarea/textarea.js +38 -0
- package/dist/components/textarea/textarea.meta.js +117 -0
- package/dist/components/theme-selector/index.js +11 -0
- package/dist/components/theme-selector/theme-selector.js +145 -0
- package/dist/components/theme-selector/theme-selector.meta.js +181 -0
- package/dist/components/theme-switcher/index.js +7 -0
- package/dist/components/theme-switcher/theme-switcher.js +106 -0
- package/dist/components/theme-switcher/theme-switcher.meta.js +99 -0
- package/dist/components/thinking-orb/index.js +9 -0
- package/dist/components/thinking-orb/thinking-orb.js +126 -0
- package/dist/components/thinking-orb/thinking-orb.meta.js +142 -0
- package/dist/components/thinking-orb/thinking-orb.render.js +252 -0
- package/dist/components/toast/index.js +29 -0
- package/dist/components/toast/toast.js +254 -0
- package/dist/components/toast/toast.meta.js +261 -0
- package/dist/components/toggle/index.js +13 -0
- package/dist/components/toggle/toggle.js +51 -0
- package/dist/components/toggle/toggle.meta.js +129 -0
- package/dist/components/toolbar/index.js +27 -0
- package/dist/components/toolbar/toolbar.js +80 -0
- package/dist/components/toolbar/toolbar.meta.js +272 -0
- package/dist/components/toolbar/toolbar.variants.js +16 -0
- package/dist/components/tooltip/index.js +21 -0
- package/dist/components/tooltip/tooltip.js +74 -0
- package/dist/components/tooltip/tooltip.meta.js +156 -0
- package/dist/components/tree/index.js +12 -0
- package/dist/components/tree/tree.js +364 -0
- package/dist/components/tree/tree.meta.js +334 -0
- package/dist/components/video/index.js +7 -0
- package/dist/components/video/video.js +59 -0
- package/dist/components/video/video.meta.js +81 -0
- package/dist/components/workspace/index.js +9 -0
- package/dist/components/workspace/workspace.js +79 -0
- package/dist/components/workspace/workspace.meta.js +183 -0
- package/dist/foundations/accessibility.js +107 -0
- package/dist/foundations/index.js +25 -792
- package/dist/foundations/motion.js +135 -0
- package/dist/foundations/scrolling.js +62 -0
- package/dist/foundations/shape.js +113 -0
- package/dist/foundations/spacing.js +121 -0
- package/dist/foundations/surfaces.js +107 -0
- package/dist/foundations/types.js +0 -0
- package/dist/foundations/typography.js +152 -0
- package/dist/icon/Icon.js +30 -0
- package/dist/icon/IconProvider.js +27 -0
- package/dist/icon/compose.js +13 -0
- package/dist/icon/index.d.ts +13 -5
- package/dist/icon/index.js +13 -2
- package/dist/icon/registry.js +0 -0
- package/dist/icon-sets/brands.generated.js +123 -0
- package/dist/icon-sets/brands.js +10 -123
- package/dist/icon-sets/reicon.d.ts +3 -1
- package/dist/icon-sets/reicon.js +209 -1
- package/dist/index.d.ts +618 -34
- package/dist/index.js +211 -11351
- package/dist/ios/chrome.js +36 -0
- package/dist/ios/index.js +19 -315
- package/dist/ios/liquid-glass.js +103 -0
- package/dist/ios/native-app.js +46 -0
- package/dist/ios/navigation.js +63 -0
- package/dist/ios/phone-frame.js +64 -0
- package/dist/ios/preview-theme.js +83 -0
- package/dist/ios/preview.js +10 -137
- package/dist/ios/sheet.js +85 -0
- package/dist/ios/theme.js +19 -0
- package/dist/layout/AspectBox.js +27 -0
- package/dist/layout/Bleed.js +15 -0
- package/dist/layout/Center.js +21 -0
- package/dist/layout/Columns.js +70 -0
- package/dist/layout/Grid.js +50 -0
- package/dist/layout/GridOverlay.js +41 -0
- package/dist/layout/Page.js +11 -0
- package/dist/layout/Region.js +39 -0
- package/dist/layout/Row.js +45 -0
- package/dist/layout/SafeArea.js +26 -0
- package/dist/layout/Spacer.js +9 -0
- package/dist/layout/SplitPane.js +55 -0
- package/dist/layout/Stack.js +42 -0
- package/dist/layout/_responsive.js +16 -0
- package/dist/layout/_safeArea.js +18 -0
- package/dist/layout/index.d.ts +30 -8
- package/dist/layout/index.js +14 -2
- package/dist/lib/anchored-scroll.js +7 -0
- package/dist/lib/bleed.js +7 -0
- package/dist/lib/chart/cartesian.js +365 -0
- package/dist/lib/chart/chart-frame.js +163 -0
- package/dist/lib/chart/chart-legend.js +13 -0
- package/dist/lib/chart/chart-tooltip.js +38 -0
- package/dist/lib/chart/format.js +24 -0
- package/dist/lib/chart/geometry.js +114 -0
- package/dist/lib/chart/motion.js +34 -0
- package/dist/lib/chart/scale.js +72 -0
- package/dist/lib/chart/types.js +45 -0
- package/dist/lib/chart/use-arrival.js +41 -0
- package/dist/{chunk-SRQWQUTW.js → lib/cn.js} +5 -5
- package/dist/lib/component-guidance.js +215 -0
- package/dist/lib/density.js +54 -0
- package/dist/lib/field-shell.js +11 -0
- package/dist/lib/glyphs.js +9 -0
- package/dist/lib/heading-row.js +36 -0
- package/dist/lib/highlight-glide.js +84 -0
- package/dist/lib/hit-target.js +5 -0
- package/dist/lib/meta.js +0 -0
- package/dist/lib/portal-theme-scope.js +74 -0
- package/dist/{chunk-FKZXAFIY.js → lib/presentation.js} +8 -3
- package/dist/lib/refs.js +14 -0
- package/dist/lib/scrub-gesture.js +50 -0
- package/dist/lib/segmented.js +13 -0
- package/dist/lib/selection-card.js +41 -0
- package/dist/lib/status-tone.js +13 -0
- package/dist/lib/taxonomy.js +214 -0
- package/dist/lib/trim-label.js +20 -0
- package/dist/lib/use-copy.js +27 -0
- package/dist/lib/use-edge-fade.js +32 -0
- package/dist/lib/use-isomorphic-layout-effect.js +8 -0
- package/dist/lib/use-resize-observer.js +18 -0
- package/dist/lib/use-scroll-activity.js +52 -0
- package/dist/lib/use-wheel-to-horizontal.js +65 -0
- package/dist/motion/MotionProvider.js +75 -0
- package/dist/motion/Presence.js +112 -0
- package/dist/motion/Reveal.js +42 -0
- package/dist/motion/TextReveal.js +61 -0
- package/dist/motion/index.js +24 -0
- package/dist/motion/useInView.js +36 -0
- package/dist/motion/useReducedMotion.js +25 -0
- package/dist/motion/viewTransition.js +47 -0
- package/dist/patterns/auth-page.js +90 -0
- package/dist/patterns/auth-page.meta.js +27 -0
- package/dist/patterns/brand-landing-page.js +341 -0
- package/dist/patterns/brand-landing-page.meta.js +28 -0
- package/dist/patterns/chat-panel.js +185 -0
- package/dist/patterns/chat-panel.meta.js +31 -0
- package/dist/patterns/dashboard-page.js +332 -0
- package/dist/patterns/dashboard-page.meta.js +29 -0
- package/dist/patterns/feed-page.js +205 -0
- package/dist/patterns/feed-page.meta.js +25 -0
- package/dist/patterns/index.js +34 -2140
- package/dist/patterns/landing-page.js +356 -0
- package/dist/patterns/landing-page.meta.js +26 -0
- package/dist/patterns/list-detail-page.js +373 -0
- package/dist/patterns/list-detail-page.meta.js +24 -0
- package/dist/patterns/settings-page.js +268 -0
- package/dist/patterns/settings-page.meta.js +27 -0
- package/dist/presets/index.d.ts +7 -1
- package/dist/presets/index.js +53 -185
- package/dist/styles.css +118 -3
- package/dist/{theme-provider-kQszHYji.d.ts → theme-provider-S0NVJs2Y.d.ts} +6 -1
- package/dist/{chunk-NETUFI34.js → themes/_contract.js} +14 -2
- package/dist/themes/atelier/index.js +17 -1
- package/dist/themes/contrast.js +288 -0
- package/dist/themes/ember/ember.css +6 -6
- package/dist/themes/ember/index.js +16 -1
- package/dist/themes/index.d.ts +1 -1
- package/dist/themes/index.js +3 -8
- package/dist/themes/meridian/index.js +16 -1
- package/dist/themes/poppy/index.js +17 -1
- package/dist/themes/prism/index.js +17 -1
- package/dist/themes/prism/prism.css +2 -2
- package/dist/themes/registry.js +59 -0
- package/dist/themes/sable/index.js +16 -1
- package/dist/{chunk-EFLC4NA3.js → themes/theme-provider.js} +38 -54
- package/dist/themes/verdant/index.js +17 -1
- package/llms/blocks/media-player-block.md +20 -7
- package/llms/blocks/product-card-block.md +6 -4
- package/llms/blocks/task-list-block.md +1 -1
- package/llms/blocks/traffic-sources-block.md +1 -1
- package/llms/blocks/traffic-trend-block.md +5 -3
- package/llms/blocks/verify-code-block.md +1 -1
- package/llms/components/app-shell.md +5 -2
- package/llms/components/avatar.md +25 -3
- package/llms/components/button.md +20 -1
- package/llms/components/card.md +1 -1
- package/llms/components/disclosure-picker.md +168 -0
- package/llms/components/font-picker.md +104 -0
- package/llms/components/media-image.md +98 -0
- package/llms/components/media-tile.md +143 -0
- package/llms/components/page-header.md +6 -5
- package/llms/components/pie-chart.md +2 -2
- package/llms/components/radial-chart.md +1 -1
- package/llms/components/section-header.md +144 -0
- package/llms/components/sheet.md +10 -0
- package/llms/components/sidebar.md +4 -3
- package/llms/components/slider.md +14 -2
- package/llms/components/theme-selector.md +44 -4
- package/llms/components/tree.md +3 -3
- package/llms/layout/grid.md +24 -3
- package/llms/layout/region.md +16 -3
- package/llms/patterns/dashboard-page.md +3 -6
- package/llms/patterns/feed-page.md +4 -4
- package/llms/patterns/list-detail-page.md +1 -0
- package/llms/patterns/settings-page.md +2 -5
- package/llms/reference/utilities.md +84 -4
- package/llms/tasks/building-a-page.md +34 -3
- package/llms/tasks/converting-an-app.md +14 -4
- package/llms/tasks/creating-a-preset.md +4 -1
- package/llms/tasks/reviewing-a-design.md +19 -0
- package/llms/tasks/using-presets.md +19 -0
- package/llms-full.txt +1066 -78
- package/llms.txt +26 -9
- package/package.json +1 -1
- package/registry.json +2342 -164
- package/scripts/agent-setup.mjs +90 -0
- package/scripts/design-presets.mjs +59 -6
- package/scripts/grade.mjs +195 -29
- package/scripts/lint-rules.mjs +486 -10
- package/scripts/review-evidence.mjs +28 -1
- package/skills/sable/SKILL.md +67 -24
- package/targets/ios.json +45 -0
- package/dist/chunk-3IWZYBGH.js +0 -398
- package/dist/chunk-7MG4SHYX.js +0 -15
- package/dist/chunk-7QSDYBXO.js +0 -7421
- package/dist/chunk-ADLLDWLW.js +0 -15
- package/dist/chunk-C4Z3CF54.js +0 -15
- package/dist/chunk-EDXLM7YL.js +0 -5078
- package/dist/chunk-EHH4MHO6.js +0 -14
- package/dist/chunk-K7RUHUGC.js +0 -14
- package/dist/chunk-KO3NAEBF.js +0 -15
- package/dist/chunk-L4IEIQQA.js +0 -14
- package/dist/chunk-QJ6FWTUG.js +0 -40
- package/dist/chunk-VVW4UCDI.js +0 -4908
- package/dist/chunk-XAZGMY73.js +0 -187
package/registry.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://sable.design/registry.schema.json",
|
|
3
3
|
"name": "@sableui/react",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.7.1",
|
|
5
5
|
"description": "Themeable React components, semantic tokens, layout patterns, presets, and package-owned agent skills. Default theme: Sable.",
|
|
6
6
|
"engine": {
|
|
7
7
|
"behavior": "@base-ui/react",
|
|
@@ -34,8 +34,8 @@
|
|
|
34
34
|
"note": "theme declares the stacks; the host app loads the font files"
|
|
35
35
|
},
|
|
36
36
|
"providers": {
|
|
37
|
-
"required": "
|
|
38
|
-
"optional": "
|
|
37
|
+
"required": "IconProvider at the app root: import { reiconIconSet } from \"@sableui/react/icons/reicon\" and wrap the app in <IconProvider set={reiconIconSet}> (or pass your own IconSet). Without it every <Icon> renders nothing; development builds warn once.",
|
|
38
|
+
"optional": "Extend Reicon with composeIconSets(appExtras, reiconIconSet) — composeIconSets from \"@sableui/react\", reiconIconSet from \"@sableui/react/icons/reicon\". MotionProvider controls motion. SableThemeProvider supplies user-selectable color modes and themes."
|
|
39
39
|
},
|
|
40
40
|
"themeSwitching": "Preferred: wrap the app in SableThemeProvider and call useSableTheme() to read mode/preference/themeId/themes and setPreference/setThemeId. The provider persists choices, toggles the `dark` class for light/dark/system, and writes data-sable-theme for non-default design themes.",
|
|
41
41
|
"darkMode": "Low-level contract: toggle the `dark` class on <html> (dark-by-default lives under .dark). Sable uses no `dark:` utilities, so a Tailwind `@custom-variant dark` is only needed if your own app code uses them.",
|
|
@@ -60,6 +60,7 @@
|
|
|
60
60
|
"precedents": "sable learn --search \"<context>\" --json",
|
|
61
61
|
"composition": "Inspect a relevant pattern skeleton. Reuse when its information architecture fits; original compositions from layout primitives are equally valid.",
|
|
62
62
|
"review": "sable docs reviewing-a-design",
|
|
63
|
+
"finish": "sable check <source paths> runs doctor, lint and grade in one pass and exits 1 on any failure, then lists the review steps no tool can verify. Optional Claude Code Stop hook: sable setup --agent --target claude --hook",
|
|
63
64
|
"evidenceTemplate": "sable grade --template",
|
|
64
65
|
"learning": "sable docs learning-from-builds",
|
|
65
66
|
"localMemory": ".sable/lessons/ for causal observations; .sable/preferences/ for scoped judgments; both project-owned and preserved across package upgrades",
|
|
@@ -1568,6 +1569,22 @@
|
|
|
1568
1569
|
{
|
|
1569
1570
|
"id": "status-text-drift",
|
|
1570
1571
|
"fails-on": "A status colour has two tokens: the fill (`--destructive`, `--success`, `--tone-warning`, `--tone-info`), which `bg-*` paints, and its `-text` (`--destructive-text` …), which `text-*`, `border-*`, `ring-*`, `outline-*`, `fill-*` and `stroke-*` read. Setting the fill alone leaves the text on the theme's colour, so a retuned red button sits beside the theme's red error message. Set the `-text` in the same theme and mode: the fill itself where it already reads on your surfaces, or a step of it moved away from them (lighter in dark mode) so it clears 4.5:1 on a card."
|
|
1572
|
+
},
|
|
1573
|
+
{
|
|
1574
|
+
"id": "lookalike-grid",
|
|
1575
|
+
"fails-on": "A raw CSS grid laid over a collection — `grid` with an auto-fill/auto-fit `minmax()` track, or uniform `grid-cols-N` columns over mapped or repeated items. That is Sable's `Grid`: `cols` takes the same counts per breakpoint (`{ base: 2, md: 4 }`), `minItemWidth` is the auto-fit track with a `min(…, 100%)` guard so a cell never overflows a narrow container (a bare `minmax(12rem,1fr)` does), every track is `minmax(0, 1fr)` so one long string cannot blow a column out, and the gap defaults to the theme's `--grid-gutter` or takes rhythm units (`gap={2}` = `gap-4`). Not flagged: a grid with no columns, a form layout (field children, no mapping), a non-uniform track (`grid-cols-[auto_1fr]`), a `ul`/`ol`/`dl` (Grid renders a div), or an element with a widget role."
|
|
1576
|
+
},
|
|
1577
|
+
{
|
|
1578
|
+
"id": "lookalike-card",
|
|
1579
|
+
"fails-on": "A link or button dressed as a card — a radius, a surface (`bg-card` / `bg-popover`, or a border) and card padding on an <a>, <button> or router <Link>. That is `<Card render={<Link to=… />}>` (or `<a href>` / `<button>`): rendered as a link or button, Card adds the hover (a stronger edge and shadow), a full-card focus-visible ring and motion-interactive easing; its padding follows the density control (`--card-p`) and its corner follows the theme radius; children answer the hover with `group-hover/card:`. Hand-built, a tile keeps none of that when the theme or density moves. Not flagged: a text link, a nav row (no surface), a pill (`rounded-full`), a button fill (lookalike-control's), or an element inside a `render` prop."
|
|
1580
|
+
},
|
|
1581
|
+
{
|
|
1582
|
+
"id": "lookalike-avatar",
|
|
1583
|
+
"fails-on": "A round, fixed-size box holding a person's or artist's picture — `rounded-full` + `size-*` (or equal `h-*`/`w-*`) around an <img>, GradientArt or initials, or a round fixed-size <img> itself. That is `Avatar`: `src`/`alt` with a fallback that shows while the image loads or when it fails, `variant=\"art\"` for initials over GradientArt seeded by the person (a stable colour each, from the theme), a `size` ladder, an accessible name (role=img), a `status` slot that clears the circle, and AvatarGroup for an overlapping stack. Not flagged: a dot or an icon in a circle (no picture or initials), a round box without a fixed size, or AvatarRoot and its parts."
|
|
1584
|
+
},
|
|
1585
|
+
{
|
|
1586
|
+
"id": "lookalike-media-frame",
|
|
1587
|
+
"fails-on": "A cover or media frame built by hand — an `aspect-*` ratio + `overflow-hidden` wrapped around an <img>, <video>, <picture> or GradientArt. That is `AspectBox`: `ratio` as a number reserves the box before the media arrives (no layout shift while a cover loads or a shader starts), it clips and positions what it holds (`relative w-full overflow-hidden`), and it is the one frame the media components share, so a change to how frames behave reaches every cover. Put the radius on it as a className. For a cover with its title beneath (an album, an episode, a product), `MediaTile` composes this frame with fallback art for a missing or broken image and an action revealed on hover. Not flagged: a ratio box with no media in it (a pad, a canvas well), a frame without overflow clipping, an <img> sized by its own aspect class, or a round fixed-size frame (lookalike-avatar's)."
|
|
1571
1588
|
}
|
|
1572
1589
|
],
|
|
1573
1590
|
"escapeHatch": {
|
|
@@ -1581,7 +1598,7 @@
|
|
|
1581
1598
|
}
|
|
1582
1599
|
},
|
|
1583
1600
|
"iconContract": {
|
|
1584
|
-
"note": "Components reference semantic names through <Icon
|
|
1601
|
+
"note": "Components reference semantic names through <Icon>, rendered by the app's IconProvider set. Reicon is opt-in from @sableui/react/icons/reicon; there is no fallback, so an app that supplies its own set ships no Reicon code.",
|
|
1585
1602
|
"names": [
|
|
1586
1603
|
"heart",
|
|
1587
1604
|
"comment",
|
|
@@ -1653,7 +1670,15 @@
|
|
|
1653
1670
|
"key",
|
|
1654
1671
|
"wallet",
|
|
1655
1672
|
"thumbs-up",
|
|
1656
|
-
"thumbs-down"
|
|
1673
|
+
"thumbs-down",
|
|
1674
|
+
"shuffle",
|
|
1675
|
+
"repeat",
|
|
1676
|
+
"repeat-one",
|
|
1677
|
+
"volume",
|
|
1678
|
+
"volume-low",
|
|
1679
|
+
"volume-off",
|
|
1680
|
+
"queue",
|
|
1681
|
+
"music"
|
|
1657
1682
|
]
|
|
1658
1683
|
},
|
|
1659
1684
|
"layoutPrimitives": {
|
|
@@ -1681,7 +1706,19 @@
|
|
|
1681
1706
|
"bestPractices": [
|
|
1682
1707
|
{
|
|
1683
1708
|
"guidance": "do",
|
|
1684
|
-
"description": "Choose the max value by
|
|
1709
|
+
"description": "Choose the max value by what the person does on the page: read (prose), fill in a form (form), scroll one stream (feed), review one record or settings (content), scan and compare rows or cards (wide)."
|
|
1710
|
+
},
|
|
1711
|
+
{
|
|
1712
|
+
"guidance": "do",
|
|
1713
|
+
"description": "Omit max (fluid) for work across panes, a board, an editor or a map, where a flexible part uses the extra width; cap text inside it with --measure-prose."
|
|
1714
|
+
},
|
|
1715
|
+
{
|
|
1716
|
+
"guidance": "do",
|
|
1717
|
+
"description": "Give sibling routes in one nav the same width so the header does not jump between them."
|
|
1718
|
+
},
|
|
1719
|
+
{
|
|
1720
|
+
"guidance": "do",
|
|
1721
|
+
"description": "Review a capped or fluid route at 1920px and 2560px: a cap is a ceiling, so below the cap plus app chrome every choice looks the same."
|
|
1685
1722
|
},
|
|
1686
1723
|
{
|
|
1687
1724
|
"guidance": "do",
|
|
@@ -1690,6 +1727,14 @@
|
|
|
1690
1727
|
{
|
|
1691
1728
|
"guidance": "dont",
|
|
1692
1729
|
"description": "Do not use Region as a global wrapper that forces every page into the same max width."
|
|
1730
|
+
},
|
|
1731
|
+
{
|
|
1732
|
+
"guidance": "dont",
|
|
1733
|
+
"description": "Do not go fluid when the extra width only widens the gaps between fixed columns; cap it."
|
|
1734
|
+
},
|
|
1735
|
+
{
|
|
1736
|
+
"guidance": "dont",
|
|
1737
|
+
"description": "Do not read full as edge to edge: it is the largest cap and keeps the page inset. Use bleed for edge to edge."
|
|
1693
1738
|
}
|
|
1694
1739
|
]
|
|
1695
1740
|
},
|
|
@@ -1716,7 +1761,7 @@
|
|
|
1716
1761
|
{
|
|
1717
1762
|
"name": "max",
|
|
1718
1763
|
"type": "\"prose\" | \"form\" | \"feed\" | \"content\" | \"wide\" | \"full\" | CSS length",
|
|
1719
|
-
"description": "Opt-in centered max-width cap, purpose-named: prose 65ch (long-form reading) · form 480px (auth, narrow single-column forms) · feed 640px (single-column timeline) · content 896px (settings/admin panes) · wide 1280px (dashboards, tables, card grids) · full 1536px (
|
|
1764
|
+
"description": "Opt-in centered max-width cap, purpose-named: prose 65ch (long-form reading) · form 480px (auth, narrow single-column forms) · feed 640px (single-column timeline) · content 896px (settings/admin panes) · wide 1280px (dashboards, tables, card grids) · full 1536px (the largest cap; keeps the inset, so not edge to edge — that is bleed). Omit for fluid: work views whose panes, board or canvas use the extra width. A cap is a ceiling: it applies only once the viewport minus app chrome exceeds it, so judge it at 1920px and up."
|
|
1720
1765
|
},
|
|
1721
1766
|
{
|
|
1722
1767
|
"name": "bleed",
|
|
@@ -1739,9 +1784,14 @@
|
|
|
1739
1784
|
"imports": "import { PageHeader, Region } from \"@sableui/react\""
|
|
1740
1785
|
},
|
|
1741
1786
|
{
|
|
1742
|
-
"label": "
|
|
1787
|
+
"label": "Dashboard region (capped at wide)",
|
|
1743
1788
|
"code": "<Region max=\"wide\"><Grid minItemWidth=\"16rem\"><Card><CardHeader><CardTitle>Revenue</CardTitle></CardHeader></Card><Card><CardHeader><CardTitle>Collects</CardTitle></CardHeader></Card></Grid></Region>",
|
|
1744
1789
|
"imports": "import { Card, CardHeader, CardTitle, Grid, Region } from \"@sableui/react\""
|
|
1790
|
+
},
|
|
1791
|
+
{
|
|
1792
|
+
"label": "Fluid workspace: the queue's table takes the extra width",
|
|
1793
|
+
"code": "<Region as=\"main\"><Columns count={{ base: 1, lg: 12 }}><Col span={{ base: 1, lg: 3 }}><SideNav aria-label=\"Views\"><SideNavItem label=\"Open\" active /><SideNavItem label=\"Pending\" /></SideNav></Col><Col span={{ base: 1, lg: 9 }}><Card><CardContent flush><DataTable columns={columns} data={tickets} /></CardContent></Card></Col></Columns></Region>",
|
|
1794
|
+
"imports": "import { Card, CardContent, Col, Columns, DataTable, Region, SideNav, SideNavItem } from \"@sableui/react\""
|
|
1745
1795
|
}
|
|
1746
1796
|
]
|
|
1747
1797
|
},
|
|
@@ -1813,7 +1863,7 @@
|
|
|
1813
1863
|
"name": "Grid",
|
|
1814
1864
|
"status": "stable",
|
|
1815
1865
|
"path": "layout",
|
|
1816
|
-
"summary": "CSS Grid within a region. Two modes: fixed/responsive cols count, or minItemWidth
|
|
1866
|
+
"summary": "CSS Grid within a region. Two modes: fixed/responsive cols count, or minItemWidth so cells breathe with NO breakpoints (the default choice for card grids). A minItemWidth grid keeps its empty columns (track=\"fill\"), so one result stays one cell wide; track=\"fit\" stretches a fixed set across the row.",
|
|
1817
1867
|
"keywords": [
|
|
1818
1868
|
"grid",
|
|
1819
1869
|
"card grid",
|
|
@@ -1827,6 +1877,14 @@
|
|
|
1827
1877
|
"guidance": "do",
|
|
1828
1878
|
"description": "Prefer minItemWidth for responsive card grids that do not need breakpoint-specific spans."
|
|
1829
1879
|
},
|
|
1880
|
+
{
|
|
1881
|
+
"guidance": "do",
|
|
1882
|
+
"description": "Keep the default track=\"fill\" for collections whose count is data (search results, shelves, galleries): sparse rows keep their empty columns, so one result stays one cell wide."
|
|
1883
|
+
},
|
|
1884
|
+
{
|
|
1885
|
+
"guidance": "do",
|
|
1886
|
+
"description": "Use track=\"fit\" only for a fixed set designed to span the row, such as three KPI cards."
|
|
1887
|
+
},
|
|
1830
1888
|
{
|
|
1831
1889
|
"guidance": "do",
|
|
1832
1890
|
"description": "Use explicit columns only when the design truly needs a fixed track count."
|
|
@@ -1834,6 +1892,10 @@
|
|
|
1834
1892
|
{
|
|
1835
1893
|
"guidance": "dont",
|
|
1836
1894
|
"description": "Do not use Grid to set a page's reading width; use Region or Center."
|
|
1895
|
+
},
|
|
1896
|
+
{
|
|
1897
|
+
"guidance": "dont",
|
|
1898
|
+
"description": "Do not use track=\"fit\" for results or other data-driven collections: with one or two items the empty columns collapse and each item stretches across the row (a single result rendered as a full-width cover)."
|
|
1837
1899
|
}
|
|
1838
1900
|
]
|
|
1839
1901
|
},
|
|
@@ -1854,13 +1916,19 @@
|
|
|
1854
1916
|
{
|
|
1855
1917
|
"name": "minItemWidth",
|
|
1856
1918
|
"type": "string",
|
|
1857
|
-
"description": "
|
|
1919
|
+
"description": "Breathing mode: repeat(auto-fill, minmax(minItemWidth, 1fr)) — as many columns of at least minItemWidth as fit, wrapping without breakpoints. e.g. \"16rem\"."
|
|
1920
|
+
},
|
|
1921
|
+
{
|
|
1922
|
+
"name": "track",
|
|
1923
|
+
"type": "\"fill\" | \"fit\"",
|
|
1924
|
+
"default": "\"fill\"",
|
|
1925
|
+
"description": "What a minItemWidth grid does with columns it has no items for (only visible when fewer items than fit one row). \"fill\" (auto-fill) keeps them, so items keep their size whatever their count — one search result is one cover wide, not a banner. Use it for collections whose count is data: results, shelves, galleries. \"fit\" (auto-fit) collapses them and stretches the items to share the row — only for a fixed set designed to span it (three KPI cards, plan tiers)."
|
|
1858
1926
|
},
|
|
1859
1927
|
{
|
|
1860
1928
|
"name": "gap",
|
|
1861
|
-
"type": "number | string",
|
|
1929
|
+
"type": "number | [row, column] | string",
|
|
1862
1930
|
"default": "var(--grid-gutter)",
|
|
1863
|
-
"description": "Gap between cells. Number = rhythm units (×8px); string = raw CSS."
|
|
1931
|
+
"description": "Gap between cells. Number = rhythm units (×8px); [row, column] = two rhythm counts, for a gallery whose rows want more air than its columns; string = raw CSS."
|
|
1864
1932
|
}
|
|
1865
1933
|
],
|
|
1866
1934
|
"tokensUsed": [],
|
|
@@ -1870,6 +1938,16 @@
|
|
|
1870
1938
|
"code": "<Grid minItemWidth=\"16rem\" gap={3}><Card><CardHeader><CardTitle>Alpha</CardTitle></CardHeader></Card><Card><CardHeader><CardTitle>Beta</CardTitle></CardHeader></Card><Card><CardHeader><CardTitle>Gamma</CardTitle></CardHeader></Card></Grid>",
|
|
1871
1939
|
"imports": "import { Card, CardHeader, CardTitle, Grid } from \"@sableui/react\""
|
|
1872
1940
|
},
|
|
1941
|
+
{
|
|
1942
|
+
"label": "Sparse results keep their size",
|
|
1943
|
+
"code": "// One match still renders one cover wide: the empty columns are kept (track=\"fill\", the default).\n<Grid minItemWidth=\"10rem\" gap={2}><Card><CardHeader><CardTitle>Night Swim</CardTitle><CardDescription>Album · Undertow</CardDescription></CardHeader></Card></Grid>",
|
|
1944
|
+
"imports": "import { Card, CardDescription, CardHeader, CardTitle, Grid } from \"@sableui/react\""
|
|
1945
|
+
},
|
|
1946
|
+
{
|
|
1947
|
+
"label": "A fixed set that spans the row",
|
|
1948
|
+
"code": "<Grid minItemWidth=\"12rem\" track=\"fit\" gap={2}><Card><CardHeader><CardDescription>Listeners</CardDescription><span className=\"text-mono-xl\">48.2k</span></CardHeader></Card><Card><CardHeader><CardDescription>Streams</CardDescription><span className=\"text-mono-xl\">1.3M</span></CardHeader></Card><Card><CardHeader><CardDescription>Saves</CardDescription><span className=\"text-mono-xl\">9,410</span></CardHeader></Card></Grid>",
|
|
1949
|
+
"imports": "import { Card, CardDescription, CardHeader, Grid } from \"@sableui/react\""
|
|
1950
|
+
},
|
|
1873
1951
|
{
|
|
1874
1952
|
"label": "Responsive fixed columns",
|
|
1875
1953
|
"code": "<Grid cols={{ base: 1, md: 2, lg: 4 }}><Badge appearance=\"plain\" tone=\"standard\" dot /><Badge appearance=\"plain\" tone=\"warning\" dot /><Badge appearance=\"plain\" tone=\"neutral\" dot /><Badge appearance=\"plain\" tone=\"destructive\" dot /></Grid>",
|
|
@@ -2745,7 +2823,7 @@
|
|
|
2745
2823
|
"group": "Layout",
|
|
2746
2824
|
"register": "page",
|
|
2747
2825
|
"path": "components/app-shell",
|
|
2748
|
-
"summary": "The application frame: slots for status banners, a mobile header, a desktop sidebar, main content, a mobile bottom nav, and floating overlays — plus a built-in skip link and #main-content target.",
|
|
2826
|
+
"summary": "The application frame: slots for status banners, a mobile header, a desktop sidebar, main content, a mobile bottom nav, a persistent dock (a now-playing or call bar), and floating overlays — plus a built-in skip link and #main-content target.",
|
|
2749
2827
|
"keywords": [
|
|
2750
2828
|
"app shell",
|
|
2751
2829
|
"layout",
|
|
@@ -2755,7 +2833,11 @@
|
|
|
2755
2833
|
"bottom nav",
|
|
2756
2834
|
"mobile",
|
|
2757
2835
|
"banners",
|
|
2758
|
-
"main"
|
|
2836
|
+
"main",
|
|
2837
|
+
"dock",
|
|
2838
|
+
"player bar",
|
|
2839
|
+
"now playing",
|
|
2840
|
+
"persistent"
|
|
2759
2841
|
],
|
|
2760
2842
|
"usage": {
|
|
2761
2843
|
"description": "Use AppShell as the outer product frame for authenticated or app-like experiences. It coordinates responsive chrome slots and guarantees a skip link plus main landmark; put route content inside the children and let AppHeader, Sidebar, BottomNav, and Banner own their own semantics.",
|
|
@@ -2810,6 +2892,11 @@
|
|
|
2810
2892
|
"name": "Bottom nav",
|
|
2811
2893
|
"required": false,
|
|
2812
2894
|
"description": "Mobile fixed navigation with reserved bottom space."
|
|
2895
|
+
},
|
|
2896
|
+
{
|
|
2897
|
+
"name": "Dock",
|
|
2898
|
+
"required": false,
|
|
2899
|
+
"description": "Persistent bottom chrome that outlives routes — a now-playing bar, a call bar. Sticky above the bottom nav; the desktop Sidebar shortens to clear it."
|
|
2813
2900
|
}
|
|
2814
2901
|
]
|
|
2815
2902
|
},
|
|
@@ -2848,6 +2935,11 @@
|
|
|
2848
2935
|
"type": "ReactNode",
|
|
2849
2936
|
"description": "Mobile bottom navigation (e.g. BottomNav); fixed to the bottom edge. main reserves space for it."
|
|
2850
2937
|
},
|
|
2938
|
+
{
|
|
2939
|
+
"name": "dock",
|
|
2940
|
+
"type": "ReactNode",
|
|
2941
|
+
"description": "Persistent chrome kept on screen across routes (a now-playing bar, an active-call bar, an upload tray). Full width, sticky to the bottom edge and above bottomNav on mobile; its height is published as --dock-height and the app Sidebar subtracts it. In flow, so main needs no reserved space. The contents paint their own surface and top border, and name themselves (e.g. <section aria-label=\"Player\">). Mount AppShell in a layout that survives navigation, or the playback restarts."
|
|
2942
|
+
},
|
|
2851
2943
|
{
|
|
2852
2944
|
"name": "overlays",
|
|
2853
2945
|
"type": "ReactNode",
|
|
@@ -2863,7 +2955,8 @@
|
|
|
2863
2955
|
"a11y": [
|
|
2864
2956
|
"Renders a skip link to #main-content as the first focusable element.",
|
|
2865
2957
|
"Wraps content in a <main id=\"main-content\"> landmark.",
|
|
2866
|
-
"Slot components own their own ARIA (nav landmarks, labels)."
|
|
2958
|
+
"Slot components own their own ARIA (nav landmarks, labels).",
|
|
2959
|
+
"The dock follows <main> in DOM order, so keyboard and screen-reader users reach the player after the page content, not before it."
|
|
2867
2960
|
],
|
|
2868
2961
|
"examples": [
|
|
2869
2962
|
{
|
|
@@ -3334,7 +3427,7 @@
|
|
|
3334
3427
|
},
|
|
3335
3428
|
{
|
|
3336
3429
|
"guidance": "do",
|
|
3337
|
-
"description": "
|
|
3430
|
+
"description": "Pick the size by the job: sm in dense rows and tables, md (the default) beside one line of text, lg in a row with a title and subtitle, xl (96px) for a profile header beside the person's name, 2xl (160px) for an artist or profile hero and roster portraits. Keep one size within a list or group."
|
|
3338
3431
|
},
|
|
3339
3432
|
{
|
|
3340
3433
|
"guidance": "do",
|
|
@@ -3351,6 +3444,10 @@
|
|
|
3351
3444
|
{
|
|
3352
3445
|
"guidance": "dont",
|
|
3353
3446
|
"description": "Overload a dense table with large avatars; use sm or omit images when scanning data is more important."
|
|
3447
|
+
},
|
|
3448
|
+
{
|
|
3449
|
+
"guidance": "dont",
|
|
3450
|
+
"description": "Build a portrait by stretching an Avatar with size classes (`className=\"size-40\"`) — the initials, the status indicator and the image fade stay at row scale. Use size=\"xl\" or \"2xl\", which scale all three. Past 160px, or for a non-circular cover, it is media: an AspectBox, not an avatar."
|
|
3354
3451
|
}
|
|
3355
3452
|
],
|
|
3356
3453
|
"anatomy": [
|
|
@@ -3422,9 +3519,9 @@
|
|
|
3422
3519
|
},
|
|
3423
3520
|
{
|
|
3424
3521
|
"name": "size",
|
|
3425
|
-
"type": "\"sm\" | \"md\" | \"lg\"",
|
|
3522
|
+
"type": "\"sm\" | \"md\" | \"lg\" | \"xl\" | \"2xl\"",
|
|
3426
3523
|
"default": "\"md\"",
|
|
3427
|
-
"description": "Square footprint: 32px / 40px / 48px."
|
|
3524
|
+
"description": "Square footprint: 32px / 40px / 48px for rows; 96px / 160px for portraits (a profile header, an artist hero, a roster tile). The initials, a status indicator and its cut-out ring scale with it, and at the portrait sizes the photo fades in over the resting surface."
|
|
3428
3525
|
},
|
|
3429
3526
|
{
|
|
3430
3527
|
"name": "variant",
|
|
@@ -3469,7 +3566,9 @@
|
|
|
3469
3566
|
"size": [
|
|
3470
3567
|
"sm",
|
|
3471
3568
|
"md",
|
|
3472
|
-
"lg"
|
|
3569
|
+
"lg",
|
|
3570
|
+
"xl",
|
|
3571
|
+
"2xl"
|
|
3473
3572
|
],
|
|
3474
3573
|
"variant": [
|
|
3475
3574
|
"neutral",
|
|
@@ -3485,6 +3584,32 @@
|
|
|
3485
3584
|
"overlay",
|
|
3486
3585
|
"overlay-foreground"
|
|
3487
3586
|
],
|
|
3587
|
+
"motion": [
|
|
3588
|
+
{
|
|
3589
|
+
"slot": "arrival",
|
|
3590
|
+
"recipe": "motion-fade",
|
|
3591
|
+
"part": "Image",
|
|
3592
|
+
"why": "At the portrait sizes (xl, 2xl) the photo replacing the initials is a visible pop across one of the page's largest elements; fading it in reads as the picture arriving, not the layout glitching. Row sizes swap instantly — too small to register, and a list of fading faces would shimmer."
|
|
3593
|
+
},
|
|
3594
|
+
{
|
|
3595
|
+
"slot": "response",
|
|
3596
|
+
"recipe": "none",
|
|
3597
|
+
"part": "Root",
|
|
3598
|
+
"why": "An avatar is not interactive itself; a link or button wrapping it owns the response."
|
|
3599
|
+
},
|
|
3600
|
+
{
|
|
3601
|
+
"slot": "transition",
|
|
3602
|
+
"recipe": "none",
|
|
3603
|
+
"part": "Root",
|
|
3604
|
+
"why": "It has no states of its own to move between beyond the image arriving."
|
|
3605
|
+
},
|
|
3606
|
+
{
|
|
3607
|
+
"slot": "activity",
|
|
3608
|
+
"recipe": "none",
|
|
3609
|
+
"part": "Root",
|
|
3610
|
+
"why": "Loading shows the fallback, which is already a finished resting state rather than a spinner."
|
|
3611
|
+
}
|
|
3612
|
+
],
|
|
3488
3613
|
"a11y": [
|
|
3489
3614
|
"Base UI tracks the image loading status and swaps to the fallback on missing/loading/error — no manual onError wiring.",
|
|
3490
3615
|
"Provide meaningful alt text via the alt prop; pass an empty alt for decorative avatars.",
|
|
@@ -3513,6 +3638,11 @@
|
|
|
3513
3638
|
"code": "<Avatar src=\"/pfp.png\" alt=\"Alex Morgan\" fallback=\"AM\" size=\"sm\" />\n<Avatar src=\"/pfp.png\" alt=\"Alex Morgan\" fallback=\"AM\" size=\"md\" />\n<Avatar src=\"/pfp.png\" alt=\"Alex Morgan\" fallback=\"AM\" size=\"lg\" />",
|
|
3514
3639
|
"imports": "import { Avatar } from \"@sableui/react\""
|
|
3515
3640
|
},
|
|
3641
|
+
{
|
|
3642
|
+
"label": "Profile portrait",
|
|
3643
|
+
"code": "<div className=\"flex flex-col items-center gap-3 text-center\">\n\t<Avatar variant=\"art\" size=\"2xl\" fallback=\"LV\" alt=\"Lena Vale\" status={<StatusDot tone=\"success\" />} />\n\t<div className=\"flex flex-col gap-1\">\n\t\t<h1 className=\"text-heading-1\">Lena Vale</h1>\n\t\t<p className=\"text-body-sm text-muted-foreground\">1.2M monthly listeners</p>\n\t</div>\n</div>",
|
|
3644
|
+
"imports": "import { Avatar, StatusDot } from \"@sableui/react\""
|
|
3645
|
+
},
|
|
3516
3646
|
{
|
|
3517
3647
|
"label": "Low-level parts",
|
|
3518
3648
|
"code": "<AvatarRoot size=\"lg\"><AvatarImage src=\"/pfp.png\" alt=\"Mara Okafor\" /><AvatarFallback>MO</AvatarFallback></AvatarRoot>",
|
|
@@ -4664,7 +4794,12 @@
|
|
|
4664
4794
|
"inverse",
|
|
4665
4795
|
"band",
|
|
4666
4796
|
"brand fill",
|
|
4667
|
-
"tone fill"
|
|
4797
|
+
"tone fill",
|
|
4798
|
+
"bleed",
|
|
4799
|
+
"optical alignment",
|
|
4800
|
+
"align label to edge",
|
|
4801
|
+
"negative margin",
|
|
4802
|
+
"view all"
|
|
4668
4803
|
],
|
|
4669
4804
|
"usage": {
|
|
4670
4805
|
"description": "Use Button when the user commits an action, submits a form, opens UI, or confirms a decision. Use the visual tier to communicate priority: one primary action per local decision area, then secondary, ghost, outline, or link for lower-emphasis choices.",
|
|
@@ -4685,6 +4820,14 @@
|
|
|
4685
4820
|
"guidance": "do",
|
|
4686
4821
|
"description": "Put icons in iconStart / iconEnd rather than as children, so the button applies its optical padding. Start icons say what the action is (plus, share); end icons say where it goes (chevron-right, external)."
|
|
4687
4822
|
},
|
|
4823
|
+
{
|
|
4824
|
+
"guidance": "do",
|
|
4825
|
+
"description": "Set bleed on a ghost or link button that sits at the start or end of a row, so its LABEL lines up with the content edge the row shares (\"View all\" over a grid, \"Add task\" under a list, \"Resend code\" at a card footer's end). The box moves out by exactly its own padding; the hit area and hover fill stay full size and run past the edge. SectionHeader, PageHeader (back and actions) and CardAction already do this for their outermost ghost/link child — don't add it there, and use bleed=\"none\" to opt one out."
|
|
4826
|
+
},
|
|
4827
|
+
{
|
|
4828
|
+
"guidance": "dont",
|
|
4829
|
+
"description": "Pull a ghost button onto an edge with -ms-3 / -me-3 classNames. That number is one size's padding: it is wrong at xs, md and cta, wrong on a side carrying an icon, and wrong once a preset retunes the spacing. bleed reads the button's own padding var. And don't bleed a filled or outlined button — its visible edge is what aligns, so bleed is a no-op there."
|
|
4830
|
+
},
|
|
4688
4831
|
{
|
|
4689
4832
|
"guidance": "dont",
|
|
4690
4833
|
"description": "Use destructive for reversible or low-risk actions; reserve it for operations with real loss or danger."
|
|
@@ -4785,6 +4928,11 @@
|
|
|
4785
4928
|
"name": "iconEnd",
|
|
4786
4929
|
"type": "ReactNode",
|
|
4787
4930
|
"description": "Icon after the label (disclosure, external link, next). Same optical correction on the end side."
|
|
4931
|
+
},
|
|
4932
|
+
{
|
|
4933
|
+
"name": "bleed",
|
|
4934
|
+
"type": "\"start\" | \"end\" | \"both\" | \"none\"",
|
|
4935
|
+
"description": "Optical alignment for a borderless button (ghost, link, on-color-ghost): a negative inline margin of exactly its own padding on that side — --button-px, or the icon side's optical padding when an icon sits there; an icon-only size bleeds by its inset to the glyph — so the label (or glyph) lands on the content edge. Hit area and hover/focus fill keep their full size. Logical sides, so it mirrors in RTL. A no-op on variants with a visible edge (primary, secondary, outline, destructive, floating, field, on-color, on-color-outline): a filled button aligns its edge, not its label. Unset, a borderless button renders data-bleed=\"auto\" and SectionHeader, PageHeader and CardAction bleed it when it is their outermost child; none opts out."
|
|
4788
4936
|
}
|
|
4789
4937
|
],
|
|
4790
4938
|
"variants": {
|
|
@@ -4864,6 +5012,7 @@
|
|
|
4864
5012
|
"Use render={<a/>} for link semantics rather than onClick navigation.",
|
|
4865
5013
|
"On-color tiers replace the --ring focus ring with one drawn in the band's ink past a transparent 2px gap, so focus stays visible on any fill (ink-on-fill is the theme's own tested pair).",
|
|
4866
5014
|
"On-color contrast is measured in button.test.tsx for every shipped theme and mode on each tone, primary, destructive, success, highlight and inverse fill: solid label at rest and hover, and outline/ghost label on the hover wash, all at least 4.5:1.",
|
|
5015
|
+
"bleed moves only the box, never the hit target: the full padded area stays clickable and the focus ring still draws around the whole button, past the content edge.",
|
|
4867
5016
|
"loading sets aria-busy and disables activation (disabled on a native button, aria-disabled on a render element) so the action can't double-fire."
|
|
4868
5017
|
],
|
|
4869
5018
|
"examples": [
|
|
@@ -4927,6 +5076,11 @@
|
|
|
4927
5076
|
"code": "<Button size=\"icon\" variant=\"ghost\" aria-label=\"Share\"><Icon name=\"share\" /></Button>",
|
|
4928
5077
|
"imports": "import { Button, Icon } from \"@sableui/react\""
|
|
4929
5078
|
},
|
|
5079
|
+
{
|
|
5080
|
+
"label": "Optically aligned to the row's edges",
|
|
5081
|
+
"code": "<Stack gap={1.5}>\n <Row justify=\"between\">\n <span className=\"text-title-md\">Tasks</span>\n <Button variant=\"ghost\" size=\"sm\" bleed=\"end\">View all</Button>\n </Row>\n <span className=\"text-body-sm text-muted-foreground\">Ship the release notes</span>\n <Button variant=\"ghost\" size=\"sm\" bleed=\"start\" iconStart={<Icon name=\"plus\" />} className=\"self-start\">Add task</Button>\n</Stack>",
|
|
5082
|
+
"imports": "import { Button, Icon, Row, Stack } from \"@sableui/react\""
|
|
5083
|
+
},
|
|
4930
5084
|
{
|
|
4931
5085
|
"label": "Icon button with an unread badge",
|
|
4932
5086
|
"code": "<Button size=\"icon\" variant=\"ghost\" aria-label=\"Notifications\" badge={<NotificationBadge dot />}><Icon name=\"bell\" /></Button>",
|
|
@@ -5218,7 +5372,7 @@
|
|
|
5218
5372
|
{
|
|
5219
5373
|
"name": "CardAction",
|
|
5220
5374
|
"type": "ComponentPropsWithRef<\"div\">",
|
|
5221
|
-
"description": "Header-aligned top-right action slot (e.g. an overflow menu, switch, or close button). Place inside CardHeader, which becomes a 2-column grid when present and collapses to a single column otherwise."
|
|
5375
|
+
"description": "Header-aligned top-right action slot (e.g. an overflow menu, switch, or close button). Place inside CardHeader, which becomes a 2-column grid when present and collapses to a single column otherwise. A ghost or link Button that ends the slot bleeds by its own padding at the end, so a \"View all\" label or a \"⋯\" glyph sits on the card's content edge; filled buttons keep their edge there."
|
|
5222
5376
|
},
|
|
5223
5377
|
{
|
|
5224
5378
|
"name": "CardContent",
|
|
@@ -8107,6 +8261,241 @@
|
|
|
8107
8261
|
}
|
|
8108
8262
|
]
|
|
8109
8263
|
},
|
|
8264
|
+
{
|
|
8265
|
+
"name": "DisclosurePicker",
|
|
8266
|
+
"status": "stable",
|
|
8267
|
+
"group": "Selection",
|
|
8268
|
+
"register": "either",
|
|
8269
|
+
"path": "components/disclosure-picker",
|
|
8270
|
+
"summary": "A property row that names the current value and opens its list in place, beneath the row, instead of in a popup — for a tool panel or settings column where the choice is made while looking at what it changes. Built on Collapsible; the list is a listbox — arrow keys move, Enter or a click picks, applies and closes — and an optional search field filters long lists.",
|
|
8271
|
+
"keywords": [
|
|
8272
|
+
"disclosure picker",
|
|
8273
|
+
"inline picker",
|
|
8274
|
+
"in-place select",
|
|
8275
|
+
"expanding list",
|
|
8276
|
+
"property row",
|
|
8277
|
+
"font picker",
|
|
8278
|
+
"preset picker",
|
|
8279
|
+
"inspector",
|
|
8280
|
+
"picker row"
|
|
8281
|
+
],
|
|
8282
|
+
"usage": {
|
|
8283
|
+
"description": "Use DisclosurePicker for one-of-many choices in a docked panel or inspector where a popup would cover the preview the choice is about: the row shows the current value (in its own face, with a tile, as the content warrants), and the list opens beneath it, pushing the panel down. Use Select for a form field or anywhere a popup is fine; use Combobox when people type to find an option among hundreds; use RadioGroup or RadioCard when every option should stay visible. Nothing applies while arrowing through the list — only Enter, Space or a click picks — so it suits choices with a cost (a confirm, a font load, a reset); where each option should apply as focus lands on it, that is a radio group (ThemeSelector, RadioGroup).",
|
|
8284
|
+
"bestPractices": [
|
|
8285
|
+
{
|
|
8286
|
+
"guidance": "do",
|
|
8287
|
+
"description": "Enable search (`search` on DisclosurePickerPanel) only for long lists — roughly a dozen options or more, i.e. once the list scrolls. A short list (a handful of presets) reads faster with no field to skip past; the panel then has no search row and no gap."
|
|
8288
|
+
},
|
|
8289
|
+
{
|
|
8290
|
+
"guidance": "do",
|
|
8291
|
+
"description": "Show the value as the thing it is: a font row renders the family in its own face, a theme row leads with a ThemePreview tile in `media`. The row is a preview of the choice, not a label for it."
|
|
8292
|
+
},
|
|
8293
|
+
{
|
|
8294
|
+
"guidance": "do",
|
|
8295
|
+
"description": "Use `size=\"xs\"` in a property grid (a compact Density / PanelSection), where it sits on the inset panel-field fill beside ScrubField and an xs SelectTrigger. Keep `md` in a settings column or a simple panel."
|
|
8296
|
+
},
|
|
8297
|
+
{
|
|
8298
|
+
"guidance": "do",
|
|
8299
|
+
"description": "When several rows share a panel, control `open` from the parent so only one list is open at a time — two open lists push each other and the preview out of view."
|
|
8300
|
+
},
|
|
8301
|
+
{
|
|
8302
|
+
"guidance": "do",
|
|
8303
|
+
"description": "Use it for a choice with a cost — one that opens a confirm, loads something, or resets other settings. Arrow keys only move focus; onValueChange fires once, on Enter, Space or a click, and only when the pick differs from `value`."
|
|
8304
|
+
},
|
|
8305
|
+
{
|
|
8306
|
+
"guidance": "do",
|
|
8307
|
+
"description": "Give options a leading visual with `media` (a ThemePreview tile, a swatch) instead of nesting another component's list: ThemeSelector's radio list applies each theme as the arrow keys land on it, which defeats commit-on-Enter. A list the picker cannot express (a Tree) can still close the row with `useDisclosurePicker().close()`."
|
|
8308
|
+
},
|
|
8309
|
+
{
|
|
8310
|
+
"guidance": "do",
|
|
8311
|
+
"description": "Render DisclosurePickerEmpty in place of the list when a search matches nothing, not inside it — a listbox holds only options."
|
|
8312
|
+
},
|
|
8313
|
+
{
|
|
8314
|
+
"guidance": "dont",
|
|
8315
|
+
"description": "Use it in a page-register form. A form field that pushes the form down on open reads as a layout jump; that is Select's job."
|
|
8316
|
+
},
|
|
8317
|
+
{
|
|
8318
|
+
"guidance": "dont",
|
|
8319
|
+
"description": "Rebuild the row from a Collapsible with hand-copied classes. The disclosure, the well, the hover step, the xs field shell, focus return and scroll-into-view all live here; two hand copies of this row in one panel is how it came to exist."
|
|
8320
|
+
},
|
|
8321
|
+
{
|
|
8322
|
+
"guidance": "dont",
|
|
8323
|
+
"description": "Turn search on for a short list just because the other rows have it. A search field over six options is a control with nothing to do."
|
|
8324
|
+
}
|
|
8325
|
+
],
|
|
8326
|
+
"anatomy": [
|
|
8327
|
+
{
|
|
8328
|
+
"name": "DisclosurePicker",
|
|
8329
|
+
"required": true,
|
|
8330
|
+
"description": "Root. Owns open state, size, focus return and scroll-into-view."
|
|
8331
|
+
},
|
|
8332
|
+
{
|
|
8333
|
+
"name": "DisclosurePickerTrigger",
|
|
8334
|
+
"required": true,
|
|
8335
|
+
"description": "The row: optional `label` caption, optional `media`, the value, and a chevron that flips when open."
|
|
8336
|
+
},
|
|
8337
|
+
{
|
|
8338
|
+
"name": "DisclosurePickerPanel",
|
|
8339
|
+
"required": true,
|
|
8340
|
+
"description": "What opens beneath the row: the opt-in search field, then the list."
|
|
8341
|
+
},
|
|
8342
|
+
{
|
|
8343
|
+
"name": "DisclosurePickerList",
|
|
8344
|
+
"required": false,
|
|
8345
|
+
"description": "A listbox of options that scrolls past 16rem. Arrow keys move; Enter, Space or a click picks."
|
|
8346
|
+
},
|
|
8347
|
+
{
|
|
8348
|
+
"name": "DisclosurePickerOption",
|
|
8349
|
+
"required": false,
|
|
8350
|
+
"description": "One option row, with optional leading media and trailing note. Chosen, it lifts out of the well; focused, it takes the ring."
|
|
8351
|
+
},
|
|
8352
|
+
{
|
|
8353
|
+
"name": "DisclosurePickerEmpty",
|
|
8354
|
+
"required": false,
|
|
8355
|
+
"description": "The no-match line, rendered in place of the list when a search filters everything out."
|
|
8356
|
+
}
|
|
8357
|
+
]
|
|
8358
|
+
},
|
|
8359
|
+
"related": {
|
|
8360
|
+
"components": [
|
|
8361
|
+
"Collapsible",
|
|
8362
|
+
"Select",
|
|
8363
|
+
"Combobox",
|
|
8364
|
+
"SearchInput",
|
|
8365
|
+
"RadioGroup",
|
|
8366
|
+
"ThemeSelector",
|
|
8367
|
+
"FontPicker",
|
|
8368
|
+
"PropertyRow"
|
|
8369
|
+
]
|
|
8370
|
+
},
|
|
8371
|
+
"props": [
|
|
8372
|
+
{
|
|
8373
|
+
"name": "open",
|
|
8374
|
+
"type": "boolean",
|
|
8375
|
+
"description": "Controlled open state. Pair with onOpenChange."
|
|
8376
|
+
},
|
|
8377
|
+
{
|
|
8378
|
+
"name": "defaultOpen",
|
|
8379
|
+
"type": "boolean",
|
|
8380
|
+
"default": "false",
|
|
8381
|
+
"description": "Uncontrolled initial open state."
|
|
8382
|
+
},
|
|
8383
|
+
{
|
|
8384
|
+
"name": "onOpenChange",
|
|
8385
|
+
"type": "(open: boolean) => void",
|
|
8386
|
+
"description": "Called with the next open state — from the trigger, a pick, or Escape."
|
|
8387
|
+
},
|
|
8388
|
+
{
|
|
8389
|
+
"name": "size",
|
|
8390
|
+
"type": "\"md\" | \"xs\"",
|
|
8391
|
+
"default": "\"md\"",
|
|
8392
|
+
"description": "md is a --control-md row (40px comfortable) in a settings column, its own well when closed. xs is the property-grid rung: a --control-xs row on the inset panel-field fill with ui-tier type. Height, padding, radius, type and surface move together."
|
|
8393
|
+
},
|
|
8394
|
+
{
|
|
8395
|
+
"name": "className",
|
|
8396
|
+
"type": "string",
|
|
8397
|
+
"description": "Merged onto the root (layout only — the look comes from size)."
|
|
8398
|
+
}
|
|
8399
|
+
],
|
|
8400
|
+
"subcomponents": [
|
|
8401
|
+
{
|
|
8402
|
+
"name": "DisclosurePickerTrigger",
|
|
8403
|
+
"type": "{ label?: ReactNode; media?: ReactNode; children?: ReactNode } + Base UI Collapsible.Trigger props",
|
|
8404
|
+
"description": "The row. `label` is a muted leading caption on a fixed column so stacked rows align; `media` a leading visual (a ThemePreview tile); children the value, truncated on one line. ArrowDown opens it, and steps into the list once open."
|
|
8405
|
+
},
|
|
8406
|
+
{
|
|
8407
|
+
"name": "DisclosurePickerPanel",
|
|
8408
|
+
"type": "{ search?: { value: string; onValueChange(value: string): void; placeholder?: string; label?: string } } + Collapsible.Panel props",
|
|
8409
|
+
"description": "Opens beneath the row. `search` opts in to a SearchInput above the list (off by default); it is controlled because the consumer filters, and it is cleared when the row closes. Escape clears a query first, then closes; ArrowDown in the field steps into the list."
|
|
8410
|
+
},
|
|
8411
|
+
{
|
|
8412
|
+
"name": "DisclosurePickerList",
|
|
8413
|
+
"type": "{ value: string; onValueChange(value: string): void; aria-label?: string } + div props",
|
|
8414
|
+
"description": "A listbox. ArrowUp/ArrowDown, Home/End and type-ahead move focus without applying anything (ArrowUp from the first option returns to the search field); Enter, Space or a click picks the focused option — onValueChange fires once, only when it differs from `value` — and closes the row. Escape closes without a pick."
|
|
8415
|
+
},
|
|
8416
|
+
{
|
|
8417
|
+
"name": "DisclosurePickerOption",
|
|
8418
|
+
"type": "{ value: string; children?: ReactNode; media?: ReactNode; note?: ReactNode; disabled?: boolean; textValue?: string }",
|
|
8419
|
+
"description": "One option row. `media` is a leading visual (a ThemePreview tile); `note` a muted trailing annotation — options without one show a check when chosen. `disabled` skips it for the keyboard and refuses the pick; `textValue` is what type-ahead matches when the name is not plain text."
|
|
8420
|
+
},
|
|
8421
|
+
{
|
|
8422
|
+
"name": "DisclosurePickerEmpty",
|
|
8423
|
+
"type": "{ children?: ReactNode }",
|
|
8424
|
+
"description": "The no-match line, rendered in place of the list."
|
|
8425
|
+
},
|
|
8426
|
+
{
|
|
8427
|
+
"name": "useDisclosurePicker",
|
|
8428
|
+
"type": "() => { open: boolean; close(): void; size: \"md\" | \"xs\" }",
|
|
8429
|
+
"description": "For a list the picker does not own (a Tree) that should close the row when something is picked."
|
|
8430
|
+
}
|
|
8431
|
+
],
|
|
8432
|
+
"tokensUsed": [
|
|
8433
|
+
"foreground",
|
|
8434
|
+
"muted-foreground",
|
|
8435
|
+
"popover",
|
|
8436
|
+
"hairline",
|
|
8437
|
+
"panel-field",
|
|
8438
|
+
"panel-field-hover",
|
|
8439
|
+
"ring",
|
|
8440
|
+
"accent"
|
|
8441
|
+
],
|
|
8442
|
+
"motion": [
|
|
8443
|
+
{
|
|
8444
|
+
"slot": "arrival",
|
|
8445
|
+
"recipe": "none",
|
|
8446
|
+
"part": "root",
|
|
8447
|
+
"why": "A property row is present from first paint; animating it in would stage chrome the user is already scanning."
|
|
8448
|
+
},
|
|
8449
|
+
{
|
|
8450
|
+
"slot": "response",
|
|
8451
|
+
"recipe": "motion-interactive",
|
|
8452
|
+
"part": "DisclosurePickerTrigger",
|
|
8453
|
+
"why": "The row reads as a value, not a button; the hover step is what says it opens before it is clicked."
|
|
8454
|
+
},
|
|
8455
|
+
{
|
|
8456
|
+
"slot": "transition",
|
|
8457
|
+
"recipe": "motion-disclosure",
|
|
8458
|
+
"part": "DisclosurePickerPanel",
|
|
8459
|
+
"why": "The list pushes the panel down; growing its height ties the movement below to this row rather than to a layout jump."
|
|
8460
|
+
},
|
|
8461
|
+
{
|
|
8462
|
+
"slot": "activity",
|
|
8463
|
+
"recipe": "none",
|
|
8464
|
+
"part": "root",
|
|
8465
|
+
"why": "Nothing is indeterminate: the options are resident. A list that loads should show its own loader inside the panel."
|
|
8466
|
+
}
|
|
8467
|
+
],
|
|
8468
|
+
"a11y": [
|
|
8469
|
+
"The trigger is a real <button>; Base UI wires aria-expanded and aria-controls to the panel. Its accessible name is its text (caption + value) unless an aria-label is given.",
|
|
8470
|
+
"The list is role=\"listbox\" with role=\"option\" rows; aria-selected marks the committed value, not the focused row, so a screen reader hears both where focus is and what is chosen. Selection does not follow focus: ArrowUp/ArrowDown, Home/End and type-ahead move focus (roving tabindex — one tab stop, so Tab leaves the list in one step) and apply nothing; Enter, Space or a click picks, applies and closes. Disabled options carry aria-disabled and are skipped.",
|
|
8471
|
+
"Why a listbox and not a radio group: a radio group's checked state follows focus, so every arrow press would be a pick — wrong for a choice that opens a confirm, loads a font or resets settings. Base UI ships no standalone listbox (Select is popup-only, Combobox's inline list is focus-in-the-input, Composite is internal), so the keyboard model lives here.",
|
|
8472
|
+
"Opening from the keyboard (Enter, Space, ArrowDown) moves focus into the panel: the search field when there is one, otherwise the chosen option (or the first). A pointer open focuses the search field on a fine pointer only, so touch does not raise the on-screen keyboard.",
|
|
8473
|
+
"Escape closes the row without changing the value (clearing a query first) and returns focus to the trigger; a pick does the same. Focus is not pulled back when it has already moved on, e.g. to a confirm dialog the pick opened.",
|
|
8474
|
+
"The panel animates with motion-disclosure, which is static under prefers-reduced-motion or MotionProvider off; the scroll that keeps the row in view is instant, never smooth."
|
|
8475
|
+
],
|
|
8476
|
+
"examples": [
|
|
8477
|
+
{
|
|
8478
|
+
"label": "A short list — no search",
|
|
8479
|
+
"code": "const [preset, setPreset] = useState(\"comfortable\")\nconst [open, setOpen] = useState(false)\n<DisclosurePicker open={open} onOpenChange={setOpen} className=\"w-72\">\n <DisclosurePickerTrigger label=\"Density\">{preset === \"compact\" ? \"Compact\" : \"Comfortable\"}</DisclosurePickerTrigger>\n <DisclosurePickerPanel>\n <DisclosurePickerList aria-label=\"Density\" value={preset} onValueChange={setPreset}>\n <DisclosurePickerOption value=\"comfortable\">Comfortable</DisclosurePickerOption>\n <DisclosurePickerOption value=\"compact\">Compact</DisclosurePickerOption>\n </DisclosurePickerList>\n </DisclosurePickerPanel>\n</DisclosurePicker>",
|
|
8480
|
+
"imports": "import { DisclosurePicker, DisclosurePickerList, DisclosurePickerOption, DisclosurePickerPanel, DisclosurePickerTrigger } from \"@sableui/react\""
|
|
8481
|
+
},
|
|
8482
|
+
{
|
|
8483
|
+
"label": "A long list — search opted in",
|
|
8484
|
+
"code": "const COUNTRIES = [\"Argentina\", \"Australia\", \"Austria\", \"Belgium\", \"Brazil\", \"Canada\", \"Chile\", \"Denmark\", \"Finland\", \"France\", \"Germany\", \"Ireland\", \"Italy\", \"Japan\", \"Mexico\", \"Norway\", \"Portugal\", \"Spain\", \"Sweden\"]\nconst [country, setCountry] = useState(\"Canada\")\nconst [query, setQuery] = useState(\"\")\nconst shown = COUNTRIES.filter((name) => name.toLowerCase().includes(query.trim().toLowerCase()))\n<DisclosurePicker className=\"w-72\">\n <DisclosurePickerTrigger label=\"Country\">{country}</DisclosurePickerTrigger>\n <DisclosurePickerPanel search={{ value: query, onValueChange: setQuery, placeholder: `Search ${COUNTRIES.length} countries` }}>\n {shown.length > 0 ? (\n <DisclosurePickerList aria-label=\"Country\" value={country} onValueChange={setCountry}>\n {shown.map((name) => (\n <DisclosurePickerOption key={name} value={name}>{name}</DisclosurePickerOption>\n ))}\n </DisclosurePickerList>\n ) : (\n <DisclosurePickerEmpty>No countries match “{query}”.</DisclosurePickerEmpty>\n )}\n </DisclosurePickerPanel>\n</DisclosurePicker>",
|
|
8485
|
+
"imports": "import { DisclosurePicker, DisclosurePickerEmpty, DisclosurePickerList, DisclosurePickerOption, DisclosurePickerPanel, DisclosurePickerTrigger } from \"@sableui/react\""
|
|
8486
|
+
},
|
|
8487
|
+
{
|
|
8488
|
+
"label": "A theme picker — media tiles, commit on Enter",
|
|
8489
|
+
"code": "const { themeId, setThemeId, themes, mode } = useSableTheme()\nconst current = themes.find((theme) => theme.id === themeId)\n<DisclosurePicker>\n <DisclosurePickerTrigger aria-label={`Theme: ${current?.label}`} media={current ? <ThemePreview theme={current} mode={mode} size=\"sm\" /> : null}>\n {current?.label}\n </DisclosurePickerTrigger>\n <DisclosurePickerPanel>\n <DisclosurePickerList aria-label=\"Theme\" value={themeId} onValueChange={setThemeId}>\n {themes.map((theme) => (\n <DisclosurePickerOption key={theme.id} value={theme.id} media={<ThemePreview theme={theme} mode={mode} size=\"sm\" />}>\n {theme.label}\n </DisclosurePickerOption>\n ))}\n </DisclosurePickerList>\n </DisclosurePickerPanel>\n</DisclosurePicker>",
|
|
8490
|
+
"imports": "import { DisclosurePicker, DisclosurePickerList, DisclosurePickerOption, DisclosurePickerPanel, DisclosurePickerTrigger, ThemePreview, useSableTheme } from \"@sableui/react\""
|
|
8491
|
+
},
|
|
8492
|
+
{
|
|
8493
|
+
"label": "Property-grid rung",
|
|
8494
|
+
"code": "const [blend, setBlend] = useState(\"normal\")\n<Density value=\"compact\" className=\"w-64\">\n <DisclosurePicker size=\"xs\">\n <DisclosurePickerTrigger label=\"Blend\">{blend}</DisclosurePickerTrigger>\n <DisclosurePickerPanel>\n <DisclosurePickerList aria-label=\"Blend mode\" value={blend} onValueChange={setBlend}>\n <DisclosurePickerOption value=\"normal\">normal</DisclosurePickerOption>\n <DisclosurePickerOption value=\"multiply\">multiply</DisclosurePickerOption>\n <DisclosurePickerOption value=\"screen\">screen</DisclosurePickerOption>\n </DisclosurePickerList>\n </DisclosurePickerPanel>\n </DisclosurePicker>\n</Density>",
|
|
8495
|
+
"imports": "import { Density, DisclosurePicker, DisclosurePickerList, DisclosurePickerOption, DisclosurePickerPanel, DisclosurePickerTrigger } from \"@sableui/react\""
|
|
8496
|
+
}
|
|
8497
|
+
]
|
|
8498
|
+
},
|
|
8110
8499
|
{
|
|
8111
8500
|
"name": "EmptyState",
|
|
8112
8501
|
"status": "stable",
|
|
@@ -8874,39 +9263,214 @@
|
|
|
8874
9263
|
]
|
|
8875
9264
|
},
|
|
8876
9265
|
{
|
|
8877
|
-
"name": "
|
|
9266
|
+
"name": "FontPicker",
|
|
8878
9267
|
"status": "stable",
|
|
8879
|
-
"group": "
|
|
9268
|
+
"group": "Selection",
|
|
8880
9269
|
"register": "either",
|
|
8881
|
-
"path": "components/
|
|
8882
|
-
"summary": "A
|
|
9270
|
+
"path": "components/font-picker",
|
|
9271
|
+
"summary": "A DisclosurePicker for a typeface: the row names the chosen family in its own face and opens in place into a searchable list of families, each drawn in itself. The app supplies the families and loads the faces; Sable bundles no catalog.",
|
|
8883
9272
|
"keywords": [
|
|
8884
|
-
"
|
|
8885
|
-
"
|
|
8886
|
-
"
|
|
8887
|
-
"
|
|
8888
|
-
"
|
|
8889
|
-
"
|
|
8890
|
-
"
|
|
8891
|
-
"
|
|
9273
|
+
"font picker",
|
|
9274
|
+
"typeface",
|
|
9275
|
+
"font family",
|
|
9276
|
+
"font selector",
|
|
9277
|
+
"typography",
|
|
9278
|
+
"google fonts",
|
|
9279
|
+
"inspector",
|
|
9280
|
+
"customizer"
|
|
8892
9281
|
],
|
|
8893
9282
|
"usage": {
|
|
8894
|
-
"description": "Use
|
|
9283
|
+
"description": "Use FontPicker wherever someone chooses a typeface for a role (body, headings, code) in a panel that shows the result: a theme editor, a document inspector, a brand customizer. It is a DisclosurePicker, so the list opens beneath the row rather than over the preview; arrow keys walk the families (each drawn in its own face) without applying or loading anything, and Enter, Space or a click picks one, loads it and closes the row.",
|
|
8895
9284
|
"bestPractices": [
|
|
8896
9285
|
{
|
|
8897
9286
|
"guidance": "do",
|
|
8898
|
-
"description": "
|
|
9287
|
+
"description": "Pass `loadFont` and load faces lazily: `\"preview\"` fires for every family when the list opens (load just the glyphs of its name — Google Fonts' `text=` parameter makes a 20-font list cost a few KB), `\"use\"` for the chosen one (load its real weights). Make it idempotent and define it once, outside render."
|
|
8899
9288
|
},
|
|
8900
9289
|
{
|
|
8901
9290
|
"guidance": "do",
|
|
8902
|
-
"description": "
|
|
9291
|
+
"description": "Put non-font choices — \"Preset (Figtree)\", \"Same as body\" — in `options`, with a short `valueLabel` for the row. They list first and render in the app's face, so they never read as a family."
|
|
8903
9292
|
},
|
|
8904
9293
|
{
|
|
8905
9294
|
"guidance": "do",
|
|
8906
|
-
"description": "
|
|
9295
|
+
"description": "Give each row a role `label` (\"Body\", \"Headings\", \"Mono\") and stack them; the caption column aligns. Control `open` from the parent so only one list is open at a time."
|
|
8907
9296
|
},
|
|
8908
9297
|
{
|
|
8909
|
-
"guidance": "
|
|
9298
|
+
"guidance": "dont",
|
|
9299
|
+
"description": "Leave search on for a short curated list — under a dozen families, pass `searchable={false}`. Search is on by default because font lists are usually long."
|
|
9300
|
+
},
|
|
9301
|
+
{
|
|
9302
|
+
"guidance": "dont",
|
|
9303
|
+
"description": "Use a Select of family names for this. A popup covers the text it changes, and a family name set in the app's face says nothing about the face."
|
|
9304
|
+
}
|
|
9305
|
+
]
|
|
9306
|
+
},
|
|
9307
|
+
"related": {
|
|
9308
|
+
"components": [
|
|
9309
|
+
"DisclosurePicker",
|
|
9310
|
+
"ThemeSelector",
|
|
9311
|
+
"Select",
|
|
9312
|
+
"Combobox"
|
|
9313
|
+
]
|
|
9314
|
+
},
|
|
9315
|
+
"props": [
|
|
9316
|
+
{
|
|
9317
|
+
"name": "value",
|
|
9318
|
+
"type": "string",
|
|
9319
|
+
"description": "The chosen family, or the value of one of `options`."
|
|
9320
|
+
},
|
|
9321
|
+
{
|
|
9322
|
+
"name": "onValueChange",
|
|
9323
|
+
"type": "(value: string) => void",
|
|
9324
|
+
"description": "Called once a family (or option) is picked — Enter, Space or a click — and only when it differs from `value`; the row then closes. Arrow keys and type-ahead only move focus; Escape closes without a pick."
|
|
9325
|
+
},
|
|
9326
|
+
{
|
|
9327
|
+
"name": "fonts",
|
|
9328
|
+
"type": "readonly { family: string; note?: string; stack?: string }[]",
|
|
9329
|
+
"description": "The families to offer, in order. `note` is a muted annotation (searched too); `stack` the CSS font-family to draw it in (default `\"<family>\"`)."
|
|
9330
|
+
},
|
|
9331
|
+
{
|
|
9332
|
+
"name": "options",
|
|
9333
|
+
"type": "readonly { value: string; label: string; valueLabel?: string }[]",
|
|
9334
|
+
"default": "[]",
|
|
9335
|
+
"description": "Non-font choices listed above the fonts, in the app's face. `valueLabel` is what the row shows while it is chosen."
|
|
9336
|
+
},
|
|
9337
|
+
{
|
|
9338
|
+
"name": "label",
|
|
9339
|
+
"type": "ReactNode",
|
|
9340
|
+
"description": "The row's leading caption — the role the font plays."
|
|
9341
|
+
},
|
|
9342
|
+
{
|
|
9343
|
+
"name": "listLabel",
|
|
9344
|
+
"type": "string",
|
|
9345
|
+
"default": "\"Font\"",
|
|
9346
|
+
"description": "Accessible name of the list."
|
|
9347
|
+
},
|
|
9348
|
+
{
|
|
9349
|
+
"name": "searchable",
|
|
9350
|
+
"type": "boolean",
|
|
9351
|
+
"default": "true",
|
|
9352
|
+
"description": "A search field above the list, filtering by family and note. Pass false for a short curated list (under a dozen)."
|
|
9353
|
+
},
|
|
9354
|
+
{
|
|
9355
|
+
"name": "searchLabel",
|
|
9356
|
+
"type": "string",
|
|
9357
|
+
"default": "\"Search fonts\"",
|
|
9358
|
+
"description": "Accessible name of the search field."
|
|
9359
|
+
},
|
|
9360
|
+
{
|
|
9361
|
+
"name": "loadFont",
|
|
9362
|
+
"type": "(font, purpose: \"preview\" | \"use\") => void",
|
|
9363
|
+
"description": "Loads a face on demand: \"preview\" for every family when the list opens, \"use\" for the chosen one once it is picked (never while the list is only being walked). Receives your own font objects. Idempotent."
|
|
9364
|
+
},
|
|
9365
|
+
{
|
|
9366
|
+
"name": "open",
|
|
9367
|
+
"type": "boolean",
|
|
9368
|
+
"description": "Controlled open state, as DisclosurePicker. Control it to keep one of several rows open at a time."
|
|
9369
|
+
},
|
|
9370
|
+
{
|
|
9371
|
+
"name": "defaultOpen",
|
|
9372
|
+
"type": "boolean",
|
|
9373
|
+
"default": "false",
|
|
9374
|
+
"description": "Uncontrolled initial open state."
|
|
9375
|
+
},
|
|
9376
|
+
{
|
|
9377
|
+
"name": "onOpenChange",
|
|
9378
|
+
"type": "(open: boolean) => void",
|
|
9379
|
+
"description": "Called with the next open state."
|
|
9380
|
+
},
|
|
9381
|
+
{
|
|
9382
|
+
"name": "size",
|
|
9383
|
+
"type": "\"md\" | \"xs\"",
|
|
9384
|
+
"default": "\"md\"",
|
|
9385
|
+
"description": "md for a settings column; xs for the property-grid rung (see DisclosurePicker)."
|
|
9386
|
+
}
|
|
9387
|
+
],
|
|
9388
|
+
"tokensUsed": [
|
|
9389
|
+
"foreground",
|
|
9390
|
+
"muted-foreground",
|
|
9391
|
+
"popover",
|
|
9392
|
+
"hairline",
|
|
9393
|
+
"panel-field",
|
|
9394
|
+
"ring"
|
|
9395
|
+
],
|
|
9396
|
+
"motion": [
|
|
9397
|
+
{
|
|
9398
|
+
"slot": "arrival",
|
|
9399
|
+
"recipe": "none",
|
|
9400
|
+
"part": "root",
|
|
9401
|
+
"why": "A property row is present from first paint."
|
|
9402
|
+
},
|
|
9403
|
+
{
|
|
9404
|
+
"slot": "response",
|
|
9405
|
+
"recipe": "motion-interactive",
|
|
9406
|
+
"part": "Trigger",
|
|
9407
|
+
"why": "Inherited from DisclosurePicker: the hover step says the value row opens."
|
|
9408
|
+
},
|
|
9409
|
+
{
|
|
9410
|
+
"slot": "transition",
|
|
9411
|
+
"recipe": "motion-disclosure",
|
|
9412
|
+
"part": "Panel",
|
|
9413
|
+
"why": "Inherited from DisclosurePicker: the list grows in place, tying the panel's movement to this row."
|
|
9414
|
+
},
|
|
9415
|
+
{
|
|
9416
|
+
"slot": "activity",
|
|
9417
|
+
"recipe": "none",
|
|
9418
|
+
"part": "root",
|
|
9419
|
+
"why": "Faces load in the background and swap in when ready; the names are legible in the fallback meanwhile, so there is nothing to wait on."
|
|
9420
|
+
}
|
|
9421
|
+
],
|
|
9422
|
+
"a11y": [
|
|
9423
|
+
"Inherits DisclosurePicker: a button trigger with aria-expanded/controls whose name is the caption plus the family; a listbox named by `listLabel`, aria-selected on the chosen family.",
|
|
9424
|
+
"Keyboard open focuses the search field; ArrowDown steps from it into the list (ArrowUp from the first option steps back); arrows, Home/End and type-ahead move without applying; Enter or Space picks and closes; Escape clears the query, then closes without a pick and returns focus to the row.",
|
|
9425
|
+
"Each family is set in its own face, but its name is still plain text, so a screen reader reads the name, not the glyphs."
|
|
9426
|
+
],
|
|
9427
|
+
"examples": [
|
|
9428
|
+
{
|
|
9429
|
+
"label": "Body font with lazy loading",
|
|
9430
|
+
"code": "const FONTS = [\n { family: \"Inter\", note: \"Neutral, dense UI\" },\n { family: \"Figtree\", note: \"Friendly geometric\" },\n { family: \"Fraunces\", note: \"Soft, wonky serif\", stack: '\"Fraunces\", serif' },\n]\n// Your loader; Sable never fetches fonts itself.\nconst loadFont = (font: { family: string }, purpose: \"preview\" | \"use\") => {\n const id = `font-${purpose}-${font.family}`\n if (document.getElementById(id)) return\n const link = Object.assign(document.createElement(\"link\"), { id, rel: \"stylesheet\" })\n const family = font.family.replace(/ /g, \"+\")\n link.href = purpose === \"preview\"\n ? `https://fonts.googleapis.com/css2?family=${family}&text=${encodeURIComponent(font.family)}`\n : `https://fonts.googleapis.com/css2?family=${family}:wght@400..700&display=swap`\n document.head.appendChild(link)\n}\nconst [family, setFamily] = useState(\"Inter\")\n<FontPicker label=\"Body\" listLabel=\"Body font\" value={family} onValueChange={setFamily} fonts={FONTS} loadFont={loadFont} searchable={false} />",
|
|
9431
|
+
"imports": "import { FontPicker } from \"@sableui/react\""
|
|
9432
|
+
},
|
|
9433
|
+
{
|
|
9434
|
+
"label": "With a preset option, one row of several",
|
|
9435
|
+
"code": "const [open, setOpen] = useState<string | null>(null)\nconst [body, setBody] = useState(\"theme\")\n<FontPicker\n label=\"Body\"\n listLabel=\"Body font\"\n value={body}\n onValueChange={setBody}\n options={[{ value: \"theme\", label: \"Preset (Figtree)\", valueLabel: \"Figtree\" }]}\n fonts={[{ family: \"Inter\" }, { family: \"Figtree\" }, { family: \"Geist\" }]}\n open={open === \"body\"}\n onOpenChange={(next) => setOpen(next ? \"body\" : null)}\n/>",
|
|
9436
|
+
"imports": "import { FontPicker } from \"@sableui/react\""
|
|
9437
|
+
}
|
|
9438
|
+
]
|
|
9439
|
+
},
|
|
9440
|
+
{
|
|
9441
|
+
"name": "Form",
|
|
9442
|
+
"status": "stable",
|
|
9443
|
+
"group": "Forms",
|
|
9444
|
+
"register": "either",
|
|
9445
|
+
"path": "components/form",
|
|
9446
|
+
"summary": "A styled <form> plus Field — the form orchestration layer, built on Base UI Form and Field. Field wires a label, control, helper text, and error message together with correct a11y (htmlFor, aria-labelledby, aria-describedby, aria-invalid, required) automatically, around any Sable control. Validation-library-agnostic: pass each Field an error string, or let Form check native constraints and Field validate functions on submit.",
|
|
9447
|
+
"keywords": [
|
|
9448
|
+
"form",
|
|
9449
|
+
"field",
|
|
9450
|
+
"label",
|
|
9451
|
+
"validation",
|
|
9452
|
+
"error",
|
|
9453
|
+
"required",
|
|
9454
|
+
"settings",
|
|
9455
|
+
"input"
|
|
9456
|
+
],
|
|
9457
|
+
"usage": {
|
|
9458
|
+
"description": "Use Form and Field as the default wrapper for Sable form controls. Field owns the label, description, required state, and error wiring so agents do not hand-roll inconsistent label/input relationships.",
|
|
9459
|
+
"bestPractices": [
|
|
9460
|
+
{
|
|
9461
|
+
"guidance": "do",
|
|
9462
|
+
"description": "Wrap each control in Field unless a specialized component already owns its label and error semantics."
|
|
9463
|
+
},
|
|
9464
|
+
{
|
|
9465
|
+
"guidance": "do",
|
|
9466
|
+
"description": "Use Field error for validation messages so aria-invalid and aria-describedby are wired together."
|
|
9467
|
+
},
|
|
9468
|
+
{
|
|
9469
|
+
"guidance": "do",
|
|
9470
|
+
"description": "For built-in checks, put required / type=\"email\" / minLength on the control (or a validate function on Field) inside a Form: on submit the message appears under the field and the first invalid control is focused. Pass server errors to Form errors, keyed by Field name."
|
|
9471
|
+
},
|
|
9472
|
+
{
|
|
9473
|
+
"guidance": "do",
|
|
8910
9474
|
"description": "Use horizontal Field orientation only for one-line boolean controls like Checkbox, Switch, or Radio."
|
|
8911
9475
|
},
|
|
8912
9476
|
{
|
|
@@ -10235,68 +10799,496 @@
|
|
|
10235
10799
|
},
|
|
10236
10800
|
"props": [
|
|
10237
10801
|
{
|
|
10238
|
-
"name": "direction",
|
|
10239
|
-
"type": "\"left\" | \"right\" | \"up\" | \"down\"",
|
|
10240
|
-
"default": "\"left\"",
|
|
10241
|
-
"description": "Scroll direction; up/down switch to a vertical loop, which needs a bounded height (e.g. className=\"h-56\")."
|
|
10802
|
+
"name": "direction",
|
|
10803
|
+
"type": "\"left\" | \"right\" | \"up\" | \"down\"",
|
|
10804
|
+
"default": "\"left\"",
|
|
10805
|
+
"description": "Scroll direction; up/down switch to a vertical loop, which needs a bounded height (e.g. className=\"h-56\")."
|
|
10806
|
+
},
|
|
10807
|
+
{
|
|
10808
|
+
"name": "speed",
|
|
10809
|
+
"type": "number",
|
|
10810
|
+
"default": "40",
|
|
10811
|
+
"description": "Constant speed in px/s — independent of content length."
|
|
10812
|
+
},
|
|
10813
|
+
{
|
|
10814
|
+
"name": "gap",
|
|
10815
|
+
"type": "string",
|
|
10816
|
+
"default": "\"calc(var(--spacing) * 8)\"",
|
|
10817
|
+
"description": "Gap between items (any CSS length). Use a spacing-scale value (calc(var(--spacing) * n)) so it moves with density."
|
|
10818
|
+
},
|
|
10819
|
+
{
|
|
10820
|
+
"name": "pauseOnHover",
|
|
10821
|
+
"type": "boolean",
|
|
10822
|
+
"default": "true",
|
|
10823
|
+
"description": "Pause while hovered or keyboard-focused (WCAG 2.2.2)."
|
|
10824
|
+
},
|
|
10825
|
+
{
|
|
10826
|
+
"name": "controls",
|
|
10827
|
+
"type": "boolean",
|
|
10828
|
+
"default": "false",
|
|
10829
|
+
"description": "Render a visible pause/play control — recommended for WCAG 2.2.2 when content auto-scrolls."
|
|
10830
|
+
},
|
|
10831
|
+
{
|
|
10832
|
+
"name": "fade",
|
|
10833
|
+
"type": "boolean",
|
|
10834
|
+
"default": "false",
|
|
10835
|
+
"description": "Fade the leading/trailing edges with a mask."
|
|
10836
|
+
},
|
|
10837
|
+
{
|
|
10838
|
+
"name": "label",
|
|
10839
|
+
"type": "string",
|
|
10840
|
+
"description": "Accessible label for the scrolling region."
|
|
10841
|
+
}
|
|
10842
|
+
],
|
|
10843
|
+
"tokensUsed": [],
|
|
10844
|
+
"a11y": [
|
|
10845
|
+
"Duplicate groups are aria-hidden, so screen readers read the content once.",
|
|
10846
|
+
"Pauses on hover and focus-within; under prefers-reduced-motion / MotionProvider off it stops and becomes a normal scroll region.",
|
|
10847
|
+
"Decorative by default — pass a label only when the row conveys meaning."
|
|
10848
|
+
],
|
|
10849
|
+
"examples": [
|
|
10850
|
+
{
|
|
10851
|
+
"label": "Logo row",
|
|
10852
|
+
"code": "<Marquee>{logos.map((l) => <img key={l.id} src={l.src} alt={l.name} className=\"h-8\" />)}</Marquee>",
|
|
10853
|
+
"imports": "import { Marquee } from \"@sableui/react\""
|
|
10854
|
+
},
|
|
10855
|
+
{
|
|
10856
|
+
"label": "Reverse, faster",
|
|
10857
|
+
"code": "<Marquee direction=\"right\" speed={70}>{tags.map((t) => <Badge key={t} appearance=\"outline\">{t}</Badge>)}</Marquee>",
|
|
10858
|
+
"imports": "import { Badge, Marquee } from \"@sableui/react\""
|
|
10859
|
+
},
|
|
10860
|
+
{
|
|
10861
|
+
"label": "Vertical",
|
|
10862
|
+
"code": "<Marquee direction=\"up\" className=\"h-64\">{items}</Marquee>",
|
|
10863
|
+
"imports": "import { Marquee } from \"@sableui/react\""
|
|
10864
|
+
}
|
|
10865
|
+
]
|
|
10866
|
+
},
|
|
10867
|
+
{
|
|
10868
|
+
"name": "MediaImage",
|
|
10869
|
+
"status": "beta",
|
|
10870
|
+
"group": "Media & mockups",
|
|
10871
|
+
"register": "either",
|
|
10872
|
+
"path": "components/media-image",
|
|
10873
|
+
"summary": "An image that fills its box and falls back to GradientArt seeded by the item when it has no source or the source fails — Avatar's image-load tracking, for covers, thumbnails and product shots instead of faces.",
|
|
10874
|
+
"keywords": [
|
|
10875
|
+
"image",
|
|
10876
|
+
"img",
|
|
10877
|
+
"image fallback",
|
|
10878
|
+
"broken image",
|
|
10879
|
+
"cover art",
|
|
10880
|
+
"album art",
|
|
10881
|
+
"thumbnail",
|
|
10882
|
+
"placeholder image",
|
|
10883
|
+
"onError",
|
|
10884
|
+
"missing image"
|
|
10885
|
+
],
|
|
10886
|
+
"usage": {
|
|
10887
|
+
"description": "Use MediaImage wherever content imagery may be missing or broken: a cover in a grid, a thumbnail in a row, a product shot. It fills the box it is given — an AspectBox for a cover, a sized and rounded box for a thumbnail — shows a quiet muted surface while the image loads, fades the image in once decoded, and paints GradientArt seeded by the item when there is no image to show, so a grid of missing covers still reads as distinct things. For a person use Avatar; for a whole tile with title and actions use MediaTile, which composes this.",
|
|
10888
|
+
"bestPractices": [
|
|
10889
|
+
{
|
|
10890
|
+
"guidance": "do",
|
|
10891
|
+
"description": "Give the box the shape: `<AspectBox ratio={1} className=\"rounded-md\"><MediaImage … /></AspectBox>`, or `size-14 rounded-md` on MediaImage itself for a thumbnail. It clips to whatever radius the box has."
|
|
10892
|
+
},
|
|
10893
|
+
{
|
|
10894
|
+
"guidance": "do",
|
|
10895
|
+
"description": "Pass `seed` (the title or an id) when `alt` is empty, so the fallback art is the item's own and stays the same in every list it appears in."
|
|
10896
|
+
},
|
|
10897
|
+
{
|
|
10898
|
+
"guidance": "do",
|
|
10899
|
+
"description": "Leave `alt` empty when a visible title sits next to the image — a tile, a row — and describe it when the image stands alone. A set `alt` also names the fallback, so the description survives a broken URL."
|
|
10900
|
+
},
|
|
10901
|
+
{
|
|
10902
|
+
"guidance": "dont",
|
|
10903
|
+
"description": "Do not hand-write `<img onError={() => setBroken(true)}>`. An image that fails before hydration never fires the handler on a prerendered page; MediaImage preloads off the DOM and catches it."
|
|
10904
|
+
},
|
|
10905
|
+
{
|
|
10906
|
+
"guidance": "dont",
|
|
10907
|
+
"description": "Do not use it for profile pictures. A person is a circle with initials — Avatar, whose `variant=\"art\"` is the same fallback with the person's initials over it."
|
|
10908
|
+
}
|
|
10909
|
+
]
|
|
10910
|
+
},
|
|
10911
|
+
"related": {
|
|
10912
|
+
"components": [
|
|
10913
|
+
"MediaTile",
|
|
10914
|
+
"Avatar",
|
|
10915
|
+
"GradientArt",
|
|
10916
|
+
"AspectBox"
|
|
10917
|
+
]
|
|
10918
|
+
},
|
|
10919
|
+
"props": [
|
|
10920
|
+
{
|
|
10921
|
+
"name": "src",
|
|
10922
|
+
"type": "string",
|
|
10923
|
+
"description": "Image URL. Omitted, empty or failing to load ⇒ the fallback shows."
|
|
10924
|
+
},
|
|
10925
|
+
{
|
|
10926
|
+
"name": "alt",
|
|
10927
|
+
"type": "string",
|
|
10928
|
+
"default": "\"\"",
|
|
10929
|
+
"description": "Alt text. Empty is decorative — right beside a visible title. When set and no image shows, it names the fallback instead."
|
|
10930
|
+
},
|
|
10931
|
+
{
|
|
10932
|
+
"name": "seed",
|
|
10933
|
+
"type": "string | number",
|
|
10934
|
+
"description": "Seeds the fallback art. Defaults to `alt`; pass the title or an id when `alt` is empty."
|
|
10935
|
+
},
|
|
10936
|
+
{
|
|
10937
|
+
"name": "fallback",
|
|
10938
|
+
"type": "ReactNode",
|
|
10939
|
+
"description": "Replaces the GradientArt fallback — a glyph or monogram. Fills the frame under the same clipping."
|
|
10940
|
+
},
|
|
10941
|
+
{
|
|
10942
|
+
"name": "fit",
|
|
10943
|
+
"type": "\"cover\" | \"contain\"",
|
|
10944
|
+
"default": "\"cover\"",
|
|
10945
|
+
"description": "`cover` crops to fill (artwork, photos); `contain` letterboxes on the muted surface (a logo, a product that must not be cropped)."
|
|
10946
|
+
},
|
|
10947
|
+
{
|
|
10948
|
+
"name": "srcSet",
|
|
10949
|
+
"type": "string",
|
|
10950
|
+
"description": "Responsive sources, passed to the `<img>`."
|
|
10951
|
+
},
|
|
10952
|
+
{
|
|
10953
|
+
"name": "sizes",
|
|
10954
|
+
"type": "string",
|
|
10955
|
+
"description": "The `sizes` attribute for `srcSet`."
|
|
10956
|
+
},
|
|
10957
|
+
{
|
|
10958
|
+
"name": "loading",
|
|
10959
|
+
"type": "\"lazy\" | \"eager\"",
|
|
10960
|
+
"default": "\"lazy\"",
|
|
10961
|
+
"description": "Native lazy loading. Use `eager` for the first row of a grid above the fold."
|
|
10962
|
+
},
|
|
10963
|
+
{
|
|
10964
|
+
"name": "className",
|
|
10965
|
+
"type": "string",
|
|
10966
|
+
"description": "Sizing and rounding when MediaImage is the box itself (`size-14 rounded-md`). Inside an AspectBox it already fills."
|
|
10967
|
+
}
|
|
10968
|
+
],
|
|
10969
|
+
"variants": {
|
|
10970
|
+
"fit": [
|
|
10971
|
+
"cover",
|
|
10972
|
+
"contain"
|
|
10973
|
+
]
|
|
10974
|
+
},
|
|
10975
|
+
"tokensUsed": [
|
|
10976
|
+
"muted",
|
|
10977
|
+
"art-1",
|
|
10978
|
+
"art-2",
|
|
10979
|
+
"art-3",
|
|
10980
|
+
"art-4",
|
|
10981
|
+
"art-5",
|
|
10982
|
+
"art-6",
|
|
10983
|
+
"art-ground"
|
|
10984
|
+
],
|
|
10985
|
+
"motion": [
|
|
10986
|
+
{
|
|
10987
|
+
"slot": "arrival",
|
|
10988
|
+
"recipe": "motion-fade",
|
|
10989
|
+
"part": "image",
|
|
10990
|
+
"why": "The image fades in over the muted surface once it has decoded, so a slow cover settles in instead of popping row by row as the grid loads."
|
|
10991
|
+
},
|
|
10992
|
+
{
|
|
10993
|
+
"slot": "response",
|
|
10994
|
+
"recipe": "none",
|
|
10995
|
+
"part": "root",
|
|
10996
|
+
"why": "Imagery, not a control. The tile or row around it answers the pointer."
|
|
10997
|
+
},
|
|
10998
|
+
{
|
|
10999
|
+
"slot": "transition",
|
|
11000
|
+
"recipe": "none",
|
|
11001
|
+
"part": "root",
|
|
11002
|
+
"why": "Its only state change is loading → loaded or failed, which the arrival covers."
|
|
11003
|
+
},
|
|
11004
|
+
{
|
|
11005
|
+
"slot": "activity",
|
|
11006
|
+
"recipe": "none",
|
|
11007
|
+
"part": "root",
|
|
11008
|
+
"why": "A loading image rests on a static muted surface; a shimmer per cover across a long grid would be noise."
|
|
11009
|
+
}
|
|
11010
|
+
],
|
|
11011
|
+
"a11y": [
|
|
11012
|
+
"`alt` defaults to empty (decorative), because the usual neighbour is a visible title that already names the item; describe the image when it stands alone.",
|
|
11013
|
+
"When no image is on screen and `alt` is set, the frame takes `role=\"img\"` with that label, so a broken URL does not erase the description. The fallback art itself is always aria-hidden.",
|
|
11014
|
+
"The `<img>` mounts only once it has loaded, so assistive technology never meets a broken-image icon."
|
|
11015
|
+
],
|
|
11016
|
+
"examples": [
|
|
11017
|
+
{
|
|
11018
|
+
"label": "Cover with fallback",
|
|
11019
|
+
"code": "<AspectBox ratio={1} className=\"rounded-md\">\n\t<MediaImage src={album.cover} seed={album.title} />\n</AspectBox>",
|
|
11020
|
+
"imports": "import { AspectBox, MediaImage } from \"@sableui/react\""
|
|
11021
|
+
},
|
|
11022
|
+
{
|
|
11023
|
+
"label": "No image yet",
|
|
11024
|
+
"code": "<AspectBox ratio={1} className=\"rounded-md\">\n\t<MediaImage seed=\"Low tide, high noon\" />\n</AspectBox>",
|
|
11025
|
+
"imports": "import { AspectBox, MediaImage } from \"@sableui/react\""
|
|
11026
|
+
},
|
|
11027
|
+
{
|
|
11028
|
+
"label": "Row thumbnail",
|
|
11029
|
+
"code": "<MediaImage src={track.art} seed={track.title} className=\"size-12 shrink-0 rounded-sm\" />",
|
|
11030
|
+
"imports": "import { MediaImage } from \"@sableui/react\""
|
|
11031
|
+
},
|
|
11032
|
+
{
|
|
11033
|
+
"label": "Logo, never cropped",
|
|
11034
|
+
"code": "<AspectBox ratio={16 / 9} className=\"rounded-lg\">\n\t<MediaImage src={brand.logo} alt={brand.name} fit=\"contain\" />\n</AspectBox>",
|
|
11035
|
+
"imports": "import { AspectBox, MediaImage } from \"@sableui/react\""
|
|
11036
|
+
}
|
|
11037
|
+
]
|
|
11038
|
+
},
|
|
11039
|
+
{
|
|
11040
|
+
"name": "MediaTile",
|
|
11041
|
+
"status": "beta",
|
|
11042
|
+
"group": "Media & mockups",
|
|
11043
|
+
"register": "page",
|
|
11044
|
+
"path": "components/media-tile",
|
|
11045
|
+
"summary": "A cover-first tile — square, 16:9 or poster artwork with an optional play-style action over it, then a title and a subtitle that can each be links — that is one link as a whole without nesting its other controls inside one.",
|
|
11046
|
+
"keywords": [
|
|
11047
|
+
"album",
|
|
11048
|
+
"album art",
|
|
11049
|
+
"cover",
|
|
11050
|
+
"tile",
|
|
11051
|
+
"media card",
|
|
11052
|
+
"video thumbnail",
|
|
11053
|
+
"podcast",
|
|
11054
|
+
"playlist",
|
|
11055
|
+
"gallery",
|
|
11056
|
+
"product tile",
|
|
11057
|
+
"poster",
|
|
11058
|
+
"grid item",
|
|
11059
|
+
"play button",
|
|
11060
|
+
"music"
|
|
11061
|
+
],
|
|
11062
|
+
"usage": {
|
|
11063
|
+
"description": "The item in a media grid or shelf: an album, a playlist, a video, a podcast, a film, a photo, a product. The cover leads and carries the weight; the title and subtitle sit underneath without a card around them. The whole tile follows the title's link, the subtitle can link somewhere else (the artist, the channel), and an action — usually play — floats over the cover's corner, revealed on hover and focus and always shown on touch. A missing or broken cover becomes GradientArt seeded by the title, so a grid of items without artwork still reads as distinct things.",
|
|
11064
|
+
"bestPractices": [
|
|
11065
|
+
{
|
|
11066
|
+
"guidance": "do",
|
|
11067
|
+
"description": "Lay tiles out with `Grid` (`<Grid minItemWidth=\"10rem\">`) or a horizontal `Scroller` for a shelf. The tile fills its cell; the grid decides the width."
|
|
11068
|
+
},
|
|
11069
|
+
{
|
|
11070
|
+
"guidance": "do",
|
|
11071
|
+
"description": "Pass `render={<Link to=\"…\" />}` (or `href`) for where the tile goes, and `subtitleRender` / `subtitleHref` for a second destination. Each stays a separate control; never wrap the tile in your own link."
|
|
11072
|
+
},
|
|
11073
|
+
{
|
|
11074
|
+
"guidance": "do",
|
|
11075
|
+
"description": "Name the action for its item — `aria-label=\"Play Low tide, high noon\"`, not \"Play\". A grid of twenty identical \"Play\" buttons is unusable from a screen reader's list of controls."
|
|
11076
|
+
},
|
|
11077
|
+
{
|
|
11078
|
+
"guidance": "do",
|
|
11079
|
+
"description": "Use `titleAs=\"h3\"` when the tiles sit under a section heading, so screen-reader users can move tile to tile by heading."
|
|
11080
|
+
},
|
|
11081
|
+
{
|
|
11082
|
+
"guidance": "dont",
|
|
11083
|
+
"description": "Do not build this from `Card render={<Link />}`. A card that is a link cannot hold a second link or a button — interactive content cannot nest — which is exactly what a media tile needs."
|
|
11084
|
+
},
|
|
11085
|
+
{
|
|
11086
|
+
"guidance": "dont",
|
|
11087
|
+
"description": "Do not put more than one action on the cover. A second control over the art is a toolbar on a thumbnail; move secondary actions (add to queue, share) into an OverflowMenu in the action slot or onto the item's page."
|
|
11088
|
+
},
|
|
11089
|
+
{
|
|
11090
|
+
"guidance": "dont",
|
|
11091
|
+
"description": "Do not use it for a framed commerce card with a price and a buy button — that is ProductCardBlock — or for a person, which is an Avatar."
|
|
11092
|
+
}
|
|
11093
|
+
],
|
|
11094
|
+
"anatomy": [
|
|
11095
|
+
{
|
|
11096
|
+
"name": "Cover",
|
|
11097
|
+
"required": true,
|
|
11098
|
+
"description": "An AspectBox holding MediaImage: the image, or GradientArt seeded by the title. Drawn first, read last."
|
|
11099
|
+
},
|
|
11100
|
+
{
|
|
11101
|
+
"name": "Title",
|
|
11102
|
+
"required": true,
|
|
11103
|
+
"description": "The item's name. When the tile links, this is the link, stretched over the whole tile."
|
|
11104
|
+
},
|
|
11105
|
+
{
|
|
11106
|
+
"name": "Subtitle",
|
|
11107
|
+
"required": false,
|
|
11108
|
+
"description": "The second line. Optionally its own link, raised above the stretched one."
|
|
11109
|
+
},
|
|
11110
|
+
{
|
|
11111
|
+
"name": "Action",
|
|
11112
|
+
"required": false,
|
|
11113
|
+
"description": "One control over the cover's bottom-end corner — usually play. Revealed on hover and focus; always visible on touch."
|
|
11114
|
+
},
|
|
11115
|
+
{
|
|
11116
|
+
"name": "Badge",
|
|
11117
|
+
"required": false,
|
|
11118
|
+
"description": "A static label over the cover's top-start corner — a duration, \"New\". Click-through."
|
|
11119
|
+
}
|
|
11120
|
+
]
|
|
11121
|
+
},
|
|
11122
|
+
"related": {
|
|
11123
|
+
"components": [
|
|
11124
|
+
"MediaImage",
|
|
11125
|
+
"GradientArt",
|
|
11126
|
+
"Grid",
|
|
11127
|
+
"AspectBox",
|
|
11128
|
+
"Card",
|
|
11129
|
+
"Scroller"
|
|
11130
|
+
]
|
|
11131
|
+
},
|
|
11132
|
+
"props": [
|
|
11133
|
+
{
|
|
11134
|
+
"name": "title",
|
|
11135
|
+
"type": "ReactNode",
|
|
11136
|
+
"description": "The item's name. Seeds the fallback art when it is a string."
|
|
11137
|
+
},
|
|
11138
|
+
{
|
|
11139
|
+
"name": "subtitle",
|
|
11140
|
+
"type": "ReactNode",
|
|
11141
|
+
"description": "The second line — an artist, a channel, a year."
|
|
11142
|
+
},
|
|
11143
|
+
{
|
|
11144
|
+
"name": "src",
|
|
11145
|
+
"type": "string",
|
|
11146
|
+
"description": "Cover image URL. Without one, or when it fails, the cover is GradientArt seeded by the item."
|
|
11147
|
+
},
|
|
11148
|
+
{
|
|
11149
|
+
"name": "alt",
|
|
11150
|
+
"type": "string",
|
|
11151
|
+
"default": "\"\"",
|
|
11152
|
+
"description": "Cover alt text. Empty by default: the title below already names the item."
|
|
11153
|
+
},
|
|
11154
|
+
{
|
|
11155
|
+
"name": "seed",
|
|
11156
|
+
"type": "string | number",
|
|
11157
|
+
"description": "Seeds the fallback art. Defaults to `title` when it is a string."
|
|
11158
|
+
},
|
|
11159
|
+
{
|
|
11160
|
+
"name": "aspect",
|
|
11161
|
+
"type": "\"square\" | \"video\" | \"poster\" | number",
|
|
11162
|
+
"default": "\"square\"",
|
|
11163
|
+
"description": "The cover's shape: square (albums, podcasts), video (16:9), poster (2:3 — films, books), or any width / height ratio."
|
|
10242
11164
|
},
|
|
10243
11165
|
{
|
|
10244
|
-
"name": "
|
|
10245
|
-
"type": "
|
|
10246
|
-
"default": "
|
|
10247
|
-
"description": "
|
|
11166
|
+
"name": "size",
|
|
11167
|
+
"type": "\"sm\" | \"md\" | \"lg\"",
|
|
11168
|
+
"default": "\"md\"",
|
|
11169
|
+
"description": "Moves the type tier, the cover-to-text gap, the corner and the overlay inset together."
|
|
10248
11170
|
},
|
|
10249
11171
|
{
|
|
10250
|
-
"name": "
|
|
11172
|
+
"name": "href",
|
|
10251
11173
|
"type": "string",
|
|
10252
|
-
"
|
|
10253
|
-
"description": "Gap between items (any CSS length). Use a spacing-scale value (calc(var(--spacing) * n)) so it moves with density."
|
|
11174
|
+
"description": "Where the tile goes. The title becomes the link, stretched over the whole tile."
|
|
10254
11175
|
},
|
|
10255
11176
|
{
|
|
10256
|
-
"name": "
|
|
10257
|
-
"type": "
|
|
10258
|
-
"
|
|
10259
|
-
"description": "Pause while hovered or keyboard-focused (WCAG 2.2.2)."
|
|
11177
|
+
"name": "render",
|
|
11178
|
+
"type": "useRender.RenderProp",
|
|
11179
|
+
"description": "The title link's element, for a router: `render={<Link to=\"/albums/1\" />}`. Takes precedence over `href`."
|
|
10260
11180
|
},
|
|
10261
11181
|
{
|
|
10262
|
-
"name": "
|
|
10263
|
-
"type": "
|
|
10264
|
-
"
|
|
10265
|
-
"description": "Render a visible pause/play control — recommended for WCAG 2.2.2 when content auto-scrolls."
|
|
11182
|
+
"name": "subtitleHref",
|
|
11183
|
+
"type": "string",
|
|
11184
|
+
"description": "Makes the subtitle its own link — a separate tab stop above the tile's."
|
|
10266
11185
|
},
|
|
10267
11186
|
{
|
|
10268
|
-
"name": "
|
|
10269
|
-
"type": "
|
|
10270
|
-
"
|
|
10271
|
-
"description": "Fade the leading/trailing edges with a mask."
|
|
11187
|
+
"name": "subtitleRender",
|
|
11188
|
+
"type": "useRender.RenderProp",
|
|
11189
|
+
"description": "The subtitle link's element, for a router."
|
|
10272
11190
|
},
|
|
10273
11191
|
{
|
|
10274
|
-
"name": "
|
|
10275
|
-
"type": "
|
|
10276
|
-
"description": "
|
|
11192
|
+
"name": "action",
|
|
11193
|
+
"type": "ReactNode",
|
|
11194
|
+
"description": "One control over the cover's bottom-end corner, usually a play Button. Hidden until hover or focus on hover-capable devices; always shown on touch."
|
|
11195
|
+
},
|
|
11196
|
+
{
|
|
11197
|
+
"name": "badge",
|
|
11198
|
+
"type": "ReactNode",
|
|
11199
|
+
"description": "A static label on the cover's top-start corner — `<Badge appearance=\"overlay\">`."
|
|
11200
|
+
},
|
|
11201
|
+
{
|
|
11202
|
+
"name": "titleAs",
|
|
11203
|
+
"type": "\"h2\" | \"h3\" | \"h4\" | \"h5\" | \"p\"",
|
|
11204
|
+
"default": "\"p\"",
|
|
11205
|
+
"description": "The title's element. A heading one level below the section's lets screen-reader users move tile to tile."
|
|
11206
|
+
},
|
|
11207
|
+
{
|
|
11208
|
+
"name": "loading",
|
|
11209
|
+
"type": "\"lazy\" | \"eager\"",
|
|
11210
|
+
"default": "\"lazy\"",
|
|
11211
|
+
"description": "Native image loading. `eager` for the first row above the fold."
|
|
11212
|
+
},
|
|
11213
|
+
{
|
|
11214
|
+
"name": "...div props",
|
|
11215
|
+
"type": "Omit<ComponentPropsWithRef<\"div\">, \"title\">",
|
|
11216
|
+
"description": "Spread onto the root."
|
|
11217
|
+
}
|
|
11218
|
+
],
|
|
11219
|
+
"variants": {
|
|
11220
|
+
"size": [
|
|
11221
|
+
"sm",
|
|
11222
|
+
"md",
|
|
11223
|
+
"lg"
|
|
11224
|
+
],
|
|
11225
|
+
"aspect": [
|
|
11226
|
+
"square",
|
|
11227
|
+
"video",
|
|
11228
|
+
"poster"
|
|
11229
|
+
]
|
|
11230
|
+
},
|
|
11231
|
+
"tokensUsed": [
|
|
11232
|
+
"foreground",
|
|
11233
|
+
"muted-foreground",
|
|
11234
|
+
"muted",
|
|
11235
|
+
"overlay",
|
|
11236
|
+
"ring"
|
|
11237
|
+
],
|
|
11238
|
+
"motion": [
|
|
11239
|
+
{
|
|
11240
|
+
"slot": "arrival",
|
|
11241
|
+
"recipe": "motion-fade",
|
|
11242
|
+
"part": "Cover",
|
|
11243
|
+
"why": "The cover image fades in over a quiet surface once it decodes, so a grid loading on a slow connection settles tile by tile instead of popping."
|
|
11244
|
+
},
|
|
11245
|
+
{
|
|
11246
|
+
"slot": "response",
|
|
11247
|
+
"recipe": "motion-interactive",
|
|
11248
|
+
"part": "Cover",
|
|
11249
|
+
"why": "Hovering the tile deepens the cover with a light scrim and lifts the action in (`reveal-on-hover`), so it is clear the whole tile is one target and where its action is."
|
|
11250
|
+
},
|
|
11251
|
+
{
|
|
11252
|
+
"slot": "transition",
|
|
11253
|
+
"recipe": "none",
|
|
11254
|
+
"part": "root",
|
|
11255
|
+
"why": "A tile has no states of its own to move between; the action carries any (play / pause)."
|
|
11256
|
+
},
|
|
11257
|
+
{
|
|
11258
|
+
"slot": "activity",
|
|
11259
|
+
"recipe": "none",
|
|
11260
|
+
"part": "root",
|
|
11261
|
+
"why": "Nothing runs inside a tile; a now-playing indicator belongs to the player, not the grid."
|
|
10277
11262
|
}
|
|
10278
11263
|
],
|
|
10279
|
-
"tokensUsed": [],
|
|
10280
11264
|
"a11y": [
|
|
10281
|
-
"
|
|
10282
|
-
"
|
|
10283
|
-
"
|
|
11265
|
+
"No nested interactive content: the title is the tile's link, and its `stretched-link` overlay covers the tile, while the subtitle link and the action sit above it as separate controls.",
|
|
11266
|
+
"Tab order is title → subtitle → action. The text comes first in the DOM and the cover is drawn above it with CSS `order`, so focus follows how a reader names the item.",
|
|
11267
|
+
"Focusing the title link rings the whole tile, rounded like the cover, because the whole tile is what the link is; the subtitle link and the action ring themselves.",
|
|
11268
|
+
"The action is hidden with opacity only, and only where the device can hover; it stays in the tab order and the accessibility tree, appears when anything in the tile has focus, and is always visible on touch.",
|
|
11269
|
+
"The cover is decorative by default (`alt=\"\"`), since the title names the item; the fallback art is aria-hidden.",
|
|
11270
|
+
"Truncated titles keep their full text in the link's accessible name and in a `title` tooltip."
|
|
10284
11271
|
],
|
|
10285
11272
|
"examples": [
|
|
10286
11273
|
{
|
|
10287
|
-
"label": "
|
|
10288
|
-
"code": "<
|
|
10289
|
-
"imports": "import {
|
|
11274
|
+
"label": "Album grid",
|
|
11275
|
+
"code": "<Grid minItemWidth=\"10rem\" gap={3}>\n\t{albums.map((album) => (\n\t\t<MediaTile\n\t\t\tkey={album.id}\n\t\t\ttitle={album.title}\n\t\t\tsubtitle={album.artist}\n\t\t\tsrc={album.cover}\n\t\t\trender={<Link to={`/albums/${album.id}`} />}\n\t\t\tsubtitleRender={<Link to={`/artists/${album.artistId}`} />}\n\t\t\taction={\n\t\t\t\t<Button size=\"icon\" aria-label={`Play ${album.title}`} onClick={() => play(album)}>\n\t\t\t\t\t<Icon name=\"play\" variant=\"solid\" />\n\t\t\t\t</Button>\n\t\t\t}\n\t\t/>\n\t))}\n</Grid>",
|
|
11276
|
+
"imports": "import { Button, Grid, Icon, MediaTile } from \"@sableui/react\"\n// <Link> = your router's link (react-router, next/link, @tanstack/router, …)"
|
|
10290
11277
|
},
|
|
10291
11278
|
{
|
|
10292
|
-
"label": "
|
|
10293
|
-
"code": "<
|
|
10294
|
-
"imports": "import { Badge,
|
|
11279
|
+
"label": "Video tile with a duration",
|
|
11280
|
+
"code": "<MediaTile\n\taspect=\"video\"\n\ttitle=\"Building a synth from scratch\"\n\tsubtitle=\"Undertow Sessions · 2 days ago\"\n\tsrc={video.thumbnail}\n\thref={`/watch/${video.id}`}\n\tbadge={<Badge appearance=\"overlay\">12:04</Badge>}\n/>",
|
|
11281
|
+
"imports": "import { Badge, MediaTile } from \"@sableui/react\""
|
|
10295
11282
|
},
|
|
10296
11283
|
{
|
|
10297
|
-
"label": "
|
|
10298
|
-
"code": "<
|
|
10299
|
-
"imports": "import {
|
|
11284
|
+
"label": "Gallery, no links",
|
|
11285
|
+
"code": "<Grid minItemWidth=\"8rem\" gap={2}>\n\t{photos.map((photo) => (\n\t\t<MediaTile key={photo.id} size=\"sm\" title={photo.caption} src={photo.url} alt={photo.description} />\n\t))}\n</Grid>",
|
|
11286
|
+
"imports": "import { Grid, MediaTile } from \"@sableui/react\""
|
|
11287
|
+
},
|
|
11288
|
+
{
|
|
11289
|
+
"label": "Poster shelf",
|
|
11290
|
+
"code": "<MediaTile aspect=\"poster\" size=\"lg\" titleAs=\"h3\" title=\"The Long Dark\" subtitle=\"2026 · Drama\" href=\"/films/the-long-dark\" />",
|
|
11291
|
+
"imports": "import { MediaTile } from \"@sableui/react\""
|
|
10300
11292
|
}
|
|
10301
11293
|
]
|
|
10302
11294
|
},
|
|
@@ -11856,6 +12848,10 @@
|
|
|
11856
12848
|
"guidance": "dont",
|
|
11857
12849
|
"description": "Do not use PageHeader inside repeated cards, dialogs, or compact panels."
|
|
11858
12850
|
},
|
|
12851
|
+
{
|
|
12852
|
+
"guidance": "dont",
|
|
12853
|
+
"description": "Do not use a second PageHeader for a section of the page. It is the route's one h1; each section below it takes a SectionHeader (an h2 with the same title row, description and actions)."
|
|
12854
|
+
},
|
|
11859
12855
|
{
|
|
11860
12856
|
"guidance": "dont",
|
|
11861
12857
|
"description": "Do not put it outside the Region that holds the page content. It then sits at a different measure from what it introduces, and the page reads as two documents."
|
|
@@ -11865,6 +12861,7 @@
|
|
|
11865
12861
|
"related": {
|
|
11866
12862
|
"components": [
|
|
11867
12863
|
"AppHeader",
|
|
12864
|
+
"SectionHeader",
|
|
11868
12865
|
"Breadcrumbs",
|
|
11869
12866
|
"Button"
|
|
11870
12867
|
]
|
|
@@ -11883,12 +12880,12 @@
|
|
|
11883
12880
|
{
|
|
11884
12881
|
"name": "back",
|
|
11885
12882
|
"type": "ReactNode",
|
|
11886
|
-
"description": "Leading element before the title — typically a back control on drill-in routes. Keep it a ghost icon Button (size=\"icon\", variant=\"ghost\", aria-label=\"Back\") so it stays quieter than the title."
|
|
12883
|
+
"description": "Leading element before the title — typically a back control on drill-in routes. Keep it a ghost icon Button (size=\"icon\", variant=\"ghost\", aria-label=\"Back\") so it stays quieter than the title. A ghost back control bleeds by its inset at the start, so its glyph lines up with the content edge below (Button bleed=\"none\" opts out)."
|
|
11887
12884
|
},
|
|
11888
12885
|
{
|
|
11889
12886
|
"name": "actions",
|
|
11890
12887
|
"type": "ReactNode",
|
|
11891
|
-
"description": "Trailing actions aligned to the title row's end. They drop under the title when the row runs out of room, and the title wraps rather than truncating."
|
|
12888
|
+
"description": "Trailing actions aligned to the title row's end. They drop under the title when the row runs out of room, and the title wraps rather than truncating. A ghost or link Button at either end of the group bleeds by its own padding, so its label meets the content edge (the end on the title row, the start once wrapped); filled buttons keep their edge on the line. Direct children only."
|
|
11892
12889
|
},
|
|
11893
12890
|
{
|
|
11894
12891
|
"name": "titleAs",
|
|
@@ -11913,7 +12910,7 @@
|
|
|
11913
12910
|
},
|
|
11914
12911
|
{
|
|
11915
12912
|
"label": "One per page, inside the region",
|
|
11916
|
-
"code": "// PageHeader introduces the current route: title, optional description, and the\n// actions that act on THIS page.\n\n<Region max=\"content\">\n <PageHeader\n title=\"Invoices\"\n description=\"Every charge on this account.\"\n actions={<Button>Export</Button>}\n />\n <p className=\"text-body-md text-muted-foreground\">Page content.</p>\n</Region>\n\n// ✗ Outside the Region. The header then sits at a different measure from the\n// content it introduces, and the page reads as two documents.\n// ✗ Two on one page. A second one is a section heading
|
|
12913
|
+
"code": "// PageHeader introduces the current route: title, optional description, and the\n// actions that act on THIS page.\n\n<Region max=\"content\">\n <PageHeader\n title=\"Invoices\"\n description=\"Every charge on this account.\"\n actions={<Button>Export</Button>}\n />\n <p className=\"text-body-md text-muted-foreground\">Page content.</p>\n</Region>\n\n// ✗ Outside the Region. The header then sits at a different measure from the\n// content it introduces, and the page reads as two documents.\n// ✗ Two on one page. A second one is a section heading: a SectionHeader\n// (an h2 on text-heading-3, same title row and actions), not another PageHeader.\n// ✗ Global actions in it (account, search). Those persist across routes and\n// belong in AppHeader.",
|
|
11917
12914
|
"imports": "import { Button, PageHeader, Region } from \"@sableui/react\""
|
|
11918
12915
|
}
|
|
11919
12916
|
]
|
|
@@ -12589,8 +13586,8 @@
|
|
|
12589
13586
|
},
|
|
12590
13587
|
{
|
|
12591
13588
|
"name": "centerLabel",
|
|
12592
|
-
"type": "{ value: string; label?: string }",
|
|
12593
|
-
"description": "A value and caption in the hole of a donut."
|
|
13589
|
+
"type": "{ value: string | number; label?: string }",
|
|
13590
|
+
"description": "A value and caption in the hole of a donut. The value holds the centre and the caption hangs below it. A number is formatted for you: in full up to 9,999, compact from 10,000 (10.2K, 102.1K, 1.2M); pass a string for units."
|
|
12594
13591
|
},
|
|
12595
13592
|
{
|
|
12596
13593
|
"name": "separator",
|
|
@@ -12703,7 +13700,7 @@
|
|
|
12703
13700
|
"examples": [
|
|
12704
13701
|
{
|
|
12705
13702
|
"label": "Donut with a total",
|
|
12706
|
-
"code": "const sources = [\n\t{ source: \"Search\", visitors: 275 },\n\t{ source: \"Direct\", visitors: 200 },\n\t{ source: \"Social\", visitors: 187 },\n\t{ source: \"Email\", visitors: 90 },\n]\n\n<PieChart\n\tlabel=\"Visitors by source\"\n\tdata={sources}\n\tindex=\"source\"\n\tvalueKey=\"visitors\"\n\tdonut\n\tcenterLabel={{ value:
|
|
13703
|
+
"code": "const sources = [\n\t{ source: \"Search\", visitors: 275 },\n\t{ source: \"Direct\", visitors: 200 },\n\t{ source: \"Social\", visitors: 187 },\n\t{ source: \"Email\", visitors: 90 },\n]\n\n<PieChart\n\tlabel=\"Visitors by source\"\n\tdata={sources}\n\tindex=\"source\"\n\tvalueKey=\"visitors\"\n\tdonut\n\tcenterLabel={{ value: 752, label: \"Visitors\" }}\n/>",
|
|
12707
13704
|
"imports": "import { PieChart } from \"@sableui/react\""
|
|
12708
13705
|
},
|
|
12709
13706
|
{
|
|
@@ -13467,8 +14464,8 @@
|
|
|
13467
14464
|
},
|
|
13468
14465
|
{
|
|
13469
14466
|
"name": "centerLabel",
|
|
13470
|
-
"type": "{ value: string; label?: string }",
|
|
13471
|
-
"description": "A value and caption in the middle."
|
|
14467
|
+
"type": "{ value: string | number; label?: string }",
|
|
14468
|
+
"description": "A value and caption in the middle. On a full ring the value holds the centre and the caption hangs below it. A number is formatted for you: in full up to 9,999, compact from 10,000 (10.2K); pass a string for units (\"7.2 GB\", \"72%\")."
|
|
13472
14469
|
},
|
|
13473
14470
|
{
|
|
13474
14471
|
"name": "legend",
|
|
@@ -14285,46 +15282,232 @@
|
|
|
14285
15282
|
"description": "Dims the control and blocks pointer interaction."
|
|
14286
15283
|
},
|
|
14287
15284
|
{
|
|
14288
|
-
"name": "...props",
|
|
14289
|
-
"type": "ComponentPropsWithRef<\"input\">",
|
|
14290
|
-
"description": "All native input attributes (name, autoFocus, ref, aria-invalid, …) pass through to the <input> except value/onChange, which are owned by value/onValueChange, and className, which sizes the whole field."
|
|
15285
|
+
"name": "...props",
|
|
15286
|
+
"type": "ComponentPropsWithRef<\"input\">",
|
|
15287
|
+
"description": "All native input attributes (name, autoFocus, ref, aria-invalid, …) pass through to the <input> except value/onChange, which are owned by value/onValueChange, and className, which sizes the whole field."
|
|
15288
|
+
}
|
|
15289
|
+
],
|
|
15290
|
+
"tokensUsed": [
|
|
15291
|
+
"input",
|
|
15292
|
+
"card",
|
|
15293
|
+
"foreground",
|
|
15294
|
+
"muted-foreground",
|
|
15295
|
+
"ring",
|
|
15296
|
+
"destructive"
|
|
15297
|
+
],
|
|
15298
|
+
"a11y": [
|
|
15299
|
+
"Renders a native <input type=\"search\"> — keyboard and form semantics come for free.",
|
|
15300
|
+
"Associate a <label htmlFor> (or wrap) — the magnifier glyph is decorative, not a label.",
|
|
15301
|
+
"The leading search Icon is pointer-events-none and purely decorative.",
|
|
15302
|
+
"The clear button is a real <button> (Sable's ghost icon Button) with aria-label='Clear search' and a focus-visible ring. It is hidden while the field is disabled.",
|
|
15303
|
+
"Focus-visible draws a 2px ring in the --ring token; aria-invalid raises the destructive border and ring."
|
|
15304
|
+
],
|
|
15305
|
+
"examples": [
|
|
15306
|
+
{
|
|
15307
|
+
"label": "Controlled",
|
|
15308
|
+
"code": "const [q, setQ] = useState(\"\")\n<SearchInput value={q} onValueChange={setQ} placeholder=\"Search posts\" />",
|
|
15309
|
+
"imports": "import { SearchInput } from \"@sableui/react\""
|
|
15310
|
+
},
|
|
15311
|
+
{
|
|
15312
|
+
"label": "With onClear",
|
|
15313
|
+
"code": "<SearchInput value={q} onValueChange={setQ} onClear={() => console.log(\"cleared\")} />",
|
|
15314
|
+
"imports": "import { SearchInput } from \"@sableui/react\""
|
|
15315
|
+
},
|
|
15316
|
+
{
|
|
15317
|
+
"label": "Uncontrolled, sized as a whole",
|
|
15318
|
+
"code": "<Field label=\"Search posts\"><SearchInput defaultValue=\"editions\" className=\"max-w-80\" /></Field>",
|
|
15319
|
+
"imports": "import { Field, SearchInput } from \"@sableui/react\""
|
|
15320
|
+
},
|
|
15321
|
+
{
|
|
15322
|
+
"label": "Disabled",
|
|
15323
|
+
"code": "<SearchInput disabled placeholder=\"Search\" />",
|
|
15324
|
+
"imports": "import { SearchInput } from \"@sableui/react\""
|
|
15325
|
+
}
|
|
15326
|
+
]
|
|
15327
|
+
},
|
|
15328
|
+
{
|
|
15329
|
+
"name": "SectionHeader",
|
|
15330
|
+
"status": "stable",
|
|
15331
|
+
"group": "Layout",
|
|
15332
|
+
"register": "page",
|
|
15333
|
+
"path": "components/section-header",
|
|
15334
|
+
"summary": "The heading block for one section of a page: a title (h2 by default), an optional line of description, and actions on the title row — a \"View all\" link or a couple of buttons — that drop beneath the title only when the row runs out of room.",
|
|
15335
|
+
"keywords": [
|
|
15336
|
+
"section header",
|
|
15337
|
+
"section heading",
|
|
15338
|
+
"section title",
|
|
15339
|
+
"shelf",
|
|
15340
|
+
"view all",
|
|
15341
|
+
"see all",
|
|
15342
|
+
"show all",
|
|
15343
|
+
"h2",
|
|
15344
|
+
"subsection",
|
|
15345
|
+
"optical alignment",
|
|
15346
|
+
"bleed"
|
|
15347
|
+
],
|
|
15348
|
+
"usage": {
|
|
15349
|
+
"description": "Use SectionHeader to head each section below the page title — a home-page shelf with View all, a settings group, a list with its own actions. PageHeader is the route's single h1; every section under it takes a SectionHeader, with titleId wired to the section's aria-labelledby.",
|
|
15350
|
+
"bestPractices": [
|
|
15351
|
+
{
|
|
15352
|
+
"guidance": "do",
|
|
15353
|
+
"description": "Wrap the header and its content in a `<section aria-labelledby={titleId}>` (a flex column with a gap, or a Stack inside it) so the section is a named region and the header's title is its name."
|
|
15354
|
+
},
|
|
15355
|
+
{
|
|
15356
|
+
"guidance": "do",
|
|
15357
|
+
"description": "Pick titleAs by the document outline (h2 under the page h1, h3 inside an h2 section) and size by visual weight: md for a page's sections and shelves, sm for denser groups like settings."
|
|
15358
|
+
},
|
|
15359
|
+
{
|
|
15360
|
+
"guidance": "do",
|
|
15361
|
+
"description": "Keep actions to one or two `size=\"sm\"` buttons — the title is centred on that height. A shelf's \"View all\" is a ghost Button rendered as a link (`render={<a href=… />}`), named for what it opens when the label alone is ambiguous (aria-label=\"View all new releases\")."
|
|
15362
|
+
},
|
|
15363
|
+
{
|
|
15364
|
+
"guidance": "do",
|
|
15365
|
+
"description": "Pass a ghost or link action as a plain Button and let the header align it: the last direct child bleeds by its own padding at the end, so \"View all\" sits on the edge of the grid below, and the first bleeds at the start, which is the edge once the actions wrap beneath the title. Filled buttons keep their edge on the line. Set bleed=\"none\" on the Button to opt one out."
|
|
15366
|
+
},
|
|
15367
|
+
{
|
|
15368
|
+
"guidance": "dont",
|
|
15369
|
+
"description": "Nudge a View all onto the edge with -me-3, or wrap the actions in your own div: the header aligns direct children only, and only by each button's real padding. If you must wrap them, set bleed on the Button inside instead."
|
|
15370
|
+
},
|
|
15371
|
+
{
|
|
15372
|
+
"guidance": "dont",
|
|
15373
|
+
"description": "Use a second PageHeader for a section — that is a second h1, and the outline then reports two documents. And do not hand-build `<div className=\"flex justify-between\"><h2 className=\"text-heading-3\">…` per shelf: it misses the wrapping, the aria wiring and the shared rhythm."
|
|
15374
|
+
},
|
|
15375
|
+
{
|
|
15376
|
+
"guidance": "dont",
|
|
15377
|
+
"description": "Use SectionHeader to title a Card (use CardHeader + CardTitle) or a docked tool panel (use PanelSection). It heads a section of the page, not a surface."
|
|
15378
|
+
},
|
|
15379
|
+
{
|
|
15380
|
+
"guidance": "dont",
|
|
15381
|
+
"description": "Add margin to the header to space it from its content. It has no outer spacing on purpose; the section's gap owns that rhythm, so every section on the page keeps the same one."
|
|
15382
|
+
}
|
|
15383
|
+
],
|
|
15384
|
+
"anatomy": [
|
|
15385
|
+
{
|
|
15386
|
+
"name": "Title",
|
|
15387
|
+
"required": true,
|
|
15388
|
+
"description": "The section's heading (h2 by default), wrapping rather than truncating. Its id names the section."
|
|
15389
|
+
},
|
|
15390
|
+
{
|
|
15391
|
+
"name": "Description",
|
|
15392
|
+
"required": false,
|
|
15393
|
+
"description": "One line of supporting copy under the title, capped at the prose measure."
|
|
15394
|
+
},
|
|
15395
|
+
{
|
|
15396
|
+
"name": "Actions",
|
|
15397
|
+
"required": false,
|
|
15398
|
+
"description": "Buttons or links at the title row's end; they move beneath the title when the row is too narrow for both. A borderless (ghost/link) Button at either end of the group is optically aligned: its label, not its invisible padding, meets the content edge."
|
|
15399
|
+
}
|
|
15400
|
+
]
|
|
15401
|
+
},
|
|
15402
|
+
"related": {
|
|
15403
|
+
"components": [
|
|
15404
|
+
"PageHeader",
|
|
15405
|
+
"CardHeader",
|
|
15406
|
+
"PanelSection",
|
|
15407
|
+
"Grid",
|
|
15408
|
+
"Stack",
|
|
15409
|
+
"Button"
|
|
15410
|
+
],
|
|
15411
|
+
"patterns": [
|
|
15412
|
+
"SettingsPage",
|
|
15413
|
+
"DashboardPage"
|
|
15414
|
+
]
|
|
15415
|
+
},
|
|
15416
|
+
"props": [
|
|
15417
|
+
{
|
|
15418
|
+
"name": "title",
|
|
15419
|
+
"type": "ReactNode",
|
|
15420
|
+
"description": "The section's title."
|
|
15421
|
+
},
|
|
15422
|
+
{
|
|
15423
|
+
"name": "description",
|
|
15424
|
+
"type": "ReactNode",
|
|
15425
|
+
"description": "One line of supporting copy beneath the title, capped at the prose measure."
|
|
15426
|
+
},
|
|
15427
|
+
{
|
|
15428
|
+
"name": "actions",
|
|
15429
|
+
"type": "ReactNode",
|
|
15430
|
+
"description": "Trailing actions on the title row — a View all link or a couple of buttons, size=\"sm\". They stay on the title's row and drop beneath it only when the row runs out of room; the title wraps rather than truncating. A ghost or link Button that ends the group bleeds by its padding at the end (and one that starts it, at the start — the edge once wrapped), so its label lines up with the content below; Button bleed=\"none\" opts out. Direct children only."
|
|
15431
|
+
},
|
|
15432
|
+
{
|
|
15433
|
+
"name": "titleAs",
|
|
15434
|
+
"type": "\"h2\" | \"h3\" | \"h4\"",
|
|
15435
|
+
"default": "\"h2\"",
|
|
15436
|
+
"description": "The heading element, chosen by the document outline: h2 for a section under the page's h1, h3 for a subsection inside one."
|
|
15437
|
+
},
|
|
15438
|
+
{
|
|
15439
|
+
"name": "titleId",
|
|
15440
|
+
"type": "string",
|
|
15441
|
+
"description": "The title's id, so the section it heads can point at it: <section aria-labelledby={titleId}>. Generated when omitted."
|
|
15442
|
+
},
|
|
15443
|
+
{
|
|
15444
|
+
"name": "size",
|
|
15445
|
+
"type": "\"md\" | \"sm\"",
|
|
15446
|
+
"default": "\"md\"",
|
|
15447
|
+
"description": "Visual weight, independent of the heading level: md (text-heading-3) for a page's sections and shelves; sm (text-heading-4) for denser sets — settings groups, subsections."
|
|
15448
|
+
}
|
|
15449
|
+
],
|
|
15450
|
+
"variants": {
|
|
15451
|
+
"size": [
|
|
15452
|
+
"md",
|
|
15453
|
+
"sm"
|
|
15454
|
+
]
|
|
15455
|
+
},
|
|
15456
|
+
"tokensUsed": [
|
|
15457
|
+
"foreground",
|
|
15458
|
+
"muted-foreground"
|
|
15459
|
+
],
|
|
15460
|
+
"motion": [
|
|
15461
|
+
{
|
|
15462
|
+
"slot": "arrival",
|
|
15463
|
+
"recipe": "none",
|
|
15464
|
+
"part": "root",
|
|
15465
|
+
"why": "A heading arrives with the section it names; animating it separately would make the section's title lag its content."
|
|
15466
|
+
},
|
|
15467
|
+
{
|
|
15468
|
+
"slot": "response",
|
|
15469
|
+
"recipe": "none",
|
|
15470
|
+
"part": "root",
|
|
15471
|
+
"why": "The header itself is not interactive; the Buttons passed as actions carry their own response."
|
|
15472
|
+
},
|
|
15473
|
+
{
|
|
15474
|
+
"slot": "transition",
|
|
15475
|
+
"recipe": "none",
|
|
15476
|
+
"part": "root",
|
|
15477
|
+
"why": "It has no states of its own to move between."
|
|
15478
|
+
},
|
|
15479
|
+
{
|
|
15480
|
+
"slot": "activity",
|
|
15481
|
+
"recipe": "none",
|
|
15482
|
+
"part": "root",
|
|
15483
|
+
"why": "It never does indeterminate work."
|
|
14291
15484
|
}
|
|
14292
15485
|
],
|
|
14293
|
-
"tokensUsed": [
|
|
14294
|
-
"input",
|
|
14295
|
-
"card",
|
|
14296
|
-
"foreground",
|
|
14297
|
-
"muted-foreground",
|
|
14298
|
-
"ring",
|
|
14299
|
-
"destructive"
|
|
14300
|
-
],
|
|
14301
15486
|
"a11y": [
|
|
14302
|
-
"Renders a
|
|
14303
|
-
"
|
|
14304
|
-
"
|
|
14305
|
-
"The clear button is a real <button> (Sable's ghost icon Button) with aria-label='Clear search' and a focus-visible ring. It is hidden while the field is disabled.",
|
|
14306
|
-
"Focus-visible draws a 2px ring in the --ring token; aria-invalid raises the destructive border and ring."
|
|
15487
|
+
"Renders a real heading (h2 by default); choose titleAs by the outline, never to get a size — size sets the look.",
|
|
15488
|
+
"The title always carries an id (titleId, or a generated one). Point the section at it with aria-labelledby so the section is announced as a named region and appears in landmark navigation.",
|
|
15489
|
+
"A repeated \"View all\" is ambiguous in a links list; name each one for its section (aria-label=\"View all new releases\") or keep the visible label specific."
|
|
14307
15490
|
],
|
|
14308
15491
|
"examples": [
|
|
14309
15492
|
{
|
|
14310
|
-
"label": "
|
|
14311
|
-
"code": "
|
|
14312
|
-
"imports": "import {
|
|
15493
|
+
"label": "A shelf with View all",
|
|
15494
|
+
"code": "<section aria-labelledby=\"new-releases\" className=\"flex flex-col gap-4\">\n\t<SectionHeader\n\t\ttitleId=\"new-releases\"\n\t\ttitle=\"New releases\"\n\t\tdescription=\"Fresh from artists you follow.\"\n\t\tactions={\n\t\t\t<Button variant=\"ghost\" size=\"sm\" render={<a href=\"/new\" />} aria-label=\"View all new releases\">\n\t\t\t\tView all\n\t\t\t</Button>\n\t\t}\n\t/>\n\t<Grid minItemWidth=\"10rem\" gap={2}>\n\t\t<Card><CardHeader><CardTitle>Night Swim</CardTitle><CardDescription>Lena Vale</CardDescription></CardHeader></Card>\n\t\t<Card><CardHeader><CardTitle>Low Tide</CardTitle><CardDescription>Harbour Lights</CardDescription></CardHeader></Card>\n\t</Grid>\n</section>",
|
|
15495
|
+
"imports": "import { Button, Card, CardDescription, CardHeader, CardTitle, Grid, SectionHeader } from \"@sableui/react\""
|
|
14313
15496
|
},
|
|
14314
15497
|
{
|
|
14315
|
-
"label": "
|
|
14316
|
-
"code": "<
|
|
14317
|
-
"imports": "import {
|
|
15498
|
+
"label": "A settings section",
|
|
15499
|
+
"code": "<section aria-labelledby=\"playback\" className=\"flex flex-col gap-4\">\n\t<SectionHeader\n\t\ttitleId=\"playback\"\n\t\tsize=\"sm\"\n\t\ttitle=\"Playback\"\n\t\tdescription=\"How tracks start, end and blend into each other.\"\n\t/>\n\t<Field label=\"Crossfade\" orientation=\"horizontal\">\n\t\t<Switch defaultChecked />\n\t</Field>\n\t<Field label=\"Normalize volume\" orientation=\"horizontal\">\n\t\t<Switch />\n\t</Field>\n</section>",
|
|
15500
|
+
"imports": "import { Field, SectionHeader, Switch } from \"@sableui/react\""
|
|
14318
15501
|
},
|
|
14319
15502
|
{
|
|
14320
|
-
"label": "
|
|
14321
|
-
"code": "<
|
|
14322
|
-
"imports": "import {
|
|
15503
|
+
"label": "A section with two actions",
|
|
15504
|
+
"code": "<section aria-labelledby=\"playlists\" className=\"flex flex-col gap-4\">\n\t<SectionHeader\n\t\ttitleId=\"playlists\"\n\t\ttitle=\"Your playlists\"\n\t\tactions={\n\t\t\t<>\n\t\t\t\t<Button variant=\"ghost\" size=\"sm\" iconStart={<Icon name=\"filter\" />}>Filter</Button>\n\t\t\t\t<Button size=\"sm\" iconStart={<Icon name=\"plus\" />}>New playlist</Button>\n\t\t\t</>\n\t\t}\n\t/>\n\t<Stack gap={1}>\n\t\t<Entity className=\"[--entity-px:0]\" title=\"Late drives\" subtitle=\"42 tracks\" />\n\t\t<Entity className=\"[--entity-px:0]\" title=\"Focus\" subtitle=\"18 tracks\" />\n\t</Stack>\n</section>",
|
|
15505
|
+
"imports": "import { Button, Entity, Icon, SectionHeader, Stack } from \"@sableui/react\""
|
|
14323
15506
|
},
|
|
14324
15507
|
{
|
|
14325
|
-
"label": "
|
|
14326
|
-
"code": "<
|
|
14327
|
-
"imports": "import {
|
|
15508
|
+
"label": "A subsection inside a section",
|
|
15509
|
+
"code": "<SectionHeader titleAs=\"h3\" size=\"sm\" title=\"Appears on\" />",
|
|
15510
|
+
"imports": "import { SectionHeader } from \"@sableui/react\""
|
|
14328
15511
|
}
|
|
14329
15512
|
]
|
|
14330
15513
|
},
|
|
@@ -15036,6 +16219,11 @@
|
|
|
15036
16219
|
"required": false,
|
|
15037
16220
|
"description": "Supporting context for the sheet."
|
|
15038
16221
|
},
|
|
16222
|
+
{
|
|
16223
|
+
"name": "SheetList",
|
|
16224
|
+
"required": false,
|
|
16225
|
+
"description": "Edge-to-edge rows (Entity) aligned with the header — a queue, notifications, a picker."
|
|
16226
|
+
},
|
|
15039
16227
|
{
|
|
15040
16228
|
"name": "SheetFooter",
|
|
15041
16229
|
"required": false,
|
|
@@ -15088,6 +16276,11 @@
|
|
|
15088
16276
|
"type": "Base UI Drawer.Description props + className",
|
|
15089
16277
|
"description": "Muted supporting line; wires aria-describedby."
|
|
15090
16278
|
},
|
|
16279
|
+
{
|
|
16280
|
+
"name": "SheetList",
|
|
16281
|
+
"type": "ComponentProps<\"div\">",
|
|
16282
|
+
"description": "Rows that run edge to edge in the sheet, Entity most often. It bleeds through the sheet's padding (--sheet-p) and hands it back to each row as --entity-px, so row content lines up with SheetHeader and a row's hover fill reaches the panel's edges. Without it a list of Entity rows sits 12px in from the header. The Sheet equivalent of CardList."
|
|
16283
|
+
},
|
|
15091
16284
|
{
|
|
15092
16285
|
"name": "SheetFooter",
|
|
15093
16286
|
"type": "ComponentProps<\"div\">",
|
|
@@ -15119,6 +16312,11 @@
|
|
|
15119
16312
|
"A mouse drag that starts on sheet content selects text rather than dragging the panel; mouse dragging only starts from the padding edge."
|
|
15120
16313
|
],
|
|
15121
16314
|
"examples": [
|
|
16315
|
+
{
|
|
16316
|
+
"label": "List of rows (a queue)",
|
|
16317
|
+
"code": "<Sheet><SheetTrigger render={<Button variant=\"secondary\">Queue</Button>} /><SheetContent><SheetHeader><SheetTitle>Up next</SheetTitle></SheetHeader><SheetList>{queue.map((track) => <Entity key={track.id} leading={<GradientArt aria-hidden seed={track.title} className=\"size-10 rounded-sm\" />} title={track.title} subtitle={track.artist} />)}</SheetList></SheetContent></Sheet>",
|
|
16318
|
+
"imports": "import { Button, Entity, GradientArt, Sheet, SheetContent, SheetHeader, SheetList, SheetTitle, SheetTrigger } from \"@sableui/react\""
|
|
16319
|
+
},
|
|
15122
16320
|
{
|
|
15123
16321
|
"label": "Basic (right)",
|
|
15124
16322
|
"code": "<Sheet><SheetTrigger render={<Button>Open</Button>} /><SheetContent><SheetHeader><SheetTitle>Filters</SheetTitle><SheetDescription>Narrow the feed.</SheetDescription></SheetHeader><SheetFooter><SheetClose render={<Button variant=\"secondary\">Cancel</Button>} /><SheetClose render={<Button>Apply</Button>} /></SheetFooter></SheetContent></Sheet>",
|
|
@@ -15226,7 +16424,7 @@
|
|
|
15226
16424
|
"group": "Navigation",
|
|
15227
16425
|
"register": "page",
|
|
15228
16426
|
"path": "components/sidebar",
|
|
15229
|
-
"summary": "Desktop side-navigation rail (in-flow sticky, hidden below lg) with SidebarHeader / SidebarNav / SidebarItem / SidebarFooter parts. Items are router-agnostic via the `render` prop; an optional collapsed icon-rail mode reveals labels in tooltips.",
|
|
16427
|
+
"summary": "Desktop side-navigation rail (in-flow sticky, hidden below lg) with SidebarHeader / SidebarBrand / SidebarNav / SidebarItem / SidebarFooter parts. Items are router-agnostic via the `render` prop; an optional collapsed icon-rail mode reveals labels in tooltips.",
|
|
15230
16428
|
"keywords": [
|
|
15231
16429
|
"sidebar",
|
|
15232
16430
|
"navigation",
|
|
@@ -15294,6 +16492,11 @@
|
|
|
15294
16492
|
}
|
|
15295
16493
|
],
|
|
15296
16494
|
"subcomponents": [
|
|
16495
|
+
{
|
|
16496
|
+
"name": "SidebarBrand",
|
|
16497
|
+
"type": "{ mark: ReactNode; label: ReactNode; render?: useRender.RenderProp } + div props",
|
|
16498
|
+
"description": "The brand row, inside SidebarHeader, on the same grid as SidebarItem: mark in the icons' 24px slot, label on the labels' column, so a logo lines up with the nav below it without copying the rail's inset numbers. Collapsed, it shows the mark alone (named by a string label). render={<Link to=\"/\" />} makes it the home link."
|
|
16499
|
+
},
|
|
15297
16500
|
{
|
|
15298
16501
|
"name": "SidebarItem",
|
|
15299
16502
|
"type": "{ label: ReactNode; icon?: ReactNode; active?: boolean; badge?: ReactNode; render?: useRender.RenderProp } + button props",
|
|
@@ -15323,8 +16526,8 @@
|
|
|
15323
16526
|
"examples": [
|
|
15324
16527
|
{
|
|
15325
16528
|
"label": "Rail with nav",
|
|
15326
|
-
"code": "<Sidebar><SidebarHeader
|
|
15327
|
-
"imports": "import { Icon, NotificationBadge, Sidebar, SidebarFooter, SidebarHeader, SidebarItem, SidebarNav } from \"@sableui/react\"\n// <Link> = your router's link (react-router, next/link, @tanstack/router, …)"
|
|
16529
|
+
"code": "<Sidebar><SidebarHeader><SidebarBrand mark={<Icon name=\"music\" variant=\"solid\" />} label=\"Undertow\" render={<Link to=\"/\" />} /></SidebarHeader><SidebarNav><SidebarItem icon={<Icon name=\"bell\" />} label=\"Notifications\" badge={<NotificationBadge count={3} />} render={<Link to=\"/notifications\" />} /></SidebarNav><SidebarFooter>{wallet}</SidebarFooter></Sidebar>",
|
|
16530
|
+
"imports": "import { Icon, NotificationBadge, Sidebar, SidebarBrand, SidebarFooter, SidebarHeader, SidebarItem, SidebarNav } from \"@sableui/react\"\n// <Link> = your router's link (react-router, next/link, @tanstack/router, …)"
|
|
15328
16531
|
},
|
|
15329
16532
|
{
|
|
15330
16533
|
"label": "Collapsible (icon rail)",
|
|
@@ -15707,6 +16910,24 @@
|
|
|
15707
16910
|
"type": "'horizontal' | 'vertical'",
|
|
15708
16911
|
"default": "horizontal",
|
|
15709
16912
|
"description": "Axis the slider tracks along. A vertical slider has no intrinsic length — give it a height (h-40, or flex-1 in a column)."
|
|
16913
|
+
},
|
|
16914
|
+
{
|
|
16915
|
+
"name": "aria-label",
|
|
16916
|
+
"type": "string",
|
|
16917
|
+
"default": "—",
|
|
16918
|
+
"description": "Accessible name, applied to each thumb's input (the element with role=slider). A range also names its group with it."
|
|
16919
|
+
},
|
|
16920
|
+
{
|
|
16921
|
+
"name": "getAriaLabel",
|
|
16922
|
+
"type": "(index) => string",
|
|
16923
|
+
"default": "—",
|
|
16924
|
+
"description": "Per-thumb name for a range (\"Minimum price\" / \"Maximum price\"). Wins over aria-label on the thumbs."
|
|
16925
|
+
},
|
|
16926
|
+
{
|
|
16927
|
+
"name": "getAriaValueText",
|
|
16928
|
+
"type": "(formattedValue, value, index) => string",
|
|
16929
|
+
"default": "—",
|
|
16930
|
+
"description": "What a screen reader announces for the value when the number alone is wrong — a seek bar's \"1:23 of 3:45\". For units or percent, `format` (Intl.NumberFormatOptions) is enough."
|
|
15710
16931
|
}
|
|
15711
16932
|
],
|
|
15712
16933
|
"subcomponents": [
|
|
@@ -15753,7 +16974,8 @@
|
|
|
15753
16974
|
"The thumb's hit area is a fixed 16px box at every state. Only the decorative inner dot resizes — rest 12px, full 16px on hover, keyboard focus, and drag — so the target a pointer or finger must land on never moves or changes size. Keyboard users, who never hover, get the enlarged dot from focus-visible.",
|
|
15754
16975
|
"The rest-to-full transition uses the motion duration/easing tokens, so it collapses to an instant state change under prefers-reduced-motion or MotionProvider off. The size change itself still happens: it reports interaction state, it is not decoration.",
|
|
15755
16976
|
"Range sliders get one thumb per value with minStepsBetweenValues honored.",
|
|
15756
|
-
"Pass
|
|
16977
|
+
"Pass aria-label (or getAriaLabel for a range) so each handle has an accessible name. On Slider it reaches the thumb inputs; on a bare SliderRoot it only names the role=group — when composing, put aria-label / getAriaLabel on each SliderThumb, or label the Root with aria-labelledby or a Field, which Base UI does carry to the thumbs.",
|
|
16978
|
+
"getAriaValueText sets aria-valuetext for values a number misreads (time, named steps)."
|
|
15757
16979
|
],
|
|
15758
16980
|
"examples": [
|
|
15759
16981
|
{
|
|
@@ -15763,7 +16985,12 @@
|
|
|
15763
16985
|
},
|
|
15764
16986
|
{
|
|
15765
16987
|
"label": "Range",
|
|
15766
|
-
"code": "const [range, setRange] = useState([20, 60])\n<Slider value={range} onValueChange={setRange} aria-label=\"Price range\" />",
|
|
16988
|
+
"code": "const [range, setRange] = useState([20, 60])\n<Slider value={range} onValueChange={setRange} aria-label=\"Price range\" getAriaLabel={(i) => (i ? \"Maximum price\" : \"Minimum price\")} />",
|
|
16989
|
+
"imports": "import { Slider } from \"@sableui/react\""
|
|
16990
|
+
},
|
|
16991
|
+
{
|
|
16992
|
+
"label": "Seek bar with spoken time",
|
|
16993
|
+
"code": "<Slider value={position} max={duration} onValueChange={seek} aria-label=\"Seek\" getAriaValueText={(_, v) => `${formatTime(v)} of ${formatTime(duration)}`} />",
|
|
15767
16994
|
"imports": "import { Slider } from \"@sableui/react\""
|
|
15768
16995
|
},
|
|
15769
16996
|
{
|
|
@@ -17211,7 +18438,7 @@
|
|
|
17211
18438
|
"group": "Selection",
|
|
17212
18439
|
"register": "either",
|
|
17213
18440
|
"path": "components/theme-selector",
|
|
17214
|
-
"summary": "A presentational radiogroup for choosing a Sable design theme from theme manifests —
|
|
18441
|
+
"summary": "A presentational radiogroup for choosing a Sable design theme from theme manifests — tiles that draw each theme as a miniature of its own interface (its colours, type and corner shape) for a settings page, or compact rows for tool chrome. Pair it with useSableTheme().themeId and setThemeId when the app uses SableThemeProvider.",
|
|
17215
18442
|
"keywords": [
|
|
17216
18443
|
"theme selector",
|
|
17217
18444
|
"theme",
|
|
@@ -17221,16 +18448,20 @@
|
|
|
17221
18448
|
"preset"
|
|
17222
18449
|
],
|
|
17223
18450
|
"usage": {
|
|
17224
|
-
"description": "Use ThemeSelector wherever a person picks a theme. The default `cards` layout is page-register furniture: a
|
|
18451
|
+
"description": "Use ThemeSelector wherever a person picks a theme. The default `cards` layout is page-register furniture: a miniature of each theme's interface (its background, \"Aa\" in its display face, a card, and a primary button in its own corner shape), with the name and a caption only where one distinguishes it (the default theme, or a single-mode theme). Use `layout=\"list\"` in tool chrome, a popover, or any docked panel — five cards is the wrong object for a 256px surface, however it is sized. For colour mode (light/dark/system) use ThemeSwitcher, which is a segmented control and belongs in chrome as-is.",
|
|
17225
18452
|
"bestPractices": [
|
|
17226
18453
|
{
|
|
17227
18454
|
"guidance": "do",
|
|
17228
|
-
"description": "Use layout=\"list\" in a popover, a menu, or a docked panel. Rows sit on the --control-sm ladder and still show each theme
|
|
18455
|
+
"description": "Use layout=\"list\" in a popover, a menu, or a docked panel. Rows sit on the --control-sm ladder and still show a small tile of each theme, so the choice stays visible rather than described. Rows take `sable-frame-inner`, so host the list in a sable-frame (`sable-frame sable-surface sable-surface-popover sable-frame-popover` — PopoverContent is not one; give it that class list, or its p-4 leaves the rows unnested) and their corners nest concentrically."
|
|
17229
18456
|
},
|
|
17230
18457
|
{
|
|
17231
18458
|
"guidance": "do",
|
|
17232
18459
|
"description": "Pass mode={useSableTheme().mode} so every preview renders in the colour mode actually in use, rather than in each theme's manifest default."
|
|
17233
18460
|
},
|
|
18461
|
+
{
|
|
18462
|
+
"guidance": "do",
|
|
18463
|
+
"description": "Use ThemeSelector when switching is free: it is a radio group, so arrow keys check the next theme and onValueChange fires on every press — the page restyles as you go, which is the point of a live theme switcher. When a switch has a cost (it opens a \"discard changes?\" confirm, loads fonts, or resets other settings), use a DisclosurePicker list instead, with a `ThemePreview size=\"sm\"` as each option's `media`: arrows only move there, and Enter, Space or a click commits."
|
|
18464
|
+
},
|
|
17234
18465
|
{
|
|
17235
18466
|
"guidance": "do",
|
|
17236
18467
|
"description": "Load each theme's fonts in the host app. Every card carries a type specimen in that theme's own face so people can compare typography as well as color. A theme whose fonts are not loaded falls back to system-ui, and its specimen stops telling the truth."
|
|
@@ -17247,6 +18478,18 @@
|
|
|
17247
18478
|
"guidance": "dont",
|
|
17248
18479
|
"description": "Use ThemeSelector for light / dark / system colour mode. That is ThemeSwitcher — a segmented control. This picks the design theme, which is a different axis entirely, and the two are frequently confused."
|
|
17249
18480
|
}
|
|
18481
|
+
],
|
|
18482
|
+
"anatomy": [
|
|
18483
|
+
{
|
|
18484
|
+
"name": "ThemeSelector",
|
|
18485
|
+
"required": true,
|
|
18486
|
+
"description": "The radiogroup of themes, as cards or list rows."
|
|
18487
|
+
},
|
|
18488
|
+
{
|
|
18489
|
+
"name": "ThemePreview",
|
|
18490
|
+
"required": false,
|
|
18491
|
+
"description": "The tile each option draws, exported for showing a theme outside the picker — the row naming the current theme, or the `media` of each option in a commit-on-Enter DisclosurePicker list."
|
|
18492
|
+
}
|
|
17250
18493
|
]
|
|
17251
18494
|
},
|
|
17252
18495
|
"related": {
|
|
@@ -17276,7 +18519,7 @@
|
|
|
17276
18519
|
{
|
|
17277
18520
|
"name": "mode",
|
|
17278
18521
|
"type": "\"light\" | \"dark\"",
|
|
17279
|
-
"description": "Preview mode for each
|
|
18522
|
+
"description": "Preview mode for each theme's tile. Pass useSableTheme().mode to match the current color mode; omitted cards use their manifest defaultMode."
|
|
17280
18523
|
},
|
|
17281
18524
|
{
|
|
17282
18525
|
"name": "label",
|
|
@@ -17296,6 +18539,13 @@
|
|
|
17296
18539
|
"description": "Merged onto the outer radiogroup grid."
|
|
17297
18540
|
}
|
|
17298
18541
|
],
|
|
18542
|
+
"subcomponents": [
|
|
18543
|
+
{
|
|
18544
|
+
"name": "ThemePreview",
|
|
18545
|
+
"type": "{ theme: ThemeManifest; mode?: \"light\" | \"dark\"; size?: \"md\" | \"sm\" }",
|
|
18546
|
+
"description": "The tile each option draws: the theme as a miniature of itself (background, \"Aa\" in its display face, a card, a primary button in its own corner shape), in its own scope. Exported for anywhere that shows a theme outside the picker, e.g. a row naming the current preset, or each option of a DisclosurePicker list where a pick has a cost. Sized in the caller's spacing unit, so it lines up with the row it sits in."
|
|
18547
|
+
}
|
|
18548
|
+
],
|
|
17299
18549
|
"tokensUsed": [
|
|
17300
18550
|
"input",
|
|
17301
18551
|
"card",
|
|
@@ -17310,11 +18560,17 @@
|
|
|
17310
18560
|
],
|
|
17311
18561
|
"a11y": [
|
|
17312
18562
|
"The container is role=\"radiogroup\" with an aria-label; each card is a role=\"radio\" button with aria-checked reflecting the selected theme.",
|
|
18563
|
+
"Standard radio keyboard: arrow keys move the check — calling onValueChange on each press — and Space checks the focused option. That instant switching is right for a free, reversible choice; where a switch has a cost, use a DisclosurePicker list (a listbox: arrows move, Enter commits) with ThemePreview tiles instead.",
|
|
17313
18564
|
"Focus-visible draws a 2px ring on the focused card.",
|
|
17314
18565
|
"The selected card wears the shared selection-card checked ring (the same as RadioCard and CheckboxCard), a muted fill and a check icon, so selection does not rely on colour alone.",
|
|
17315
18566
|
"Presentational only: it does not change the document theme; pass useSableTheme().setThemeId or apply the change in onValueChange."
|
|
17316
18567
|
],
|
|
17317
18568
|
"examples": [
|
|
18569
|
+
{
|
|
18570
|
+
"label": "When a switch has a cost: a DisclosurePicker list of ThemePreview tiles",
|
|
18571
|
+
"code": "// Arrows move; Enter, Space or a click commits — so whatever the switch costs\n// (a confirm, a font load, a reset) happens once, on the pick.\nconst { themeId, setThemeId, themes, mode } = useSableTheme()\nconst current = themes.find((theme) => theme.id === themeId)\n<DisclosurePicker>\n <DisclosurePickerTrigger aria-label={`Theme: ${current?.label}`} media={current ? <ThemePreview theme={current} mode={mode} size=\"sm\" /> : null}>\n {current?.label}\n </DisclosurePickerTrigger>\n <DisclosurePickerPanel>\n <DisclosurePickerList aria-label=\"Theme\" value={themeId} onValueChange={setThemeId}>\n {themes.map((theme) => (\n <DisclosurePickerOption key={theme.id} value={theme.id} media={<ThemePreview theme={theme} mode={mode} size=\"sm\" />}>\n {theme.label}\n </DisclosurePickerOption>\n ))}\n </DisclosurePickerList>\n </DisclosurePickerPanel>\n</DisclosurePicker>",
|
|
18572
|
+
"imports": "import { DisclosurePicker, DisclosurePickerList, DisclosurePickerOption, DisclosurePickerPanel, DisclosurePickerTrigger, ThemePreview, useSableTheme } from \"@sableui/react\""
|
|
18573
|
+
},
|
|
17318
18574
|
{
|
|
17319
18575
|
"label": "With SableThemeProvider",
|
|
17320
18576
|
"code": "const { themeId, setThemeId, themes, mode } = useSableTheme()\n<ThemeSelector value={themeId} onValueChange={setThemeId} themes={themes} mode={mode} />",
|
|
@@ -18466,7 +19722,7 @@
|
|
|
18466
19722
|
},
|
|
18467
19723
|
{
|
|
18468
19724
|
"guidance": "do",
|
|
18469
|
-
"description": "Put mutable per-row state (visibility, solo, a row menu) in `actions`, on the right rail. Show it on hover, and at rest only when its value is the non-default one."
|
|
19725
|
+
"description": "Put mutable per-row state (visibility, solo, a row menu) in `actions`, on the right rail. Show it on hover with `reveal-on-hover` (the row is its `group`), and at rest only when its value is the non-default one."
|
|
18470
19726
|
},
|
|
18471
19727
|
{
|
|
18472
19728
|
"guidance": "do",
|
|
@@ -18591,7 +19847,7 @@
|
|
|
18591
19847
|
{
|
|
18592
19848
|
"name": "disclosureIcon",
|
|
18593
19849
|
"type": "ReactNode",
|
|
18594
|
-
"description": "Replaces TreeRow's semantic chevron-down icon. Omit to use the
|
|
19850
|
+
"description": "Replaces TreeRow's semantic chevron-down icon. Omit to use the chevron-down from the app's IconProvider set."
|
|
18595
19851
|
},
|
|
18596
19852
|
{
|
|
18597
19853
|
"name": "actions",
|
|
@@ -18646,7 +19902,7 @@
|
|
|
18646
19902
|
"Each treeitem is named by its own label (aria-labelledby the name), never by the text of its icon or action buttons.",
|
|
18647
19903
|
"The disclosure is a real button with aria-expanded and a label naming what it discloses, and it stops propagation so collapsing a group never changes the selection. Inside a Tree the treeitem carries aria-expanded and the arrow keys, so the button becomes a pointer target only: aria-hidden, out of the tab order, and it leaves focus where it was when clicked.",
|
|
18648
19904
|
"The disclosure's POINTER TARGET is bigger than the mark it draws. `--tree-column` is 20px comfortable and 18px compact, both under WCAG 2.5.8's 24x24 floor, so a centred pseudo-element takes the hit at `--control-xs` (28/24) instead. The drawn box stays on the column, because that column is also the indent step — growing the button would push every name in the tree one column right.",
|
|
18649
|
-
"Right-rail actions stay in the tab order, so a row with actions costs extra Tab stops. Keep actions few and
|
|
19905
|
+
"Right-rail actions stay in the tab order, so a row with actions costs extra Tab stops. Keep actions few and reveal them with `reveal-on-hover`, which also shows them on focus and always on touch.",
|
|
18650
19906
|
"`dimmed` is presentation only. A row switched off must also say so in its actions' aria-pressed or its accessible name; opacity alone is invisible to a screen reader and fails contrast.",
|
|
18651
19907
|
"Right-rail controls that appear on hover must also appear on focus-visible, or they are unreachable by keyboard."
|
|
18652
19908
|
],
|
|
@@ -19055,8 +20311,8 @@
|
|
|
19055
20311
|
"Each sell-through Meter has an aria-label naming the edition and aria-valuetext reading \"184 of 250 sold\".",
|
|
19056
20312
|
"Avatars beside a visible name use an empty alt so the name is not read twice."
|
|
19057
20313
|
],
|
|
19058
|
-
"imports": "import { AppHeader, AppShell, Avatar, Badge, BottomNav, BottomNavItem, Button, Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardList, CardTitle, DataTable, Entity, Grid, Icon, MenuItem, MenuSeparator, Meter, NotificationBadge, OverflowMenu, Page, PageHeader, RelativeTime, Row, Sidebar, SidebarFooter, SidebarHeader, SidebarItem, SidebarNav, Stack } from \"@sableui/react\"",
|
|
19059
|
-
"code": "// Edition status → Badge tone. Sable has no status vocabulary of its own;\n// an app maps ITS states onto tones once, here, and every badge follows.\nconst STATUS_TONE = { live: \"success\", scheduled: \"info\", \"sold-out\": \"edition\", closed: \"neutral\" } as const\nconst STATUS_LABEL = { live: \"Live\", scheduled: \"Scheduled\", \"sold-out\": \"Sold out\", closed: \"Closed\" } as const\ntype EditionStatus = keyof typeof STATUS_TONE\n\nconst MIN = 60 * 1000\nconst HOUR = 60 * MIN\nconst DAY = 24 * HOUR\n\nconst usd = new Intl.NumberFormat(\"en-US\", { style: \"currency\", currency: \"USD\", maximumFractionDigits: 0 })\nconst usdCents = new Intl.NumberFormat(\"en-US\", { style: \"currency\", currency: \"USD\" })\nconst shortDate = (offset: number) =>\n\tnew Date(Date.now() + offset).toLocaleDateString(\"en-US\", { month: \"short\", day: \"numeric\" })\n\nconst stats = [\n\t{ label: \"Revenue\", value: \"$18,240\", delta: \"+8.2%\", up: true, note: \"vs. $16,857\" },\n\t{ label: \"Collects\", value: \"1,284\", delta: \"+3.1%\", up: true, note: \"182 first-time\" },\n\t{ label: \"Collectors\", value: \"312\", delta: \"+12.4%\", up: true, note: \"Best month yet\" },\n\t{ label: \"Sell-through\", value: \"64%\", delta: \"−1.8%\", up: false, note: \"6 open editions\" },\n]\n\nconst editions = [\n\t{ id: \"ed_01\", title: \"Gold Rush\", status: \"live\" as const, sold: 184, supply: 250, price: 48, released: -6 * DAY },\n\t{ id: \"ed_02\", title: \"Quiet Harbor\", status: \"sold-out\" as const, sold: 100, supply: 100, price: 120, released: -19 * DAY },\n\t{ id: \"ed_03\", title: \"Dusk, no. 4\", status: \"live\" as const, sold: 61, supply: 150, price: 64, released: -2 * DAY },\n\t{ id: \"ed_04\", title: \"Tidewater\", status: \"live\" as const, sold: 212, supply: 300, price: 36, released: -27 * DAY },\n\t{ id: \"ed_05\", title: \"Salt Study\", status: \"closed\" as const, sold: 38, supply: 75, price: 90, released: -44 * DAY },\n\t{ id: \"ed_06\", title: \"Paper Moon\", status: \"sold-out\" as const, sold: 50, supply: 50, price: 220, released: -58 * DAY },\n\t{ id: \"ed_07\", title: \"Night Signal\", status: \"scheduled\" as const, sold: 0, supply: 200, price: 56, released: 3 * DAY },\n\t{ id: \"ed_08\", title: \"Low Tide, Blue\", status: \"closed\" as const, sold: 72, supply: 120, price: 42, released: -71 * DAY },\n\t{ id: \"ed_09\", title: \"Ember Field\", status: \"sold-out\" as const, sold: 80, supply: 80, price: 140, released: -86 * DAY },\n]\nconst editionCount = 24\n\nconst payout = {\n\tamount: 8214.6,\n\tdate: 5 * DAY,\n\tlines: [\n\t\t{ label: \"Sales\", value: 9127.33 },\n\t\t{ label: \"Platform fee (10%)\", value: -912.73 },\n\t\t{ label: \"Deposits to\", value: \"Checking ••4821\" },\n\t],\n}\n\nconst collectors = [\n\t{ id: \"co_01\", name: \"Nina Simone\", handle: \"@nina\", initials: \"NS\", pieces: 14, spent: 1460 },\n\t{ id: \"co_02\", name: \"Grace Hopper\", handle: \"@grace\", initials: \"GH\", pieces: 11, spent: 1128 },\n\t{ id: \"co_03\", name: \"Frida Kahlo\", handle: \"@frida\", initials: \"FK\", pieces: 9, spent: 942 },\n\t{ id: \"co_04\", name: \"Mary Shelley\", handle: \"@mary\", initials: \"MS\", pieces: 6, spent: 588 },\n]\n\nconst upcoming = [\n\t{ id: \"up_01\", title: \"Night Signal\", detail: \"Edition of 200 · $56\", at: 3 * DAY },\n\t{ id: \"up_02\", title: \"Tidewater, second printing\", detail: \"Edition of 100 · $40\", at: 12 * DAY },\n\t{ id: \"up_03\", title: \"Dusk, no. 5\", detail: \"Edition of 150 · $64\", at: 26 * DAY },\n\t{ id: \"up_04\", title: \"Quiet Harbor, artist proofs\", detail: \"Edition of 10 · $480\", at: 34 * DAY },\n]\n\nconst activity = [\n\t{ id: \"ac_01\", who: \"Nina Simone\", initials: \"NS\", what: \"collected Gold Rush · 185 of 250\", when: 2 * MIN },\n\t{ id: \"ac_02\", who: \"Grace Hopper\", initials: \"GH\", what: \"collected Dusk, no. 4 · 61 of 150\", when: 26 * MIN },\n\t{ id: \"ac_03\", who: \"Frida Kahlo\", initials: \"FK\", what: \"tipped $20 on Tidewater\", when: 1 * HOUR },\n\t{ id: \"ac_04\", who: \"Mary Shelley\", initials: \"MS\", what: \"started following you\", when: 3 * HOUR },\n\t{ id: \"ac_05\", who: \"Nina Simone\", initials: \"NS\", what: \"set a reminder for Night Signal\", when: 6 * HOUR },\n]\n\nexport function DashboardPage() {\n\treturn (\n\t\t<AppShell\n\t\t\t// Sidebar hides below lg, so the same destinations ride in the shell's\n\t\t\t// mobile slots — without them the route has no navigation under 1024px.\n\t\t\theader={\n\t\t\t\t<AppHeader\n\t\t\t\t\ttitle={\n\t\t\t\t\t\t<Row gap={1} align=\"center\">\n\t\t\t\t\t\t\t<Icon name=\"gem\" variant=\"solid\" className=\"text-xl\" />\n\t\t\t\t\t\t\t<span className=\"text-title-sm\">Atelier</span>\n\t\t\t\t\t\t</Row>\n\t\t\t\t\t}\n\t\t\t\t\tactions={<Avatar variant=\"art\" size=\"sm\" fallback=\"AL\" alt=\"Ada Lovelace\" />}\n\t\t\t\t/>\n\t\t\t}\n\t\t\tbottomNav={\n\t\t\t\t<BottomNav aria-label=\"Primary\">\n\t\t\t\t\t<BottomNavItem icon={<Icon name=\"grid\" />} label=\"Overview\" showLabel active render={<a href=\"/\" />} />\n\t\t\t\t\t<BottomNavItem icon={<Icon name=\"image-stack\" />} label=\"Editions\" showLabel render={<a href=\"/editions\" />} />\n\t\t\t\t\t<BottomNavItem icon={<Icon name=\"user\" />} label=\"Collectors\" showLabel render={<a href=\"/collectors\" />} />\n\t\t\t\t\t<BottomNavItem icon={<Icon name=\"bell\" />} label=\"Activity\" showLabel render={<a href=\"/activity\" />} />\n\t\t\t\t\t<BottomNavItem icon={<Icon name=\"gear\" />} label=\"Settings\" showLabel render={<a href=\"/settings\" />} />\n\t\t\t\t</BottomNav>\n\t\t\t}\n\t\t\tsidebar={\n\t\t\t\t<Sidebar>\n\t\t\t\t\t<SidebarHeader>\n\t\t\t\t\t\t<Row gap={1} align=\"center\" className=\"px-3\">\n\t\t\t\t\t\t\t<Icon name=\"gem\" variant=\"solid\" className=\"text-xl\" />\n\t\t\t\t\t\t\t<span className=\"text-title-sm\">Atelier</span>\n\t\t\t\t\t\t</Row>\n\t\t\t\t\t</SidebarHeader>\n\t\t\t\t\t<SidebarNav>\n\t\t\t\t\t\t<SidebarItem icon={<Icon name=\"grid\" />} label=\"Overview\" active render={<a href=\"/\" />} />\n\t\t\t\t\t\t<SidebarItem icon={<Icon name=\"image-stack\" />} label=\"Editions\" render={<a href=\"/editions\" />} />\n\t\t\t\t\t\t<SidebarItem icon={<Icon name=\"user\" />} label=\"Collectors\" render={<a href=\"/collectors\" />} />\n\t\t\t\t\t\t<SidebarItem\n\t\t\t\t\t\t\ticon={<Icon name=\"bell\" />}\n\t\t\t\t\t\t\tlabel=\"Activity\"\n\t\t\t\t\t\t\tbadge={<NotificationBadge count={4} tone=\"neutral\" />}\n\t\t\t\t\t\t\trender={<a href=\"/activity\" />}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<SidebarItem icon={<Icon name=\"gear\" />} label=\"Settings\" render={<a href=\"/settings\" />} />\n\t\t\t\t\t</SidebarNav>\n\t\t\t\t\t<SidebarFooter>\n\t\t\t\t\t\t<Entity\n\t\t\t\t\t\t\tclassName=\"[--entity-py:0]\"\n\t\t\t\t\t\t\tleading={<Avatar variant=\"art\" size=\"sm\" fallback=\"AL\" alt=\"\" />}\n\t\t\t\t\t\t\ttitle=\"Ada Lovelace\"\n\t\t\t\t\t\t\tsubtitle=\"@ada\"\n\t\t\t\t\t\t\taction={\n\t\t\t\t\t\t\t\t<OverflowMenu label=\"Account\" size=\"icon-sm\">\n\t\t\t\t\t\t\t\t\t<MenuItem render={<a href=\"/@ada\" />}>View profile</MenuItem>\n\t\t\t\t\t\t\t\t\t<MenuItem render={<a href=\"/settings\" />}>Settings</MenuItem>\n\t\t\t\t\t\t\t\t\t<MenuSeparator />\n\t\t\t\t\t\t\t\t\t<MenuItem>Sign out</MenuItem>\n\t\t\t\t\t\t\t\t</OverflowMenu>\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</SidebarFooter>\n\t\t\t\t</Sidebar>\n\t\t\t}\n\t\t>\n\t\t\t<Page max=\"wide\" gap={4} className=\"py-6\">\n\t\t\t\t<PageHeader\n\t\t\t\t\ttitle=\"Overview\"\n\t\t\t\t\tdescription=\"Sales and collector activity for the last 30 days.\"\n\t\t\t\t\tactions={\n\t\t\t\t\t\t<Row gap={2}>\n\t\t\t\t\t\t\t<Button variant=\"outline\">\n\t\t\t\t\t\t\t\t<Icon name=\"download\" />\n\t\t\t\t\t\t\t\tExport\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t<Button>\n\t\t\t\t\t\t\t\t<Icon name=\"plus\" />\n\t\t\t\t\t\t\t\tNew edition\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t</Row>\n\t\t\t\t\t}\n\t\t\t\t/>\n\t\t\t\t<Grid cols={{ base: 2, xl: 4 }} gap={3}>\n\t\t\t\t\t{stats.map((stat) => (\n\t\t\t\t\t\t<Card key={stat.label}>\n\t\t\t\t\t\t\t<CardHeader>\n\t\t\t\t\t\t\t\t<CardDescription>{stat.label}</CardDescription>\n\t\t\t\t\t\t\t\t<CardTitle className=\"text-mono-xl\">{stat.value}</CardTitle>\n\t\t\t\t\t\t\t</CardHeader>\n\t\t\t\t\t\t\t<CardContent>\n\t\t\t\t\t\t\t\t<Row gap={1} wrap>\n\t\t\t\t\t\t\t\t\t<Badge\n\t\t\t\t\t\t\t\t\t\ttone={stat.up ? \"success\" : \"warning\"}\n\t\t\t\t\t\t\t\t\t\tsize=\"sm\"\n\t\t\t\t\t\t\t\t\t\ticon={<Icon name={stat.up ? \"arrow-up\" : \"arrow-down\"} />}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{stat.delta}\n\t\t\t\t\t\t\t\t\t</Badge>\n\t\t\t\t\t\t\t\t\t<span className=\"text-caption text-muted-foreground\">{stat.note}</span>\n\t\t\t\t\t\t\t\t</Row>\n\t\t\t\t\t\t\t</CardContent>\n\t\t\t\t\t\t</Card>\n\t\t\t\t\t))}\n\t\t\t\t</Grid>\n\t\t\t\t<Grid cols={{ base: 1, xl: 3 }} gap={3}>\n\t\t\t\t\t<Card className=\"flex flex-col xl:col-span-2\">\n\t\t\t\t\t\t<CardHeader>\n\t\t\t\t\t\t\t<CardTitle>Editions</CardTitle>\n\t\t\t\t\t\t\t<CardDescription>Sales across your releases, newest first.</CardDescription>\n\t\t\t\t\t\t</CardHeader>\n\t\t\t\t\t\t<CardContent flush className=\"mt-4 flex-1\">\n\t\t\t\t\t\t\t<DataTable\n\t\t\t\t\t\t\t\tdata={editions}\n\t\t\t\t\t\t\t\trowKey={(row) => row.id}\n\t\t\t\t\t\t\t\tdefaultSort={{ key: \"released\", direction: \"desc\" }}\n\t\t\t\t\t\t\t\tcolumns={[\n\t\t\t\t\t\t\t\t\t{ key: \"title\", header: \"Edition\", sortable: true, className: \"text-label-md whitespace-nowrap\" },\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tkey: \"status\",\n\t\t\t\t\t\t\t\t\t\theader: \"Status\",\n\t\t\t\t\t\t\t\t\t\tcell: (row) => (\n\t\t\t\t\t\t\t\t\t\t\t<Badge appearance=\"plain\" tone={STATUS_TONE[row.status as EditionStatus]} dot>\n\t\t\t\t\t\t\t\t\t\t\t\t{STATUS_LABEL[row.status as EditionStatus]}\n\t\t\t\t\t\t\t\t\t\t\t</Badge>\n\t\t\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tkey: \"sold\",\n\t\t\t\t\t\t\t\t\t\theader: \"Sold\",\n\t\t\t\t\t\t\t\t\t\tsortable: true,\n\t\t\t\t\t\t\t\t\t\tsortValue: (row) => row.sold / row.supply,\n\t\t\t\t\t\t\t\t\t\tcell: (row) => (\n\t\t\t\t\t\t\t\t\t\t\t<Row gap={3} align=\"center\" className=\"min-w-40\">\n\t\t\t\t\t\t\t\t\t\t\t\t<Meter\n\t\t\t\t\t\t\t\t\t\t\t\t\tvalue={row.sold}\n\t\t\t\t\t\t\t\t\t\t\t\t\tmax={row.supply}\n\t\t\t\t\t\t\t\t\t\t\t\t\tsize=\"sm\"\n\t\t\t\t\t\t\t\t\t\t\t\t\ttone={row.sold === row.supply ? \"success\" : \"primary\"}\n\t\t\t\t\t\t\t\t\t\t\t\t\taria-label={`${row.title} sold`}\n\t\t\t\t\t\t\t\t\t\t\t\t\tgetAriaValueText={(_, v) => `${v} of ${row.supply} sold`}\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"flex-1\"\n\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-mono-caption whitespace-nowrap text-muted-foreground\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t{row.sold}/{row.supply}\n\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t</Row>\n\t\t\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tkey: \"revenue\",\n\t\t\t\t\t\t\t\t\t\theader: \"Revenue\",\n\t\t\t\t\t\t\t\t\t\talign: \"end\",\n\t\t\t\t\t\t\t\t\t\tsortable: true,\n\t\t\t\t\t\t\t\t\t\tsortValue: (row) => row.sold * row.price,\n\t\t\t\t\t\t\t\t\t\tcell: (row) =>\n\t\t\t\t\t\t\t\t\t\t\trow.sold > 0 ? (\n\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-mono-sm\">{usd.format(row.sold * row.price)}</span>\n\t\t\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-mono-sm text-muted-foreground\">—</span>\n\t\t\t\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tkey: \"released\",\n\t\t\t\t\t\t\t\t\t\theader: \"Release\",\n\t\t\t\t\t\t\t\t\t\talign: \"end\",\n\t\t\t\t\t\t\t\t\t\tsortable: true,\n\t\t\t\t\t\t\t\t\t\tcell: (row) => (\n\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-body-sm whitespace-nowrap text-muted-foreground\">{shortDate(row.released)}</span>\n\t\t\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t]}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</CardContent>\n\t\t\t\t\t\t<CardFooter className=\"justify-between\">\n\t\t\t\t\t\t\t<span className=\"text-caption text-muted-foreground\">\n\t\t\t\t\t\t\t\tShowing {editions.length} of {editionCount} editions\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<Button variant=\"ghost\" size=\"sm\" render={<a href=\"/editions\" />}>\n\t\t\t\t\t\t\t\tView all editions\n\t\t\t\t\t\t\t\t<Icon name=\"arrow-right\" />\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t</CardFooter>\n\t\t\t\t\t</Card>\n\t\t\t\t\t<Stack gap={3}>\n\t\t\t\t\t\t<Card>\n\t\t\t\t\t\t\t<CardHeader>\n\t\t\t\t\t\t\t\t<CardDescription>Next payout</CardDescription>\n\t\t\t\t\t\t\t\t<CardTitle className=\"text-mono-xl\">{usdCents.format(payout.amount)}</CardTitle>\n\t\t\t\t\t\t\t\t<CardAction>\n\t\t\t\t\t\t\t\t\t<Badge tone=\"info\" size=\"sm\" icon={<Icon name=\"calendar\" />}>\n\t\t\t\t\t\t\t\t\t\t{shortDate(payout.date)}\n\t\t\t\t\t\t\t\t\t</Badge>\n\t\t\t\t\t\t\t\t</CardAction>\n\t\t\t\t\t\t\t</CardHeader>\n\t\t\t\t\t\t\t<CardContent>\n\t\t\t\t\t\t\t\t<dl className=\"sable-list-dl text-body-sm\">\n\t\t\t\t\t\t\t\t\t{payout.lines.map((line) => (\n\t\t\t\t\t\t\t\t\t\t<div key={line.label} className=\"contents\">\n\t\t\t\t\t\t\t\t\t\t\t<dt>{line.label}</dt>\n\t\t\t\t\t\t\t\t\t\t\t<dd className={typeof line.value === \"number\" ? \"text-end text-mono-sm\" : \"text-end\"}>\n\t\t\t\t\t\t\t\t\t\t\t\t{typeof line.value === \"number\" ? usdCents.format(line.value) : line.value}\n\t\t\t\t\t\t\t\t\t\t\t</dd>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t</dl>\n\t\t\t\t\t\t\t</CardContent>\n\t\t\t\t\t\t</Card>\n\t\t\t\t\t\t<Card>\n\t\t\t\t\t\t\t<CardHeader>\n\t\t\t\t\t\t\t\t<CardTitle>Top collectors</CardTitle>\n\t\t\t\t\t\t\t\t<CardDescription>By spend this month.</CardDescription>\n\t\t\t\t\t\t\t\t<CardAction>\n\t\t\t\t\t\t\t\t\t<Button variant=\"ghost\" size=\"sm\" render={<a href=\"/collectors\" />}>\n\t\t\t\t\t\t\t\t\t\tView all\n\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t</CardAction>\n\t\t\t\t\t\t\t</CardHeader>\n\t\t\t\t\t\t\t<CardList>\n\t\t\t\t\t\t\t\t{collectors.map((person) => (\n\t\t\t\t\t\t\t\t\t<Entity\n\t\t\t\t\t\t\t\t\t\tkey={person.id}\n\t\t\t\t\t\t\t\t\t\tleading={<Avatar variant=\"art\" size=\"sm\" fallback={person.initials} alt=\"\" />}\n\t\t\t\t\t\t\t\t\t\ttitle={person.name}\n\t\t\t\t\t\t\t\t\t\tsubtitle={`${person.handle} · ${person.pieces} pieces`}\n\t\t\t\t\t\t\t\t\t\taction={<span className=\"text-mono-sm\">{usd.format(person.spent)}</span>}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t</CardList>\n\t\t\t\t\t\t</Card>\n\t\t\t\t\t</Stack>\n\t\t\t\t</Grid>\n\t\t\t\t<Grid cols={{ base: 1, xl: 3 }} gap={3}>\n\t\t\t\t\t<Card className=\"flex flex-col\">\n\t\t\t\t\t\t<CardHeader>\n\t\t\t\t\t\t\t<CardTitle>Upcoming releases</CardTitle>\n\t\t\t\t\t\t\t<CardDescription>Scheduled to open to collectors.</CardDescription>\n\t\t\t\t\t\t</CardHeader>\n\t\t\t\t\t\t<CardList>\n\t\t\t\t\t\t\t{upcoming.map((release) => (\n\t\t\t\t\t\t\t\t<Entity\n\t\t\t\t\t\t\t\t\tkey={release.id}\n\t\t\t\t\t\t\t\t\ttitle={release.title}\n\t\t\t\t\t\t\t\t\tsubtitle={release.detail}\n\t\t\t\t\t\t\t\t\taction={<RelativeTime date={Date.now() + release.at} className=\"text-caption text-muted-foreground\" />}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t</CardList>\n\t\t\t\t\t\t<CardFooter className=\"mt-auto\">\n\t\t\t\t\t\t\t<Button variant=\"outline\" size=\"sm\" render={<a href=\"/editions/new\" />}>\n\t\t\t\t\t\t\t\t<Icon name=\"calendar\" />\n\t\t\t\t\t\t\t\tSchedule a release\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t</CardFooter>\n\t\t\t\t\t</Card>\n\t\t\t\t\t<Card className=\"xl:col-span-2\">\n\t\t\t\t\t\t<CardHeader>\n\t\t\t\t\t\t\t<CardTitle>Activity</CardTitle>\n\t\t\t\t\t\t\t<CardDescription>Collects, tips, and follows.</CardDescription>\n\t\t\t\t\t\t\t<CardAction>\n\t\t\t\t\t\t\t\t<Button variant=\"ghost\" size=\"sm\" render={<a href=\"/activity\" />}>\n\t\t\t\t\t\t\t\t\tView all\n\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t</CardAction>\n\t\t\t\t\t\t</CardHeader>\n\t\t\t\t\t\t<CardList>\n\t\t\t\t\t\t\t{activity.map((item) => (\n\t\t\t\t\t\t\t\t<Entity\n\t\t\t\t\t\t\t\t\tkey={item.id}\n\t\t\t\t\t\t\t\t\tleading={<Avatar variant=\"art\" size=\"sm\" fallback={item.initials} alt=\"\" />}\n\t\t\t\t\t\t\t\t\ttitle={item.who}\n\t\t\t\t\t\t\t\t\tsubtitle={item.what}\n\t\t\t\t\t\t\t\t\taction={<RelativeTime date={Date.now() - item.when} className=\"text-caption text-muted-foreground\" />}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t</CardList>\n\t\t\t\t\t</Card>\n\t\t\t\t</Grid>\n\t\t\t</Page>\n\t\t</AppShell>\n\t)\n}"
|
|
20314
|
+
"imports": "import { AppHeader, AppShell, Avatar, Badge, BottomNav, BottomNavItem, Button, Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardList, CardTitle, DataTable, Entity, Grid, Icon, MenuItem, MenuSeparator, Meter, NotificationBadge, OverflowMenu, Page, PageHeader, RelativeTime, Row, Sidebar, SidebarBrand, SidebarFooter, SidebarHeader, SidebarItem, SidebarNav, Stack } from \"@sableui/react\"",
|
|
20315
|
+
"code": "// Edition status → Badge tone. Sable has no status vocabulary of its own;\n// an app maps ITS states onto tones once, here, and every badge follows.\nconst STATUS_TONE = { live: \"success\", scheduled: \"info\", \"sold-out\": \"edition\", closed: \"neutral\" } as const\nconst STATUS_LABEL = { live: \"Live\", scheduled: \"Scheduled\", \"sold-out\": \"Sold out\", closed: \"Closed\" } as const\ntype EditionStatus = keyof typeof STATUS_TONE\n\nconst MIN = 60 * 1000\nconst HOUR = 60 * MIN\nconst DAY = 24 * HOUR\n\nconst usd = new Intl.NumberFormat(\"en-US\", { style: \"currency\", currency: \"USD\", maximumFractionDigits: 0 })\nconst usdCents = new Intl.NumberFormat(\"en-US\", { style: \"currency\", currency: \"USD\" })\nconst shortDate = (offset: number) =>\n\tnew Date(Date.now() + offset).toLocaleDateString(\"en-US\", { month: \"short\", day: \"numeric\" })\n\nconst stats = [\n\t{ label: \"Revenue\", value: \"$18,240\", delta: \"+8.2%\", up: true, note: \"vs. $16,857\" },\n\t{ label: \"Collects\", value: \"1,284\", delta: \"+3.1%\", up: true, note: \"182 first-time\" },\n\t{ label: \"Collectors\", value: \"312\", delta: \"+12.4%\", up: true, note: \"Best month yet\" },\n\t{ label: \"Sell-through\", value: \"64%\", delta: \"−1.8%\", up: false, note: \"6 open editions\" },\n]\n\nconst editions = [\n\t{ id: \"ed_01\", title: \"Gold Rush\", status: \"live\" as const, sold: 184, supply: 250, price: 48, released: -6 * DAY },\n\t{ id: \"ed_02\", title: \"Quiet Harbor\", status: \"sold-out\" as const, sold: 100, supply: 100, price: 120, released: -19 * DAY },\n\t{ id: \"ed_03\", title: \"Dusk, no. 4\", status: \"live\" as const, sold: 61, supply: 150, price: 64, released: -2 * DAY },\n\t{ id: \"ed_04\", title: \"Tidewater\", status: \"live\" as const, sold: 212, supply: 300, price: 36, released: -27 * DAY },\n\t{ id: \"ed_05\", title: \"Salt Study\", status: \"closed\" as const, sold: 38, supply: 75, price: 90, released: -44 * DAY },\n\t{ id: \"ed_06\", title: \"Paper Moon\", status: \"sold-out\" as const, sold: 50, supply: 50, price: 220, released: -58 * DAY },\n\t{ id: \"ed_07\", title: \"Night Signal\", status: \"scheduled\" as const, sold: 0, supply: 200, price: 56, released: 3 * DAY },\n\t{ id: \"ed_08\", title: \"Low Tide, Blue\", status: \"closed\" as const, sold: 72, supply: 120, price: 42, released: -71 * DAY },\n\t{ id: \"ed_09\", title: \"Ember Field\", status: \"sold-out\" as const, sold: 80, supply: 80, price: 140, released: -86 * DAY },\n]\nconst editionCount = 24\n\nconst payout = {\n\tamount: 8214.6,\n\tdate: 5 * DAY,\n\tlines: [\n\t\t{ label: \"Sales\", value: 9127.33 },\n\t\t{ label: \"Platform fee (10%)\", value: -912.73 },\n\t\t{ label: \"Deposits to\", value: \"Checking ••4821\" },\n\t],\n}\n\nconst collectors = [\n\t{ id: \"co_01\", name: \"Nina Simone\", handle: \"@nina\", initials: \"NS\", pieces: 14, spent: 1460 },\n\t{ id: \"co_02\", name: \"Grace Hopper\", handle: \"@grace\", initials: \"GH\", pieces: 11, spent: 1128 },\n\t{ id: \"co_03\", name: \"Frida Kahlo\", handle: \"@frida\", initials: \"FK\", pieces: 9, spent: 942 },\n\t{ id: \"co_04\", name: \"Mary Shelley\", handle: \"@mary\", initials: \"MS\", pieces: 6, spent: 588 },\n]\n\nconst upcoming = [\n\t{ id: \"up_01\", title: \"Night Signal\", detail: \"Edition of 200 · $56\", at: 3 * DAY },\n\t{ id: \"up_02\", title: \"Tidewater, second printing\", detail: \"Edition of 100 · $40\", at: 12 * DAY },\n\t{ id: \"up_03\", title: \"Dusk, no. 5\", detail: \"Edition of 150 · $64\", at: 26 * DAY },\n\t{ id: \"up_04\", title: \"Quiet Harbor, artist proofs\", detail: \"Edition of 10 · $480\", at: 34 * DAY },\n]\n\nconst activity = [\n\t{ id: \"ac_01\", who: \"Nina Simone\", initials: \"NS\", what: \"collected Gold Rush · 185 of 250\", when: 2 * MIN },\n\t{ id: \"ac_02\", who: \"Grace Hopper\", initials: \"GH\", what: \"collected Dusk, no. 4 · 61 of 150\", when: 26 * MIN },\n\t{ id: \"ac_03\", who: \"Frida Kahlo\", initials: \"FK\", what: \"tipped $20 on Tidewater\", when: 1 * HOUR },\n\t{ id: \"ac_04\", who: \"Mary Shelley\", initials: \"MS\", what: \"started following you\", when: 3 * HOUR },\n\t{ id: \"ac_05\", who: \"Nina Simone\", initials: \"NS\", what: \"set a reminder for Night Signal\", when: 6 * HOUR },\n]\n\nexport function DashboardPage() {\n\treturn (\n\t\t<AppShell\n\t\t\t// Sidebar hides below lg, so the same destinations ride in the shell's\n\t\t\t// mobile slots — without them the route has no navigation under 1024px.\n\t\t\theader={\n\t\t\t\t<AppHeader\n\t\t\t\t\ttitle={\n\t\t\t\t\t\t<Row gap={1} align=\"center\">\n\t\t\t\t\t\t\t<Icon name=\"gem\" variant=\"solid\" className=\"text-xl\" />\n\t\t\t\t\t\t\t<span className=\"text-title-sm\">Atelier</span>\n\t\t\t\t\t\t</Row>\n\t\t\t\t\t}\n\t\t\t\t\tactions={<Avatar variant=\"art\" size=\"sm\" fallback=\"AL\" alt=\"Ada Lovelace\" />}\n\t\t\t\t/>\n\t\t\t}\n\t\t\tbottomNav={\n\t\t\t\t<BottomNav aria-label=\"Primary\">\n\t\t\t\t\t<BottomNavItem icon={<Icon name=\"grid\" />} label=\"Overview\" showLabel active render={<a href=\"/\" />} />\n\t\t\t\t\t<BottomNavItem icon={<Icon name=\"image-stack\" />} label=\"Editions\" showLabel render={<a href=\"/editions\" />} />\n\t\t\t\t\t<BottomNavItem icon={<Icon name=\"user\" />} label=\"Collectors\" showLabel render={<a href=\"/collectors\" />} />\n\t\t\t\t\t<BottomNavItem icon={<Icon name=\"bell\" />} label=\"Activity\" showLabel render={<a href=\"/activity\" />} />\n\t\t\t\t\t<BottomNavItem icon={<Icon name=\"gear\" />} label=\"Settings\" showLabel render={<a href=\"/settings\" />} />\n\t\t\t\t</BottomNav>\n\t\t\t}\n\t\t\tsidebar={\n\t\t\t\t<Sidebar>\n\t\t\t\t\t<SidebarHeader>\n\t\t\t\t\t\t<SidebarBrand mark={<Icon name=\"gem\" variant=\"solid\" />} label=\"Atelier\" render={<a href=\"/\" />} />\n\t\t\t\t\t</SidebarHeader>\n\t\t\t\t\t<SidebarNav>\n\t\t\t\t\t\t<SidebarItem icon={<Icon name=\"grid\" />} label=\"Overview\" active render={<a href=\"/\" />} />\n\t\t\t\t\t\t<SidebarItem icon={<Icon name=\"image-stack\" />} label=\"Editions\" render={<a href=\"/editions\" />} />\n\t\t\t\t\t\t<SidebarItem icon={<Icon name=\"user\" />} label=\"Collectors\" render={<a href=\"/collectors\" />} />\n\t\t\t\t\t\t<SidebarItem\n\t\t\t\t\t\t\ticon={<Icon name=\"bell\" />}\n\t\t\t\t\t\t\tlabel=\"Activity\"\n\t\t\t\t\t\t\tbadge={<NotificationBadge count={4} tone=\"neutral\" />}\n\t\t\t\t\t\t\trender={<a href=\"/activity\" />}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<SidebarItem icon={<Icon name=\"gear\" />} label=\"Settings\" render={<a href=\"/settings\" />} />\n\t\t\t\t\t</SidebarNav>\n\t\t\t\t\t<SidebarFooter>\n\t\t\t\t\t\t<Entity\n\t\t\t\t\t\t\tclassName=\"[--entity-py:0]\"\n\t\t\t\t\t\t\tleading={<Avatar variant=\"art\" size=\"sm\" fallback=\"AL\" alt=\"\" />}\n\t\t\t\t\t\t\ttitle=\"Ada Lovelace\"\n\t\t\t\t\t\t\tsubtitle=\"@ada\"\n\t\t\t\t\t\t\taction={\n\t\t\t\t\t\t\t\t<OverflowMenu label=\"Account\" size=\"icon-sm\">\n\t\t\t\t\t\t\t\t\t<MenuItem render={<a href=\"/@ada\" />}>View profile</MenuItem>\n\t\t\t\t\t\t\t\t\t<MenuItem render={<a href=\"/settings\" />}>Settings</MenuItem>\n\t\t\t\t\t\t\t\t\t<MenuSeparator />\n\t\t\t\t\t\t\t\t\t<MenuItem>Sign out</MenuItem>\n\t\t\t\t\t\t\t\t</OverflowMenu>\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</SidebarFooter>\n\t\t\t\t</Sidebar>\n\t\t\t}\n\t\t>\n\t\t\t<Page max=\"wide\" gap={4} className=\"py-6\">\n\t\t\t\t<PageHeader\n\t\t\t\t\ttitle=\"Overview\"\n\t\t\t\t\tdescription=\"Sales and collector activity for the last 30 days.\"\n\t\t\t\t\tactions={\n\t\t\t\t\t\t<Row gap={2}>\n\t\t\t\t\t\t\t<Button variant=\"outline\">\n\t\t\t\t\t\t\t\t<Icon name=\"download\" />\n\t\t\t\t\t\t\t\tExport\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t<Button>\n\t\t\t\t\t\t\t\t<Icon name=\"plus\" />\n\t\t\t\t\t\t\t\tNew edition\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t</Row>\n\t\t\t\t\t}\n\t\t\t\t/>\n\t\t\t\t<Grid cols={{ base: 2, xl: 4 }} gap={3}>\n\t\t\t\t\t{stats.map((stat) => (\n\t\t\t\t\t\t<Card key={stat.label}>\n\t\t\t\t\t\t\t<CardHeader>\n\t\t\t\t\t\t\t\t<CardDescription>{stat.label}</CardDescription>\n\t\t\t\t\t\t\t\t<CardTitle className=\"text-mono-xl\">{stat.value}</CardTitle>\n\t\t\t\t\t\t\t</CardHeader>\n\t\t\t\t\t\t\t<CardContent>\n\t\t\t\t\t\t\t\t<Row gap={1} wrap>\n\t\t\t\t\t\t\t\t\t<Badge\n\t\t\t\t\t\t\t\t\t\ttone={stat.up ? \"success\" : \"warning\"}\n\t\t\t\t\t\t\t\t\t\tsize=\"sm\"\n\t\t\t\t\t\t\t\t\t\ticon={<Icon name={stat.up ? \"arrow-up\" : \"arrow-down\"} />}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{stat.delta}\n\t\t\t\t\t\t\t\t\t</Badge>\n\t\t\t\t\t\t\t\t\t<span className=\"text-caption text-muted-foreground\">{stat.note}</span>\n\t\t\t\t\t\t\t\t</Row>\n\t\t\t\t\t\t\t</CardContent>\n\t\t\t\t\t\t</Card>\n\t\t\t\t\t))}\n\t\t\t\t</Grid>\n\t\t\t\t<Grid cols={{ base: 1, xl: 3 }} gap={3}>\n\t\t\t\t\t<Card className=\"flex flex-col xl:col-span-2\">\n\t\t\t\t\t\t<CardHeader>\n\t\t\t\t\t\t\t<CardTitle>Editions</CardTitle>\n\t\t\t\t\t\t\t<CardDescription>Sales across your releases, newest first.</CardDescription>\n\t\t\t\t\t\t</CardHeader>\n\t\t\t\t\t\t<CardContent flush className=\"mt-4 flex-1\">\n\t\t\t\t\t\t\t<DataTable\n\t\t\t\t\t\t\t\tdata={editions}\n\t\t\t\t\t\t\t\trowKey={(row) => row.id}\n\t\t\t\t\t\t\t\tdefaultSort={{ key: \"released\", direction: \"desc\" }}\n\t\t\t\t\t\t\t\tcolumns={[\n\t\t\t\t\t\t\t\t\t{ key: \"title\", header: \"Edition\", sortable: true, className: \"text-label-md whitespace-nowrap\" },\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tkey: \"status\",\n\t\t\t\t\t\t\t\t\t\theader: \"Status\",\n\t\t\t\t\t\t\t\t\t\tcell: (row) => (\n\t\t\t\t\t\t\t\t\t\t\t<Badge appearance=\"plain\" tone={STATUS_TONE[row.status as EditionStatus]} dot>\n\t\t\t\t\t\t\t\t\t\t\t\t{STATUS_LABEL[row.status as EditionStatus]}\n\t\t\t\t\t\t\t\t\t\t\t</Badge>\n\t\t\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tkey: \"sold\",\n\t\t\t\t\t\t\t\t\t\theader: \"Sold\",\n\t\t\t\t\t\t\t\t\t\tsortable: true,\n\t\t\t\t\t\t\t\t\t\tsortValue: (row) => row.sold / row.supply,\n\t\t\t\t\t\t\t\t\t\tcell: (row) => (\n\t\t\t\t\t\t\t\t\t\t\t<Row gap={3} align=\"center\" className=\"min-w-40\">\n\t\t\t\t\t\t\t\t\t\t\t\t<Meter\n\t\t\t\t\t\t\t\t\t\t\t\t\tvalue={row.sold}\n\t\t\t\t\t\t\t\t\t\t\t\t\tmax={row.supply}\n\t\t\t\t\t\t\t\t\t\t\t\t\tsize=\"sm\"\n\t\t\t\t\t\t\t\t\t\t\t\t\ttone={row.sold === row.supply ? \"success\" : \"primary\"}\n\t\t\t\t\t\t\t\t\t\t\t\t\taria-label={`${row.title} sold`}\n\t\t\t\t\t\t\t\t\t\t\t\t\tgetAriaValueText={(_, v) => `${v} of ${row.supply} sold`}\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"flex-1\"\n\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-mono-caption whitespace-nowrap text-muted-foreground\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t{row.sold}/{row.supply}\n\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t</Row>\n\t\t\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tkey: \"revenue\",\n\t\t\t\t\t\t\t\t\t\theader: \"Revenue\",\n\t\t\t\t\t\t\t\t\t\talign: \"end\",\n\t\t\t\t\t\t\t\t\t\tsortable: true,\n\t\t\t\t\t\t\t\t\t\tsortValue: (row) => row.sold * row.price,\n\t\t\t\t\t\t\t\t\t\tcell: (row) =>\n\t\t\t\t\t\t\t\t\t\t\trow.sold > 0 ? (\n\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-mono-sm\">{usd.format(row.sold * row.price)}</span>\n\t\t\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-mono-sm text-muted-foreground\">—</span>\n\t\t\t\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tkey: \"released\",\n\t\t\t\t\t\t\t\t\t\theader: \"Release\",\n\t\t\t\t\t\t\t\t\t\talign: \"end\",\n\t\t\t\t\t\t\t\t\t\tsortable: true,\n\t\t\t\t\t\t\t\t\t\tcell: (row) => (\n\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-body-sm whitespace-nowrap text-muted-foreground\">{shortDate(row.released)}</span>\n\t\t\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t]}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</CardContent>\n\t\t\t\t\t\t<CardFooter className=\"justify-between\">\n\t\t\t\t\t\t\t<span className=\"text-caption text-muted-foreground\">\n\t\t\t\t\t\t\t\tShowing {editions.length} of {editionCount} editions\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<Button variant=\"ghost\" size=\"sm\" bleed=\"end\" render={<a href=\"/editions\" />}>\n\t\t\t\t\t\t\t\tView all editions\n\t\t\t\t\t\t\t\t<Icon name=\"arrow-right\" />\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t</CardFooter>\n\t\t\t\t\t</Card>\n\t\t\t\t\t<Stack gap={3}>\n\t\t\t\t\t\t<Card>\n\t\t\t\t\t\t\t<CardHeader>\n\t\t\t\t\t\t\t\t<CardDescription>Next payout</CardDescription>\n\t\t\t\t\t\t\t\t<CardTitle className=\"text-mono-xl\">{usdCents.format(payout.amount)}</CardTitle>\n\t\t\t\t\t\t\t\t<CardAction>\n\t\t\t\t\t\t\t\t\t<Badge tone=\"info\" size=\"sm\" icon={<Icon name=\"calendar\" />}>\n\t\t\t\t\t\t\t\t\t\t{shortDate(payout.date)}\n\t\t\t\t\t\t\t\t\t</Badge>\n\t\t\t\t\t\t\t\t</CardAction>\n\t\t\t\t\t\t\t</CardHeader>\n\t\t\t\t\t\t\t<CardContent>\n\t\t\t\t\t\t\t\t<dl className=\"sable-list-dl text-body-sm\">\n\t\t\t\t\t\t\t\t\t{payout.lines.map((line) => (\n\t\t\t\t\t\t\t\t\t\t<div key={line.label} className=\"contents\">\n\t\t\t\t\t\t\t\t\t\t\t<dt>{line.label}</dt>\n\t\t\t\t\t\t\t\t\t\t\t<dd className={typeof line.value === \"number\" ? \"text-end text-mono-sm\" : \"text-end\"}>\n\t\t\t\t\t\t\t\t\t\t\t\t{typeof line.value === \"number\" ? usdCents.format(line.value) : line.value}\n\t\t\t\t\t\t\t\t\t\t\t</dd>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t</dl>\n\t\t\t\t\t\t\t</CardContent>\n\t\t\t\t\t\t</Card>\n\t\t\t\t\t\t<Card>\n\t\t\t\t\t\t\t<CardHeader>\n\t\t\t\t\t\t\t\t<CardTitle>Top collectors</CardTitle>\n\t\t\t\t\t\t\t\t<CardDescription>By spend this month.</CardDescription>\n\t\t\t\t\t\t\t\t<CardAction>\n\t\t\t\t\t\t\t\t\t<Button variant=\"ghost\" size=\"sm\" render={<a href=\"/collectors\" />}>\n\t\t\t\t\t\t\t\t\t\tView all\n\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t</CardAction>\n\t\t\t\t\t\t\t</CardHeader>\n\t\t\t\t\t\t\t<CardList>\n\t\t\t\t\t\t\t\t{collectors.map((person) => (\n\t\t\t\t\t\t\t\t\t<Entity\n\t\t\t\t\t\t\t\t\t\tkey={person.id}\n\t\t\t\t\t\t\t\t\t\tleading={<Avatar variant=\"art\" size=\"sm\" fallback={person.initials} alt=\"\" />}\n\t\t\t\t\t\t\t\t\t\ttitle={person.name}\n\t\t\t\t\t\t\t\t\t\tsubtitle={`${person.handle} · ${person.pieces} pieces`}\n\t\t\t\t\t\t\t\t\t\taction={<span className=\"text-mono-sm\">{usd.format(person.spent)}</span>}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t</CardList>\n\t\t\t\t\t\t</Card>\n\t\t\t\t\t</Stack>\n\t\t\t\t</Grid>\n\t\t\t\t<Grid cols={{ base: 1, xl: 3 }} gap={3}>\n\t\t\t\t\t<Card className=\"flex flex-col\">\n\t\t\t\t\t\t<CardHeader>\n\t\t\t\t\t\t\t<CardTitle>Upcoming releases</CardTitle>\n\t\t\t\t\t\t\t<CardDescription>Scheduled to open to collectors.</CardDescription>\n\t\t\t\t\t\t</CardHeader>\n\t\t\t\t\t\t<CardList>\n\t\t\t\t\t\t\t{upcoming.map((release) => (\n\t\t\t\t\t\t\t\t<Entity\n\t\t\t\t\t\t\t\t\tkey={release.id}\n\t\t\t\t\t\t\t\t\ttitle={release.title}\n\t\t\t\t\t\t\t\t\tsubtitle={release.detail}\n\t\t\t\t\t\t\t\t\taction={<RelativeTime date={Date.now() + release.at} className=\"text-caption text-muted-foreground\" />}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t</CardList>\n\t\t\t\t\t\t<CardFooter className=\"mt-auto\">\n\t\t\t\t\t\t\t<Button variant=\"outline\" size=\"sm\" render={<a href=\"/editions/new\" />}>\n\t\t\t\t\t\t\t\t<Icon name=\"calendar\" />\n\t\t\t\t\t\t\t\tSchedule a release\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t</CardFooter>\n\t\t\t\t\t</Card>\n\t\t\t\t\t<Card className=\"xl:col-span-2\">\n\t\t\t\t\t\t<CardHeader>\n\t\t\t\t\t\t\t<CardTitle>Activity</CardTitle>\n\t\t\t\t\t\t\t<CardDescription>Collects, tips, and follows.</CardDescription>\n\t\t\t\t\t\t\t<CardAction>\n\t\t\t\t\t\t\t\t<Button variant=\"ghost\" size=\"sm\" render={<a href=\"/activity\" />}>\n\t\t\t\t\t\t\t\t\tView all\n\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t</CardAction>\n\t\t\t\t\t\t</CardHeader>\n\t\t\t\t\t\t<CardList>\n\t\t\t\t\t\t\t{activity.map((item) => (\n\t\t\t\t\t\t\t\t<Entity\n\t\t\t\t\t\t\t\t\tkey={item.id}\n\t\t\t\t\t\t\t\t\tleading={<Avatar variant=\"art\" size=\"sm\" fallback={item.initials} alt=\"\" />}\n\t\t\t\t\t\t\t\t\ttitle={item.who}\n\t\t\t\t\t\t\t\t\tsubtitle={item.what}\n\t\t\t\t\t\t\t\t\taction={<RelativeTime date={Date.now() - item.when} className=\"text-caption text-muted-foreground\" />}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t</CardList>\n\t\t\t\t\t</Card>\n\t\t\t\t</Grid>\n\t\t\t</Page>\n\t\t</AppShell>\n\t)\n}"
|
|
19060
20316
|
},
|
|
19061
20317
|
{
|
|
19062
20318
|
"name": "FeedPage",
|
|
@@ -19080,7 +20336,7 @@
|
|
|
19080
20336
|
"The Skeleton card is aria-busy; the loading Button keeps its label (\"Loading posts\")."
|
|
19081
20337
|
],
|
|
19082
20338
|
"imports": "import { AspectBox, Avatar, Badge, Button, Card, CardContent, CardHeader, CardList, CardTitle, Entity, Icon, MenuItem, MenuSeparator, Meter, MeterLabel, MeterTrack, MeterValue, OverflowMenu, Page, PageHeader, Row, Skeleton, Stack, Tabs, TabsList, TabsPanel, TabsTrigger, Textarea } from \"@sableui/react\"",
|
|
19083
|
-
"code": "type Post = {\n\tid: string\n\tauthor: string\n\thandle: string\n\tinitials: string\n\ttime: string\n\tfollowing: boolean\n\tbody: string\n\tmedia?: { src: string; alt: string }\n\tdrop?: { title: string; price: string; collected: number; supply: number }\n\tlikes: number\n\tcomments: number\n}\n\nconst posts: Post[] = [\n\t{\n\t\tid: \"p_01\",\n\t\tauthor: \"Ada Lovelace\",\n\t\thandle: \"@ada\",\n\t\tinitials: \"AL\",\n\t\ttime: \"2h\",\n\t\tfollowing: true,\n\t\tbody: \"Gold Rush is live. 100 signed prints, each paired with the on-chain original. Thank you for waiting.\",\n\t\tmedia: { src: \"/media/edition-cover.svg\", alt: \"Gold Rush, a gold-leaf study in overlapping arcs\" },\n\t\tdrop: { title: \"Gold Rush\", price: \"$120\", collected: 62, supply: 100 },\n\t\tlikes: 128,\n\t\tcomments: 24,\n\t},\n\t{\n\t\tid: \"p_02\",\n\t\tauthor: \"Grace Hopper\",\n\t\thandle: \"@grace\",\n\t\tinitials: \"GH\",\n\t\ttime: \"5h\",\n\t\tfollowing: true,\n\t\tbody: \"Process shot from this week. Compilers and copper ink — the plotter ran for eleven hours on this one.\",\n\t\tmedia: { src: \"/media/process-shot.svg\", alt: \"Work-in-progress plotter drawing in copper ink\" },\n\t\tlikes: 86,\n\t\tcomments: 9,\n\t},\n\t{\n\t\tid: \"p_03\",\n\t\tauthor: \"Nina Simone\",\n\t\thandle: \"@nina\",\n\t\tinitials: \"NS\",\n\t\ttime: \"8h\",\n\t\tfollowing: false,\n\t\tbody: \"Collectors of Dusk, no. 3 — your unlockable score is now in your library. Headphones on for this one.\",\n\t\tlikes: 214,\n\t\tcomments: 31,\n\t},\n]\n\nconst suggestions = [\n\t{ id: \"u_01\", name: \"Mary Shelley\", handle: \"@mary\", initials: \"MS\" },\n\t{ id: \"u_02\", name: \"Nina Simone\", handle: \"@nina\", initials: \"NS\" },\n]\n\nconst upcoming = [\n\t{ id: \"d_01\", title: \"Quiet Harbor\", artist: \"Mary Shelley\", initials: \"MS\", date: \"Sat, Oct 12\" },\n\t{ id: \"d_02\", title: \"Dusk, no. 4\", artist: \"Nina Simone\", initials: \"NS\", date: \"Tue, Oct 15\" },\n]\n\nconst renderPost = (post: Post) => (\n\t<article key={post.id}>\n\t\t<Card>\n\t\t\t<CardList>\n\t\t\t\t<Entity\n\t\t\t\t\tleading={<Avatar variant=\"art\" fallback={post.initials} alt={post.author} />}\n\t\t\t\t\ttitle={post.author}\n\t\t\t\t\tsubtitle={`${post.handle} · ${post.time}`}\n\t\t\t\t\taction={\n\t\t\t\t\t\t<OverflowMenu label={`Post options for ${post.author}`} size=\"icon-sm\">\n\t\t\t\t\t\t\t<MenuItem>Copy link</MenuItem>\n\t\t\t\t\t\t\t<MenuItem>Mute {post.handle}</MenuItem>\n\t\t\t\t\t\t\t<MenuSeparator />\n\t\t\t\t\t\t\t<MenuItem variant=\"destructive\">Report post</MenuItem>\n\t\t\t\t\t\t</OverflowMenu>\n\t\t\t\t\t}\n\t\t\t\t/>\n\t\t\t</CardList>\n\t\t\t<CardContent className=\"flex flex-col gap-4 pt-0\">\n\t\t\t\t<p className=\"text-body-md text-foreground\">{post.body}</p>\n\t\t\t\t{post.media ? (\n\t\t\t\t\t<AspectBox ratio={16 / 9} className=\"overflow-hidden rounded-concentric-lg/6 bg-muted\">\n\t\t\t\t\t\t<img src={post.media.src} alt={post.media.alt} className=\"size-full object-cover\" />\n\t\t\t\t\t\t{post.drop ? (\n\t\t\t\t\t\t\t<div className=\"absolute start-3 top-3\">\n\t\t\t\t\t\t\t\t<Badge appearance=\"overlay\" size=\"sm\" dot>\n\t\t\t\t\t\t\t\t\tLive drop\n\t\t\t\t\t\t\t\t</Badge>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t) : null}\n\t\t\t\t\t</AspectBox>\n\t\t\t\t) : null}\n\t\t\t\t{post.drop ? (\n\t\t\t\t\t<Stack gap={1.5}>\n\t\t\t\t\t\t<Row gap={2} align=\"center\" justify=\"between\">\n\t\t\t\t\t\t\t<div className=\"min-w-0\">\n\t\t\t\t\t\t\t\t<p className=\"text-title-md text-foreground\">{post.drop.title}</p>\n\t\t\t\t\t\t\t\t<p className=\"text-body-sm text-muted-foreground\">\n\t\t\t\t\t\t\t\t\t{`${post.drop.price} · Edition of ${post.drop.supply}`}\n\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<Button>{`Collect for ${post.drop.price}`}</Button>\n\t\t\t\t\t\t</Row>\n\t\t\t\t\t\t<Meter\n\t\t\t\t\t\t\tvalue={post.drop.collected}\n\t\t\t\t\t\t\tmax={post.drop.supply}\n\t\t\t\t\t\t\tsize=\"sm\"\n\t\t\t\t\t\t\tgetAriaValueText={(_, value) => `${value} of ${post.drop?.supply} collected`}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<Row gap={2} align=\"center\" justify=\"between\" className=\"mb-2\">\n\t\t\t\t\t\t\t\t<MeterLabel>{`${post.drop.collected} collected`}</MeterLabel>\n\t\t\t\t\t\t\t\t<MeterValue>{(_, value) => `${(post.drop?.supply ?? 0) - value} left`}</MeterValue>\n\t\t\t\t\t\t\t</Row>\n\t\t\t\t\t\t\t<MeterTrack />\n\t\t\t\t\t\t</Meter>\n\t\t\t\t\t</Stack>\n\t\t\t\t) : null}\n\t\t\t\t{/*
|
|
20339
|
+
"code": "type Post = {\n\tid: string\n\tauthor: string\n\thandle: string\n\tinitials: string\n\ttime: string\n\tfollowing: boolean\n\tbody: string\n\tmedia?: { src: string; alt: string }\n\tdrop?: { title: string; price: string; collected: number; supply: number }\n\tlikes: number\n\tcomments: number\n}\n\nconst posts: Post[] = [\n\t{\n\t\tid: \"p_01\",\n\t\tauthor: \"Ada Lovelace\",\n\t\thandle: \"@ada\",\n\t\tinitials: \"AL\",\n\t\ttime: \"2h\",\n\t\tfollowing: true,\n\t\tbody: \"Gold Rush is live. 100 signed prints, each paired with the on-chain original. Thank you for waiting.\",\n\t\tmedia: { src: \"/media/edition-cover.svg\", alt: \"Gold Rush, a gold-leaf study in overlapping arcs\" },\n\t\tdrop: { title: \"Gold Rush\", price: \"$120\", collected: 62, supply: 100 },\n\t\tlikes: 128,\n\t\tcomments: 24,\n\t},\n\t{\n\t\tid: \"p_02\",\n\t\tauthor: \"Grace Hopper\",\n\t\thandle: \"@grace\",\n\t\tinitials: \"GH\",\n\t\ttime: \"5h\",\n\t\tfollowing: true,\n\t\tbody: \"Process shot from this week. Compilers and copper ink — the plotter ran for eleven hours on this one.\",\n\t\tmedia: { src: \"/media/process-shot.svg\", alt: \"Work-in-progress plotter drawing in copper ink\" },\n\t\tlikes: 86,\n\t\tcomments: 9,\n\t},\n\t{\n\t\tid: \"p_03\",\n\t\tauthor: \"Nina Simone\",\n\t\thandle: \"@nina\",\n\t\tinitials: \"NS\",\n\t\ttime: \"8h\",\n\t\tfollowing: false,\n\t\tbody: \"Collectors of Dusk, no. 3 — your unlockable score is now in your library. Headphones on for this one.\",\n\t\tlikes: 214,\n\t\tcomments: 31,\n\t},\n]\n\nconst suggestions = [\n\t{ id: \"u_01\", name: \"Mary Shelley\", handle: \"@mary\", initials: \"MS\" },\n\t{ id: \"u_02\", name: \"Nina Simone\", handle: \"@nina\", initials: \"NS\" },\n]\n\nconst upcoming = [\n\t{ id: \"d_01\", title: \"Quiet Harbor\", artist: \"Mary Shelley\", initials: \"MS\", date: \"Sat, Oct 12\" },\n\t{ id: \"d_02\", title: \"Dusk, no. 4\", artist: \"Nina Simone\", initials: \"NS\", date: \"Tue, Oct 15\" },\n]\n\nconst renderPost = (post: Post) => (\n\t<article key={post.id}>\n\t\t<Card>\n\t\t\t<CardList>\n\t\t\t\t<Entity\n\t\t\t\t\tleading={<Avatar variant=\"art\" fallback={post.initials} alt={post.author} />}\n\t\t\t\t\ttitle={post.author}\n\t\t\t\t\tsubtitle={`${post.handle} · ${post.time}`}\n\t\t\t\t\taction={\n\t\t\t\t\t\t<OverflowMenu label={`Post options for ${post.author}`} size=\"icon-sm\">\n\t\t\t\t\t\t\t<MenuItem>Copy link</MenuItem>\n\t\t\t\t\t\t\t<MenuItem>Mute {post.handle}</MenuItem>\n\t\t\t\t\t\t\t<MenuSeparator />\n\t\t\t\t\t\t\t<MenuItem variant=\"destructive\">Report post</MenuItem>\n\t\t\t\t\t\t</OverflowMenu>\n\t\t\t\t\t}\n\t\t\t\t/>\n\t\t\t</CardList>\n\t\t\t<CardContent className=\"flex flex-col gap-4 pt-0\">\n\t\t\t\t<p className=\"text-body-md text-foreground\">{post.body}</p>\n\t\t\t\t{post.media ? (\n\t\t\t\t\t<AspectBox ratio={16 / 9} className=\"overflow-hidden rounded-concentric-lg/6 bg-muted\">\n\t\t\t\t\t\t<img src={post.media.src} alt={post.media.alt} className=\"size-full object-cover\" />\n\t\t\t\t\t\t{post.drop ? (\n\t\t\t\t\t\t\t<div className=\"absolute start-3 top-3\">\n\t\t\t\t\t\t\t\t<Badge appearance=\"overlay\" size=\"sm\" dot>\n\t\t\t\t\t\t\t\t\tLive drop\n\t\t\t\t\t\t\t\t</Badge>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t) : null}\n\t\t\t\t\t</AspectBox>\n\t\t\t\t) : null}\n\t\t\t\t{post.drop ? (\n\t\t\t\t\t<Stack gap={1.5}>\n\t\t\t\t\t\t<Row gap={2} align=\"center\" justify=\"between\">\n\t\t\t\t\t\t\t<div className=\"min-w-0\">\n\t\t\t\t\t\t\t\t<p className=\"text-title-md text-foreground\">{post.drop.title}</p>\n\t\t\t\t\t\t\t\t<p className=\"text-body-sm text-muted-foreground\">\n\t\t\t\t\t\t\t\t\t{`${post.drop.price} · Edition of ${post.drop.supply}`}\n\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<Button>{`Collect for ${post.drop.price}`}</Button>\n\t\t\t\t\t\t</Row>\n\t\t\t\t\t\t<Meter\n\t\t\t\t\t\t\tvalue={post.drop.collected}\n\t\t\t\t\t\t\tmax={post.drop.supply}\n\t\t\t\t\t\t\tsize=\"sm\"\n\t\t\t\t\t\t\tgetAriaValueText={(_, value) => `${value} of ${post.drop?.supply} collected`}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<Row gap={2} align=\"center\" justify=\"between\" className=\"mb-2\">\n\t\t\t\t\t\t\t\t<MeterLabel>{`${post.drop.collected} collected`}</MeterLabel>\n\t\t\t\t\t\t\t\t<MeterValue>{(_, value) => `${(post.drop?.supply ?? 0) - value} left`}</MeterValue>\n\t\t\t\t\t\t\t</Row>\n\t\t\t\t\t\t\t<MeterTrack />\n\t\t\t\t\t\t</Meter>\n\t\t\t\t\t</Stack>\n\t\t\t\t) : null}\n\t\t\t\t{/* bleed=\"start\" pulls the first button out by exactly its own\n\t\t\t\t padding, so its icon lands on the text edge above it. */}\n\t\t\t\t<Row gap={0.5}>\n\t\t\t\t\t<Button variant=\"ghost\" size=\"sm\" bleed=\"start\" aria-label={`Like, ${post.likes} likes`}>\n\t\t\t\t\t\t<Icon name=\"heart\" />\n\t\t\t\t\t\t{post.likes}\n\t\t\t\t\t</Button>\n\t\t\t\t\t<Button variant=\"ghost\" size=\"sm\" aria-label={`Comment, ${post.comments} comments`}>\n\t\t\t\t\t\t<Icon name=\"comment\" />\n\t\t\t\t\t\t{post.comments}\n\t\t\t\t\t</Button>\n\t\t\t\t\t<Button variant=\"ghost\" size=\"sm\" aria-label=\"Share\">\n\t\t\t\t\t\t<Icon name=\"share\" />\n\t\t\t\t\t</Button>\n\t\t\t\t</Row>\n\t\t\t</CardContent>\n\t\t</Card>\n\t</article>\n)\n\nexport function FeedPage() {\n\treturn (\n\t\t<Page as=\"main\" max=\"wide\" gap={4} className=\"py-6\">\n\t\t\t<Row gap={4} align=\"start\" justify=\"center\">\n\t\t\t\t<Stack gap={3} className=\"min-w-0 max-w-(--region-feed) flex-1\">\n\t\t\t\t\t<PageHeader title=\"Home\" />\n\t\t\t\t\t<Card>\n\t\t\t\t\t\t<CardContent>\n\t\t\t\t\t\t\t<Row gap={1.5} align=\"start\">\n\t\t\t\t\t\t\t\t<Avatar variant=\"art\" fallback=\"AL\" alt=\"Ada Lovelace\" />\n\t\t\t\t\t\t\t\t<Stack gap={1.5} className=\"min-w-0 flex-1\">\n\t\t\t\t\t\t\t\t\t<Textarea\n\t\t\t\t\t\t\t\t\t\tautoResize\n\t\t\t\t\t\t\t\t\t\trows={2}\n\t\t\t\t\t\t\t\t\t\taria-label=\"New post\"\n\t\t\t\t\t\t\t\t\t\tplaceholder=\"Share a process shot or an upcoming drop\"\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t<Row gap={1} align=\"center\" justify=\"between\">\n\t\t\t\t\t\t\t\t\t\t<Row gap={0.5}>\n\t\t\t\t\t\t\t\t\t\t\t<Button variant=\"ghost\" size=\"icon-sm\" aria-label=\"Add images\">\n\t\t\t\t\t\t\t\t\t\t\t\t<Icon name=\"image-stack\" />\n\t\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t\t\t<Button variant=\"ghost\" size=\"icon-sm\" aria-label=\"Attach an edition\">\n\t\t\t\t\t\t\t\t\t\t\t\t<Icon name=\"gem\" />\n\t\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t\t</Row>\n\t\t\t\t\t\t\t\t\t\t<Button variant=\"secondary\" size=\"sm\">\n\t\t\t\t\t\t\t\t\t\t\tPost\n\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t</Row>\n\t\t\t\t\t\t\t\t</Stack>\n\t\t\t\t\t\t\t</Row>\n\t\t\t\t\t\t</CardContent>\n\t\t\t\t\t</Card>\n\t\t\t\t\t<Tabs defaultValue=\"for-you\">\n\t\t\t\t\t\t<TabsList className=\"flex\">\n\t\t\t\t\t\t\t<TabsTrigger value=\"for-you\">For you</TabsTrigger>\n\t\t\t\t\t\t\t<TabsTrigger value=\"following\">Following</TabsTrigger>\n\t\t\t\t\t\t</TabsList>\n\t\t\t\t\t\t<TabsPanel value=\"for-you\">\n\t\t\t\t\t\t\t<Stack gap={3}>\n\t\t\t\t\t\t\t\t{posts.map(renderPost)}\n\t\t\t\t\t\t\t\t{/* The next page, in the shape it will land in, while it fetches. */}\n\t\t\t\t\t\t\t\t<Card aria-busy=\"true\">\n\t\t\t\t\t\t\t\t\t<CardContent className=\"flex flex-col gap-4\">\n\t\t\t\t\t\t\t\t\t\t<Row gap={1.5} align=\"center\">\n\t\t\t\t\t\t\t\t\t\t\t<Skeleton className=\"size-10 rounded-full\" />\n\t\t\t\t\t\t\t\t\t\t\t<Stack gap={1}>\n\t\t\t\t\t\t\t\t\t\t\t\t<Skeleton className=\"h-3 w-32\" />\n\t\t\t\t\t\t\t\t\t\t\t\t<Skeleton className=\"h-3 w-20\" />\n\t\t\t\t\t\t\t\t\t\t\t</Stack>\n\t\t\t\t\t\t\t\t\t\t</Row>\n\t\t\t\t\t\t\t\t\t\t<Skeleton className=\"h-3 w-full\" />\n\t\t\t\t\t\t\t\t\t\t<Skeleton className=\"h-3 w-2/3\" />\n\t\t\t\t\t\t\t\t\t</CardContent>\n\t\t\t\t\t\t\t\t</Card>\n\t\t\t\t\t\t\t\t<Button variant=\"secondary\" loading loadingLabel=\"Loading posts\">\n\t\t\t\t\t\t\t\t\tLoad more posts\n\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t</Stack>\n\t\t\t\t\t\t</TabsPanel>\n\t\t\t\t\t\t<TabsPanel value=\"following\">\n\t\t\t\t\t\t\t<Stack gap={3}>{posts.filter((post) => post.following).map(renderPost)}</Stack>\n\t\t\t\t\t\t</TabsPanel>\n\t\t\t\t\t</Tabs>\n\t\t\t\t</Stack>\n\t\t\t\t<aside aria-label=\"Suggestions\" className=\"sticky top-6 hidden w-80 shrink-0 lg:block\">\n\t\t\t\t\t<Stack gap={3}>\n\t\t\t\t\t\t<Card>\n\t\t\t\t\t\t\t<CardHeader>\n\t\t\t\t\t\t\t\t<CardTitle render={<h2 />}>Who to follow</CardTitle>\n\t\t\t\t\t\t\t</CardHeader>\n\t\t\t\t\t\t\t<CardList>\n\t\t\t\t\t\t\t\t{suggestions.map((person) => (\n\t\t\t\t\t\t\t\t\t<Entity\n\t\t\t\t\t\t\t\t\t\tkey={person.id}\n\t\t\t\t\t\t\t\t\t\tleading={<Avatar variant=\"art\" fallback={person.initials} alt={person.name} />}\n\t\t\t\t\t\t\t\t\t\ttitle={person.name}\n\t\t\t\t\t\t\t\t\t\tsubtitle={person.handle}\n\t\t\t\t\t\t\t\t\t\taction={\n\t\t\t\t\t\t\t\t\t\t\t<Button variant=\"secondary\" size=\"sm\" aria-label={`Follow ${person.name}`}>\n\t\t\t\t\t\t\t\t\t\t\t\tFollow\n\t\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t</CardList>\n\t\t\t\t\t\t</Card>\n\t\t\t\t\t\t<Card>\n\t\t\t\t\t\t\t<CardHeader>\n\t\t\t\t\t\t\t\t<CardTitle render={<h2 />}>Upcoming drops</CardTitle>\n\t\t\t\t\t\t\t</CardHeader>\n\t\t\t\t\t\t\t<CardList>\n\t\t\t\t\t\t\t\t{upcoming.map((drop) => (\n\t\t\t\t\t\t\t\t\t<Entity\n\t\t\t\t\t\t\t\t\t\tkey={drop.id}\n\t\t\t\t\t\t\t\t\t\tleading={<Avatar variant=\"art\" fallback={drop.initials} alt={drop.artist} />}\n\t\t\t\t\t\t\t\t\t\ttitle={drop.title}\n\t\t\t\t\t\t\t\t\t\tsubtitle={`${drop.artist} · ${drop.date}`}\n\t\t\t\t\t\t\t\t\t\taction={\n\t\t\t\t\t\t\t\t\t\t\t<Button variant=\"ghost\" size=\"icon-sm\" aria-label={`Remind me about ${drop.title}`}>\n\t\t\t\t\t\t\t\t\t\t\t\t<Icon name=\"bell\" />\n\t\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t</CardList>\n\t\t\t\t\t\t</Card>\n\t\t\t\t\t</Stack>\n\t\t\t\t</aside>\n\t\t\t</Row>\n\t\t</Page>\n\t)\n}"
|
|
19084
20340
|
},
|
|
19085
20341
|
{
|
|
19086
20342
|
"name": "LandingPage",
|
|
@@ -19128,7 +20384,7 @@
|
|
|
19128
20384
|
"On a phone, drilling in moves focus to the back button; going back returns focus to the row that was open. After a remove, focus lands on the newly selected row; after Clear search, on the search field."
|
|
19129
20385
|
],
|
|
19130
20386
|
"imports": "import { useState, useEffect, useRef } from \"react\"\nimport { Avatar, Badge, Button, Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardList, CardTitle, Col, Columns, DestructiveActionModal, EmptyState, Entity, Field, Icon, Page, PageHeader, Row, SearchInput, Select, SelectContent, SelectItem, SelectTrigger, Stack, StatusDot } from \"@sableui/react\"",
|
|
19131
|
-
"code": "type Role = \"owner\" | \"admin\" | \"editor\" | \"viewer\"\n\nconst roles: { value: Role; label: string }[] = [\n\t{ value: \"owner\", label: \"Owner\" },\n\t{ value: \"admin\", label: \"Admin\" },\n\t{ value: \"editor\", label: \"Editor\" },\n\t{ value: \"viewer\", label: \"Viewer\" },\n]\n\nconst roleLabel: Record<Role, string> = {\n\towner: \"Owner\",\n\tadmin: \"Admin\",\n\teditor: \"Editor\",\n\tviewer: \"Viewer\",\n}\n\nconst roleSummary: Record<Role, string> = {\n\towner: \"Full access, including payouts and billing. Transfer ownership in studio settings.\",\n\tadmin: \"Manages members, editions and payouts.\",\n\teditor: \"Drafts, prices and releases editions.\",\n\tviewer: \"Sees editions and sales, but can't change them.\",\n}\n\ntype Activity = { id: string; icon: string; text: string; when: string }\n\ntype Member = {\n\tid: string\n\tname: string\n\temail: string\n\tinitials: string\n\trole: Role\n\tpending: boolean\n\thandle?: string\n\tjoined?: string\n\tlastActive?: string\n\teditions?: number\n\tinvitedBy?: string\n\tsent?: string\n\texpires?: string\n\tresent?: boolean\n\tactivity: Activity[]\n}\n\nconst seats = 10\n\nconst initialMembers: Member[] = [\n\t{\n\t\tid: \"u_01\", name: \"Ada Lovelace\", handle: \"@ada\", email: \"ada@atelier.art\", initials: \"AL\", role: \"owner\", pending: false,\n\t\tjoined: \"Mar 4, 2024\", lastActive: \"Online now\", editions: 12,\n\t\tactivity: [\n\t\t\t{ id: \"a1\", icon: \"gem\", text: \"Released Analytical Engine No. 3 — edition of 50\", when: \"2 hours ago\" },\n\t\t\t{ id: \"a2\", icon: \"mail\", text: \"Invited louise@bourgeois.studio as Editor\", when: \"2 days ago\" },\n\t\t],\n\t},\n\t{\n\t\tid: \"u_02\", name: \"Grace Hopper\", handle: \"@grace\", email: \"grace@atelier.art\", initials: \"GH\", role: \"admin\", pending: false,\n\t\tjoined: \"Mar 9, 2024\", lastActive: \"20 minutes ago\", editions: 4,\n\t\tactivity: [\n\t\t\t{ id: \"a1\", icon: \"user\", text: \"Changed Frida Kahlo's role to Viewer\", when: \"Yesterday\" },\n\t\t\t{ id: \"a2\", icon: \"edit\", text: \"Updated pricing on First Compiler — edition of 10\", when: \"3 days ago\" },\n\t\t\t{ id: \"a3\", icon: \"gem\", text: \"Released First Compiler — edition of 10\", when: \"Sep 28\" },\n\t\t],\n\t},\n\t{\n\t\tid: \"u_03\", name: \"Mary Shelley\", handle: \"@mary\", email: \"mary@atelier.art\", initials: \"MS\", role: \"editor\", pending: false,\n\t\tjoined: \"Jun 18, 2024\", lastActive: \"3 hours ago\", editions: 7,\n\t\tactivity: [\n\t\t\t{ id: \"a1\", icon: \"calendar\", text: \"Scheduled Frankenstein Studies for Oct 14\", when: \"3 hours ago\" },\n\t\t\t{ id: \"a2\", icon: \"gem\", text: \"Released The Last Man — edition of 25\", when: \"Sep 21\" },\n\t\t],\n\t},\n\t{\n\t\tid: \"u_04\", name: \"Nina Simone\", handle: \"@nina\", email: \"nina@atelier.art\", initials: \"NS\", role: \"editor\", pending: false,\n\t\tjoined: \"Jan 12, 2025\", lastActive: \"Yesterday\", editions: 3,\n\t\tactivity: [\n\t\t\t{ id: \"a1\", icon: \"upload\", text: \"Uploaded artwork for Little Girl Blue\", when: \"Yesterday\" },\n\t\t\t{ id: \"a2\", icon: \"gem\", text: \"Released Feeling Good — edition of 100\", when: \"Aug 30\" },\n\t\t],\n\t},\n\t{\n\t\tid: \"u_05\", name: \"Frida Kahlo\", handle: \"@frida\", email: \"frida@atelier.art\", initials: \"FK\", role: \"viewer\", pending: false,\n\t\tjoined: \"Jul 2, 2025\", lastActive: \"4 days ago\", editions: 0,\n\t\tactivity: [{ id: \"a1\", icon: \"comment\", text: \"Commented on the Las Dos Fridas draft\", when: \"4 days ago\" }],\n\t},\n\t{\n\t\tid: \"i_01\", name: \"louise@bourgeois.studio\", email: \"louise@bourgeois.studio\", initials: \"\", role: \"editor\", pending: true,\n\t\tinvitedBy: \"Ada Lovelace\", sent: \"2 days ago\", expires: \"In 5 days\",\n\t\tactivity: [],\n\t},\n]\n\nexport function ListDetailPage() {\n\tconst [members, setMembers] = useState(initialMembers)\n\tconst [selectedId, setSelectedId] = useState<string | null>(\"u_02\")\n\tconst [query, setQuery] = useState(\"\")\n\t// Below lg the list and the detail share one column: selecting drills in.\n\tconst [showDetail, setShowDetail] = useState(false)\n\tconst [confirming, setConfirming] = useState(false)\n\tconst listRef = useRef<HTMLDivElement>(null)\n\tconst backRef = useRef<HTMLButtonElement>(null)\n\tconst searchRef = useRef<HTMLInputElement>(null)\n\n\tconst needle = query.trim().toLowerCase()\n\tconst visible = members.filter((m) =>\n\t\t[m.name, m.email, m.handle ?? \"\", roleLabel[m.role]].some((v) => v.toLowerCase().includes(needle)),\n\t)\n\tconst selected = members.find((m) => m.id === selectedId) ?? null\n\tconst active = members.filter((m) => !m.pending).length\n\tconst pending = members.length - active\n\n\t// Drilling in on a phone moves focus to the detail's back control; the\n\t// control is display:none at lg, so desktop focus stays on the list.\n\tuseEffect(() => {\n\t\tif (showDetail && backRef.current?.offsetParent) backRef.current.focus()\n\t}, [showDetail, selectedId])\n\n\tfunction select(id: string) {\n\t\tsetSelectedId(id)\n\t\tsetShowDetail(true)\n\t}\n\n\tfunction focusRow(id: string | undefined) {\n\t\trequestAnimationFrame(() => {\n\t\t\tlistRef.current?.querySelector<HTMLButtonElement>(`[data-member-id=\"${id}\"] button`)?.focus()\n\t\t})\n\t}\n\n\tfunction backToList() {\n\t\tsetShowDetail(false)\n\t\tfocusRow(selectedId ?? undefined)\n\t}\n\n\tfunction setRole(id: string, role: Role) {\n\t\tsetMembers((list) => list.map((m) => (m.id === id ? { ...m, role } : m)))\n\t}\n\n\tfunction resend(id: string) {\n\t\tsetMembers((list) =>\n\t\t\tlist.map((m) => (m.id === id ? { ...m, sent: \"Just now\", expires: \"In 7 days\", resent: true } : m)),\n\t\t)\n\t}\n\n\tfunction remove(id: string) {\n\t\tconst index = members.findIndex((m) => m.id === id)\n\t\tconst rest = members.filter((m) => m.id !== id)\n\t\tsetMembers(rest)\n\t\tconst next = rest[Math.min(index, rest.length - 1)]?.id\n\t\tsetSelectedId(next ?? null)\n\t\tsetShowDetail(false)\n\t\t// The control that opened the confirmation is gone; land on the next row.\n\t\tfocusRow(next)\n\t}\n\n\treturn (\n\t\t<Page as=\"main\" max=\"wide\" gap={4} className=\"py-6\">\n\t\t\t<PageHeader\n\t\t\t\ttitle=\"Members\"\n\t\t\t\tdescription={`${active} members · ${pending} pending ${pending === 1 ? \"invite\" : \"invites\"} · ${members.length} of ${seats} seats used`}\n\t\t\t\tactions={<Button iconStart={<Icon name=\"plus\" />}>Invite member</Button>}\n\t\t\t/>\n\t\t\t<Columns count={{ base: 1, lg: 12 }}>\n\t\t\t\t<Col span={{ base: 1, lg: 5 }} className={showDetail ? \"max-lg:hidden\" : undefined}>\n\t\t\t\t\t<Stack gap={3}>\n\t\t\t\t\t\t<SearchInput\n\t\t\t\t\t\t\tref={searchRef}\n\t\t\t\t\t\t\tvalue={query}\n\t\t\t\t\t\t\tonValueChange={setQuery}\n\t\t\t\t\t\t\tplaceholder=\"Search name, email or role\"\n\t\t\t\t\t\t\taria-label=\"Search members\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t{/* sable-surface derives the rows' selected step from the card (12% ink),\n\t\t\t\t\t\t so a selected row stays distinct from its muted avatar. */}\n\t\t\t\t\t\t<Card ref={listRef} className=\"sable-surface\">\n\t\t\t\t\t\t\t{visible.length > 0 ? (\n\t\t\t\t\t\t\t\t<CardList aria-label=\"Members\">\n\t\t\t\t\t\t\t\t\t{visible.map((m) => {\n\t\t\t\t\t\t\t\t\t\tconst isSelected = m.id === selectedId\n\t\t\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t\t\t<Entity\n\t\t\t\t\t\t\t\t\t\t\t\tkey={m.id}\n\t\t\t\t\t\t\t\t\t\t\t\tdata-member-id={m.id}\n\t\t\t\t\t\t\t\t\t\t\t\tinteractive\n\t\t\t\t\t\t\t\t\t\t\t\tselected={isSelected}\n\t\t\t\t\t\t\t\t\t\t\t\tonSelect={() => select(m.id)}\n\t\t\t\t\t\t\t\t\t\t\t\tleading={\n\t\t\t\t\t\t\t\t\t\t\t\t\t<Avatar variant=\"art\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tseed={m.name}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tfallback={m.pending ? <Icon name=\"mail\" className=\"size-5\" /> : m.initials}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tstatus={m.lastActive === \"Online now\" ? <StatusDot tone=\"success\" /> : undefined}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t// The row's title already names it: keep the initials out of the row's name.\n\t\t\t\t\t\t\t\t\t\t\t\t\t\taria-hidden\n\t\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t\ttitle={m.name}\n\t\t\t\t\t\t\t\t\t\t\t\tsubtitle={\n\t\t\t\t\t\t\t\t\t\t\t\t\tm.pending\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t? `${roleLabel[m.role]} · invite sent ${m.sent?.toLowerCase()}`\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t: `${roleLabel[m.role]} · ${m.handle}`\n\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t\taction={m.pending ? <Badge tone=\"info\">Invited</Badge> : undefined}\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t\t</CardList>\n\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t<CardContent>\n\t\t\t\t\t\t\t\t\t<EmptyState\n\t\t\t\t\t\t\t\t\t\ticon={<Icon name=\"search\" />}\n\t\t\t\t\t\t\t\t\t\ttitle=\"No members found\"\n\t\t\t\t\t\t\t\t\t\tdescription={`Nobody matches “${query.trim()}”. Try a name, email or role.`}\n\t\t\t\t\t\t\t\t\t\taction={\n\t\t\t\t\t\t\t\t\t\t\t<Button variant=\"secondary\" size=\"sm\" onClick={() => {\n\t\t\t\t\t\t\t\t\t\t\t\tsetQuery(\"\")\n\t\t\t\t\t\t\t\t\t\t\t\tsearchRef.current?.focus()\n\t\t\t\t\t\t\t\t\t\t\t}}>\n\t\t\t\t\t\t\t\t\t\t\t\tClear search\n\t\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t</CardContent>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</Card>\n\t\t\t\t\t</Stack>\n\t\t\t\t</Col>\n\t\t\t\t<Col span={{ base: 1, lg: 7 }} className={showDetail ? undefined : \"max-lg:hidden\"}>\n\t\t\t\t\t<Stack role=\"region\" aria-label=\"Member details\" gap={3} className=\"lg:sticky lg:top-6\">\n\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\tref={backRef}\n\t\t\t\t\t\t\tvariant=\"ghost\"\n\t\t\t\t\t\t\tsize=\"sm\"\n\t\t\t\t\t\t\ticonStart={<Icon name=\"chevron-left\" />}\n\t\t\t\t\t\t\tonClick={backToList}\n\t\t\t\t\t\t\tclassName=\"self-start lg:hidden\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\tAll members\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t{selected === null ? (\n\t\t\t\t\t\t\t<EmptyState\n\t\t\t\t\t\t\t\tsurface=\"card\"\n\t\t\t\t\t\t\t\ticon={<Icon name=\"user\" />}\n\t\t\t\t\t\t\t\ttitle=\"No member selected\"\n\t\t\t\t\t\t\t\tdescription=\"Choose someone from the list to see their role and activity.\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t<Card>\n\t\t\t\t\t\t\t\t\t<CardHeader>\n\t\t\t\t\t\t\t\t\t\t<Row gap={2}>\n\t\t\t\t\t\t\t\t\t\t\t<Avatar variant=\"art\"\n\t\t\t\t\t\t\t\t\t\t\t\tsize=\"lg\"\n\t\t\t\t\t\t\t\t\t\t\t\tfallback={selected.pending ? <Icon name=\"mail\" className=\"size-6\" /> : selected.initials}\n\t\t\t\t\t\t\t\t\t\t\t\tstatus={selected.lastActive === \"Online now\" ? <StatusDot tone=\"success\" /> : undefined}\n\t\t\t\t\t\t\t\t\t\t\t\talt={selected.pending ? \"Pending invite\" : selected.name}\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t<Stack gap={0} className=\"min-w-0\">\n\t\t\t\t\t\t\t\t\t\t\t\t<CardTitle render={<h2 />} className=\"truncate\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t{selected.name}\n\t\t\t\t\t\t\t\t\t\t\t\t</CardTitle>\n\t\t\t\t\t\t\t\t\t\t\t\t<CardDescription className=\"truncate\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t{selected.pending ? \"Hasn't joined yet\" : `${selected.handle} · ${selected.email}`}\n\t\t\t\t\t\t\t\t\t\t\t\t</CardDescription>\n\t\t\t\t\t\t\t\t\t\t\t</Stack>\n\t\t\t\t\t\t\t\t\t\t</Row>\n\t\t\t\t\t\t\t\t\t\t{selected.pending ? (\n\t\t\t\t\t\t\t\t\t\t\t<CardAction>\n\t\t\t\t\t\t\t\t\t\t\t\t<Badge tone=\"info\">Invited</Badge>\n\t\t\t\t\t\t\t\t\t\t\t</CardAction>\n\t\t\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t\t\t</CardHeader>\n\t\t\t\t\t\t\t\t\t<CardContent>\n\t\t\t\t\t\t\t\t\t\t<Stack gap={5}>\n\t\t\t\t\t\t\t\t\t\t\t<Field\n\t\t\t\t\t\t\t\t\t\t\t\tlabel={selected.pending ? \"Role on joining\" : \"Role\"}\n\t\t\t\t\t\t\t\t\t\t\t\tdescription={roleSummary[selected.role]}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\t\t\t\t\t\t\titems={roles}\n\t\t\t\t\t\t\t\t\t\t\t\t\tvalue={selected.role}\n\t\t\t\t\t\t\t\t\t\t\t\t\tonValueChange={(value) => setRole(selected.id, value as Role)}\n\t\t\t\t\t\t\t\t\t\t\t\t\tdisabled={selected.role === \"owner\"}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<SelectTrigger className=\"max-w-72\" />\n\t\t\t\t\t\t\t\t\t\t\t\t\t<SelectContent>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{roles\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t.filter((r) => r.value !== \"owner\" || selected.role === \"owner\")\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t.map((r) => (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<SelectItem key={r.value} value={r.value}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{r.label}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t</SelectItem>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</SelectContent>\n\t\t\t\t\t\t\t\t\t\t\t\t</Select>\n\t\t\t\t\t\t\t\t\t\t\t</Field>\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"@container\">\n\t\t\t\t\t\t\t\t\t\t\t\t{selected.pending ? (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<dl className=\"sable-list-dl sable-list-dl-2\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<dt>Sent</dt>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<dd>{selected.sent}</dd>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<dt>Expires</dt>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<dd>{selected.expires}</dd>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<dt>Invited by</dt>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<dd>{selected.invitedBy}</dd>\n\t\t\t\t\t\t\t\t\t\t\t\t\t</dl>\n\t\t\t\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<dl className=\"sable-list-dl sable-list-dl-2\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<dt>Email</dt>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<dd>{selected.email}</dd>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<dt>Joined</dt>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<dd>{selected.joined}</dd>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<dt>Last active</dt>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<dd className={selected.lastActive === \"Online now\" ? \"text-success\" : undefined}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{selected.lastActive}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</dd>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<dt>Editions released</dt>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<dd>{selected.editions}</dd>\n\t\t\t\t\t\t\t\t\t\t\t\t\t</dl>\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t</Stack>\n\t\t\t\t\t\t\t\t\t</CardContent>\n\t\t\t\t\t\t\t\t\t{selected.role !== \"owner\" ? (\n\t\t\t\t\t\t\t\t\t\t<CardFooter>\n\t\t\t\t\t\t\t\t\t\t\t<Row gap={2} wrap>\n\t\t\t\t\t\t\t\t\t\t\t\t{selected.pending ? (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tvariant=\"secondary\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tsize=\"sm\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\ticonStart={<Icon name={selected.resent ? \"check\" : \"send\"} />}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tdisabled={selected.resent}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={() => resend(selected.id)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{selected.resent ? \"Invite resent\" : \"Resend invite\"}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t\t\t\t\t\t<Button variant=\"outline\" size=\"sm\" onClick={() => setConfirming(true)}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{selected.pending ? \"Revoke invite\" : \"Remove from studio\"}\n\t\t\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t\t\t</Row>\n\t\t\t\t\t\t\t\t\t\t</CardFooter>\n\t\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t\t</Card>\n\t\t\t\t\t\t\t\t{selected.activity.length > 0 ? (\n\t\t\t\t\t\t\t\t\t<Card>\n\t\t\t\t\t\t\t\t\t\t<CardHeader>\n\t\t\t\t\t\t\t\t\t\t\t<CardTitle render={<h3 />}>\n\t\t\t\t\t\t\t\t\t\t\t\tRecent activity\n\t\t\t\t\t\t\t\t\t\t\t</CardTitle>\n\t\t\t\t\t\t\t\t\t\t</CardHeader>\n\t\t\t\t\t\t\t\t\t\t<CardList>\n\t\t\t\t\t\t\t\t\t\t\t{selected.activity.map((a) => (\n\t\t\t\t\t\t\t\t\t\t\t\t<Entity\n\t\t\t\t\t\t\t\t\t\t\t\t\tkey={a.id}\n\t\t\t\t\t\t\t\t\t\t\t\t\tleading={<Icon name={a.icon} className=\"text-muted-foreground\" />}\n\t\t\t\t\t\t\t\t\t\t\t\t\ttitle={a.text}\n\t\t\t\t\t\t\t\t\t\t\t\t\tsubtitle={a.when}\n\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\t\t</CardList>\n\t\t\t\t\t\t\t\t\t</Card>\n\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t\t<DestructiveActionModal\n\t\t\t\t\t\t\t\t\topen={confirming}\n\t\t\t\t\t\t\t\t\tonOpenChange={setConfirming}\n\t\t\t\t\t\t\t\t\ttitle={selected.pending ? \"Revoke this invite?\" : `Remove ${selected.name}?`}\n\t\t\t\t\t\t\t\t\tdescription={\n\t\t\t\t\t\t\t\t\t\tselected.pending\n\t\t\t\t\t\t\t\t\t\t\t? `${selected.email} won't be able to join with the link they were sent.`\n\t\t\t\t\t\t\t\t\t\t\t: \"They lose access to the studio right away. Editions they released stay on sale.\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\tconfirmLabel={selected.pending ? \"Revoke invite\" : \"Remove member\"}\n\t\t\t\t\t\t\t\t\tonConfirm={() => remove(selected.id)}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</Stack>\n\t\t\t\t</Col>\n\t\t\t</Columns>\n\t\t</Page>\n\t)\n}"
|
|
20387
|
+
"code": "type Role = \"owner\" | \"admin\" | \"editor\" | \"viewer\"\n\nconst roles: { value: Role; label: string }[] = [\n\t{ value: \"owner\", label: \"Owner\" },\n\t{ value: \"admin\", label: \"Admin\" },\n\t{ value: \"editor\", label: \"Editor\" },\n\t{ value: \"viewer\", label: \"Viewer\" },\n]\n\nconst roleLabel: Record<Role, string> = {\n\towner: \"Owner\",\n\tadmin: \"Admin\",\n\teditor: \"Editor\",\n\tviewer: \"Viewer\",\n}\n\nconst roleSummary: Record<Role, string> = {\n\towner: \"Full access, including payouts and billing. Transfer ownership in studio settings.\",\n\tadmin: \"Manages members, editions and payouts.\",\n\teditor: \"Drafts, prices and releases editions.\",\n\tviewer: \"Sees editions and sales, but can't change them.\",\n}\n\ntype Activity = { id: string; icon: string; text: string; when: string }\n\ntype Member = {\n\tid: string\n\tname: string\n\temail: string\n\tinitials: string\n\trole: Role\n\tpending: boolean\n\thandle?: string\n\tjoined?: string\n\tlastActive?: string\n\teditions?: number\n\tinvitedBy?: string\n\tsent?: string\n\texpires?: string\n\tresent?: boolean\n\tactivity: Activity[]\n}\n\nconst seats = 10\n\nconst initialMembers: Member[] = [\n\t{\n\t\tid: \"u_01\", name: \"Ada Lovelace\", handle: \"@ada\", email: \"ada@atelier.art\", initials: \"AL\", role: \"owner\", pending: false,\n\t\tjoined: \"Mar 4, 2024\", lastActive: \"Online now\", editions: 12,\n\t\tactivity: [\n\t\t\t{ id: \"a1\", icon: \"gem\", text: \"Released Analytical Engine No. 3 — edition of 50\", when: \"2 hours ago\" },\n\t\t\t{ id: \"a2\", icon: \"mail\", text: \"Invited louise@bourgeois.studio as Editor\", when: \"2 days ago\" },\n\t\t],\n\t},\n\t{\n\t\tid: \"u_02\", name: \"Grace Hopper\", handle: \"@grace\", email: \"grace@atelier.art\", initials: \"GH\", role: \"admin\", pending: false,\n\t\tjoined: \"Mar 9, 2024\", lastActive: \"20 minutes ago\", editions: 4,\n\t\tactivity: [\n\t\t\t{ id: \"a1\", icon: \"user\", text: \"Changed Frida Kahlo's role to Viewer\", when: \"Yesterday\" },\n\t\t\t{ id: \"a2\", icon: \"edit\", text: \"Updated pricing on First Compiler — edition of 10\", when: \"3 days ago\" },\n\t\t\t{ id: \"a3\", icon: \"gem\", text: \"Released First Compiler — edition of 10\", when: \"Sep 28\" },\n\t\t],\n\t},\n\t{\n\t\tid: \"u_03\", name: \"Mary Shelley\", handle: \"@mary\", email: \"mary@atelier.art\", initials: \"MS\", role: \"editor\", pending: false,\n\t\tjoined: \"Jun 18, 2024\", lastActive: \"3 hours ago\", editions: 7,\n\t\tactivity: [\n\t\t\t{ id: \"a1\", icon: \"calendar\", text: \"Scheduled Frankenstein Studies for Oct 14\", when: \"3 hours ago\" },\n\t\t\t{ id: \"a2\", icon: \"gem\", text: \"Released The Last Man — edition of 25\", when: \"Sep 21\" },\n\t\t],\n\t},\n\t{\n\t\tid: \"u_04\", name: \"Nina Simone\", handle: \"@nina\", email: \"nina@atelier.art\", initials: \"NS\", role: \"editor\", pending: false,\n\t\tjoined: \"Jan 12, 2025\", lastActive: \"Yesterday\", editions: 3,\n\t\tactivity: [\n\t\t\t{ id: \"a1\", icon: \"upload\", text: \"Uploaded artwork for Little Girl Blue\", when: \"Yesterday\" },\n\t\t\t{ id: \"a2\", icon: \"gem\", text: \"Released Feeling Good — edition of 100\", when: \"Aug 30\" },\n\t\t],\n\t},\n\t{\n\t\tid: \"u_05\", name: \"Frida Kahlo\", handle: \"@frida\", email: \"frida@atelier.art\", initials: \"FK\", role: \"viewer\", pending: false,\n\t\tjoined: \"Jul 2, 2025\", lastActive: \"4 days ago\", editions: 0,\n\t\tactivity: [{ id: \"a1\", icon: \"comment\", text: \"Commented on the Las Dos Fridas draft\", when: \"4 days ago\" }],\n\t},\n\t{\n\t\tid: \"i_01\", name: \"louise@bourgeois.studio\", email: \"louise@bourgeois.studio\", initials: \"\", role: \"editor\", pending: true,\n\t\tinvitedBy: \"Ada Lovelace\", sent: \"2 days ago\", expires: \"In 5 days\",\n\t\tactivity: [],\n\t},\n]\n\nexport function ListDetailPage() {\n\tconst [members, setMembers] = useState(initialMembers)\n\tconst [selectedId, setSelectedId] = useState<string | null>(\"u_02\")\n\tconst [query, setQuery] = useState(\"\")\n\t// Below lg the list and the detail share one column: selecting drills in.\n\tconst [showDetail, setShowDetail] = useState(false)\n\tconst [confirming, setConfirming] = useState(false)\n\tconst listRef = useRef<HTMLDivElement>(null)\n\tconst backRef = useRef<HTMLButtonElement>(null)\n\tconst searchRef = useRef<HTMLInputElement>(null)\n\n\tconst needle = query.trim().toLowerCase()\n\tconst visible = members.filter((m) =>\n\t\t[m.name, m.email, m.handle ?? \"\", roleLabel[m.role]].some((v) => v.toLowerCase().includes(needle)),\n\t)\n\tconst selected = members.find((m) => m.id === selectedId) ?? null\n\tconst active = members.filter((m) => !m.pending).length\n\tconst pending = members.length - active\n\n\t// Drilling in on a phone moves focus to the detail's back control; the\n\t// control is display:none at lg, so desktop focus stays on the list.\n\tuseEffect(() => {\n\t\tif (showDetail && backRef.current?.offsetParent) backRef.current.focus()\n\t}, [showDetail, selectedId])\n\n\tfunction select(id: string) {\n\t\tsetSelectedId(id)\n\t\tsetShowDetail(true)\n\t}\n\n\tfunction focusRow(id: string | undefined) {\n\t\trequestAnimationFrame(() => {\n\t\t\tlistRef.current?.querySelector<HTMLButtonElement>(`[data-member-id=\"${id}\"] button`)?.focus()\n\t\t})\n\t}\n\n\tfunction backToList() {\n\t\tsetShowDetail(false)\n\t\tfocusRow(selectedId ?? undefined)\n\t}\n\n\tfunction setRole(id: string, role: Role) {\n\t\tsetMembers((list) => list.map((m) => (m.id === id ? { ...m, role } : m)))\n\t}\n\n\tfunction resend(id: string) {\n\t\tsetMembers((list) =>\n\t\t\tlist.map((m) => (m.id === id ? { ...m, sent: \"Just now\", expires: \"In 7 days\", resent: true } : m)),\n\t\t)\n\t}\n\n\tfunction remove(id: string) {\n\t\tconst index = members.findIndex((m) => m.id === id)\n\t\tconst rest = members.filter((m) => m.id !== id)\n\t\tsetMembers(rest)\n\t\tconst next = rest[Math.min(index, rest.length - 1)]?.id\n\t\tsetSelectedId(next ?? null)\n\t\tsetShowDetail(false)\n\t\t// The control that opened the confirmation is gone; land on the next row.\n\t\tfocusRow(next)\n\t}\n\n\treturn (\n\t\t<Page as=\"main\" max=\"wide\" gap={4} className=\"py-6\">\n\t\t\t<PageHeader\n\t\t\t\ttitle=\"Members\"\n\t\t\t\tdescription={`${active} members · ${pending} pending ${pending === 1 ? \"invite\" : \"invites\"} · ${members.length} of ${seats} seats used`}\n\t\t\t\tactions={<Button iconStart={<Icon name=\"plus\" />}>Invite member</Button>}\n\t\t\t/>\n\t\t\t<Columns count={{ base: 1, lg: 12 }}>\n\t\t\t\t<Col span={{ base: 1, lg: 5 }} className={showDetail ? \"max-lg:hidden\" : undefined}>\n\t\t\t\t\t<Stack gap={3}>\n\t\t\t\t\t\t<SearchInput\n\t\t\t\t\t\t\tref={searchRef}\n\t\t\t\t\t\t\tvalue={query}\n\t\t\t\t\t\t\tonValueChange={setQuery}\n\t\t\t\t\t\t\tplaceholder=\"Search name, email or role\"\n\t\t\t\t\t\t\taria-label=\"Search members\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t{/* sable-surface derives the rows' selected step from the card (12% ink),\n\t\t\t\t\t\t so a selected row stays distinct from its muted avatar. */}\n\t\t\t\t\t\t<Card ref={listRef} className=\"sable-surface\">\n\t\t\t\t\t\t\t{visible.length > 0 ? (\n\t\t\t\t\t\t\t\t<CardList aria-label=\"Members\">\n\t\t\t\t\t\t\t\t\t{visible.map((m) => {\n\t\t\t\t\t\t\t\t\t\tconst isSelected = m.id === selectedId\n\t\t\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t\t\t<Entity\n\t\t\t\t\t\t\t\t\t\t\t\tkey={m.id}\n\t\t\t\t\t\t\t\t\t\t\t\tdata-member-id={m.id}\n\t\t\t\t\t\t\t\t\t\t\t\tinteractive\n\t\t\t\t\t\t\t\t\t\t\t\tselected={isSelected}\n\t\t\t\t\t\t\t\t\t\t\t\tonSelect={() => select(m.id)}\n\t\t\t\t\t\t\t\t\t\t\t\tleading={\n\t\t\t\t\t\t\t\t\t\t\t\t\t<Avatar variant=\"art\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tseed={m.name}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tfallback={m.pending ? <Icon name=\"mail\" className=\"size-5\" /> : m.initials}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tstatus={m.lastActive === \"Online now\" ? <StatusDot tone=\"success\" /> : undefined}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t// The row's title already names it: keep the initials out of the row's name.\n\t\t\t\t\t\t\t\t\t\t\t\t\t\taria-hidden\n\t\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t\ttitle={m.name}\n\t\t\t\t\t\t\t\t\t\t\t\tsubtitle={\n\t\t\t\t\t\t\t\t\t\t\t\t\tm.pending\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t? `${roleLabel[m.role]} · invite sent ${m.sent?.toLowerCase()}`\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t: `${roleLabel[m.role]} · ${m.handle}`\n\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t\taction={m.pending ? <Badge tone=\"info\">Invited</Badge> : undefined}\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t\t</CardList>\n\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t<CardContent>\n\t\t\t\t\t\t\t\t\t<EmptyState\n\t\t\t\t\t\t\t\t\t\ticon={<Icon name=\"search\" />}\n\t\t\t\t\t\t\t\t\t\ttitle=\"No members found\"\n\t\t\t\t\t\t\t\t\t\tdescription={`Nobody matches “${query.trim()}”. Try a name, email or role.`}\n\t\t\t\t\t\t\t\t\t\taction={\n\t\t\t\t\t\t\t\t\t\t\t<Button variant=\"secondary\" size=\"sm\" onClick={() => {\n\t\t\t\t\t\t\t\t\t\t\t\tsetQuery(\"\")\n\t\t\t\t\t\t\t\t\t\t\t\tsearchRef.current?.focus()\n\t\t\t\t\t\t\t\t\t\t\t}}>\n\t\t\t\t\t\t\t\t\t\t\t\tClear search\n\t\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t</CardContent>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</Card>\n\t\t\t\t\t</Stack>\n\t\t\t\t</Col>\n\t\t\t\t<Col span={{ base: 1, lg: 7 }} className={showDetail ? undefined : \"max-lg:hidden\"}>\n\t\t\t\t\t<Stack role=\"region\" aria-label=\"Member details\" gap={3} className=\"lg:sticky lg:top-6\">\n\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\tref={backRef}\n\t\t\t\t\t\t\tvariant=\"ghost\"\n\t\t\t\t\t\t\tsize=\"sm\"\n\t\t\t\t\t\t\tbleed=\"start\"\n\t\t\t\t\t\t\ticonStart={<Icon name=\"chevron-left\" />}\n\t\t\t\t\t\t\tonClick={backToList}\n\t\t\t\t\t\t\tclassName=\"self-start lg:hidden\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\tAll members\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t{selected === null ? (\n\t\t\t\t\t\t\t<EmptyState\n\t\t\t\t\t\t\t\tsurface=\"card\"\n\t\t\t\t\t\t\t\ticon={<Icon name=\"user\" />}\n\t\t\t\t\t\t\t\ttitle=\"No member selected\"\n\t\t\t\t\t\t\t\tdescription=\"Choose someone from the list to see their role and activity.\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t<Card>\n\t\t\t\t\t\t\t\t\t<CardHeader>\n\t\t\t\t\t\t\t\t\t\t<Row gap={2}>\n\t\t\t\t\t\t\t\t\t\t\t<Avatar variant=\"art\"\n\t\t\t\t\t\t\t\t\t\t\t\tsize=\"lg\"\n\t\t\t\t\t\t\t\t\t\t\t\tfallback={selected.pending ? <Icon name=\"mail\" className=\"size-6\" /> : selected.initials}\n\t\t\t\t\t\t\t\t\t\t\t\tstatus={selected.lastActive === \"Online now\" ? <StatusDot tone=\"success\" /> : undefined}\n\t\t\t\t\t\t\t\t\t\t\t\talt={selected.pending ? \"Pending invite\" : selected.name}\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t<Stack gap={0} className=\"min-w-0\">\n\t\t\t\t\t\t\t\t\t\t\t\t<CardTitle render={<h2 />} className=\"truncate\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t{selected.name}\n\t\t\t\t\t\t\t\t\t\t\t\t</CardTitle>\n\t\t\t\t\t\t\t\t\t\t\t\t<CardDescription className=\"truncate\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t{selected.pending ? \"Hasn't joined yet\" : `${selected.handle} · ${selected.email}`}\n\t\t\t\t\t\t\t\t\t\t\t\t</CardDescription>\n\t\t\t\t\t\t\t\t\t\t\t</Stack>\n\t\t\t\t\t\t\t\t\t\t</Row>\n\t\t\t\t\t\t\t\t\t\t{selected.pending ? (\n\t\t\t\t\t\t\t\t\t\t\t<CardAction>\n\t\t\t\t\t\t\t\t\t\t\t\t<Badge tone=\"info\">Invited</Badge>\n\t\t\t\t\t\t\t\t\t\t\t</CardAction>\n\t\t\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t\t\t</CardHeader>\n\t\t\t\t\t\t\t\t\t<CardContent>\n\t\t\t\t\t\t\t\t\t\t<Stack gap={5}>\n\t\t\t\t\t\t\t\t\t\t\t<Field\n\t\t\t\t\t\t\t\t\t\t\t\tlabel={selected.pending ? \"Role on joining\" : \"Role\"}\n\t\t\t\t\t\t\t\t\t\t\t\tdescription={roleSummary[selected.role]}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\t\t\t\t\t\t\titems={roles}\n\t\t\t\t\t\t\t\t\t\t\t\t\tvalue={selected.role}\n\t\t\t\t\t\t\t\t\t\t\t\t\tonValueChange={(value) => setRole(selected.id, value as Role)}\n\t\t\t\t\t\t\t\t\t\t\t\t\tdisabled={selected.role === \"owner\"}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<SelectTrigger className=\"max-w-72\" />\n\t\t\t\t\t\t\t\t\t\t\t\t\t<SelectContent>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{roles\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t.filter((r) => r.value !== \"owner\" || selected.role === \"owner\")\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t.map((r) => (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<SelectItem key={r.value} value={r.value}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{r.label}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t</SelectItem>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</SelectContent>\n\t\t\t\t\t\t\t\t\t\t\t\t</Select>\n\t\t\t\t\t\t\t\t\t\t\t</Field>\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"@container\">\n\t\t\t\t\t\t\t\t\t\t\t\t{selected.pending ? (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<dl className=\"sable-list-dl sable-list-dl-2\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<dt>Sent</dt>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<dd>{selected.sent}</dd>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<dt>Expires</dt>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<dd>{selected.expires}</dd>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<dt>Invited by</dt>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<dd>{selected.invitedBy}</dd>\n\t\t\t\t\t\t\t\t\t\t\t\t\t</dl>\n\t\t\t\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<dl className=\"sable-list-dl sable-list-dl-2\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<dt>Email</dt>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<dd>{selected.email}</dd>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<dt>Joined</dt>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<dd>{selected.joined}</dd>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<dt>Last active</dt>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<dd className={selected.lastActive === \"Online now\" ? \"text-success\" : undefined}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{selected.lastActive}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</dd>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<dt>Editions released</dt>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<dd>{selected.editions}</dd>\n\t\t\t\t\t\t\t\t\t\t\t\t\t</dl>\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t</Stack>\n\t\t\t\t\t\t\t\t\t</CardContent>\n\t\t\t\t\t\t\t\t\t{selected.role !== \"owner\" ? (\n\t\t\t\t\t\t\t\t\t\t<CardFooter>\n\t\t\t\t\t\t\t\t\t\t\t<Row gap={2} wrap>\n\t\t\t\t\t\t\t\t\t\t\t\t{selected.pending ? (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tvariant=\"secondary\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tsize=\"sm\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\ticonStart={<Icon name={selected.resent ? \"check\" : \"send\"} />}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tdisabled={selected.resent}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={() => resend(selected.id)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{selected.resent ? \"Invite resent\" : \"Resend invite\"}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t\t\t\t\t\t<Button variant=\"outline\" size=\"sm\" onClick={() => setConfirming(true)}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{selected.pending ? \"Revoke invite\" : \"Remove from studio\"}\n\t\t\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t\t\t</Row>\n\t\t\t\t\t\t\t\t\t\t</CardFooter>\n\t\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t\t</Card>\n\t\t\t\t\t\t\t\t{selected.activity.length > 0 ? (\n\t\t\t\t\t\t\t\t\t<Card>\n\t\t\t\t\t\t\t\t\t\t<CardHeader>\n\t\t\t\t\t\t\t\t\t\t\t<CardTitle render={<h3 />}>\n\t\t\t\t\t\t\t\t\t\t\t\tRecent activity\n\t\t\t\t\t\t\t\t\t\t\t</CardTitle>\n\t\t\t\t\t\t\t\t\t\t</CardHeader>\n\t\t\t\t\t\t\t\t\t\t<CardList>\n\t\t\t\t\t\t\t\t\t\t\t{selected.activity.map((a) => (\n\t\t\t\t\t\t\t\t\t\t\t\t<Entity\n\t\t\t\t\t\t\t\t\t\t\t\t\tkey={a.id}\n\t\t\t\t\t\t\t\t\t\t\t\t\tleading={<Icon name={a.icon} className=\"text-muted-foreground\" />}\n\t\t\t\t\t\t\t\t\t\t\t\t\ttitle={a.text}\n\t\t\t\t\t\t\t\t\t\t\t\t\tsubtitle={a.when}\n\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\t\t</CardList>\n\t\t\t\t\t\t\t\t\t</Card>\n\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t\t<DestructiveActionModal\n\t\t\t\t\t\t\t\t\topen={confirming}\n\t\t\t\t\t\t\t\t\tonOpenChange={setConfirming}\n\t\t\t\t\t\t\t\t\ttitle={selected.pending ? \"Revoke this invite?\" : `Remove ${selected.name}?`}\n\t\t\t\t\t\t\t\t\tdescription={\n\t\t\t\t\t\t\t\t\t\tselected.pending\n\t\t\t\t\t\t\t\t\t\t\t? `${selected.email} won't be able to join with the link they were sent.`\n\t\t\t\t\t\t\t\t\t\t\t: \"They lose access to the studio right away. Editions they released stay on sale.\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\tconfirmLabel={selected.pending ? \"Revoke invite\" : \"Remove member\"}\n\t\t\t\t\t\t\t\t\tonConfirm={() => remove(selected.id)}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</Stack>\n\t\t\t\t</Col>\n\t\t\t</Columns>\n\t\t</Page>\n\t)\n}"
|
|
19132
20388
|
},
|
|
19133
20389
|
{
|
|
19134
20390
|
"name": "SettingsPage",
|
|
@@ -19153,8 +20409,8 @@
|
|
|
19153
20409
|
"Field auto-wires htmlFor/id, aria-describedby (description + error), aria-invalid, and required.",
|
|
19154
20410
|
"DestructiveActionModal is an alertdialog: focus opens on Cancel, the backdrop does not dismiss, and the confirm stays disabled until the handle is typed."
|
|
19155
20411
|
],
|
|
19156
|
-
"imports": "import { useState } from \"react\"\nimport { AppHeader, AppShell, Avatar, BottomNav, BottomNavItem, Button, Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardList, CardTitle, Col, Columns, DestructiveActionModal, Entity, Field, Form, Grid, Icon, Input, MenuItem, MenuSeparator, NotificationBadge, OverflowMenu, Page, PageHeader, Row, Select, SelectContent, SelectItem, SelectTrigger, Sidebar, SidebarFooter, SidebarHeader, SidebarItem, SidebarNav, SideNav, SideNavItem, Stack, Switch, Textarea } from \"@sableui/react\"",
|
|
19157
|
-
"code": "const sections = [\n\t{ id: \"profile\", label: \"Profile\" },\n\t{ id: \"notifications\", label: \"Notifications\" },\n\t{ id: \"payouts\", label: \"Payouts\" },\n\t{ id: \"account\", label: \"Account\" },\n]\n\nconst timezones = [\n\t{ value: \"europe-london\", label: \"London (GMT+1)\" },\n\t{ value: \"europe-paris\", label: \"Paris (GMT+2)\" },\n\t{ value: \"america-new-york\", label: \"New York (GMT−4)\" },\n\t{ value: \"america-los-angeles\", label: \"Los Angeles (GMT−7)\" },\n\t{ value: \"asia-tokyo\", label: \"Tokyo (GMT+9)\" },\n]\n\nconst notifications = [\n\t{ name: \"notifyCollects\", title: \"New collects\", description: \"When someone collects your work\", on: true },\n\t{ name: \"notifySoldOut\", title: \"Sold out\", description: \"When an edition sells its last copy\", on: true },\n\t{ name: \"notifyFollowers\", title: \"New followers\", description: \"When a collector follows you\", on: false },\n\t{ name: \"notifyDigest\", title: \"Weekly digest\", description: \"Sales and collectors, every Monday\", on: true },\n\t{ name: \"notifyProduct\", title: \"Product updates\", description: \"New Atelier features\", on: false },\n]\n\nconst payouts = [\n\t{ label: \"Next payout\", value: \"$1,284.60\", note: \"Friday, Oct 16\" },\n\t{ label: \"Pending\", value: \"$412.00\", note: \"Clears in 7 days\" },\n\t{ label: \"Paid this year\", value: \"$18,920.00\", note: \"Across 214 collects\" },\n]\n\nexport function SettingsPage() {\n\tconst [activeSection, setActiveSection] = useState(\"profile\")\n\tconst [deleteOpen, setDeleteOpen] = useState(false)\n\n\treturn (\n\t\t<AppShell\n\t\t\t// Sidebar hides below lg, so the same destinations ride in the shell's\n\t\t\t// mobile slots — without them the route has no navigation under 1024px.\n\t\t\theader={\n\t\t\t\t<AppHeader\n\t\t\t\t\ttitle={\n\t\t\t\t\t\t<Row gap={1} align=\"center\">\n\t\t\t\t\t\t\t<Icon name=\"gem\" variant=\"solid\" className=\"text-xl\" />\n\t\t\t\t\t\t\t<span className=\"text-title-sm\">Atelier</span>\n\t\t\t\t\t\t</Row>\n\t\t\t\t\t}\n\t\t\t\t\tactions={<Avatar variant=\"art\" size=\"sm\" fallback=\"AL\" alt=\"Ada Lovelace\" />}\n\t\t\t\t/>\n\t\t\t}\n\t\t\tbottomNav={\n\t\t\t\t<BottomNav aria-label=\"Primary\">\n\t\t\t\t\t<BottomNavItem icon={<Icon name=\"grid\" />} label=\"Overview\" showLabel render={<a href=\"/\" />} />\n\t\t\t\t\t<BottomNavItem icon={<Icon name=\"image-stack\" />} label=\"Editions\" showLabel render={<a href=\"/editions\" />} />\n\t\t\t\t\t<BottomNavItem icon={<Icon name=\"user\" />} label=\"Collectors\" showLabel render={<a href=\"/collectors\" />} />\n\t\t\t\t\t<BottomNavItem icon={<Icon name=\"bell\" />} label=\"Activity\" showLabel render={<a href=\"/activity\" />} />\n\t\t\t\t\t<BottomNavItem icon={<Icon name=\"gear\" />} label=\"Settings\" showLabel active render={<a href=\"/settings\" />} />\n\t\t\t\t</BottomNav>\n\t\t\t}\n\t\t\tsidebar={\n\t\t\t\t<Sidebar>\n\t\t\t\t\t<SidebarHeader>\n\t\t\t\t\t\t<Row gap={1} align=\"center\" className=\"px-3\">\n\t\t\t\t\t\t\t<Icon name=\"gem\" variant=\"solid\" className=\"text-xl\" />\n\t\t\t\t\t\t\t<span className=\"text-title-sm\">Atelier</span>\n\t\t\t\t\t\t</Row>\n\t\t\t\t\t</SidebarHeader>\n\t\t\t\t\t<SidebarNav>\n\t\t\t\t\t\t<SidebarItem icon={<Icon name=\"grid\" />} label=\"Overview\" render={<a href=\"/\" />} />\n\t\t\t\t\t\t<SidebarItem icon={<Icon name=\"image-stack\" />} label=\"Editions\" render={<a href=\"/editions\" />} />\n\t\t\t\t\t\t<SidebarItem icon={<Icon name=\"user\" />} label=\"Collectors\" render={<a href=\"/collectors\" />} />\n\t\t\t\t\t\t<SidebarItem\n\t\t\t\t\t\t\ticon={<Icon name=\"bell\" />}\n\t\t\t\t\t\t\tlabel=\"Activity\"\n\t\t\t\t\t\t\tbadge={<NotificationBadge count={4} tone=\"neutral\" />}\n\t\t\t\t\t\t\trender={<a href=\"/activity\" />}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<SidebarItem icon={<Icon name=\"gear\" />} label=\"Settings\" active render={<a href=\"/settings\" />} />\n\t\t\t\t\t</SidebarNav>\n\t\t\t\t\t<SidebarFooter>\n\t\t\t\t\t\t<Entity\n\t\t\t\t\t\t\tclassName=\"[--entity-py:0]\"\n\t\t\t\t\t\t\tleading={<Avatar variant=\"art\" size=\"sm\" fallback=\"AL\" alt=\"\" />}\n\t\t\t\t\t\t\ttitle=\"Ada Lovelace\"\n\t\t\t\t\t\t\tsubtitle=\"@ada\"\n\t\t\t\t\t\t\taction={\n\t\t\t\t\t\t\t\t<OverflowMenu label=\"Account\" size=\"icon-sm\">\n\t\t\t\t\t\t\t\t\t<MenuItem render={<a href=\"/@ada\" />}>View profile</MenuItem>\n\t\t\t\t\t\t\t\t\t<MenuItem render={<a href=\"/settings\" />}>Settings</MenuItem>\n\t\t\t\t\t\t\t\t\t<MenuSeparator />\n\t\t\t\t\t\t\t\t\t<MenuItem>Sign out</MenuItem>\n\t\t\t\t\t\t\t\t</OverflowMenu>\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</SidebarFooter>\n\t\t\t\t</Sidebar>\n\t\t\t}\n\t\t>\n\t\t\t{/* No `as=\"main\"`: AppShell already renders <main id=\"main-content\">. */}\n\t\t\t<Page max=\"wide\" gap={4} className=\"py-6\">\n\t\t\t\t<PageHeader\n\t\t\t\t\ttitle=\"Settings\"\n\t\t\t\t\tdescription=\"Manage your profile, notifications, and payouts.\"\n\t\t\t\t/>\n\t\t\t\t<Columns gap={4}>\n\t\t\t\t\t{/* The section rail is a desktop aid; on mobile the sections simply stack. */}\n\t\t\t\t\t<Col span={{ base: 12, lg: 3 }} className=\"hidden lg:block\">\n\t\t\t\t\t\t<SideNav aria-label=\"Settings sections\" className=\"sticky top-6\">\n\t\t\t\t\t\t\t{sections.map((section) => (\n\t\t\t\t\t\t\t\t<SideNavItem\n\t\t\t\t\t\t\t\t\tkey={section.id}\n\t\t\t\t\t\t\t\t\tlabel={section.label}\n\t\t\t\t\t\t\t\t\tactive={activeSection === section.id}\n\t\t\t\t\t\t\t\t\tonClick={() => setActiveSection(section.id)}\n\t\t\t\t\t\t\t\t\trender={<a href={`#${section.id}`} />}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t</SideNav>\n\t\t\t\t\t</Col>\n\t\t\t\t\t<Col span={{ base: 12, lg: 9 }}>\n\t\t\t\t\t\t<Stack gap={3}>\n\t\t\t\t\t\t\t<Card id=\"profile\" className=\"scroll-mt-6\">\n\t\t\t\t\t\t\t\t<CardHeader>\n\t\t\t\t\t\t\t\t\t<CardTitle render={<h2 />}>Profile</CardTitle>\n\t\t\t\t\t\t\t\t\t<CardDescription>How collectors see you on Atelier.</CardDescription>\n\t\t\t\t\t\t\t\t</CardHeader>\n\t\t\t\t\t\t\t\t{/* The one explicit save on the page: profile text is drafted, then\n\t\t\t\t\t\t\t\t committed. Toggles below apply the moment they change. */}\n\t\t\t\t\t\t\t\t<Form onSubmit={(event) => event.preventDefault()} className=\"gap-0\">\n\t\t\t\t\t\t\t\t\t<CardContent>\n\t\t\t\t\t\t\t\t\t\t<Stack gap={3}>\n\t\t\t\t\t\t\t\t\t\t\t<Row gap={2}>\n\t\t\t\t\t\t\t\t\t\t\t\t<Avatar variant=\"art\" size=\"lg\" fallback=\"AL\" alt=\"Ada Lovelace\" />\n\t\t\t\t\t\t\t\t\t\t\t\t<Stack gap={0.5} align=\"start\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t<Row gap={1}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<Button type=\"button\" variant=\"outline\" size=\"sm\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tUpload photo\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<Button type=\"button\" variant=\"ghost\" size=\"sm\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tRemove\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t\t\t\t\t</Row>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<p className=\"text-caption text-muted-foreground\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tSquare PNG or JPG, at least 400 px.\n\t\t\t\t\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t\t\t\t\t</Stack>\n\t\t\t\t\t\t\t\t\t\t\t</Row>\n\t\t\t\t\t\t\t\t\t\t\t<Grid cols={{ base: 1, sm: 2 }} gap={3}>\n\t\t\t\t\t\t\t\t\t\t\t\t<Field label=\"Display name\" name=\"displayName\" required>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<Input defaultValue=\"Ada Lovelace\" autoComplete=\"name\" />\n\t\t\t\t\t\t\t\t\t\t\t\t</Field>\n\t\t\t\t\t\t\t\t\t\t\t\t<Field label=\"Handle\" name=\"handle\" description=\"atelier.com/@ada\" required>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<Input defaultValue=\"ada\" autoComplete=\"username\" spellCheck={false} />\n\t\t\t\t\t\t\t\t\t\t\t\t</Field>\n\t\t\t\t\t\t\t\t\t\t\t</Grid>\n\t\t\t\t\t\t\t\t\t\t\t<Field label=\"Bio\" name=\"bio\" description=\"Shown on your profile and edition pages.\">\n\t\t\t\t\t\t\t\t\t\t\t\t<Textarea\n\t\t\t\t\t\t\t\t\t\t\t\t\trows={3}\n\t\t\t\t\t\t\t\t\t\t\t\t\tdefaultValue=\"Generative artist working in plotted ink and code. New editions of 25 to 100 every month.\"\n\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t</Field>\n\t\t\t\t\t\t\t\t\t\t\t<Grid cols={{ base: 1, sm: 2 }} gap={3}>\n\t\t\t\t\t\t\t\t\t\t\t\t<Field label=\"Timezone\" name=\"timezone\" description=\"Release times use this zone.\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t<Select defaultValue=\"europe-london\" items={timezones}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<SelectTrigger />\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<SelectContent>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{timezones.map((zone) => (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<SelectItem key={zone.value} value={zone.value}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{zone.label}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t</SelectItem>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</SelectContent>\n\t\t\t\t\t\t\t\t\t\t\t\t\t</Select>\n\t\t\t\t\t\t\t\t\t\t\t\t</Field>\n\t\t\t\t\t\t\t\t\t\t\t</Grid>\n\t\t\t\t\t\t\t\t\t\t</Stack>\n\t\t\t\t\t\t\t\t\t</CardContent>\n\t\t\t\t\t\t\t\t\t<CardFooter className=\"justify-end\">\n\t\t\t\t\t\t\t\t\t\t<Button type=\"submit\">Save profile</Button>\n\t\t\t\t\t\t\t\t\t</CardFooter>\n\t\t\t\t\t\t\t\t</Form>\n\t\t\t\t\t\t\t</Card>\n\n\t\t\t\t\t\t\t<Card id=\"notifications\" className=\"scroll-mt-6\">\n\t\t\t\t\t\t\t\t<CardHeader>\n\t\t\t\t\t\t\t\t\t<CardTitle render={<h2 />}>Notifications</CardTitle>\n\t\t\t\t\t\t\t\t\t<CardDescription>\n\t\t\t\t\t\t\t\t\t\tEmail updates about your editions. Changes save automatically.\n\t\t\t\t\t\t\t\t\t</CardDescription>\n\t\t\t\t\t\t\t\t</CardHeader>\n\t\t\t\t\t\t\t\t<CardList>\n\t\t\t\t\t\t\t\t\t{notifications.map((item) => (\n\t\t\t\t\t\t\t\t\t\t<Entity\n\t\t\t\t\t\t\t\t\t\t\tkey={item.name}\n\t\t\t\t\t\t\t\t\t\t\ttitle={item.title}\n\t\t\t\t\t\t\t\t\t\t\tsubtitle={item.description}\n\t\t\t\t\t\t\t\t\t\t\taction={\n\t\t\t\t\t\t\t\t\t\t\t\t<Switch\n\t\t\t\t\t\t\t\t\t\t\t\t\tname={item.name}\n\t\t\t\t\t\t\t\t\t\t\t\t\tdefaultChecked={item.on}\n\t\t\t\t\t\t\t\t\t\t\t\t\taria-label={item.title}\n\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t</CardList>\n\t\t\t\t\t\t\t</Card>\n\n\t\t\t\t\t\t\t<Card id=\"payouts\" className=\"scroll-mt-6\">\n\t\t\t\t\t\t\t\t<CardHeader>\n\t\t\t\t\t\t\t\t\t<CardTitle render={<h2 />}>Payouts</CardTitle>\n\t\t\t\t\t\t\t\t\t<CardDescription>Paid in USD every Friday to Chase checking ••4821.</CardDescription>\n\t\t\t\t\t\t\t\t\t<CardAction>\n\t\t\t\t\t\t\t\t\t\t<Button variant=\"outline\" size=\"sm\" render={<a href=\"/settings/payouts\" />}>\n\t\t\t\t\t\t\t\t\t\t\tManage payouts\n\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t</CardAction>\n\t\t\t\t\t\t\t\t</CardHeader>\n\t\t\t\t\t\t\t\t<CardContent>\n\t\t\t\t\t\t\t\t\t<Grid cols={{ base: 1, sm: 3 }} gap={3}>\n\t\t\t\t\t\t\t\t\t\t{payouts.map((stat) => (\n\t\t\t\t\t\t\t\t\t\t\t<Stack key={stat.label} gap={0.5}>\n\t\t\t\t\t\t\t\t\t\t\t\t<p className=\"text-body-sm text-muted-foreground\">{stat.label}</p>\n\t\t\t\t\t\t\t\t\t\t\t\t<p className=\"text-mono-lg text-foreground\">{stat.value}</p>\n\t\t\t\t\t\t\t\t\t\t\t\t<p className=\"text-caption text-muted-foreground\">{stat.note}</p>\n\t\t\t\t\t\t\t\t\t\t\t</Stack>\n\t\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\t</Grid>\n\t\t\t\t\t\t\t\t</CardContent>\n\t\t\t\t\t\t\t</Card>\n\n\t\t\t\t\t\t\t<Card id=\"account\" className=\"scroll-mt-6\">\n\t\t\t\t\t\t\t\t<CardHeader>\n\t\t\t\t\t\t\t\t\t<CardTitle render={<h2 />}>Account</CardTitle>\n\t\t\t\t\t\t\t\t\t<CardDescription>Sign-in details and your data.</CardDescription>\n\t\t\t\t\t\t\t\t</CardHeader>\n\t\t\t\t\t\t\t\t<CardList>\n\t\t\t\t\t\t\t\t\t<Entity\n\t\t\t\t\t\t\t\t\t\tleading={<Icon name=\"mail\" className=\"text-lg text-muted-foreground\" />}\n\t\t\t\t\t\t\t\t\t\ttitle=\"Email\"\n\t\t\t\t\t\t\t\t\t\tsubtitle=\"ada@atelier.art\"\n\t\t\t\t\t\t\t\t\t\taction={\n\t\t\t\t\t\t\t\t\t\t\t<Button variant=\"outline\" size=\"sm\">\n\t\t\t\t\t\t\t\t\t\t\t\tChange\n\t\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t<Entity\n\t\t\t\t\t\t\t\t\t\tleading={<Icon name=\"download\" className=\"text-lg text-muted-foreground\" />}\n\t\t\t\t\t\t\t\t\t\ttitle=\"Export data\"\n\t\t\t\t\t\t\t\t\t\tsubtitle=\"Editions, sales, and collectors\"\n\t\t\t\t\t\t\t\t\t\taction={\n\t\t\t\t\t\t\t\t\t\t\t<Button variant=\"outline\" size=\"sm\">\n\t\t\t\t\t\t\t\t\t\t\t\tExport\n\t\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t</CardList>\n\t\t\t\t\t\t\t</Card>\n\n\t\t\t\t\t\t\t<Card>\n\t\t\t\t\t\t\t\t<CardHeader>\n\t\t\t\t\t\t\t\t\t<CardTitle render={<h2 />}>Delete account</CardTitle>\n\t\t\t\t\t\t\t\t\t<CardDescription>\n\t\t\t\t\t\t\t\t\t\tPermanently remove your profile, editions, and sales history. Collectors keep\n\t\t\t\t\t\t\t\t\t\tthe editions they own.\n\t\t\t\t\t\t\t\t\t</CardDescription>\n\t\t\t\t\t\t\t\t</CardHeader>\n\t\t\t\t\t\t\t\t<CardFooter className=\"justify-end\">\n\t\t\t\t\t\t\t\t\t<Button variant=\"destructive\" onClick={() => setDeleteOpen(true)}>\n\t\t\t\t\t\t\t\t\t\tDelete account\n\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t</CardFooter>\n\t\t\t\t\t\t\t</Card>\n\t\t\t\t\t\t</Stack>\n\t\t\t\t\t</Col>\n\t\t\t\t</Columns>\n\t\t\t</Page>\n\t\t\t<DestructiveActionModal\n\t\t\t\topen={deleteOpen}\n\t\t\t\tonOpenChange={setDeleteOpen}\n\t\t\t\ttitle=\"Delete your account?\"\n\t\t\t\tdescription=\"This permanently removes your profile, 12 editions, and sales history. Pending payouts are still sent. This can't be undone.\"\n\t\t\t\tconfirmLabel=\"Delete account\"\n\t\t\t\tconfirmingLabel=\"Deleting…\"\n\t\t\t\tconfirmText=\"ada\"\n\t\t\t\tonConfirm={() => setDeleteOpen(false)}\n\t\t\t/>\n\t\t</AppShell>\n\t)\n}"
|
|
20412
|
+
"imports": "import { useState } from \"react\"\nimport { AppHeader, AppShell, Avatar, BottomNav, BottomNavItem, Button, Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardList, CardTitle, Col, Columns, DestructiveActionModal, Entity, Field, Form, Grid, Icon, Input, MenuItem, MenuSeparator, NotificationBadge, OverflowMenu, Page, PageHeader, Row, Select, SelectContent, SelectItem, SelectTrigger, Sidebar, SidebarBrand, SidebarFooter, SidebarHeader, SidebarItem, SidebarNav, SideNav, SideNavItem, Stack, Switch, Textarea } from \"@sableui/react\"",
|
|
20413
|
+
"code": "const sections = [\n\t{ id: \"profile\", label: \"Profile\" },\n\t{ id: \"notifications\", label: \"Notifications\" },\n\t{ id: \"payouts\", label: \"Payouts\" },\n\t{ id: \"account\", label: \"Account\" },\n]\n\nconst timezones = [\n\t{ value: \"europe-london\", label: \"London (GMT+1)\" },\n\t{ value: \"europe-paris\", label: \"Paris (GMT+2)\" },\n\t{ value: \"america-new-york\", label: \"New York (GMT−4)\" },\n\t{ value: \"america-los-angeles\", label: \"Los Angeles (GMT−7)\" },\n\t{ value: \"asia-tokyo\", label: \"Tokyo (GMT+9)\" },\n]\n\nconst notifications = [\n\t{ name: \"notifyCollects\", title: \"New collects\", description: \"When someone collects your work\", on: true },\n\t{ name: \"notifySoldOut\", title: \"Sold out\", description: \"When an edition sells its last copy\", on: true },\n\t{ name: \"notifyFollowers\", title: \"New followers\", description: \"When a collector follows you\", on: false },\n\t{ name: \"notifyDigest\", title: \"Weekly digest\", description: \"Sales and collectors, every Monday\", on: true },\n\t{ name: \"notifyProduct\", title: \"Product updates\", description: \"New Atelier features\", on: false },\n]\n\nconst payouts = [\n\t{ label: \"Next payout\", value: \"$1,284.60\", note: \"Friday, Oct 16\" },\n\t{ label: \"Pending\", value: \"$412.00\", note: \"Clears in 7 days\" },\n\t{ label: \"Paid this year\", value: \"$18,920.00\", note: \"Across 214 collects\" },\n]\n\nexport function SettingsPage() {\n\tconst [activeSection, setActiveSection] = useState(\"profile\")\n\tconst [deleteOpen, setDeleteOpen] = useState(false)\n\n\treturn (\n\t\t<AppShell\n\t\t\t// Sidebar hides below lg, so the same destinations ride in the shell's\n\t\t\t// mobile slots — without them the route has no navigation under 1024px.\n\t\t\theader={\n\t\t\t\t<AppHeader\n\t\t\t\t\ttitle={\n\t\t\t\t\t\t<Row gap={1} align=\"center\">\n\t\t\t\t\t\t\t<Icon name=\"gem\" variant=\"solid\" className=\"text-xl\" />\n\t\t\t\t\t\t\t<span className=\"text-title-sm\">Atelier</span>\n\t\t\t\t\t\t</Row>\n\t\t\t\t\t}\n\t\t\t\t\tactions={<Avatar variant=\"art\" size=\"sm\" fallback=\"AL\" alt=\"Ada Lovelace\" />}\n\t\t\t\t/>\n\t\t\t}\n\t\t\tbottomNav={\n\t\t\t\t<BottomNav aria-label=\"Primary\">\n\t\t\t\t\t<BottomNavItem icon={<Icon name=\"grid\" />} label=\"Overview\" showLabel render={<a href=\"/\" />} />\n\t\t\t\t\t<BottomNavItem icon={<Icon name=\"image-stack\" />} label=\"Editions\" showLabel render={<a href=\"/editions\" />} />\n\t\t\t\t\t<BottomNavItem icon={<Icon name=\"user\" />} label=\"Collectors\" showLabel render={<a href=\"/collectors\" />} />\n\t\t\t\t\t<BottomNavItem icon={<Icon name=\"bell\" />} label=\"Activity\" showLabel render={<a href=\"/activity\" />} />\n\t\t\t\t\t<BottomNavItem icon={<Icon name=\"gear\" />} label=\"Settings\" showLabel active render={<a href=\"/settings\" />} />\n\t\t\t\t</BottomNav>\n\t\t\t}\n\t\t\tsidebar={\n\t\t\t\t<Sidebar>\n\t\t\t\t\t<SidebarHeader>\n\t\t\t\t\t\t<SidebarBrand mark={<Icon name=\"gem\" variant=\"solid\" />} label=\"Atelier\" render={<a href=\"/\" />} />\n\t\t\t\t\t</SidebarHeader>\n\t\t\t\t\t<SidebarNav>\n\t\t\t\t\t\t<SidebarItem icon={<Icon name=\"grid\" />} label=\"Overview\" render={<a href=\"/\" />} />\n\t\t\t\t\t\t<SidebarItem icon={<Icon name=\"image-stack\" />} label=\"Editions\" render={<a href=\"/editions\" />} />\n\t\t\t\t\t\t<SidebarItem icon={<Icon name=\"user\" />} label=\"Collectors\" render={<a href=\"/collectors\" />} />\n\t\t\t\t\t\t<SidebarItem\n\t\t\t\t\t\t\ticon={<Icon name=\"bell\" />}\n\t\t\t\t\t\t\tlabel=\"Activity\"\n\t\t\t\t\t\t\tbadge={<NotificationBadge count={4} tone=\"neutral\" />}\n\t\t\t\t\t\t\trender={<a href=\"/activity\" />}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<SidebarItem icon={<Icon name=\"gear\" />} label=\"Settings\" active render={<a href=\"/settings\" />} />\n\t\t\t\t\t</SidebarNav>\n\t\t\t\t\t<SidebarFooter>\n\t\t\t\t\t\t<Entity\n\t\t\t\t\t\t\tclassName=\"[--entity-py:0]\"\n\t\t\t\t\t\t\tleading={<Avatar variant=\"art\" size=\"sm\" fallback=\"AL\" alt=\"\" />}\n\t\t\t\t\t\t\ttitle=\"Ada Lovelace\"\n\t\t\t\t\t\t\tsubtitle=\"@ada\"\n\t\t\t\t\t\t\taction={\n\t\t\t\t\t\t\t\t<OverflowMenu label=\"Account\" size=\"icon-sm\">\n\t\t\t\t\t\t\t\t\t<MenuItem render={<a href=\"/@ada\" />}>View profile</MenuItem>\n\t\t\t\t\t\t\t\t\t<MenuItem render={<a href=\"/settings\" />}>Settings</MenuItem>\n\t\t\t\t\t\t\t\t\t<MenuSeparator />\n\t\t\t\t\t\t\t\t\t<MenuItem>Sign out</MenuItem>\n\t\t\t\t\t\t\t\t</OverflowMenu>\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</SidebarFooter>\n\t\t\t\t</Sidebar>\n\t\t\t}\n\t\t>\n\t\t\t{/* No `as=\"main\"`: AppShell already renders <main id=\"main-content\">. */}\n\t\t\t<Page max=\"wide\" gap={4} className=\"py-6\">\n\t\t\t\t<PageHeader\n\t\t\t\t\ttitle=\"Settings\"\n\t\t\t\t\tdescription=\"Manage your profile, notifications, and payouts.\"\n\t\t\t\t/>\n\t\t\t\t<Columns gap={4}>\n\t\t\t\t\t{/* The section rail is a desktop aid; on mobile the sections simply stack. */}\n\t\t\t\t\t<Col span={{ base: 12, lg: 3 }} className=\"hidden lg:block\">\n\t\t\t\t\t\t<SideNav aria-label=\"Settings sections\" className=\"sticky top-6\">\n\t\t\t\t\t\t\t{sections.map((section) => (\n\t\t\t\t\t\t\t\t<SideNavItem\n\t\t\t\t\t\t\t\t\tkey={section.id}\n\t\t\t\t\t\t\t\t\tlabel={section.label}\n\t\t\t\t\t\t\t\t\tactive={activeSection === section.id}\n\t\t\t\t\t\t\t\t\tonClick={() => setActiveSection(section.id)}\n\t\t\t\t\t\t\t\t\trender={<a href={`#${section.id}`} />}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t</SideNav>\n\t\t\t\t\t</Col>\n\t\t\t\t\t<Col span={{ base: 12, lg: 9 }}>\n\t\t\t\t\t\t<Stack gap={3}>\n\t\t\t\t\t\t\t<Card id=\"profile\" className=\"scroll-mt-6\">\n\t\t\t\t\t\t\t\t<CardHeader>\n\t\t\t\t\t\t\t\t\t<CardTitle render={<h2 />}>Profile</CardTitle>\n\t\t\t\t\t\t\t\t\t<CardDescription>How collectors see you on Atelier.</CardDescription>\n\t\t\t\t\t\t\t\t</CardHeader>\n\t\t\t\t\t\t\t\t{/* The one explicit save on the page: profile text is drafted, then\n\t\t\t\t\t\t\t\t committed. Toggles below apply the moment they change. */}\n\t\t\t\t\t\t\t\t<Form onSubmit={(event) => event.preventDefault()} className=\"gap-0\">\n\t\t\t\t\t\t\t\t\t<CardContent>\n\t\t\t\t\t\t\t\t\t\t<Stack gap={3}>\n\t\t\t\t\t\t\t\t\t\t\t<Row gap={2}>\n\t\t\t\t\t\t\t\t\t\t\t\t<Avatar variant=\"art\" size=\"lg\" fallback=\"AL\" alt=\"Ada Lovelace\" />\n\t\t\t\t\t\t\t\t\t\t\t\t<Stack gap={0.5} align=\"start\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t<Row gap={1}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<Button type=\"button\" variant=\"outline\" size=\"sm\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tUpload photo\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<Button type=\"button\" variant=\"ghost\" size=\"sm\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tRemove\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t\t\t\t\t</Row>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<p className=\"text-caption text-muted-foreground\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tSquare PNG or JPG, at least 400 px.\n\t\t\t\t\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t\t\t\t\t</Stack>\n\t\t\t\t\t\t\t\t\t\t\t</Row>\n\t\t\t\t\t\t\t\t\t\t\t<Grid cols={{ base: 1, sm: 2 }} gap={3}>\n\t\t\t\t\t\t\t\t\t\t\t\t<Field label=\"Display name\" name=\"displayName\" required>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<Input defaultValue=\"Ada Lovelace\" autoComplete=\"name\" />\n\t\t\t\t\t\t\t\t\t\t\t\t</Field>\n\t\t\t\t\t\t\t\t\t\t\t\t<Field label=\"Handle\" name=\"handle\" description=\"atelier.com/@ada\" required>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<Input defaultValue=\"ada\" autoComplete=\"username\" spellCheck={false} />\n\t\t\t\t\t\t\t\t\t\t\t\t</Field>\n\t\t\t\t\t\t\t\t\t\t\t</Grid>\n\t\t\t\t\t\t\t\t\t\t\t<Field label=\"Bio\" name=\"bio\" description=\"Shown on your profile and edition pages.\">\n\t\t\t\t\t\t\t\t\t\t\t\t<Textarea\n\t\t\t\t\t\t\t\t\t\t\t\t\trows={3}\n\t\t\t\t\t\t\t\t\t\t\t\t\tdefaultValue=\"Generative artist working in plotted ink and code. New editions of 25 to 100 every month.\"\n\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t</Field>\n\t\t\t\t\t\t\t\t\t\t\t<Grid cols={{ base: 1, sm: 2 }} gap={3}>\n\t\t\t\t\t\t\t\t\t\t\t\t<Field label=\"Timezone\" name=\"timezone\" description=\"Release times use this zone.\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t<Select defaultValue=\"europe-london\" items={timezones}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<SelectTrigger />\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<SelectContent>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{timezones.map((zone) => (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<SelectItem key={zone.value} value={zone.value}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{zone.label}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t</SelectItem>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</SelectContent>\n\t\t\t\t\t\t\t\t\t\t\t\t\t</Select>\n\t\t\t\t\t\t\t\t\t\t\t\t</Field>\n\t\t\t\t\t\t\t\t\t\t\t</Grid>\n\t\t\t\t\t\t\t\t\t\t</Stack>\n\t\t\t\t\t\t\t\t\t</CardContent>\n\t\t\t\t\t\t\t\t\t<CardFooter className=\"justify-end\">\n\t\t\t\t\t\t\t\t\t\t<Button type=\"submit\">Save profile</Button>\n\t\t\t\t\t\t\t\t\t</CardFooter>\n\t\t\t\t\t\t\t\t</Form>\n\t\t\t\t\t\t\t</Card>\n\n\t\t\t\t\t\t\t<Card id=\"notifications\" className=\"scroll-mt-6\">\n\t\t\t\t\t\t\t\t<CardHeader>\n\t\t\t\t\t\t\t\t\t<CardTitle render={<h2 />}>Notifications</CardTitle>\n\t\t\t\t\t\t\t\t\t<CardDescription>\n\t\t\t\t\t\t\t\t\t\tEmail updates about your editions. Changes save automatically.\n\t\t\t\t\t\t\t\t\t</CardDescription>\n\t\t\t\t\t\t\t\t</CardHeader>\n\t\t\t\t\t\t\t\t<CardList>\n\t\t\t\t\t\t\t\t\t{notifications.map((item) => (\n\t\t\t\t\t\t\t\t\t\t<Entity\n\t\t\t\t\t\t\t\t\t\t\tkey={item.name}\n\t\t\t\t\t\t\t\t\t\t\ttitle={item.title}\n\t\t\t\t\t\t\t\t\t\t\tsubtitle={item.description}\n\t\t\t\t\t\t\t\t\t\t\taction={\n\t\t\t\t\t\t\t\t\t\t\t\t<Switch\n\t\t\t\t\t\t\t\t\t\t\t\t\tname={item.name}\n\t\t\t\t\t\t\t\t\t\t\t\t\tdefaultChecked={item.on}\n\t\t\t\t\t\t\t\t\t\t\t\t\taria-label={item.title}\n\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t</CardList>\n\t\t\t\t\t\t\t</Card>\n\n\t\t\t\t\t\t\t<Card id=\"payouts\" className=\"scroll-mt-6\">\n\t\t\t\t\t\t\t\t<CardHeader>\n\t\t\t\t\t\t\t\t\t<CardTitle render={<h2 />}>Payouts</CardTitle>\n\t\t\t\t\t\t\t\t\t<CardDescription>Paid in USD every Friday to Chase checking ••4821.</CardDescription>\n\t\t\t\t\t\t\t\t\t<CardAction>\n\t\t\t\t\t\t\t\t\t\t<Button variant=\"outline\" size=\"sm\" render={<a href=\"/settings/payouts\" />}>\n\t\t\t\t\t\t\t\t\t\t\tManage payouts\n\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t</CardAction>\n\t\t\t\t\t\t\t\t</CardHeader>\n\t\t\t\t\t\t\t\t<CardContent>\n\t\t\t\t\t\t\t\t\t<Grid cols={{ base: 1, sm: 3 }} gap={3}>\n\t\t\t\t\t\t\t\t\t\t{payouts.map((stat) => (\n\t\t\t\t\t\t\t\t\t\t\t<Stack key={stat.label} gap={0.5}>\n\t\t\t\t\t\t\t\t\t\t\t\t<p className=\"text-body-sm text-muted-foreground\">{stat.label}</p>\n\t\t\t\t\t\t\t\t\t\t\t\t<p className=\"text-mono-lg text-foreground\">{stat.value}</p>\n\t\t\t\t\t\t\t\t\t\t\t\t<p className=\"text-caption text-muted-foreground\">{stat.note}</p>\n\t\t\t\t\t\t\t\t\t\t\t</Stack>\n\t\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\t</Grid>\n\t\t\t\t\t\t\t\t</CardContent>\n\t\t\t\t\t\t\t</Card>\n\n\t\t\t\t\t\t\t<Card id=\"account\" className=\"scroll-mt-6\">\n\t\t\t\t\t\t\t\t<CardHeader>\n\t\t\t\t\t\t\t\t\t<CardTitle render={<h2 />}>Account</CardTitle>\n\t\t\t\t\t\t\t\t\t<CardDescription>Sign-in details and your data.</CardDescription>\n\t\t\t\t\t\t\t\t</CardHeader>\n\t\t\t\t\t\t\t\t<CardList>\n\t\t\t\t\t\t\t\t\t<Entity\n\t\t\t\t\t\t\t\t\t\tleading={<Icon name=\"mail\" className=\"text-lg text-muted-foreground\" />}\n\t\t\t\t\t\t\t\t\t\ttitle=\"Email\"\n\t\t\t\t\t\t\t\t\t\tsubtitle=\"ada@atelier.art\"\n\t\t\t\t\t\t\t\t\t\taction={\n\t\t\t\t\t\t\t\t\t\t\t<Button variant=\"outline\" size=\"sm\">\n\t\t\t\t\t\t\t\t\t\t\t\tChange\n\t\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t<Entity\n\t\t\t\t\t\t\t\t\t\tleading={<Icon name=\"download\" className=\"text-lg text-muted-foreground\" />}\n\t\t\t\t\t\t\t\t\t\ttitle=\"Export data\"\n\t\t\t\t\t\t\t\t\t\tsubtitle=\"Editions, sales, and collectors\"\n\t\t\t\t\t\t\t\t\t\taction={\n\t\t\t\t\t\t\t\t\t\t\t<Button variant=\"outline\" size=\"sm\">\n\t\t\t\t\t\t\t\t\t\t\t\tExport\n\t\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t</CardList>\n\t\t\t\t\t\t\t</Card>\n\n\t\t\t\t\t\t\t<Card>\n\t\t\t\t\t\t\t\t<CardHeader>\n\t\t\t\t\t\t\t\t\t<CardTitle render={<h2 />}>Delete account</CardTitle>\n\t\t\t\t\t\t\t\t\t<CardDescription>\n\t\t\t\t\t\t\t\t\t\tPermanently remove your profile, editions, and sales history. Collectors keep\n\t\t\t\t\t\t\t\t\t\tthe editions they own.\n\t\t\t\t\t\t\t\t\t</CardDescription>\n\t\t\t\t\t\t\t\t</CardHeader>\n\t\t\t\t\t\t\t\t<CardFooter className=\"justify-end\">\n\t\t\t\t\t\t\t\t\t<Button variant=\"destructive\" onClick={() => setDeleteOpen(true)}>\n\t\t\t\t\t\t\t\t\t\tDelete account\n\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t</CardFooter>\n\t\t\t\t\t\t\t</Card>\n\t\t\t\t\t\t</Stack>\n\t\t\t\t\t</Col>\n\t\t\t\t</Columns>\n\t\t\t</Page>\n\t\t\t<DestructiveActionModal\n\t\t\t\topen={deleteOpen}\n\t\t\t\tonOpenChange={setDeleteOpen}\n\t\t\t\ttitle=\"Delete your account?\"\n\t\t\t\tdescription=\"This permanently removes your profile, 12 editions, and sales history. Pending payouts are still sent. This can't be undone.\"\n\t\t\t\tconfirmLabel=\"Delete account\"\n\t\t\t\tconfirmingLabel=\"Deleting…\"\n\t\t\t\tconfirmText=\"ada\"\n\t\t\t\tonConfirm={() => setDeleteOpen(false)}\n\t\t\t/>\n\t\t</AppShell>\n\t)\n}"
|
|
19158
20414
|
}
|
|
19159
20415
|
]
|
|
19160
20416
|
},
|
|
@@ -19507,8 +20763,8 @@
|
|
|
19507
20763
|
"Every icon-only Button carries aria-label, and play/pause's label follows its state.",
|
|
19508
20764
|
"The Slider carries aria-label=\"Position\". Give it aria-valuetext in your player (\"1:24 of 3:40\") so the position is read as time, not a percentage."
|
|
19509
20765
|
],
|
|
19510
|
-
"imports": "import { useState } from \"react\"\nimport { Button, Card, CardContent, cn, Entity,
|
|
19511
|
-
"code": "
|
|
20766
|
+
"imports": "import { useState } from \"react\"\nimport { Button, Card, CardContent, cn, Entity, Icon, MediaImage, Slider } from \"@sableui/react\"",
|
|
20767
|
+
"code": "// `cover` is the artwork URL; left empty, the cover paints art seeded by the title.\nconst track = { title: \"Low tide, high noon\", artist: \"Juno Avery\", cover: \"\", duration: 220, position: 84 }\n\n/** Seconds → m:ss, the way the readout and the screen reader both say it. */\nfunction formatTime(seconds: number) {\n\treturn `${Math.floor(seconds / 60)}:${String(Math.floor(seconds % 60)).padStart(2, \"0\")}`\n}\n\n\nexport interface MediaPlayerBlockProps {\n\tclassName?: string\n\t/** false drops the card's surface and border — for a Sheet or muted section that already frames it. */\n\tframed?: boolean\n\t/** Called with the new playing state. */\n\tonPlayingChange?: (playing: boolean) => void\n\tonPrevious?: () => void\n\tonNext?: () => void\n}\n\nexport function MediaPlayerBlock({ className, framed = true, onPlayingChange, onPrevious, onNext }: MediaPlayerBlockProps) {\n\tconst [playing, setPlaying] = useState(true)\n\tconst [position, setPosition] = useState(track.position)\n\treturn (\n\t\t<Card variant={framed ? \"default\" : \"flat\"} className={cn(\"w-full max-w-xs\", className)}>\n\t\t\t<CardContent className=\"flex flex-col gap-4\">\n\t\t\t\t{/* Entity's own row padding is for lists; inside a card's content the\n\t\t\t\t card already pads it. */}\n\t\t\t\t<Entity\n\t\t\t\t\tclassName=\"p-0\"\n\t\t\t\t\tleading={<MediaImage src={track.cover} seed={track.title} className=\"size-14 shrink-0 rounded-md\" />}\n\t\t\t\t\ttitle={track.title}\n\t\t\t\t\tsubtitle={track.artist}\n\t\t\t\t\taction={\n\t\t\t\t\t\t<Button variant=\"ghost\" size=\"icon-sm\" aria-label=\"Like\">\n\t\t\t\t\t\t\t<Icon name=\"heart\" />\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t}\n\t\t\t\t/>\n\t\t\t\t<div className=\"flex flex-col gap-1.5\">\n\t\t\t\t\t<Slider\n\t\t\t\t\t\tvalue={position}\n\t\t\t\t\t\tmax={track.duration}\n\t\t\t\t\t\tonValueChange={setPosition}\n\t\t\t\t\t\taria-label=\"Position\"\n\t\t\t\t\t\t// Spoken as a time, not \"38\": the number alone means nothing.\n\t\t\t\t\t\tgetAriaValueText={(_, value) => `${formatTime(value)} of ${formatTime(track.duration)}`}\n\t\t\t\t\t/>\n\t\t\t\t\t<div className=\"flex justify-between text-mono-caption text-muted-foreground\">\n\t\t\t\t\t\t<span>{formatTime(position)}</span>\n\t\t\t\t\t\t<span>-{formatTime(track.duration - position)}</span>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<div className=\"flex items-center justify-center gap-3\">\n\t\t\t\t\t<Button variant=\"ghost\" size=\"icon\" aria-label=\"Previous\" onClick={onPrevious}>\n\t\t\t\t\t\t<Icon name=\"previous\" variant=\"solid\" />\n\t\t\t\t\t</Button>\n\t\t\t\t\t<Button\n\t\t\t\t\t\tsize=\"icon\"\n\t\t\t\t\t\taria-label={playing ? \"Pause\" : \"Play\"}\n\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\tsetPlaying(!playing)\n\t\t\t\t\t\t\tonPlayingChange?.(!playing)\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t<Icon name={playing ? \"pause\" : \"play\"} variant=\"solid\" />\n\t\t\t\t\t</Button>\n\t\t\t\t\t<Button variant=\"ghost\" size=\"icon\" aria-label=\"Next\" onClick={onNext}>\n\t\t\t\t\t\t<Icon name=\"next\" variant=\"solid\" />\n\t\t\t\t\t</Button>\n\t\t\t\t</div>\n\t\t\t</CardContent>\n\t\t</Card>\n\t)\n}"
|
|
19512
20768
|
},
|
|
19513
20769
|
{
|
|
19514
20770
|
"name": "NewsletterBlock",
|
|
@@ -19681,8 +20937,8 @@
|
|
|
19681
20937
|
"The placeholder art is aria-hidden. A real product image needs alt text naming the product.",
|
|
19682
20938
|
"The card is not a link: it holds its own button. To make the whole card navigate, move the action to the detail page and render the Card as an <a>."
|
|
19683
20939
|
],
|
|
19684
|
-
"imports": "import { Badge, Button, Card, CardAction, CardDescription, CardFooter, CardHeader, CardTitle, cn,
|
|
19685
|
-
"code": "
|
|
20940
|
+
"imports": "import { AspectBox, Badge, Button, Card, CardAction, CardDescription, CardFooter, CardHeader, CardTitle, cn, MediaImage } from \"@sableui/react\"",
|
|
20941
|
+
"code": "// `image` is the product shot URL; left empty, it paints art seeded by the title.\nconst product = { title: \"Untitled (gold rush)\", maker: \"Mara Okafor\", image: \"\", edition: \"12 of 50\", price: \"0.42 SOL\" }\n\n\nexport interface ProductCardBlockProps {\n\tclassName?: string\n\t/** false drops the card's surface and border — for a muted section that already frames it. */\n\tframed?: boolean\n\t/** The buy action. */\n\tonCollect?: () => void\n}\n\nexport function ProductCardBlock({ className, framed = true, onCollect }: ProductCardBlockProps) {\n\treturn (\n\t\t<Card variant={framed ? \"default\" : \"flat\"} className={cn(\"w-full max-w-xs overflow-hidden\", className)}>\n\t\t\t<AspectBox ratio={4 / 3}>\n\t\t\t\t<MediaImage src={product.image} seed={product.title} />\n\t\t\t</AspectBox>\n\t\t\t<CardHeader>\n\t\t\t\t<CardTitle>{product.title}</CardTitle>\n\t\t\t\t<CardDescription>{product.maker}</CardDescription>\n\t\t\t\t<CardAction>\n\t\t\t\t\t<Badge tone=\"edition\">{product.edition}</Badge>\n\t\t\t\t</CardAction>\n\t\t\t</CardHeader>\n\t\t\t<CardFooter className=\"justify-between pt-4\">\n\t\t\t\t<span className=\"text-mono-lg\">{product.price}</span>\n\t\t\t\t<Button onClick={onCollect}>Collect</Button>\n\t\t\t</CardFooter>\n\t\t</Card>\n\t)\n}"
|
|
19686
20942
|
},
|
|
19687
20943
|
{
|
|
19688
20944
|
"name": "ProfileCardBlock",
|
|
@@ -19878,7 +21134,7 @@
|
|
|
19878
21134
|
"Category Badges carry text; the tone only groups them visually."
|
|
19879
21135
|
],
|
|
19880
21136
|
"imports": "import { useState, useId } from \"react\"\nimport { Badge, Button, Card, CardAction, CardContent, CardFooter, CardHeader, CardTitle, Checkbox, cn, Icon, Label } from \"@sableui/react\"",
|
|
19881
|
-
"code": "const tasks = [\n\t{ title: \"Pick the cover type\", tag: \"Design\", tone: \"collectible\", done: true },\n\t{ title: \"Wire up checkout\", tag: \"Engineering\", tone: \"info\", done: false },\n\t{ title: \"Draft launch post\", tag: \"Launch\", tone: \"edition\", done: false },\n] as const\n\nexport interface TaskListBlockProps {\n\tclassName?: string\n\t/** false drops the card's surface and border — for a muted section that already frames it. */\n\tframed?: boolean\n\tonAddTask?: () => void\n}\n\nexport function TaskListBlock({ className, framed = true, onAddTask }: TaskListBlockProps) {\n\tconst [done, setDone] = useState<boolean[]>(tasks.map((t) => t.done))\n\tconst id = useId()\n\tconst left = done.filter((d) => !d).length\n\treturn (\n\t\t<Card variant={framed ? \"default\" : \"flat\"} className={cn(\"w-full max-w-xs\", className)}>\n\t\t\t<CardHeader>\n\t\t\t\t<CardTitle>This week</CardTitle>\n\t\t\t\t<CardAction>\n\t\t\t\t\t<span className=\"text-caption text-muted-foreground\">{left} left</span>\n\t\t\t\t</CardAction>\n\t\t\t</CardHeader>\n\t\t\t<CardContent>\n\t\t\t\t<ul className=\"flex flex-col gap-1\">\n\t\t\t\t\t{tasks.map((task, i) => (\n\t\t\t\t\t\t<li key={task.title} className=\"flex items-center gap-3 py-1.5\">\n\t\t\t\t\t\t\t<Checkbox\n\t\t\t\t\t\t\t\tid={`${id}-${i}`}\n\t\t\t\t\t\t\t\tchecked={done[i]}\n\t\t\t\t\t\t\t\tonCheckedChange={(v: boolean) => setDone(done.map((d, j) => (j === i ? v : d)))}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<Label\n\t\t\t\t\t\t\t\thtmlFor={`${id}-${i}`}\n\t\t\t\t\t\t\t\tclassName={cn(\"min-w-0 flex-1 motion-interactive\", done[i] && \"text-muted-foreground line-through\")}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{task.title}\n\t\t\t\t\t\t\t</Label>\n\t\t\t\t\t\t\t<Badge tone={task.tone} size=\"sm\">\n\t\t\t\t\t\t\t\t{task.tag}\n\t\t\t\t\t\t\t</Badge>\n\t\t\t\t\t\t</li>\n\t\t\t\t\t))}\n\t\t\t\t</ul>\n\t\t\t</CardContent>\n\t\t\t<CardFooter>\n\t\t\t\t<Button variant=\"ghost\" size=\"sm\" iconStart={<Icon name=\"plus\" />} onClick={onAddTask}>\n\t\t\t\t\tAdd task\n\t\t\t\t</Button>\n\t\t\t</CardFooter>\n\t\t</Card>\n\t)\n}"
|
|
21137
|
+
"code": "const tasks = [\n\t{ title: \"Pick the cover type\", tag: \"Design\", tone: \"collectible\", done: true },\n\t{ title: \"Wire up checkout\", tag: \"Engineering\", tone: \"info\", done: false },\n\t{ title: \"Draft launch post\", tag: \"Launch\", tone: \"edition\", done: false },\n] as const\n\nexport interface TaskListBlockProps {\n\tclassName?: string\n\t/** false drops the card's surface and border — for a muted section that already frames it. */\n\tframed?: boolean\n\tonAddTask?: () => void\n}\n\nexport function TaskListBlock({ className, framed = true, onAddTask }: TaskListBlockProps) {\n\tconst [done, setDone] = useState<boolean[]>(tasks.map((t) => t.done))\n\tconst id = useId()\n\tconst left = done.filter((d) => !d).length\n\treturn (\n\t\t<Card variant={framed ? \"default\" : \"flat\"} className={cn(\"w-full max-w-xs\", className)}>\n\t\t\t<CardHeader>\n\t\t\t\t<CardTitle>This week</CardTitle>\n\t\t\t\t<CardAction>\n\t\t\t\t\t<span className=\"text-caption text-muted-foreground\">{left} left</span>\n\t\t\t\t</CardAction>\n\t\t\t</CardHeader>\n\t\t\t<CardContent>\n\t\t\t\t<ul className=\"flex flex-col gap-1\">\n\t\t\t\t\t{tasks.map((task, i) => (\n\t\t\t\t\t\t<li key={task.title} className=\"flex items-center gap-3 py-1.5\">\n\t\t\t\t\t\t\t<Checkbox\n\t\t\t\t\t\t\t\tid={`${id}-${i}`}\n\t\t\t\t\t\t\t\tchecked={done[i]}\n\t\t\t\t\t\t\t\tonCheckedChange={(v: boolean) => setDone(done.map((d, j) => (j === i ? v : d)))}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<Label\n\t\t\t\t\t\t\t\thtmlFor={`${id}-${i}`}\n\t\t\t\t\t\t\t\tclassName={cn(\"min-w-0 flex-1 motion-interactive\", done[i] && \"text-muted-foreground line-through\")}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{task.title}\n\t\t\t\t\t\t\t</Label>\n\t\t\t\t\t\t\t<Badge tone={task.tone} size=\"sm\">\n\t\t\t\t\t\t\t\t{task.tag}\n\t\t\t\t\t\t\t</Badge>\n\t\t\t\t\t\t</li>\n\t\t\t\t\t))}\n\t\t\t\t</ul>\n\t\t\t</CardContent>\n\t\t\t<CardFooter>\n\t\t\t\t<Button variant=\"ghost\" size=\"sm\" bleed=\"start\" iconStart={<Icon name=\"plus\" />} onClick={onAddTask}>\n\t\t\t\t\tAdd task\n\t\t\t\t</Button>\n\t\t\t</CardFooter>\n\t\t</Card>\n\t)\n}"
|
|
19882
21138
|
},
|
|
19883
21139
|
{
|
|
19884
21140
|
"name": "TeamMembersBlock",
|
|
@@ -19943,7 +21199,7 @@
|
|
|
19943
21199
|
"Hovering or arrowing through the slices pulls each out and announces it."
|
|
19944
21200
|
],
|
|
19945
21201
|
"imports": "import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, cn, PieChart } from \"@sableui/react\"",
|
|
19946
|
-
"code": "const sources = [\n\t{ source: \"Search\", visitors: 4280 },\n\t{ source: \"Direct\", visitors: 2910 },\n\t{ source: \"Social\", visitors: 1840 },\n\t{ source: \"Email\", visitors: 960 },\n]\n\nconst total = sources.reduce((sum, s) => sum + s.visitors, 0)\n\nexport interface TrafficSourcesBlockProps {\n\tclassName?: string\n\t/** false drops the card's surface and border — for a muted section that already frames it. */\n\tframed?: boolean\n}\n\nexport function TrafficSourcesBlock({ className, framed = true }: TrafficSourcesBlockProps) {\n\treturn (\n\t\t<Card variant={framed ? \"default\" : \"flat\"} className={cn(\"w-full max-w-sm\", className)}>\n\t\t\t<CardHeader>\n\t\t\t\t<CardTitle render={<h2 />}>Traffic sources</CardTitle>\n\t\t\t\t<CardDescription>Where this month's visitors came from.</CardDescription>\n\t\t\t</CardHeader>\n\t\t\t<CardContent>\n\t\t\t\t<PieChart\n\t\t\t\t\tlabel=\"Visitors by source, this month\"\n\t\t\t\t\tdata={sources}\n\t\t\t\t\tindex=\"source\"\n\t\t\t\t\tvalueKey=\"visitors\"\n\t\t\t\t\tdonut\n\t\t\t\t\tcenterLabel={{ value: total
|
|
21202
|
+
"code": "const sources = [\n\t{ source: \"Search\", visitors: 4280 },\n\t{ source: \"Direct\", visitors: 2910 },\n\t{ source: \"Social\", visitors: 1840 },\n\t{ source: \"Email\", visitors: 960 },\n]\n\nconst total = sources.reduce((sum, s) => sum + s.visitors, 0)\n\nexport interface TrafficSourcesBlockProps {\n\tclassName?: string\n\t/** false drops the card's surface and border — for a muted section that already frames it. */\n\tframed?: boolean\n}\n\nexport function TrafficSourcesBlock({ className, framed = true }: TrafficSourcesBlockProps) {\n\treturn (\n\t\t<Card variant={framed ? \"default\" : \"flat\"} className={cn(\"w-full max-w-sm\", className)}>\n\t\t\t<CardHeader>\n\t\t\t\t<CardTitle render={<h2 />}>Traffic sources</CardTitle>\n\t\t\t\t<CardDescription>Where this month's visitors came from.</CardDescription>\n\t\t\t</CardHeader>\n\t\t\t<CardContent>\n\t\t\t\t<PieChart\n\t\t\t\t\tlabel=\"Visitors by source, this month\"\n\t\t\t\t\tdata={sources}\n\t\t\t\t\tindex=\"source\"\n\t\t\t\t\tvalueKey=\"visitors\"\n\t\t\t\t\tdonut\n\t\t\t\t\tcenterLabel={{ value: total, label: \"Visitors\" }}\n\t\t\t\t/>\n\t\t\t</CardContent>\n\t\t\t<CardFooter className=\"text-body-sm text-muted-foreground\">\n\t\t\t\tSearch brings {Math.round((sources[0].visitors / total) * 100)}% of visitors.\n\t\t\t</CardFooter>\n\t\t</Card>\n\t)\n}"
|
|
19947
21203
|
},
|
|
19948
21204
|
{
|
|
19949
21205
|
"name": "TrafficTrendBlock",
|
|
@@ -19966,7 +21222,7 @@
|
|
|
19966
21222
|
"The range control is a labelled ToggleGroup; the chart is one tab stop with arrow-key navigation."
|
|
19967
21223
|
],
|
|
19968
21224
|
"imports": "import { useState } from \"react\"\nimport { AreaChart, Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, cn, Icon, Toggle, ToggleGroup } from \"@sableui/react\"",
|
|
19969
|
-
"code": "/** 90 days of visitors — replace with your own rows ({ date, desktop, mobile }). */\nconst days = Array.from({ length: 90 }, (_, i) => {\n\tconst wave = Math.sin(i / 6) * 60 + Math.sin(i / 2.3) * 25\n\treturn {\n\t\tdate: new Date(2026, 6, 1 + i).toISOString().slice(0, 10),\n\t\tdesktop: Math.round(240 + wave + (i % 7) * 9),\n\t\tmobile: Math.round(160 + wave * 0.7 + ((i * 3) % 11) * 6),\n\t}\n})\n\nconst series = [\n\t{ key: \"desktop\", label: \"Desktop\" },\n\t{ key: \"mobile\", label: \"Mobile\" },\n]\n\nconst ranges = [\n\t{ id: \"90d\", label: \"3 months\", days: 90 },\n\t{ id: \"30d\", label: \"30 days\", days: 30 },\n\t{ id: \"7d\", label: \"7 days\", days: 7 },\n]\n\nconst shortDate = (iso: string) => new Date(`${iso}T00:00:00`).toLocaleDateString(\"en-US\", { month: \"short\", day: \"numeric\" })\n\nexport interface TrafficTrendBlockProps {\n\tclassName?: string\n\t/** false drops the card's surface and border — for a muted section that already frames it. */\n\tframed?: boolean\n}\n\nexport function TrafficTrendBlock({ className, framed = true }: TrafficTrendBlockProps) {\n\tconst [range, setRange] = useState(\"90d\")\n\tconst shown = days.slice(-(ranges.find((r) => r.id === range)?.days ?? 90))\n\treturn (\n\t\t<Card variant={framed ? \"default\" : \"flat\"} className={cn(\"w-full max-w-3xl\", className)}>\n\t\t\t<CardHeader>\n\t\t\t\t<CardTitle render={<h2 />}>Visitors</CardTitle>\n\t\t\t\t<CardDescription>Desktop and mobile, by day.</CardDescription>\n\t\t\t\t<CardAction>\n\t\t\t\t\t<ToggleGroup value={[range]} onValueChange={(v: unknown[]) => v[0] && setRange(String(v[0]))} aria-label=\"Time range\">\n\t\t\t\t\t\t{ranges.map((r) => (\n\t\t\t\t\t\t\t<Toggle key={r.id} value={r.id}>\n\t\t\t\t\t\t\t\t{r.label}\n\t\t\t\t\t\t\t</Toggle>\n\t\t\t\t\t\t))}\n\t\t\t\t\t</ToggleGroup>\n\t\t\t\t</CardAction>\n\t\t\t</CardHeader>\n\t\t\t<CardContent>\n\t\t\t\t<AreaChart\n\t\t\t\t\tlabel={`Visitors per day, last ${ranges.find((r) => r.id === range)?.label}`}\n\t\t\t\t\tdata={shown}\n\t\t\t\t\tindex=\"date\"\n\t\t\t\t\tseries={series}\n\t\t\t\t\tindexFormatter={shortDate}\n\t\t\t\t\ttooltip={{ labelFormatter: (d) => new Date(`${d}T00:00:00`).toLocaleDateString(\"en-US\", { weekday: \"short\", month: \"short\", day: \"numeric\" }) }}\n\t\t\t\t\tstacked\n\t\t\t\t\tgradient\n\t\t\t\t\tclassName=\"h-64\"\n\t\t\t\t/>\n\t\t\t</CardContent>\n\t\t\t<CardFooter className=\"gap-1.5 text-body-sm\">\n\t\t\t\t<Icon name=\"arrow-up\" className=\"text-success\" />\n\t\t\t\t<span>\n\t\t\t\t\tUp 5.2% on the previous period <span className=\"text-muted-foreground\">· desktop leads every week</span>\n\t\t\t\t</span>\n\t\t\t</CardFooter>\n\t\t</Card>\n\t)\n}"
|
|
21225
|
+
"code": "/** 90 days of visitors — replace with your own rows ({ date, desktop, mobile }). */\nconst days = Array.from({ length: 90 }, (_, i) => {\n\tconst wave = Math.sin(i / 6) * 60 + Math.sin(i / 2.3) * 25\n\treturn {\n\t\tdate: new Date(2026, 6, 1 + i).toISOString().slice(0, 10),\n\t\tdesktop: Math.round(240 + wave + (i % 7) * 9),\n\t\tmobile: Math.round(160 + wave * 0.7 + ((i * 3) % 11) * 6),\n\t}\n})\n\nconst series = [\n\t{ key: \"desktop\", label: \"Desktop\" },\n\t{ key: \"mobile\", label: \"Mobile\" },\n]\n\nconst ranges = [\n\t{ id: \"90d\", label: \"3 months\", days: 90 },\n\t{ id: \"30d\", label: \"30 days\", days: 30 },\n\t{ id: \"7d\", label: \"7 days\", days: 7 },\n]\n\nconst shortDate = (iso: string) => new Date(`${iso}T00:00:00`).toLocaleDateString(\"en-US\", { month: \"short\", day: \"numeric\" })\n\nexport interface TrafficTrendBlockProps {\n\tclassName?: string\n\t/** false drops the card's surface and border — for a muted section that already frames it. */\n\tframed?: boolean\n}\n\nexport function TrafficTrendBlock({ className, framed = true }: TrafficTrendBlockProps) {\n\tconst [range, setRange] = useState(\"90d\")\n\tconst shown = days.slice(-(ranges.find((r) => r.id === range)?.days ?? 90))\n\treturn (\n\t\t<Card variant={framed ? \"default\" : \"flat\"} className={cn(\"@container/trend w-full max-w-3xl\", className)}>\n\t\t\t<CardHeader className=\"@max-sm/trend:has-data-[slot=card-action]:grid-cols-1\">\n\t\t\t\t<CardTitle render={<h2 />}>Visitors</CardTitle>\n\t\t\t\t<CardDescription>Desktop and mobile, by day.</CardDescription>\n\t\t\t\t{/* Beside the title on a wide card; under it on a narrow one, where three\n\t\t\t\t ranges would squeeze the title and description into a sliver. */}\n\t\t\t\t<CardAction className=\"@max-sm/trend:col-start-1 @max-sm/trend:row-span-1 @max-sm/trend:row-start-auto @max-sm/trend:mt-2 @max-sm/trend:justify-self-start\">\n\t\t\t\t\t<ToggleGroup value={[range]} onValueChange={(v: unknown[]) => v[0] && setRange(String(v[0]))} aria-label=\"Time range\">\n\t\t\t\t\t\t{ranges.map((r) => (\n\t\t\t\t\t\t\t<Toggle key={r.id} value={r.id}>\n\t\t\t\t\t\t\t\t{r.label}\n\t\t\t\t\t\t\t</Toggle>\n\t\t\t\t\t\t))}\n\t\t\t\t\t</ToggleGroup>\n\t\t\t\t</CardAction>\n\t\t\t</CardHeader>\n\t\t\t<CardContent>\n\t\t\t\t<AreaChart\n\t\t\t\t\tlabel={`Visitors per day, last ${ranges.find((r) => r.id === range)?.label}`}\n\t\t\t\t\tdata={shown}\n\t\t\t\t\tindex=\"date\"\n\t\t\t\t\tseries={series}\n\t\t\t\t\tindexFormatter={shortDate}\n\t\t\t\t\ttooltip={{ labelFormatter: (d) => new Date(`${d}T00:00:00`).toLocaleDateString(\"en-US\", { weekday: \"short\", month: \"short\", day: \"numeric\" }) }}\n\t\t\t\t\tstacked\n\t\t\t\t\tgradient\n\t\t\t\t\tclassName=\"h-64\"\n\t\t\t\t/>\n\t\t\t</CardContent>\n\t\t\t<CardFooter className=\"gap-1.5 text-body-sm\">\n\t\t\t\t<Icon name=\"arrow-up\" className=\"text-success\" />\n\t\t\t\t<span>\n\t\t\t\t\tUp 5.2% on the previous period <span className=\"text-muted-foreground\">· desktop leads every week</span>\n\t\t\t\t</span>\n\t\t\t</CardFooter>\n\t\t</Card>\n\t)\n}"
|
|
19970
21226
|
},
|
|
19971
21227
|
{
|
|
19972
21228
|
"name": "UploadListBlock",
|
|
@@ -20012,7 +21268,7 @@
|
|
|
20012
21268
|
"Field labels the group and reads the expiry as its description."
|
|
20013
21269
|
],
|
|
20014
21270
|
"imports": "import { Button, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, cn, Field, OtpField } from \"@sableui/react\"",
|
|
20015
|
-
"code": "const sentTo = \"ada@studio.com\"\n\nexport interface VerifyCodeBlockProps {\n\tclassName?: string\n\t/** false drops the card's surface and border — for a Sheet, Dialog or muted section that already frames it. */\n\tframed?: boolean\n\t/** Called with the code as soon as the last digit is entered. */\n\tonVerify?: (code: string) => void\n\tonResend?: () => void\n}\n\nexport function VerifyCodeBlock({ className, framed = true, onVerify, onResend }: VerifyCodeBlockProps) {\n\treturn (\n\t\t<Card variant={framed ? \"default\" : \"flat\"} className={cn(\"w-full max-w-sm\", className)}>\n\t\t\t<CardHeader>\n\t\t\t\t<CardTitle>Check your email</CardTitle>\n\t\t\t\t<CardDescription>\n\t\t\t\t\tWe sent a 6-digit code to <span className=\"text-foreground\">{sentTo}</span>.\n\t\t\t\t</CardDescription>\n\t\t\t</CardHeader>\n\t\t\t<CardContent>\n\t\t\t\t<Field label=\"Verification code\" description=\"The code expires in 10 minutes.\">\n\t\t\t\t\t<OtpField length={6} groupSize={3} onValueComplete={(code: string) => onVerify?.(code)} />\n\t\t\t\t</Field>\n\t\t\t</CardContent>\n\t\t\t<CardFooter className=\"justify-between gap-3\">\n\t\t\t\t<span className=\"text-caption text-muted-foreground\">Didn’t get it?</span>\n\t\t\t\t<Button variant=\"ghost\" size=\"sm\" onClick={onResend}>\n\t\t\t\t\tResend code\n\t\t\t\t</Button>\n\t\t\t</CardFooter>\n\t\t</Card>\n\t)\n}"
|
|
21271
|
+
"code": "const sentTo = \"ada@studio.com\"\n\nexport interface VerifyCodeBlockProps {\n\tclassName?: string\n\t/** false drops the card's surface and border — for a Sheet, Dialog or muted section that already frames it. */\n\tframed?: boolean\n\t/** Called with the code as soon as the last digit is entered. */\n\tonVerify?: (code: string) => void\n\tonResend?: () => void\n}\n\nexport function VerifyCodeBlock({ className, framed = true, onVerify, onResend }: VerifyCodeBlockProps) {\n\treturn (\n\t\t<Card variant={framed ? \"default\" : \"flat\"} className={cn(\"w-full max-w-sm\", className)}>\n\t\t\t<CardHeader>\n\t\t\t\t<CardTitle>Check your email</CardTitle>\n\t\t\t\t<CardDescription>\n\t\t\t\t\tWe sent a 6-digit code to <span className=\"text-foreground\">{sentTo}</span>.\n\t\t\t\t</CardDescription>\n\t\t\t</CardHeader>\n\t\t\t<CardContent>\n\t\t\t\t<Field label=\"Verification code\" description=\"The code expires in 10 minutes.\">\n\t\t\t\t\t<OtpField length={6} groupSize={3} onValueComplete={(code: string) => onVerify?.(code)} />\n\t\t\t\t</Field>\n\t\t\t</CardContent>\n\t\t\t<CardFooter className=\"justify-between gap-3\">\n\t\t\t\t<span className=\"text-caption text-muted-foreground\">Didn’t get it?</span>\n\t\t\t\t<Button variant=\"ghost\" size=\"sm\" bleed=\"end\" onClick={onResend}>\n\t\t\t\t\tResend code\n\t\t\t\t</Button>\n\t\t\t</CardFooter>\n\t\t</Card>\n\t)\n}"
|
|
20016
21272
|
},
|
|
20017
21273
|
{
|
|
20018
21274
|
"name": "WalletBlock",
|
|
@@ -21040,11 +22296,10 @@
|
|
|
21040
22296
|
}
|
|
21041
22297
|
],
|
|
21042
22298
|
"utilities": {
|
|
21043
|
-
"about": "The CSS layer between tokens and components: relative surfaces that derive hover and well fills from the surface they sit in, frames that compute concentric inner radii, state variants that match Base UI data attributes and ARIA alike, scroll policies,
|
|
22299
|
+
"about": "The CSS layer between tokens and components: relative surfaces that derive hover and well fills from the surface they sit in, frames that compute concentric inner radii, state variants that match Base UI data attributes and ARIA alike, scroll policies, chrome type tiers, and the motion recipes. Reach for these before hand-writing CSS, mixing colours or writing a transition. Generated from src/utilities/utility-docs.ts — do not hand-edit.",
|
|
21044
22300
|
"retrieve": "sable utility <name>",
|
|
21045
22301
|
"elsewhere": {
|
|
21046
|
-
"typography": "The paired `text-*` page tiers (`text-heading-1`, `text-body-md`, `text-label-sm`, `text-mono-md`, …) are documented as `registry.json → tokens.typography` (named without the `text-` prefix). `sable token typography`."
|
|
21047
|
-
"motion": "The `motion-*` and `animate-*` recipes are listed in `registry.json → tokens.motion.recipes`, with the slot model in `registry.json → motionLayer`. `sable docs motion`."
|
|
22302
|
+
"typography": "The paired `text-*` page tiers (`text-heading-1`, `text-body-md`, `text-label-sm`, `text-mono-md`, …) are documented as `registry.json → tokens.typography` (named without the `text-` prefix). Retrieve one with `sable utility text-heading-1`, all with `sable token typography`."
|
|
21048
22303
|
},
|
|
21049
22304
|
"families": [
|
|
21050
22305
|
"relative surface",
|
|
@@ -21053,6 +22308,7 @@
|
|
|
21053
22308
|
"state variant",
|
|
21054
22309
|
"scroll",
|
|
21055
22310
|
"text",
|
|
22311
|
+
"motion",
|
|
21056
22312
|
"helper"
|
|
21057
22313
|
],
|
|
21058
22314
|
"items": [
|
|
@@ -21687,32 +22943,495 @@
|
|
|
21687
22943
|
"insteadOf": "`text-ui-sm` (proportional digits make a column jitter while a slider drags) or `text-mono-sm`, which is page-register.",
|
|
21688
22944
|
"example": "<span className=\"text-ui-num\">128</span>",
|
|
21689
22945
|
"keywords": [
|
|
21690
|
-
"number",
|
|
21691
|
-
"tabular numbers",
|
|
21692
|
-
"numeric value",
|
|
21693
|
-
"readout",
|
|
21694
|
-
"monospace number"
|
|
22946
|
+
"number",
|
|
22947
|
+
"tabular numbers",
|
|
22948
|
+
"numeric value",
|
|
22949
|
+
"readout",
|
|
22950
|
+
"monospace number"
|
|
22951
|
+
]
|
|
22952
|
+
},
|
|
22953
|
+
{
|
|
22954
|
+
"name": "stream-tail",
|
|
22955
|
+
"kind": "utility",
|
|
22956
|
+
"family": "text",
|
|
22957
|
+
"intent": "The soft trailing edge of a live text stream: a slight blur plus a fading mask, so the newest token reads as 'more is coming' rather than a sentence that has ended. Static.",
|
|
22958
|
+
"whenToUse": "On the last run of text while a model or source is still streaming; remove it when the stream finishes. `StreamingText` applies it for you.",
|
|
22959
|
+
"insteadOf": "An animated fade or a hand-written `mask-image` gradient. Use `StreamingText` first; this is for a stream rendered without it.",
|
|
22960
|
+
"example": "<span className=\"stream-tail\">{latestChunk}</span>",
|
|
22961
|
+
"keywords": [
|
|
22962
|
+
"streaming",
|
|
22963
|
+
"stream edge",
|
|
22964
|
+
"typing",
|
|
22965
|
+
"llm output",
|
|
22966
|
+
"fade tail",
|
|
22967
|
+
"generating text"
|
|
22968
|
+
],
|
|
22969
|
+
"pairsWith": [
|
|
22970
|
+
"motion-stream-in",
|
|
22971
|
+
"motion-caret"
|
|
22972
|
+
]
|
|
22973
|
+
},
|
|
22974
|
+
{
|
|
22975
|
+
"name": "motion-interactive",
|
|
22976
|
+
"kind": "utility",
|
|
22977
|
+
"family": "motion",
|
|
22978
|
+
"intent": "The transition for interactive chrome: colour, background, border, shadow, opacity, fill, stroke, scale and translate at the fast duration and standard easing. Also removes the mobile tap delay and the platform's grey tap flash.",
|
|
22979
|
+
"whenToUse": "On anything clickable that changes on hover, focus, press or selection: a custom row, a tile, a link, an app-owned button. It is the `response` slot, and the only transition declarer such an element needs.",
|
|
22980
|
+
"insteadOf": "Tailwind's colour-only or all-property transition utilities with a hand-picked duration. Those ignore --motion-scale, skip the transform properties `motion-press` needs, and leave the 300ms tap delay on touch screens. sable-lint's `static-interactive` fires on a hover style with no recipe.",
|
|
22981
|
+
"example": "<a href=\"/inbox\" className=\"motion-interactive link-underline text-primary\">Inbox</a>",
|
|
22982
|
+
"keywords": [
|
|
22983
|
+
"transition",
|
|
22984
|
+
"hover transition",
|
|
22985
|
+
"hover animation",
|
|
22986
|
+
"focus transition",
|
|
22987
|
+
"interactive",
|
|
22988
|
+
"tap delay",
|
|
22989
|
+
"response"
|
|
22990
|
+
],
|
|
22991
|
+
"pairsWith": [
|
|
22992
|
+
"motion-press",
|
|
22993
|
+
"sable-surface-interactive"
|
|
22994
|
+
]
|
|
22995
|
+
},
|
|
22996
|
+
{
|
|
22997
|
+
"name": "motion-press",
|
|
22998
|
+
"kind": "utility",
|
|
22999
|
+
"family": "motion",
|
|
23000
|
+
"intent": "A small tactile settle (scale 0.97) while the element is pressed. Skips disabled elements. Declares only the pressed state, not a transition.",
|
|
23001
|
+
"whenToUse": "A pressable surface that is not a Sable Button (Button already has it): a tile, a card that acts as a button, an icon control.",
|
|
23002
|
+
"insteadOf": "An `active:` scale utility with its own transition, which fights `motion-interactive` for the transition property and keeps animating with motion off.",
|
|
23003
|
+
"example": "<button type=\"button\" className=\"motion-interactive motion-press sable-surface sable-frame sable-frame-r-lg sable-frame-p-4\">Open</button>",
|
|
23004
|
+
"keywords": [
|
|
23005
|
+
"press",
|
|
23006
|
+
"pressed state",
|
|
23007
|
+
"tap feedback",
|
|
23008
|
+
"active scale",
|
|
23009
|
+
"tactile",
|
|
23010
|
+
"click feedback"
|
|
23011
|
+
],
|
|
23012
|
+
"pairsWith": [
|
|
23013
|
+
"motion-interactive"
|
|
23014
|
+
]
|
|
23015
|
+
},
|
|
23016
|
+
{
|
|
23017
|
+
"name": "motion-fade",
|
|
23018
|
+
"kind": "utility",
|
|
23019
|
+
"family": "motion",
|
|
23020
|
+
"intent": "Opacity-only enter and exit, keyed to Base UI's `data-starting-style` / `data-ending-style`: fades in at the enter duration, out at the exit duration.",
|
|
23021
|
+
"whenToUse": "Scrims, backdrops and overlays that should appear without moving. The element rests visible; the data attributes supply the from and to.",
|
|
23022
|
+
"insteadOf": "A keyframe fade or a conditional opacity class, which cannot animate the exit of an element Base UI is unmounting.",
|
|
23023
|
+
"example": "<div className=\"motion-fade fixed inset-0 bg-overlay\" />",
|
|
23024
|
+
"keywords": [
|
|
23025
|
+
"fade",
|
|
23026
|
+
"backdrop",
|
|
23027
|
+
"scrim",
|
|
23028
|
+
"overlay fade",
|
|
23029
|
+
"enter exit",
|
|
23030
|
+
"opacity transition"
|
|
23031
|
+
]
|
|
23032
|
+
},
|
|
23033
|
+
{
|
|
23034
|
+
"name": "motion-pop",
|
|
23035
|
+
"kind": "utility",
|
|
23036
|
+
"family": "motion",
|
|
23037
|
+
"intent": "Enter with a slight scale from the anchor edge (spring easing) and exit faster without overshoot, keyed to `data-starting-style` / `data-ending-style`. Scales from Base UI's `--transform-origin`.",
|
|
23038
|
+
"whenToUse": "Anchored or centred floating surfaces: popovers, menus, selects, tooltips, dialogs, command menus. Sable's own overlays already carry it.",
|
|
23039
|
+
"insteadOf": "A custom zoom keyframe, or a spring on exit, which draws the eye to what the user just dismissed.",
|
|
23040
|
+
"example": "<div className=\"motion-pop sable-surface-popover bg-popover text-popover-foreground rounded-lg shadow-lg\">…</div>",
|
|
23041
|
+
"keywords": [
|
|
23042
|
+
"pop",
|
|
23043
|
+
"popover animation",
|
|
23044
|
+
"menu animation",
|
|
23045
|
+
"dialog animation",
|
|
23046
|
+
"scale in",
|
|
23047
|
+
"zoom",
|
|
23048
|
+
"arrival"
|
|
23049
|
+
],
|
|
23050
|
+
"pairsWith": [
|
|
23051
|
+
"motion-fade"
|
|
23052
|
+
]
|
|
23053
|
+
},
|
|
23054
|
+
{
|
|
23055
|
+
"name": "motion-slide-up",
|
|
23056
|
+
"kind": "utility",
|
|
23057
|
+
"family": "motion",
|
|
23058
|
+
"intent": "Fade plus a 0.5rem rise on enter, reversed faster on exit, keyed to `data-starting-style` / `data-ending-style`. The suffix is the travel direction on enter.",
|
|
23059
|
+
"whenToUse": "A surface arriving from below: a toast at the bottom of the screen, a bottom bar, a banner under its trigger.",
|
|
23060
|
+
"insteadOf": "A translate keyframe with a literal duration, which keeps moving under reduced motion.",
|
|
23061
|
+
"example": "<div className=\"motion-slide-up sable-surface rounded-lg\">Saved</div>",
|
|
23062
|
+
"keywords": [
|
|
23063
|
+
"slide up",
|
|
23064
|
+
"rise",
|
|
23065
|
+
"toast animation",
|
|
23066
|
+
"enter from bottom",
|
|
23067
|
+
"directional"
|
|
23068
|
+
],
|
|
23069
|
+
"pairsWith": [
|
|
23070
|
+
"motion-slide-down",
|
|
23071
|
+
"motion-slide-left",
|
|
23072
|
+
"motion-slide-right"
|
|
23073
|
+
]
|
|
23074
|
+
},
|
|
23075
|
+
{
|
|
23076
|
+
"name": "motion-slide-down",
|
|
23077
|
+
"kind": "utility",
|
|
23078
|
+
"family": "motion",
|
|
23079
|
+
"intent": "Fade plus a 0.5rem drop on enter, reversed faster on exit, keyed to `data-starting-style` / `data-ending-style`.",
|
|
23080
|
+
"whenToUse": "A surface arriving from above: a top toast, a dropdown panel that reads better sliding than scaling.",
|
|
23081
|
+
"insteadOf": "A translate keyframe with a literal duration.",
|
|
23082
|
+
"example": "<div className=\"motion-slide-down sable-surface rounded-lg\">Connected</div>",
|
|
23083
|
+
"keywords": [
|
|
23084
|
+
"slide down",
|
|
23085
|
+
"drop in",
|
|
23086
|
+
"enter from top",
|
|
23087
|
+
"dropdown animation",
|
|
23088
|
+
"directional"
|
|
23089
|
+
],
|
|
23090
|
+
"pairsWith": [
|
|
23091
|
+
"motion-slide-up"
|
|
23092
|
+
]
|
|
23093
|
+
},
|
|
23094
|
+
{
|
|
23095
|
+
"name": "motion-slide-left",
|
|
23096
|
+
"kind": "utility",
|
|
23097
|
+
"family": "motion",
|
|
23098
|
+
"intent": "Fade plus a 0.5rem travel leftward on enter (it starts offset to the right), reversed faster on exit.",
|
|
23099
|
+
"whenToUse": "A surface arriving from the right edge: a side panel, a right-anchored toast.",
|
|
23100
|
+
"insteadOf": "A translate keyframe with a literal duration.",
|
|
23101
|
+
"example": "<aside className=\"motion-slide-left sable-surface\">…</aside>",
|
|
23102
|
+
"keywords": [
|
|
23103
|
+
"slide left",
|
|
23104
|
+
"enter from right",
|
|
23105
|
+
"side panel animation",
|
|
23106
|
+
"directional"
|
|
23107
|
+
],
|
|
23108
|
+
"pairsWith": [
|
|
23109
|
+
"motion-slide-right"
|
|
23110
|
+
]
|
|
23111
|
+
},
|
|
23112
|
+
{
|
|
23113
|
+
"name": "motion-slide-right",
|
|
23114
|
+
"kind": "utility",
|
|
23115
|
+
"family": "motion",
|
|
23116
|
+
"intent": "Fade plus a 0.5rem travel rightward on enter (it starts offset to the left), reversed faster on exit.",
|
|
23117
|
+
"whenToUse": "A surface arriving from the left edge: a navigation drawer, a left-anchored toast.",
|
|
23118
|
+
"insteadOf": "A translate keyframe with a literal duration.",
|
|
23119
|
+
"example": "<nav className=\"motion-slide-right sable-surface\">…</nav>",
|
|
23120
|
+
"keywords": [
|
|
23121
|
+
"slide right",
|
|
23122
|
+
"enter from left",
|
|
23123
|
+
"drawer animation",
|
|
23124
|
+
"directional"
|
|
23125
|
+
],
|
|
23126
|
+
"pairsWith": [
|
|
23127
|
+
"motion-slide-left"
|
|
23128
|
+
]
|
|
23129
|
+
},
|
|
23130
|
+
{
|
|
23131
|
+
"name": "motion-disclosure",
|
|
23132
|
+
"kind": "utility",
|
|
23133
|
+
"family": "motion",
|
|
23134
|
+
"intent": "Height plus fade for a panel that opens and closes: carries the overflow, the transition and the content fade. The component sets the target height and zeroes it on the starting/ending style.",
|
|
23135
|
+
"whenToUse": "A hand-built expanding region. Accordion and Collapsible already use it; reach for them first.",
|
|
23136
|
+
"insteadOf": "Animating max-height to a guessed number, or a show/hide with no transition. Height is the one sanctioned non-compositor property.",
|
|
23137
|
+
"example": "<div className=\"motion-disclosure h-[var(--collapsible-panel-height)]\">…</div>",
|
|
23138
|
+
"keywords": [
|
|
23139
|
+
"expand",
|
|
23140
|
+
"collapse",
|
|
23141
|
+
"accordion animation",
|
|
23142
|
+
"height transition",
|
|
23143
|
+
"disclosure",
|
|
23144
|
+
"reveal panel"
|
|
23145
|
+
]
|
|
23146
|
+
},
|
|
23147
|
+
{
|
|
23148
|
+
"name": "motion-icon-swap",
|
|
23149
|
+
"kind": "utility",
|
|
23150
|
+
"family": "motion",
|
|
23151
|
+
"intent": "Cross-fades two glyphs stacked in one cell when a control's icon changes with its state: the outgoing one shrinks and blurs out, the incoming one resolves in. The inactive glyph is the one marked `aria-hidden=\"true\"` (or `data-active=\"false\"`).",
|
|
23152
|
+
"whenToUse": "Play/pause, mute/unmute, outline/fill, copy/check, menu/close: one control whose icon changes. Keep both icons mounted; the correct accessibility markup is also the animation hook.",
|
|
23153
|
+
"insteadOf": "Rendering one icon or the other (it snaps between two frames), or absolutely positioning the second icon, which collapses the wrapper. Under reduced motion the swap is instant and the glyph still changes.",
|
|
23154
|
+
"example": "<span className=\"motion-icon-swap\"><Icon name=\"play\" aria-hidden={playing} /><Icon name=\"pause\" aria-hidden={!playing} /></span>",
|
|
23155
|
+
"keywords": [
|
|
23156
|
+
"icon swap",
|
|
23157
|
+
"icon transition",
|
|
23158
|
+
"play pause",
|
|
23159
|
+
"toggle icon",
|
|
23160
|
+
"morph icon",
|
|
23161
|
+
"copy check",
|
|
23162
|
+
"icon animation"
|
|
23163
|
+
],
|
|
23164
|
+
"pairsWith": [
|
|
23165
|
+
"motion-interactive"
|
|
23166
|
+
]
|
|
23167
|
+
},
|
|
23168
|
+
{
|
|
23169
|
+
"name": "motion-spin",
|
|
23170
|
+
"kind": "utility",
|
|
23171
|
+
"family": "motion",
|
|
23172
|
+
"intent": "A continuous rotation for a spinner glyph, scaled by --motion-scale so reduced motion freezes it.",
|
|
23173
|
+
"whenToUse": "A loading glyph that is not the Spinner component (use Spinner first), such as an icon inside a busy button.",
|
|
23174
|
+
"insteadOf": "Tailwind's built-in spin animation, which ignores --motion-scale. sable-lint's `builtin-animate` flags it.",
|
|
23175
|
+
"example": "<Icon name=\"spinner\" className=\"motion-spin\" />",
|
|
23176
|
+
"keywords": [
|
|
23177
|
+
"spinner",
|
|
23178
|
+
"spin",
|
|
23179
|
+
"loading",
|
|
23180
|
+
"rotate",
|
|
23181
|
+
"busy",
|
|
23182
|
+
"activity"
|
|
23183
|
+
],
|
|
23184
|
+
"pairsWith": [
|
|
23185
|
+
"motion-pulse"
|
|
23186
|
+
]
|
|
23187
|
+
},
|
|
23188
|
+
{
|
|
23189
|
+
"name": "motion-orbit",
|
|
23190
|
+
"kind": "utility",
|
|
23191
|
+
"family": "motion",
|
|
23192
|
+
"intent": "A slow continuous rotation (24s by default) for ambient line art. `--motion-orbit-duration` sets the speed and `--motion-orbit-direction` the direction; it rotates about the centre of any viewBox.",
|
|
23193
|
+
"whenToUse": "The idle loop of an illustration or schematic after it draws on, usually with `motion-draw` on the same figure.",
|
|
23194
|
+
"insteadOf": "`motion-spin`, which is a loader's speed, or a hand-written keyframe that keeps turning under reduced motion.",
|
|
23195
|
+
"example": "<svg className=\"motion-orbit [--motion-orbit-duration:40s]\" viewBox=\"0 0 200 200\">…</svg>",
|
|
23196
|
+
"keywords": [
|
|
23197
|
+
"orbit",
|
|
23198
|
+
"slow rotation",
|
|
23199
|
+
"ambient",
|
|
23200
|
+
"rotate illustration",
|
|
23201
|
+
"decorative loop"
|
|
23202
|
+
],
|
|
23203
|
+
"pairsWith": [
|
|
23204
|
+
"motion-draw"
|
|
23205
|
+
]
|
|
23206
|
+
},
|
|
23207
|
+
{
|
|
23208
|
+
"name": "motion-pulse",
|
|
23209
|
+
"kind": "utility",
|
|
23210
|
+
"family": "motion",
|
|
23211
|
+
"intent": "A soft looping opacity pulse, scaled by --motion-scale.",
|
|
23212
|
+
"whenToUse": "A live or pending indicator: a recording dot, an unsaved marker, a pending status.",
|
|
23213
|
+
"insteadOf": "Tailwind's built-in pulse animation, which ignores --motion-scale. For a loading placeholder use Skeleton or `motion-shimmer`.",
|
|
23214
|
+
"example": "<span className=\"motion-pulse size-2 rounded-full bg-destructive\" />",
|
|
23215
|
+
"keywords": [
|
|
23216
|
+
"pulse",
|
|
23217
|
+
"breathing",
|
|
23218
|
+
"live indicator",
|
|
23219
|
+
"recording",
|
|
23220
|
+
"pending",
|
|
23221
|
+
"activity"
|
|
23222
|
+
],
|
|
23223
|
+
"pairsWith": [
|
|
23224
|
+
"motion-ping"
|
|
23225
|
+
]
|
|
23226
|
+
},
|
|
23227
|
+
{
|
|
23228
|
+
"name": "motion-shimmer",
|
|
23229
|
+
"kind": "utility",
|
|
23230
|
+
"family": "motion",
|
|
23231
|
+
"intent": "The skeleton sweep: one band of light that passes across every skeleton on screen in sequence, because the gradient is fixed to the viewport. Rests off-screen, so with motion off a skeleton is a calm block.",
|
|
23232
|
+
"whenToUse": "A hand-built loading placeholder. The Skeleton component already uses it.",
|
|
23233
|
+
"insteadOf": "A pulse on each block, or a per-box gradient, where a short line and a tall card sweep at different speeds.",
|
|
23234
|
+
"example": "<div className=\"motion-shimmer h-4 w-48 rounded-md bg-muted\" />",
|
|
23235
|
+
"keywords": [
|
|
23236
|
+
"shimmer",
|
|
23237
|
+
"skeleton",
|
|
23238
|
+
"loading placeholder",
|
|
23239
|
+
"sweep",
|
|
23240
|
+
"shine"
|
|
23241
|
+
],
|
|
23242
|
+
"pairsWith": [
|
|
23243
|
+
"motion-text-shimmer"
|
|
23244
|
+
]
|
|
23245
|
+
},
|
|
23246
|
+
{
|
|
23247
|
+
"name": "motion-text-shimmer",
|
|
23248
|
+
"kind": "utility",
|
|
23249
|
+
"family": "motion",
|
|
23250
|
+
"intent": "The sweep run through the glyphs instead of behind them, for a live status line. The gradient goes muted to foreground and back, so the text stays readable on every frame.",
|
|
23251
|
+
"whenToUse": "Status text while work is happening: \"Thinking…\", \"Searching\", \"Generating response\".",
|
|
23252
|
+
"insteadOf": "`motion-shimmer` on text, which paints a band behind real words and reads as a skeleton. Named `motion-*`, not `text-*`, so tailwind-merge does not drop the colour class beside it.",
|
|
23253
|
+
"example": "<span className=\"motion-text-shimmer text-body-sm\">Thinking…</span>",
|
|
23254
|
+
"keywords": [
|
|
23255
|
+
"text shimmer",
|
|
23256
|
+
"thinking",
|
|
23257
|
+
"generating",
|
|
23258
|
+
"status text",
|
|
23259
|
+
"loading text",
|
|
23260
|
+
"ai status"
|
|
23261
|
+
],
|
|
23262
|
+
"pairsWith": [
|
|
23263
|
+
"motion-shimmer"
|
|
23264
|
+
]
|
|
23265
|
+
},
|
|
23266
|
+
{
|
|
23267
|
+
"name": "motion-dot-bounce",
|
|
23268
|
+
"kind": "utility",
|
|
23269
|
+
"family": "motion",
|
|
23270
|
+
"intent": "A looping vertical bounce for one dot of a typing or waiting indicator. Offset each dot with an animation delay.",
|
|
23271
|
+
"whenToUse": "A three-dot typing indicator in a chat or assistant surface.",
|
|
23272
|
+
"insteadOf": "Tailwind's built-in bounce animation, which ignores --motion-scale.",
|
|
23273
|
+
"example": "<span className=\"motion-dot-bounce size-1.5 rounded-full bg-muted-foreground\" />",
|
|
23274
|
+
"keywords": [
|
|
23275
|
+
"typing indicator",
|
|
23276
|
+
"dots",
|
|
23277
|
+
"bounce",
|
|
23278
|
+
"waiting",
|
|
23279
|
+
"chat loading"
|
|
23280
|
+
]
|
|
23281
|
+
},
|
|
23282
|
+
{
|
|
23283
|
+
"name": "motion-ping",
|
|
23284
|
+
"kind": "utility",
|
|
23285
|
+
"family": "motion",
|
|
23286
|
+
"intent": "A looping ring that expands and fades from a point, scaled by --motion-scale.",
|
|
23287
|
+
"whenToUse": "Drawing attention to a live point: a new-activity dot, a location marker, a beacon behind a status dot.",
|
|
23288
|
+
"insteadOf": "Tailwind's built-in ping animation, which ignores --motion-scale.",
|
|
23289
|
+
"example": "<span className=\"motion-ping absolute inset-0 rounded-full bg-success\" />",
|
|
23290
|
+
"keywords": [
|
|
23291
|
+
"ping",
|
|
23292
|
+
"beacon",
|
|
23293
|
+
"ripple",
|
|
23294
|
+
"attention",
|
|
23295
|
+
"live dot"
|
|
23296
|
+
],
|
|
23297
|
+
"pairsWith": [
|
|
23298
|
+
"motion-pulse"
|
|
23299
|
+
]
|
|
23300
|
+
},
|
|
23301
|
+
{
|
|
23302
|
+
"name": "animate-fade-in",
|
|
23303
|
+
"kind": "utility",
|
|
23304
|
+
"family": "motion",
|
|
23305
|
+
"intent": "A one-shot fade in on mount at the base duration, decelerating, with `both` fill so there is no flash before it starts.",
|
|
23306
|
+
"whenToUse": "Content that appears once when a route or panel mounts.",
|
|
23307
|
+
"insteadOf": "A keyframe with a literal duration. For content entering on scroll use Reveal (`motion-reveal`).",
|
|
23308
|
+
"example": "<section className=\"animate-fade-in\">…</section>",
|
|
23309
|
+
"keywords": [
|
|
23310
|
+
"fade in",
|
|
23311
|
+
"mount animation",
|
|
23312
|
+
"entrance",
|
|
23313
|
+
"page transition",
|
|
23314
|
+
"appear"
|
|
23315
|
+
],
|
|
23316
|
+
"pairsWith": [
|
|
23317
|
+
"animate-fade-up",
|
|
23318
|
+
"animate-fade-down"
|
|
23319
|
+
]
|
|
23320
|
+
},
|
|
23321
|
+
{
|
|
23322
|
+
"name": "animate-fade-up",
|
|
23323
|
+
"kind": "utility",
|
|
23324
|
+
"family": "motion",
|
|
23325
|
+
"intent": "A one-shot fade plus rise on mount at the slow duration, decelerating, with `both` fill.",
|
|
23326
|
+
"whenToUse": "A page section or list arriving from below on mount.",
|
|
23327
|
+
"insteadOf": "A translate keyframe with a literal duration. For scroll-triggered entrances use Reveal.",
|
|
23328
|
+
"example": "<div className=\"animate-fade-up\">…</div>",
|
|
23329
|
+
"keywords": [
|
|
23330
|
+
"fade up",
|
|
23331
|
+
"rise in",
|
|
23332
|
+
"entrance",
|
|
23333
|
+
"mount animation",
|
|
23334
|
+
"slide in"
|
|
23335
|
+
],
|
|
23336
|
+
"pairsWith": [
|
|
23337
|
+
"animate-fade-in"
|
|
23338
|
+
]
|
|
23339
|
+
},
|
|
23340
|
+
{
|
|
23341
|
+
"name": "animate-fade-down",
|
|
23342
|
+
"kind": "utility",
|
|
23343
|
+
"family": "motion",
|
|
23344
|
+
"intent": "A one-shot fade plus drop on mount at the slow duration, decelerating, with `both` fill.",
|
|
23345
|
+
"whenToUse": "Content arriving from above on mount, such as a notice under a header.",
|
|
23346
|
+
"insteadOf": "A translate keyframe with a literal duration.",
|
|
23347
|
+
"example": "<div className=\"animate-fade-down\">…</div>",
|
|
23348
|
+
"keywords": [
|
|
23349
|
+
"fade down",
|
|
23350
|
+
"drop in",
|
|
23351
|
+
"entrance",
|
|
23352
|
+
"mount animation"
|
|
23353
|
+
],
|
|
23354
|
+
"pairsWith": [
|
|
23355
|
+
"animate-fade-in"
|
|
23356
|
+
]
|
|
23357
|
+
},
|
|
23358
|
+
{
|
|
23359
|
+
"name": "motion-stream-in",
|
|
23360
|
+
"kind": "utility",
|
|
23361
|
+
"family": "motion",
|
|
23362
|
+
"intent": "Each token of a live stream resolves in from a slight blur once, on mount. Knows nothing of the final length, so there is no stagger.",
|
|
23363
|
+
"whenToUse": "Wrapping each arriving chunk of a streamed response. StreamingText already does it.",
|
|
23364
|
+
"insteadOf": "The reveal recipes, which stage text that is already there, or a typewriter effect.",
|
|
23365
|
+
"example": "<span key={chunk.id} className=\"motion-stream-in\">{chunk.text}</span>",
|
|
23366
|
+
"keywords": [
|
|
23367
|
+
"streaming",
|
|
23368
|
+
"token animation",
|
|
23369
|
+
"llm output",
|
|
23370
|
+
"typewriter",
|
|
23371
|
+
"generating"
|
|
23372
|
+
],
|
|
23373
|
+
"pairsWith": [
|
|
23374
|
+
"stream-tail",
|
|
23375
|
+
"motion-caret"
|
|
23376
|
+
]
|
|
23377
|
+
},
|
|
23378
|
+
{
|
|
23379
|
+
"name": "motion-caret",
|
|
23380
|
+
"kind": "utility",
|
|
23381
|
+
"family": "motion",
|
|
23382
|
+
"intent": "A caret that blinks only when idle and holds steady while `data-streaming=\"true\"`, so the blink returning signals the stream has finished.",
|
|
23383
|
+
"whenToUse": "The cursor at the end of streamed or typed text.",
|
|
23384
|
+
"insteadOf": "A caret that blinks while text is arriving, which sets two competing rhythms.",
|
|
23385
|
+
"example": "<span className=\"motion-caret\" data-streaming={streaming} aria-hidden=\"true\">▍</span>",
|
|
23386
|
+
"keywords": [
|
|
23387
|
+
"caret",
|
|
23388
|
+
"cursor",
|
|
23389
|
+
"blink",
|
|
23390
|
+
"typing cursor",
|
|
23391
|
+
"streaming"
|
|
23392
|
+
],
|
|
23393
|
+
"pairsWith": [
|
|
23394
|
+
"motion-stream-in"
|
|
23395
|
+
]
|
|
23396
|
+
},
|
|
23397
|
+
{
|
|
23398
|
+
"name": "motion-reveal",
|
|
23399
|
+
"kind": "utility",
|
|
23400
|
+
"family": "motion",
|
|
23401
|
+
"intent": "Fade plus a 0.75rem rise when `data-reveal` flips from \"out\" to \"in\". Out of view it rests hidden and nudged down; with motion off the content stays visible.",
|
|
23402
|
+
"whenToUse": "Through the Reveal and TextReveal components, which set `data-reveal` when the element scrolls into view. Use the class directly only on an element whose `data-reveal` you set yourself.",
|
|
23403
|
+
"insteadOf": "An IntersectionObserver plus hand-written opacity classes.",
|
|
23404
|
+
"example": "<Reveal><Card>…</Card></Reveal>",
|
|
23405
|
+
"keywords": [
|
|
23406
|
+
"scroll reveal",
|
|
23407
|
+
"reveal on scroll",
|
|
23408
|
+
"in view",
|
|
23409
|
+
"entrance",
|
|
23410
|
+
"scroll animation"
|
|
23411
|
+
],
|
|
23412
|
+
"pairsWith": [
|
|
23413
|
+
"motion-draw"
|
|
21695
23414
|
]
|
|
21696
23415
|
},
|
|
21697
23416
|
{
|
|
21698
|
-
"name": "
|
|
23417
|
+
"name": "motion-draw",
|
|
21699
23418
|
"kind": "utility",
|
|
21700
|
-
"family": "
|
|
21701
|
-
"intent": "
|
|
21702
|
-
"whenToUse": "
|
|
21703
|
-
"insteadOf": "
|
|
21704
|
-
"example": "<
|
|
23419
|
+
"family": "motion",
|
|
23420
|
+
"intent": "Draws an SVG path on when an ancestor's `data-reveal` flips to \"in\". The path needs `pathLength=\"1\"`. `--motion-draw-duration` and `--motion-draw-delay` (both scaled by --motion-scale) set its pace and stagger.",
|
|
23421
|
+
"whenToUse": "Line art and schematics inside a Reveal. Stagger sibling paths with `--motion-draw-delay`.",
|
|
23422
|
+
"insteadOf": "A hand-written dash-offset animation with literal timing, which keeps animating, or keeps its delay, under reduced motion.",
|
|
23423
|
+
"example": "<Reveal><svg viewBox=\"0 0 100 100\"><path className=\"motion-draw\" pathLength=\"1\" d=\"M10 50 L90 50\" /></svg></Reveal>",
|
|
21705
23424
|
"keywords": [
|
|
21706
|
-
"
|
|
21707
|
-
"
|
|
21708
|
-
"
|
|
21709
|
-
"
|
|
21710
|
-
"
|
|
21711
|
-
"
|
|
23425
|
+
"draw on",
|
|
23426
|
+
"line art",
|
|
23427
|
+
"svg animation",
|
|
23428
|
+
"stroke animation",
|
|
23429
|
+
"illustration",
|
|
23430
|
+
"path draw"
|
|
21712
23431
|
],
|
|
21713
23432
|
"pairsWith": [
|
|
21714
|
-
"motion-
|
|
21715
|
-
"motion-
|
|
23433
|
+
"motion-reveal",
|
|
23434
|
+
"motion-orbit"
|
|
21716
23435
|
]
|
|
21717
23436
|
},
|
|
21718
23437
|
{
|
|
@@ -21733,12 +23452,56 @@
|
|
|
21733
23452
|
"small button"
|
|
21734
23453
|
]
|
|
21735
23454
|
},
|
|
23455
|
+
{
|
|
23456
|
+
"name": "reveal-on-hover",
|
|
23457
|
+
"kind": "utility",
|
|
23458
|
+
"family": "helper",
|
|
23459
|
+
"intent": "Hides a row or tile action until its item is hovered or holds focus — on hover-capable devices only. Touch and no-hover devices always show it, and it fades with opacity, so it never leaves the tab order or the accessibility tree. The item is the nearest ancestor with `group` (or `data-reveal-scope`); an open popup keeps its trigger visible. `--reveal-offset` adds a short rise.",
|
|
23460
|
+
"whenToUse": "Secondary per-item actions in a dense list or grid — a tile's play button, a row's overflow menu or visibility toggle — where showing every action at rest would turn the list into a wall of controls. Put `group` on the item (the row, the tile), not on the list, and the class on the action or its wrapper.",
|
|
23461
|
+
"insteadOf": "`opacity-0 group-hover:opacity-100` (hidden forever on a phone), `hidden group-hover:flex` (drops it from the tab order and the accessibility tree), or a hand-written `[@media(hover:hover)]:opacity-0` pair (snaps, and misses focus and open menus). Not for a primary action — that stays visible.",
|
|
23462
|
+
"example": "<li className=\"group flex items-center gap-2\"><span className=\"flex-1\">Track</span><Button variant=\"ghost\" size=\"icon-sm\" aria-label=\"Like Track\" className=\"reveal-on-hover\"><Icon name=\"heart\" /></Button></li>",
|
|
23463
|
+
"keywords": [
|
|
23464
|
+
"show on hover",
|
|
23465
|
+
"hover reveal",
|
|
23466
|
+
"hover actions",
|
|
23467
|
+
"row actions",
|
|
23468
|
+
"hidden until hover",
|
|
23469
|
+
"group-hover opacity",
|
|
23470
|
+
"hover:hover",
|
|
23471
|
+
"touch visible",
|
|
23472
|
+
"focus-within"
|
|
23473
|
+
],
|
|
23474
|
+
"pairsWith": [
|
|
23475
|
+
"motion-interactive"
|
|
23476
|
+
]
|
|
23477
|
+
},
|
|
23478
|
+
{
|
|
23479
|
+
"name": "stretched-link",
|
|
23480
|
+
"kind": "utility",
|
|
23481
|
+
"family": "helper",
|
|
23482
|
+
"intent": "Makes one link cover its whole item with a transparent `::after`, so a tile or card is clickable everywhere while its other links and buttons stay separate controls. Focus draws the ring on the overlay, rounded by `--stretched-link-radius`.",
|
|
23483
|
+
"whenToUse": "A card, tile or row that IS a link but also holds a second control — a subtitle link to the artist, a play button, a menu. Put it on the primary link (usually the title), `relative` on the item, and `relative z-10` on every other control.",
|
|
23484
|
+
"insteadOf": "Wrapping the item in an `<a>` or `Card render={<a />}` — invalid as soon as it nests another link or button, which browsers re-parent and screen readers read as one long link. Not with `hit-target` on the same element (both own `::after`).",
|
|
23485
|
+
"example": "<article className=\"relative [--stretched-link-radius:var(--radius-lg)]\"><a href=\"/albums/1\" className=\"stretched-link\">Low tide</a><a href=\"/artists/2\" className=\"relative z-10\">Juno Avery</a></article>",
|
|
23486
|
+
"keywords": [
|
|
23487
|
+
"stretched link",
|
|
23488
|
+
"card link",
|
|
23489
|
+
"clickable card",
|
|
23490
|
+
"whole card clickable",
|
|
23491
|
+
"block link",
|
|
23492
|
+
"nested link",
|
|
23493
|
+
"link overlay"
|
|
23494
|
+
],
|
|
23495
|
+
"pairsWith": [
|
|
23496
|
+
"reveal-on-hover"
|
|
23497
|
+
]
|
|
23498
|
+
},
|
|
21736
23499
|
{
|
|
21737
23500
|
"name": "link-underline",
|
|
21738
23501
|
"kind": "utility",
|
|
21739
23502
|
"family": "helper",
|
|
21740
|
-
"intent": "An underline drawn from the font's own metrics (position and thickness `from-font`) with ink skipping descenders, so it retunes when a theme swaps typeface.",
|
|
21741
|
-
"whenToUse": "
|
|
23503
|
+
"intent": "An underline drawn from the font's own metrics (position and thickness `from-font`) with ink skipping descenders, so it retunes when a theme swaps typeface. Also carries the link's focus-visible outline in `--ring`, so a plain `<a>` meets the focus floor without app CSS.",
|
|
23504
|
+
"whenToUse": "Every plain text link — inline in running text or standalone (\"Forgot password?\"). Pair with `motion-interactive` for a colour change on hover.",
|
|
21742
23505
|
"insteadOf": "`underline underline-offset-4 decoration-2` — tuned to one face and wrong for the next theme's. Animating thickness or offset is not supported; a growing underline is a separate pseudo-element.",
|
|
21743
23506
|
"example": "<a href=\"/terms\" className=\"link-underline motion-interactive text-primary\">terms</a>",
|
|
21744
23507
|
"keywords": [
|
|
@@ -21868,6 +23631,7 @@
|
|
|
21868
23631
|
"banners",
|
|
21869
23632
|
"bottomNav",
|
|
21870
23633
|
"children",
|
|
23634
|
+
"dock",
|
|
21871
23635
|
"header",
|
|
21872
23636
|
"overlays",
|
|
21873
23637
|
"sidebar"
|
|
@@ -22030,6 +23794,7 @@
|
|
|
22030
23794
|
],
|
|
22031
23795
|
"Button": [
|
|
22032
23796
|
"badge",
|
|
23797
|
+
"bleed",
|
|
22033
23798
|
"iconEnd",
|
|
22034
23799
|
"iconStart",
|
|
22035
23800
|
"loading",
|
|
@@ -22689,6 +24454,53 @@
|
|
|
22689
24454
|
"render",
|
|
22690
24455
|
"style"
|
|
22691
24456
|
],
|
|
24457
|
+
"DisclosurePicker": [
|
|
24458
|
+
"className",
|
|
24459
|
+
"defaultOpen",
|
|
24460
|
+
"disabled",
|
|
24461
|
+
"onOpenChange",
|
|
24462
|
+
"open",
|
|
24463
|
+
"render",
|
|
24464
|
+
"size",
|
|
24465
|
+
"style"
|
|
24466
|
+
],
|
|
24467
|
+
"DisclosurePickerEmpty": [
|
|
24468
|
+
"children",
|
|
24469
|
+
"className"
|
|
24470
|
+
],
|
|
24471
|
+
"DisclosurePickerList": [
|
|
24472
|
+
"aria-label",
|
|
24473
|
+
"children",
|
|
24474
|
+
"onValueChange",
|
|
24475
|
+
"value"
|
|
24476
|
+
],
|
|
24477
|
+
"DisclosurePickerOption": [
|
|
24478
|
+
"children",
|
|
24479
|
+
"className",
|
|
24480
|
+
"disabled",
|
|
24481
|
+
"media",
|
|
24482
|
+
"note",
|
|
24483
|
+
"textValue",
|
|
24484
|
+
"value"
|
|
24485
|
+
],
|
|
24486
|
+
"DisclosurePickerPanel": [
|
|
24487
|
+
"children",
|
|
24488
|
+
"className",
|
|
24489
|
+
"hiddenUntilFound",
|
|
24490
|
+
"keepMounted",
|
|
24491
|
+
"render",
|
|
24492
|
+
"search",
|
|
24493
|
+
"style"
|
|
24494
|
+
],
|
|
24495
|
+
"DisclosurePickerTrigger": [
|
|
24496
|
+
"children",
|
|
24497
|
+
"className",
|
|
24498
|
+
"label",
|
|
24499
|
+
"media",
|
|
24500
|
+
"nativeButton",
|
|
24501
|
+
"render",
|
|
24502
|
+
"style"
|
|
24503
|
+
],
|
|
22692
24504
|
"EmptyState": [
|
|
22693
24505
|
"action",
|
|
22694
24506
|
"code",
|
|
@@ -22758,6 +24570,25 @@
|
|
|
22758
24570
|
"required",
|
|
22759
24571
|
"size"
|
|
22760
24572
|
],
|
|
24573
|
+
"FontPicker": [
|
|
24574
|
+
"className",
|
|
24575
|
+
"defaultOpen",
|
|
24576
|
+
"disabled",
|
|
24577
|
+
"fonts",
|
|
24578
|
+
"label",
|
|
24579
|
+
"listLabel",
|
|
24580
|
+
"loadFont",
|
|
24581
|
+
"onOpenChange",
|
|
24582
|
+
"onValueChange",
|
|
24583
|
+
"open",
|
|
24584
|
+
"options",
|
|
24585
|
+
"render",
|
|
24586
|
+
"searchLabel",
|
|
24587
|
+
"searchable",
|
|
24588
|
+
"size",
|
|
24589
|
+
"style",
|
|
24590
|
+
"value"
|
|
24591
|
+
],
|
|
22761
24592
|
"Form": [
|
|
22762
24593
|
"actionsRef",
|
|
22763
24594
|
"errors",
|
|
@@ -22778,7 +24609,8 @@
|
|
|
22778
24609
|
"Grid": [
|
|
22779
24610
|
"cols",
|
|
22780
24611
|
"gap",
|
|
22781
|
-
"minItemWidth"
|
|
24612
|
+
"minItemWidth",
|
|
24613
|
+
"track"
|
|
22782
24614
|
],
|
|
22783
24615
|
"GridOverlay": [
|
|
22784
24616
|
"className",
|
|
@@ -22858,6 +24690,33 @@
|
|
|
22858
24690
|
"play",
|
|
22859
24691
|
"speed"
|
|
22860
24692
|
],
|
|
24693
|
+
"MediaImage": [
|
|
24694
|
+
"alt",
|
|
24695
|
+
"fallback",
|
|
24696
|
+
"fit",
|
|
24697
|
+
"loading",
|
|
24698
|
+
"seed",
|
|
24699
|
+
"sizes",
|
|
24700
|
+
"src",
|
|
24701
|
+
"srcSet"
|
|
24702
|
+
],
|
|
24703
|
+
"MediaTile": [
|
|
24704
|
+
"action",
|
|
24705
|
+
"alt",
|
|
24706
|
+
"aspect",
|
|
24707
|
+
"badge",
|
|
24708
|
+
"href",
|
|
24709
|
+
"loading",
|
|
24710
|
+
"render",
|
|
24711
|
+
"seed",
|
|
24712
|
+
"size",
|
|
24713
|
+
"src",
|
|
24714
|
+
"subtitle",
|
|
24715
|
+
"subtitleHref",
|
|
24716
|
+
"subtitleRender",
|
|
24717
|
+
"title",
|
|
24718
|
+
"titleAs"
|
|
24719
|
+
],
|
|
22861
24720
|
"Menu": [
|
|
22862
24721
|
"actionsRef",
|
|
22863
24722
|
"children",
|
|
@@ -23496,6 +25355,14 @@
|
|
|
23496
25355
|
"onValueChange",
|
|
23497
25356
|
"value"
|
|
23498
25357
|
],
|
|
25358
|
+
"SectionHeader": [
|
|
25359
|
+
"actions",
|
|
25360
|
+
"description",
|
|
25361
|
+
"size",
|
|
25362
|
+
"title",
|
|
25363
|
+
"titleAs",
|
|
25364
|
+
"titleId"
|
|
25365
|
+
],
|
|
23499
25366
|
"SegmentedBar": [
|
|
23500
25367
|
"label",
|
|
23501
25368
|
"locale",
|
|
@@ -23687,6 +25554,11 @@
|
|
|
23687
25554
|
"icons",
|
|
23688
25555
|
"variant"
|
|
23689
25556
|
],
|
|
25557
|
+
"SidebarBrand": [
|
|
25558
|
+
"label",
|
|
25559
|
+
"mark",
|
|
25560
|
+
"render"
|
|
25561
|
+
],
|
|
23690
25562
|
"SidebarItem": [
|
|
23691
25563
|
"active",
|
|
23692
25564
|
"badge",
|
|
@@ -23729,11 +25601,14 @@
|
|
|
23729
25601
|
"triggerId"
|
|
23730
25602
|
],
|
|
23731
25603
|
"Slider": [
|
|
25604
|
+
"aria-label",
|
|
23732
25605
|
"className",
|
|
23733
25606
|
"defaultValue",
|
|
23734
25607
|
"disabled",
|
|
23735
25608
|
"form",
|
|
23736
25609
|
"format",
|
|
25610
|
+
"getAriaLabel",
|
|
25611
|
+
"getAriaValueText",
|
|
23737
25612
|
"largeStep",
|
|
23738
25613
|
"locale",
|
|
23739
25614
|
"max",
|
|
@@ -23942,6 +25817,11 @@
|
|
|
23942
25817
|
"text",
|
|
23943
25818
|
"when"
|
|
23944
25819
|
],
|
|
25820
|
+
"ThemePreview": [
|
|
25821
|
+
"mode",
|
|
25822
|
+
"size",
|
|
25823
|
+
"theme"
|
|
25824
|
+
],
|
|
23945
25825
|
"ThemeSelector": [
|
|
23946
25826
|
"label",
|
|
23947
25827
|
"layout",
|
|
@@ -24193,5 +26073,303 @@
|
|
|
24193
26073
|
"toolbar"
|
|
24194
26074
|
]
|
|
24195
26075
|
}
|
|
26076
|
+
},
|
|
26077
|
+
"providers": {
|
|
26078
|
+
"about": "App-level context providers: what each one does, whether an app needs it, where it goes, its props and a copyable example. App-wide providers are documented in src/providers/providers.meta.ts; a provider owned by one component (`partOf`) is documented in that component's meta and summarised here. Generated — do not hand-edit.",
|
|
26079
|
+
"retrieve": "sable provider <Name>",
|
|
26080
|
+
"items": [
|
|
26081
|
+
{
|
|
26082
|
+
"name": "CodeHighlightProvider",
|
|
26083
|
+
"partOf": "CodeBlock",
|
|
26084
|
+
"summary": "Supplies one highlighter to every CodeBlock beneath it, so an app wires highlighting once. Map the highlighter's token colors to Sable tone tokens so code follows the theme.",
|
|
26085
|
+
"signature": "{ highlight: (code: string) => ReactNode; children: ReactNode }",
|
|
26086
|
+
"import": "import { CodeHighlightProvider } from \"@sableui/react\"",
|
|
26087
|
+
"examples": [
|
|
26088
|
+
{
|
|
26089
|
+
"label": "With a highlighter for the whole app",
|
|
26090
|
+
"code": "<CodeHighlightProvider highlight={(code) => <span dangerouslySetInnerHTML={{ __html: highlightToHtml(code) }} />}><CodeBlock code={\"const a = 1\"} /></CodeHighlightProvider>",
|
|
26091
|
+
"imports": "import { CodeBlock, CodeHighlightProvider } from \"@sableui/react\""
|
|
26092
|
+
}
|
|
26093
|
+
]
|
|
26094
|
+
},
|
|
26095
|
+
{
|
|
26096
|
+
"name": "DensityProvider",
|
|
26097
|
+
"summary": "The bare React context behind density. It feeds only the React side, which is the half that reaches portaled popups; it renders no element and sets no `data-density`, so the CSS control ladder in its subtree does not change.",
|
|
26098
|
+
"required": "Optional, and rarely the right choice. To make a region compact use the Density component (or Workspace), which sets both the context and `data-density`.",
|
|
26099
|
+
"placement": "Around an element you already own that carries `data-density` itself.",
|
|
26100
|
+
"props": [
|
|
26101
|
+
{
|
|
26102
|
+
"name": "value",
|
|
26103
|
+
"type": "\"compact\" | \"comfortable\"",
|
|
26104
|
+
"description": "The density popups opened from this subtree should use."
|
|
26105
|
+
}
|
|
26106
|
+
],
|
|
26107
|
+
"companions": [
|
|
26108
|
+
"Density",
|
|
26109
|
+
"useDensity",
|
|
26110
|
+
"useTypeTier"
|
|
26111
|
+
],
|
|
26112
|
+
"related": [
|
|
26113
|
+
"Workspace"
|
|
26114
|
+
],
|
|
26115
|
+
"import": "import { DensityProvider } from \"@sableui/react\"",
|
|
26116
|
+
"examples": [
|
|
26117
|
+
{
|
|
26118
|
+
"label": "An element that sets data-density itself",
|
|
26119
|
+
"code": "function CompactRegion({ children }: { children: ReactNode }) {\n\treturn (\n\t\t<DensityProvider value=\"compact\">\n\t\t\t<section data-density=\"compact\">{children}</section>\n\t\t</DensityProvider>\n\t)\n}",
|
|
26120
|
+
"imports": "import type { ReactNode } from \"react\"\nimport { DensityProvider } from \"@sableui/react\""
|
|
26121
|
+
}
|
|
26122
|
+
]
|
|
26123
|
+
},
|
|
26124
|
+
{
|
|
26125
|
+
"name": "IconProvider",
|
|
26126
|
+
"summary": "Supplies the icon set every <Icon> renders with, including the icons inside Sable's own components. Components name icons semantically (`chevron-down`, `check`); the set maps each name to artwork.",
|
|
26127
|
+
"required": "Required for icons. `<Icon>` has no built-in fallback: without a provider, icons render nothing and a development build warns once.",
|
|
26128
|
+
"placement": "Once, at the app root, above every Sable component.",
|
|
26129
|
+
"props": [
|
|
26130
|
+
{
|
|
26131
|
+
"name": "set",
|
|
26132
|
+
"type": "IconSet",
|
|
26133
|
+
"description": "The icon set: `reiconIconSet` from `@sableui/react/icons/reicon`, another complete set, or `composeIconSets(appExtras, reiconIconSet)` to add app icons over Reicon."
|
|
26134
|
+
},
|
|
26135
|
+
{
|
|
26136
|
+
"name": "defaultVariant",
|
|
26137
|
+
"type": "IconVariant",
|
|
26138
|
+
"default": "\"regular\"",
|
|
26139
|
+
"description": "Variant used when an <Icon> does not set one."
|
|
26140
|
+
}
|
|
26141
|
+
],
|
|
26142
|
+
"companions": [
|
|
26143
|
+
"Icon",
|
|
26144
|
+
"composeIconSets",
|
|
26145
|
+
"useIconSet"
|
|
26146
|
+
],
|
|
26147
|
+
"notes": [
|
|
26148
|
+
"The first set in composeIconSets that returns a node for a name wins, so app icons can also override Reicon's.",
|
|
26149
|
+
"List the names an app adds in .sable/icons.json ({ \"names\": [...] }) so `sable grade` does not report them as off-contract."
|
|
26150
|
+
],
|
|
26151
|
+
"related": [
|
|
26152
|
+
"Icon"
|
|
26153
|
+
],
|
|
26154
|
+
"import": "import { IconProvider } from \"@sableui/react\"",
|
|
26155
|
+
"examples": [
|
|
26156
|
+
{
|
|
26157
|
+
"label": "Reicon, Sable's bundled set",
|
|
26158
|
+
"code": "export function Root({ children }: { children: ReactNode }) {\n\treturn <IconProvider set={reiconIconSet}>{children}</IconProvider>\n}",
|
|
26159
|
+
"imports": "import type { ReactNode } from \"react\"\nimport { reiconIconSet } from \"@sableui/react/icons/reicon\"\nimport { IconProvider } from \"@sableui/react\""
|
|
26160
|
+
},
|
|
26161
|
+
{
|
|
26162
|
+
"label": "App icons added over Reicon",
|
|
26163
|
+
"code": "const icons = composeIconSets(appIcons, reiconIconSet)\n\nexport function Root({ children }: { children: ReactNode }) {\n\treturn <IconProvider set={icons}>{children}</IconProvider>\n}",
|
|
26164
|
+
"imports": "import type { ReactNode } from \"react\"\nimport { reiconIconSet } from \"@sableui/react/icons/reicon\"\nimport { appIcons } from \"./icons\"\nimport { composeIconSets, IconProvider } from \"@sableui/react\""
|
|
26165
|
+
}
|
|
26166
|
+
]
|
|
26167
|
+
},
|
|
26168
|
+
{
|
|
26169
|
+
"name": "MotionProvider",
|
|
26170
|
+
"summary": "Holds the app's motion policy and writes it as `data-motion` on <html>, which drives the `--motion-scale` switch every Sable motion recipe reads. Lets an app offer its own reduce-motion setting.",
|
|
26171
|
+
"required": "Optional. Without it, motion follows the visitor's OS `prefers-reduced-motion` setting, and useMotion() still works (its setPolicy does nothing).",
|
|
26172
|
+
"placement": "Once, at the app root, when the app has an in-app motion setting.",
|
|
26173
|
+
"props": [
|
|
26174
|
+
{
|
|
26175
|
+
"name": "defaultPolicy",
|
|
26176
|
+
"type": "\"system\" | \"full\" | \"reduced\" | \"off\"",
|
|
26177
|
+
"default": "\"system\"",
|
|
26178
|
+
"description": "Initial policy when uncontrolled. \"system\" defers to the OS preference."
|
|
26179
|
+
},
|
|
26180
|
+
{
|
|
26181
|
+
"name": "policy",
|
|
26182
|
+
"type": "\"system\" | \"full\" | \"reduced\" | \"off\"",
|
|
26183
|
+
"description": "Controlled policy. Pair with onPolicyChange."
|
|
26184
|
+
},
|
|
26185
|
+
{
|
|
26186
|
+
"name": "onPolicyChange",
|
|
26187
|
+
"type": "(policy) => void",
|
|
26188
|
+
"description": "Called whenever the policy changes."
|
|
26189
|
+
},
|
|
26190
|
+
{
|
|
26191
|
+
"name": "target",
|
|
26192
|
+
"type": "\"root\" | (() => HTMLElement | null)",
|
|
26193
|
+
"default": "\"root\"",
|
|
26194
|
+
"description": "Where to write `data-motion`. The default covers portaled overlays too; a getter scopes motion to a subtree."
|
|
26195
|
+
}
|
|
26196
|
+
],
|
|
26197
|
+
"companions": [
|
|
26198
|
+
"useMotion",
|
|
26199
|
+
"useReducedMotion"
|
|
26200
|
+
],
|
|
26201
|
+
"notes": [
|
|
26202
|
+
"JavaScript motion must read useMotion().enabled and do nothing when it is false; CSS recipes stop on their own."
|
|
26203
|
+
],
|
|
26204
|
+
"related": [
|
|
26205
|
+
"Switch"
|
|
26206
|
+
],
|
|
26207
|
+
"import": "import { MotionProvider } from \"@sableui/react\"",
|
|
26208
|
+
"examples": [
|
|
26209
|
+
{
|
|
26210
|
+
"label": "At the app root",
|
|
26211
|
+
"code": "export function Root({ children }: { children: ReactNode }) {\n\treturn <MotionProvider defaultPolicy=\"system\">{children}</MotionProvider>\n}",
|
|
26212
|
+
"imports": "import type { ReactNode } from \"react\"\nimport { MotionProvider } from \"@sableui/react\""
|
|
26213
|
+
},
|
|
26214
|
+
{
|
|
26215
|
+
"label": "A reduce-motion setting",
|
|
26216
|
+
"code": "function ReduceMotionSetting() {\n\tconst { policy, setPolicy } = useMotion()\n\treturn (\n\t\t<Switch\n\t\t\tchecked={policy === \"reduced\"}\n\t\t\tonCheckedChange={(on) => setPolicy(on ? \"reduced\" : \"system\")}\n\t\t\taria-label=\"Reduce motion\"\n\t\t/>\n\t)\n}",
|
|
26217
|
+
"imports": "import { Switch, useMotion } from \"@sableui/react\""
|
|
26218
|
+
}
|
|
26219
|
+
]
|
|
26220
|
+
},
|
|
26221
|
+
{
|
|
26222
|
+
"name": "PresentationProvider",
|
|
26223
|
+
"summary": "Declares what its subtree is presented in, a page or a sheet. Action rows (CardFooter actions, SheetFooter) read it: on a page they are a short row; in a sheet they stack full width and pin to the bottom.",
|
|
26224
|
+
"required": "Optional. A bottom Sheet and IosSheet already provide \"sheet\", and Dialog, Popover and side Sheets provide \"page\". Mount it only for a container of your own that presents content like a sheet.",
|
|
26225
|
+
"placement": "Around the content of a custom sheet-like surface.",
|
|
26226
|
+
"props": [
|
|
26227
|
+
{
|
|
26228
|
+
"name": "value",
|
|
26229
|
+
"type": "\"page\" | \"sheet\"",
|
|
26230
|
+
"description": "What the subtree is presented in."
|
|
26231
|
+
}
|
|
26232
|
+
],
|
|
26233
|
+
"companions": [
|
|
26234
|
+
"usePresentation"
|
|
26235
|
+
],
|
|
26236
|
+
"related": [
|
|
26237
|
+
"Sheet",
|
|
26238
|
+
"Card"
|
|
26239
|
+
],
|
|
26240
|
+
"import": "import { PresentationProvider } from \"@sableui/react\"",
|
|
26241
|
+
"examples": [
|
|
26242
|
+
{
|
|
26243
|
+
"label": "A custom bottom drawer",
|
|
26244
|
+
"code": "function Drawer({ children }: { children: ReactNode }) {\n\treturn (\n\t\t<PresentationProvider value=\"sheet\">\n\t\t\t<div className=\"sable-surface-popover bg-popover text-popover-foreground\">{children}</div>\n\t\t</PresentationProvider>\n\t)\n}",
|
|
26245
|
+
"imports": "import type { ReactNode } from \"react\"\nimport { PresentationProvider } from \"@sableui/react\""
|
|
26246
|
+
}
|
|
26247
|
+
]
|
|
26248
|
+
},
|
|
26249
|
+
{
|
|
26250
|
+
"name": "SableThemeProvider",
|
|
26251
|
+
"summary": "Applies and persists Sable's two theme axes on <html>: the colour preference (light, dark or system, written as the `.dark` class) and the design theme (written as `data-sable-theme`). Read and change both anywhere below it with useSableTheme().",
|
|
26252
|
+
"required": "Optional. Without it the app renders the default Sable theme in whatever mode the `dark` class on <html> says. Mount it when users can switch colour mode or theme, or to register an app-owned preset theme.",
|
|
26253
|
+
"placement": "Once, at the app root, above everything that renders Sable components or calls useSableTheme(). It writes to <html> by default so portaled overlays (dialogs, menus, toasts) share the same tokens.",
|
|
26254
|
+
"props": [
|
|
26255
|
+
{
|
|
26256
|
+
"name": "defaultPreference",
|
|
26257
|
+
"type": "\"light\" | \"dark\" | \"system\"",
|
|
26258
|
+
"default": "\"system\"",
|
|
26259
|
+
"description": "Colour preference on a first visit, before anything is stored. Pass \"dark\" (or a preset's `defaultMode`) to open in dark."
|
|
26260
|
+
},
|
|
26261
|
+
{
|
|
26262
|
+
"name": "preference",
|
|
26263
|
+
"type": "\"light\" | \"dark\" | \"system\"",
|
|
26264
|
+
"description": "Controlled colour preference. Pair with onPreferenceChange."
|
|
26265
|
+
},
|
|
26266
|
+
{
|
|
26267
|
+
"name": "onPreferenceChange",
|
|
26268
|
+
"type": "(preference) => void",
|
|
26269
|
+
"description": "Called whenever the colour preference is set."
|
|
26270
|
+
},
|
|
26271
|
+
{
|
|
26272
|
+
"name": "defaultThemeId",
|
|
26273
|
+
"type": "ThemeId | string",
|
|
26274
|
+
"default": "\"sable\"",
|
|
26275
|
+
"description": "Design theme on a first visit, before anything is stored."
|
|
26276
|
+
},
|
|
26277
|
+
{
|
|
26278
|
+
"name": "themeId",
|
|
26279
|
+
"type": "ThemeId | string",
|
|
26280
|
+
"description": "Controlled design theme id. Pair with onThemeIdChange."
|
|
26281
|
+
},
|
|
26282
|
+
{
|
|
26283
|
+
"name": "onThemeIdChange",
|
|
26284
|
+
"type": "(themeId) => void",
|
|
26285
|
+
"description": "Called whenever the design theme is set."
|
|
26286
|
+
},
|
|
26287
|
+
{
|
|
26288
|
+
"name": "colorStorageKey",
|
|
26289
|
+
"type": "string | null",
|
|
26290
|
+
"default": "SABLE_COLOR_STORAGE_KEY",
|
|
26291
|
+
"description": "localStorage key for the colour preference. null disables persistence."
|
|
26292
|
+
},
|
|
26293
|
+
{
|
|
26294
|
+
"name": "themeStorageKey",
|
|
26295
|
+
"type": "string | null",
|
|
26296
|
+
"default": "SABLE_THEME_STORAGE_KEY",
|
|
26297
|
+
"description": "localStorage key for the design theme. null disables persistence."
|
|
26298
|
+
},
|
|
26299
|
+
{
|
|
26300
|
+
"name": "target",
|
|
26301
|
+
"type": "\"root\" | (() => HTMLElement | null)",
|
|
26302
|
+
"default": "\"root\"",
|
|
26303
|
+
"description": "Where to write `.dark` and `data-sable-theme`. A getter scopes theming to a subtree."
|
|
26304
|
+
},
|
|
26305
|
+
{
|
|
26306
|
+
"name": "customThemes",
|
|
26307
|
+
"type": "ThemeManifest[]",
|
|
26308
|
+
"description": "Extra theme manifests, for example `presetManifest(preset)` for an app-owned preset. Their ids become valid themeId values. Sable does not load their CSS: import it yourself."
|
|
26309
|
+
}
|
|
26310
|
+
],
|
|
26311
|
+
"companions": [
|
|
26312
|
+
"useSableTheme",
|
|
26313
|
+
"ThemeSwitcher",
|
|
26314
|
+
"ThemeSelector",
|
|
26315
|
+
"applySableTheme",
|
|
26316
|
+
"applySableColorMode"
|
|
26317
|
+
],
|
|
26318
|
+
"notes": [
|
|
26319
|
+
"Only the alternate design themes need theme CSS: import `@sableui/react/themes.css`, one `@sableui/react/theme/<id>.css`, or the scoped CSS from `sable preset --css`. Light/dark of the default theme needs nothing extra.",
|
|
26320
|
+
"A colour-mode toggle is the ThemeSwitcher component; it reads this provider."
|
|
26321
|
+
],
|
|
26322
|
+
"related": [
|
|
26323
|
+
"ThemeSwitcher",
|
|
26324
|
+
"ThemeSelector"
|
|
26325
|
+
],
|
|
26326
|
+
"import": "import { SableThemeProvider } from \"@sableui/react\"",
|
|
26327
|
+
"examples": [
|
|
26328
|
+
{
|
|
26329
|
+
"label": "At the app root, following the visitor's OS",
|
|
26330
|
+
"code": "export function Root({ children }: { children: ReactNode }) {\n\treturn <SableThemeProvider>{children}</SableThemeProvider>\n}",
|
|
26331
|
+
"imports": "import type { ReactNode } from \"react\"\nimport { SableThemeProvider } from \"@sableui/react\""
|
|
26332
|
+
},
|
|
26333
|
+
{
|
|
26334
|
+
"label": "Dark by default, with an app-owned preset theme",
|
|
26335
|
+
"code": "const customThemes = [presetManifest(parsePreset(presetJson))]\n\nexport function Root({ children }: { children: ReactNode }) {\n\treturn (\n\t\t<SableThemeProvider defaultPreference=\"dark\" defaultThemeId=\"my-preset\" customThemes={customThemes}>\n\t\t\t{children}\n\t\t</SableThemeProvider>\n\t)\n}",
|
|
26336
|
+
"imports": "import type { ReactNode } from \"react\"\nimport { parsePreset, presetManifest } from \"@sableui/react/presets\"\nimport presetJson from \"../.sable/presets/my-preset.json\"\nimport { defaultThemeId, SableThemeProvider } from \"@sableui/react\""
|
|
26337
|
+
}
|
|
26338
|
+
]
|
|
26339
|
+
},
|
|
26340
|
+
{
|
|
26341
|
+
"name": "ToastProvider",
|
|
26342
|
+
"partOf": "Toast",
|
|
26343
|
+
"summary": "Wraps Base UI Toast.Provider and renders the themed Viewport + toast list. Mount once near the app root, above any caller of useToast().",
|
|
26344
|
+
"signature": "{ children, timeout?, limit?, closeIcon?, position?, stacked?, viewportClassName? }",
|
|
26345
|
+
"import": "import { ToastProvider } from \"@sableui/react\"",
|
|
26346
|
+
"examples": [
|
|
26347
|
+
{
|
|
26348
|
+
"label": "Stacked",
|
|
26349
|
+
"code": "<ToastProvider stacked position=\"bottom-right\">{children}</ToastProvider>",
|
|
26350
|
+
"imports": "import { ToastProvider } from \"@sableui/react\""
|
|
26351
|
+
},
|
|
26352
|
+
{
|
|
26353
|
+
"label": "Provider with app chrome",
|
|
26354
|
+
"code": "<ToastProvider position=\"bottom-right\" limit={3}><AppShell><Region max=\"feed\"><Button onClick={() => toast.success(\"Saved\")}>Save</Button></Region></AppShell></ToastProvider>",
|
|
26355
|
+
"imports": "import { AppShell, Button, Region, toast, ToastProvider } from \"@sableui/react\""
|
|
26356
|
+
}
|
|
26357
|
+
]
|
|
26358
|
+
},
|
|
26359
|
+
{
|
|
26360
|
+
"name": "TooltipProvider",
|
|
26361
|
+
"partOf": "Tooltip",
|
|
26362
|
+
"summary": "Wraps an app or group of Tooltips to share open/close delays. Once one tooltip is open, adjacent tooltips open instantly.",
|
|
26363
|
+
"signature": "Base UI Tooltip.Provider props (delay, closeDelay, timeout)",
|
|
26364
|
+
"import": "import { TooltipProvider } from \"@sableui/react\"",
|
|
26365
|
+
"examples": [
|
|
26366
|
+
{
|
|
26367
|
+
"label": "Basic",
|
|
26368
|
+
"code": "<TooltipProvider><Tooltip><TooltipTrigger render={<Button variant=\"ghost\" size=\"icon-sm\" aria-label=\"Info\"><Icon name=\"info\" /></Button>} /><TooltipContent>Stored permanently on Arweave.</TooltipContent></Tooltip></TooltipProvider>",
|
|
26369
|
+
"imports": "import { Button, Icon, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from \"@sableui/react\""
|
|
26370
|
+
}
|
|
26371
|
+
]
|
|
26372
|
+
}
|
|
26373
|
+
]
|
|
24196
26374
|
}
|
|
24197
26375
|
}
|