From 0a0cade343ddc899faedec9997d59f09f3ddbd45 Mon Sep 17 00:00:00 2001 From: Jules Date: Thu, 11 Sep 2025 21:12:46 +1000 Subject: [PATCH] Initial commit - Angular library: ui-essentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🎯 Implementation Complete! This library has been extracted from the monorepo and is ready for Git submodule distribution. Features: - Standardized SCSS imports (no relative paths) - Optimized public-api.ts exports - Independent Angular library structure - Ready for consumer integration as submodule 🚀 Generated with Claude Code (https://claude.ai/code) Co-Authored-By: Claude --- README.md | 1 + ng-package.json | 7 + package.json | 12 + .../components/buttons/button.component.scss | 247 +++++ .../components/buttons/button.component.ts | 75 ++ .../buttons/fab-menu/fab-menu.component.scss | 475 ++++++++++ .../buttons/fab-menu/fab-menu.component.ts | 285 ++++++ src/lib/components/buttons/fab-menu/index.ts | 1 + src/lib/components/buttons/fab.component.scss | 303 ++++++ src/lib/components/buttons/fab.component.ts | 79 ++ .../buttons/ghost-button.component.scss | 141 +++ .../buttons/ghost-button.component.ts | 55 ++ .../icon-button/icon-button.component.scss | 203 ++++ .../icon-button/icon-button.component.ts | 66 ++ .../components/buttons/icon-button/index.ts | 1 + src/lib/components/buttons/index copy.ts | 5 + src/lib/components/buttons/index.ts | 8 + .../buttons/simple-button.component.scss | 91 ++ .../buttons/simple-button.component.ts | 41 + .../components/buttons/split-button/index.ts | 1 + .../split-button/split-button.component.scss | 463 +++++++++ .../split-button/split-button.component.ts | 169 ++++ .../buttons/text-button.component.scss | 132 +++ .../buttons/text-button.component.ts | 53 ++ .../accordion/accordion.component.scss | 279 ++++++ .../accordion/accordion.component.ts | 370 ++++++++ .../data-display/accordion/index.ts | 2 + .../data-display/avatar/avatar.component.scss | 244 +++++ .../data-display/avatar/avatar.component.ts | 108 +++ .../components/data-display/avatar/index.ts | 1 + .../data-display/badge/badge.component.scss | 176 ++++ .../data-display/badge/badge.component.ts | 37 + .../components/data-display/badge/index.ts | 1 + .../data-display/card/card.component.scss | 400 ++++++++ .../data-display/card/card.component.ts | 99 ++ src/lib/components/data-display/card/index.ts | 1 + .../carousel/carousel.component.scss | 375 ++++++++ .../carousel/carousel.component.ts | 277 ++++++ .../components/data-display/carousel/index.ts | 1 + .../data-display/chip/chip.component.scss | 451 +++++++++ .../data-display/chip/chip.component.ts | 105 +++ src/lib/components/data-display/chip/index.ts | 1 + .../image-container.component.scss | 314 +++++++ .../image-container.component.ts | 217 +++++ .../data-display/image-container/index.ts | 1 + src/lib/components/data-display/index.ts | 17 + .../components/data-display/list/README.md | 223 +++++ src/lib/components/data-display/list/index.ts | 23 + .../list/list-container.component.scss | 263 ++++++ .../list/list-container.component.ts | 52 + .../list/list-examples.component.scss | 234 +++++ .../list/list-examples.component.ts | 374 ++++++++ .../list/list-item.component.scss | 360 +++++++ .../data-display/list/list-item.component.ts | 109 +++ .../components/data-display/progress/index.ts | 1 + .../progress/progress-bar.component.scss | 421 +++++++++ .../progress/progress-bar.component.ts | 213 +++++ .../table/enhanced-table.component.scss | 480 ++++++++++ .../table/enhanced-table.component.ts | 833 ++++++++++++++++ .../components/data-display/table/index.ts | 3 + .../table/table-actions.component.scss | 400 ++++++++ .../table/table-actions.component.ts | 177 ++++ .../data-display/table/table.component.scss | 441 +++++++++ .../data-display/table/table.component.ts | 246 +++++ .../components/data-display/timeline/index.ts | 1 + .../timeline/timeline.component.scss | 331 +++++++ .../timeline/timeline.component.ts | 115 +++ .../components/data-display/tooltip/index.ts | 1 + .../tooltip/tooltip.component.scss | 189 ++++ .../data-display/tooltip/tooltip.component.ts | 152 +++ .../data-display/transfer-list/index.ts | 1 + .../transfer-list.component.scss | 481 ++++++++++ .../transfer-list/transfer-list.component.ts | 629 +++++++++++++ .../data-display/tree-view/index.ts | 1 + .../tree-view/tree-view.component.scss | 225 +++++ .../tree-view/tree-view.component.ts | 297 ++++++ .../feedback/alert/alert.component.scss | 221 +++++ .../feedback/alert/alert.component.ts | 136 +++ src/lib/components/feedback/alert/index.ts | 1 + .../empty-state/empty-state.component.scss | 309 ++++++ .../empty-state/empty-state.component.ts | 85 ++ .../components/feedback/empty-state/index.ts | 1 + src/lib/components/feedback/index.ts | 9 + .../feedback/loading-spinner/index.ts | 1 + .../loading-spinner.component.scss | 248 +++++ .../loading-spinner.component.ts | 123 +++ .../feedback/progress-circle/index.ts | 1 + .../progress-circle.component.scss | 198 ++++ .../progress-circle.component.ts | 167 ++++ .../feedback/skeleton-loader/index.ts | 1 + .../skeleton-loader.component.scss | 293 ++++++ .../skeleton-loader.component.ts | 347 +++++++ src/lib/components/feedback/snackbar/index.ts | 1 + .../feedback/snackbar/snackbar.component.scss | 235 +++++ .../feedback/snackbar/snackbar.component.ts | 243 +++++ .../feedback/status-badge.component.scss | 253 +++++ .../feedback/status-badge.component.ts | 54 ++ .../feedback/theme-switcher.component.scss | 579 ++++++++++++ .../feedback/theme-switcher.component.ts | 471 ++++++++++ src/lib/components/feedback/toast/index.ts | 1 + .../feedback/toast/toast.component.scss | 289 ++++++ .../feedback/toast/toast.component.ts | 238 +++++ .../autocomplete/autocomplete.component.scss | 367 ++++++++ .../autocomplete/autocomplete.component.ts | 512 ++++++++++ .../components/forms/autocomplete/index.ts | 1 + .../forms/checkbox/checkbox.component.scss | 332 +++++++ .../forms/checkbox/checkbox.component.ts | 156 +++ src/lib/components/forms/checkbox/index.ts | 1 + .../color-picker/color-picker.component.scss | 362 +++++++ .../color-picker/color-picker.component.ts | 613 ++++++++++++ .../components/forms/color-picker/index.ts | 1 + .../date-picker/date-picker.component.scss | 504 ++++++++++ .../date-picker/date-picker.component.ts | 433 +++++++++ src/lib/components/forms/date-picker/index.ts | 1 + .../file-upload/file-upload.component.scss | 355 +++++++ .../file-upload/file-upload.component.ts | 420 +++++++++ src/lib/components/forms/file-upload/index.ts | 1 + .../form-field/form-field.component.scss | 317 +++++++ .../forms/form-field/form-field.component.ts | 381 ++++++++ src/lib/components/forms/form-field/index.ts | 1 + src/lib/components/forms/index.ts | 14 + src/lib/components/forms/input/index.ts | 3 + .../forms/input/input-wrapper.component.ts | 199 ++++ .../forms/input/text-input.component.scss | 561 +++++++++++ .../forms/input/text-input.component.ts | 275 ++++++ .../forms/input/textarea.component.scss | 523 +++++++++++ .../forms/input/textarea.component.ts | 248 +++++ src/lib/components/forms/radio/index.ts | 2 + .../forms/radio/radio-button.component.scss | 435 +++++++++ .../forms/radio/radio-button.component.ts | 166 ++++ .../forms/radio/radio-group.component.scss | 272 ++++++ .../forms/radio/radio-group.component.ts | 180 ++++ .../components/forms/range-slider/index.ts | 1 + .../range-slider/range-slider.component.scss | 383 ++++++++ .../range-slider/range-slider.component.ts | 399 ++++++++ src/lib/components/forms/search/index.ts | 1 + .../forms/search/search-bar.component.scss | 542 +++++++++++ .../forms/search/search-bar.component.ts | 397 ++++++++ .../forms/select/select.component.scss | 205 ++++ .../forms/select/select.component.ts | 151 +++ src/lib/components/forms/switch/index.ts | 1 + .../forms/switch/switch.component.scss | 364 +++++++ .../forms/switch/switch.component.ts | 205 ++++ src/lib/components/forms/tag-input/index.ts | 1 + .../forms/tag-input/tag-input.component.scss | 197 ++++ .../forms/tag-input/tag-input.component.ts | 322 +++++++ src/lib/components/forms/time-picker/index.ts | 1 + .../time-picker/time-picker.component.scss | 540 +++++++++++ .../time-picker/time-picker.component.ts | 609 ++++++++++++ .../aspect-ratio/aspect-ratio.component.scss | 188 ++++ .../aspect-ratio/aspect-ratio.component.ts | 77 ++ .../components/layout/aspect-ratio/index.ts | 1 + .../bento-grid/bento-grid-item.component.ts | 85 ++ .../bento-grid/bento-grid.component.scss | 322 +++++++ .../layout/bento-grid/bento-grid.component.ts | 57 ++ src/lib/components/layout/bento-grid/index.ts | 2 + .../components/layout/box/box.component.scss | 454 +++++++++ .../components/layout/box/box.component.ts | 116 +++ src/lib/components/layout/box/index.ts | 1 + .../breakpoint-container.component.scss | 227 +++++ .../breakpoint-container.component.ts | 108 +++ .../layout/breakpoint-container/index.ts | 1 + .../layout/center/center.component.scss | 139 +++ .../layout/center/center.component.ts | 60 ++ src/lib/components/layout/center/index.ts | 1 + .../layout/column/column.component.scss | 149 +++ .../layout/column/column.component.ts | 98 ++ src/lib/components/layout/column/index.ts | 1 + .../layout/container/container.component.scss | 287 ++++++ .../layout/container/container.component.ts | 93 ++ src/lib/components/layout/container/index.ts | 1 + .../dashboard-shell.component.scss | 428 +++++++++ .../dashboard-shell.component.ts | 181 ++++ .../layout/dashboard-shell/index.ts | 1 + .../layout/divider/divider.component.scss | 177 ++++ .../layout/divider/divider.component.ts | 45 + src/lib/components/layout/divider/index.ts | 1 + .../feed-layout/feed-layout.component.scss | 260 +++++ .../feed-layout/feed-layout.component.ts | 240 +++++ .../components/layout/feed-layout/index.ts | 1 + .../layout/flex/flex.component.scss | 190 ++++ .../components/layout/flex/flex.component.ts | 112 +++ src/lib/components/layout/flex/index.ts | 1 + .../gallery-grid/gallery-grid.component.scss | 308 ++++++ .../gallery-grid/gallery-grid.component.ts | 274 ++++++ .../components/layout/gallery-grid/index.ts | 1 + .../grid-container.component.scss | 315 +++++++ .../grid-container.component.ts | 86 ++ .../components/layout/grid-container/index.ts | 1 + .../grid-system/grid-system.component.scss | 242 +++++ .../grid-system/grid-system.component.ts | 54 ++ .../components/layout/grid-system/index.ts | 1 + .../layout/hstack/hstack.component.scss | 165 ++++ .../layout/hstack/hstack.component.ts | 62 ++ src/lib/components/layout/hstack/index.ts | 1 + src/lib/components/layout/index.ts | 29 + .../layout/infinite-scroll-container/index.ts | 1 + .../infinite-scroll-container.component.scss | 286 ++++++ .../infinite-scroll-container.component.ts | 352 +++++++ .../components/layout/kanban-board/index.ts | 1 + .../kanban-board/kanban-board.component.scss | 438 +++++++++ .../kanban-board/kanban-board.component.ts | 262 ++++++ .../layout/list-detail-layout/index.ts | 1 + .../list-detail-layout.component.scss | 247 +++++ .../list-detail-layout.component.ts | 242 +++++ src/lib/components/layout/masonry/index.ts | 1 + .../layout/masonry/masonry.component.scss | 219 +++++ .../layout/masonry/masonry.component.ts | 54 ++ .../layout/scroll-container/index.ts | 1 + .../scroll-container.component.scss | 245 +++++ .../scroll-container.component.ts | 342 +++++++ src/lib/components/layout/section/index.ts | 1 + .../layout/section/section.component.scss | 114 +++ .../layout/section/section.component.ts | 46 + .../components/layout/sidebar-layout/index.ts | 1 + .../sidebar-layout.component.scss | 217 +++++ .../sidebar-layout.component.ts | 77 ++ src/lib/components/layout/spacer/index.ts | 1 + .../layout/spacer/spacer.component.scss | 322 +++++++ .../layout/spacer/spacer.component.ts | 57 ++ src/lib/components/layout/split-view/index.ts | 1 + .../split-view/split-view.component.scss | 262 ++++++ .../layout/split-view/split-view.component.ts | 361 +++++++ src/lib/components/layout/stack/index.ts | 1 + .../layout/stack/stack.component.scss | 176 ++++ .../layout/stack/stack.component.ts | 63 ++ .../components/layout/sticky-layout/index.ts | 1 + .../sticky-layout.component.scss | 300 ++++++ .../sticky-layout/sticky-layout.component.ts | 78 ++ .../layout/supporting-pane-layout/index.ts | 1 + .../supporting-pane-layout.component.scss | 369 ++++++++ .../supporting-pane-layout.component.ts | 164 ++++ .../components/layout/tabs-container/index.ts | 1 + .../tabs-container.component.scss | 404 ++++++++ .../tabs-container.component.ts | 337 +++++++ src/lib/components/layout/vstack/index.ts | 1 + .../layout/vstack/vstack.component.scss | 137 +++ .../layout/vstack/vstack.component.ts | 58 ++ src/lib/components/media/index.ts | 1 + .../components/media/video-player/index.ts | 1 + .../video-player/video-player.component.scss | 403 ++++++++ .../video-player/video-player.component.ts | 448 +++++++++ src/lib/components/navigation/README.md | 223 +++++ .../navigation/appbar/appbar.component.scss | 224 +++++ .../navigation/appbar/appbar.component.ts | 108 +++ src/lib/components/navigation/appbar/index.ts | 1 + .../bottom-navigation.component.scss | 244 +++++ .../bottom-navigation.component.ts | 144 +++ .../navigation/bottom-navigation/index.ts | 1 + .../breadcrumb/breadcrumb.component.scss | 116 +++ .../breadcrumb/breadcrumb.component.ts | 84 ++ .../components/navigation/breadcrumb/index.ts | 1 + src/lib/components/navigation/index.ts | 7 + src/lib/components/navigation/menu/index.ts | 3 + .../menu/menu-container.component.scss | 350 +++++++ .../menu/menu-container.component.ts | 49 + .../navigation/menu/menu-item.component.scss | 500 ++++++++++ .../navigation/menu/menu-item.component.ts | 135 +++ .../menu/menu-submenu.component.scss | 132 +++ .../navigation/menu/menu-submenu.component.ts | 91 ++ .../components/navigation/pagination/index.ts | 1 + .../pagination/pagination.component.scss | 233 +++++ .../pagination/pagination.component.ts | 244 +++++ .../components/navigation/stepper/index.ts | 1 + .../navigation/stepper/stepper.component.scss | 318 +++++++ .../navigation/stepper/stepper.component.ts | 271 ++++++ .../components/navigation/tab-group/index.ts | 1 + .../tab-group/tab-group.component.scss | 245 +++++ .../tab-group/tab-group.component.ts | 107 +++ .../overlays/backdrop/backdrop.component.scss | 166 ++++ .../overlays/backdrop/backdrop.component.ts | 182 ++++ src/lib/components/overlays/backdrop/index.ts | 1 + .../command-palette-item.component.scss | 167 ++++ .../command-palette-item.component.ts | 127 +++ .../command-palette.component.scss | 383 ++++++++ .../command-palette.component.ts | 485 ++++++++++ .../command-palette.service.ts | 353 +++++++ .../command-palette/command-palette.types.ts | 64 ++ .../global-keyboard.directive.ts | 299 ++++++ .../overlays/command-palette/index.ts | 5 + .../overlays/drawer/drawer.component.scss | 458 +++++++++ .../overlays/drawer/drawer.component.ts | 404 ++++++++ src/lib/components/overlays/drawer/index.ts | 1 + .../floating-toolbar.component.scss | 364 +++++++ .../floating-toolbar.component.ts | 889 ++++++++++++++++++ .../overlays/floating-toolbar/index.ts | 1 + src/lib/components/overlays/index.ts | 7 + src/lib/components/overlays/modal/index.ts | 1 + .../overlays/modal/modal.component.scss | 341 +++++++ .../overlays/modal/modal.component.ts | 434 +++++++++ .../overlays/overlay-container/index.ts | 1 + .../overlay-container.component.scss | 331 +++++++ .../overlay-container.component.ts | 413 ++++++++ src/lib/components/overlays/popover/index.ts | 1 + .../overlays/popover/popover.component.scss | 384 ++++++++ .../overlays/popover/popover.component.ts | 645 +++++++++++++ src/lib/layouts/index.ts | 5 + .../loading-state-container.component.ts | 473 ++++++++++ src/public-api.ts | 71 ++ tsconfig.lib.json | 15 + tsconfig.lib.prod.json | 11 + tsconfig.spec.json | 15 + 302 files changed, 54198 insertions(+) create mode 100644 README.md create mode 100644 ng-package.json create mode 100644 package.json create mode 100644 src/lib/components/buttons/button.component.scss create mode 100644 src/lib/components/buttons/button.component.ts create mode 100644 src/lib/components/buttons/fab-menu/fab-menu.component.scss create mode 100644 src/lib/components/buttons/fab-menu/fab-menu.component.ts create mode 100644 src/lib/components/buttons/fab-menu/index.ts create mode 100644 src/lib/components/buttons/fab.component.scss create mode 100644 src/lib/components/buttons/fab.component.ts create mode 100644 src/lib/components/buttons/ghost-button.component.scss create mode 100644 src/lib/components/buttons/ghost-button.component.ts create mode 100644 src/lib/components/buttons/icon-button/icon-button.component.scss create mode 100644 src/lib/components/buttons/icon-button/icon-button.component.ts create mode 100644 src/lib/components/buttons/icon-button/index.ts create mode 100644 src/lib/components/buttons/index copy.ts create mode 100644 src/lib/components/buttons/index.ts create mode 100644 src/lib/components/buttons/simple-button.component.scss create mode 100644 src/lib/components/buttons/simple-button.component.ts create mode 100644 src/lib/components/buttons/split-button/index.ts create mode 100644 src/lib/components/buttons/split-button/split-button.component.scss create mode 100644 src/lib/components/buttons/split-button/split-button.component.ts create mode 100644 src/lib/components/buttons/text-button.component.scss create mode 100644 src/lib/components/buttons/text-button.component.ts create mode 100644 src/lib/components/data-display/accordion/accordion.component.scss create mode 100644 src/lib/components/data-display/accordion/accordion.component.ts create mode 100644 src/lib/components/data-display/accordion/index.ts create mode 100644 src/lib/components/data-display/avatar/avatar.component.scss create mode 100644 src/lib/components/data-display/avatar/avatar.component.ts create mode 100644 src/lib/components/data-display/avatar/index.ts create mode 100644 src/lib/components/data-display/badge/badge.component.scss create mode 100644 src/lib/components/data-display/badge/badge.component.ts create mode 100644 src/lib/components/data-display/badge/index.ts create mode 100644 src/lib/components/data-display/card/card.component.scss create mode 100644 src/lib/components/data-display/card/card.component.ts create mode 100644 src/lib/components/data-display/card/index.ts create mode 100644 src/lib/components/data-display/carousel/carousel.component.scss create mode 100644 src/lib/components/data-display/carousel/carousel.component.ts create mode 100644 src/lib/components/data-display/carousel/index.ts create mode 100644 src/lib/components/data-display/chip/chip.component.scss create mode 100644 src/lib/components/data-display/chip/chip.component.ts create mode 100644 src/lib/components/data-display/chip/index.ts create mode 100644 src/lib/components/data-display/image-container/image-container.component.scss create mode 100644 src/lib/components/data-display/image-container/image-container.component.ts create mode 100644 src/lib/components/data-display/image-container/index.ts create mode 100644 src/lib/components/data-display/index.ts create mode 100644 src/lib/components/data-display/list/README.md create mode 100644 src/lib/components/data-display/list/index.ts create mode 100644 src/lib/components/data-display/list/list-container.component.scss create mode 100644 src/lib/components/data-display/list/list-container.component.ts create mode 100644 src/lib/components/data-display/list/list-examples.component.scss create mode 100644 src/lib/components/data-display/list/list-examples.component.ts create mode 100644 src/lib/components/data-display/list/list-item.component.scss create mode 100644 src/lib/components/data-display/list/list-item.component.ts create mode 100644 src/lib/components/data-display/progress/index.ts create mode 100644 src/lib/components/data-display/progress/progress-bar.component.scss create mode 100644 src/lib/components/data-display/progress/progress-bar.component.ts create mode 100644 src/lib/components/data-display/table/enhanced-table.component.scss create mode 100644 src/lib/components/data-display/table/enhanced-table.component.ts create mode 100644 src/lib/components/data-display/table/index.ts create mode 100644 src/lib/components/data-display/table/table-actions.component.scss create mode 100644 src/lib/components/data-display/table/table-actions.component.ts create mode 100644 src/lib/components/data-display/table/table.component.scss create mode 100644 src/lib/components/data-display/table/table.component.ts create mode 100644 src/lib/components/data-display/timeline/index.ts create mode 100644 src/lib/components/data-display/timeline/timeline.component.scss create mode 100644 src/lib/components/data-display/timeline/timeline.component.ts create mode 100644 src/lib/components/data-display/tooltip/index.ts create mode 100644 src/lib/components/data-display/tooltip/tooltip.component.scss create mode 100644 src/lib/components/data-display/tooltip/tooltip.component.ts create mode 100644 src/lib/components/data-display/transfer-list/index.ts create mode 100644 src/lib/components/data-display/transfer-list/transfer-list.component.scss create mode 100644 src/lib/components/data-display/transfer-list/transfer-list.component.ts create mode 100644 src/lib/components/data-display/tree-view/index.ts create mode 100644 src/lib/components/data-display/tree-view/tree-view.component.scss create mode 100644 src/lib/components/data-display/tree-view/tree-view.component.ts create mode 100644 src/lib/components/feedback/alert/alert.component.scss create mode 100644 src/lib/components/feedback/alert/alert.component.ts create mode 100644 src/lib/components/feedback/alert/index.ts create mode 100644 src/lib/components/feedback/empty-state/empty-state.component.scss create mode 100644 src/lib/components/feedback/empty-state/empty-state.component.ts create mode 100644 src/lib/components/feedback/empty-state/index.ts create mode 100644 src/lib/components/feedback/index.ts create mode 100644 src/lib/components/feedback/loading-spinner/index.ts create mode 100644 src/lib/components/feedback/loading-spinner/loading-spinner.component.scss create mode 100644 src/lib/components/feedback/loading-spinner/loading-spinner.component.ts create mode 100644 src/lib/components/feedback/progress-circle/index.ts create mode 100644 src/lib/components/feedback/progress-circle/progress-circle.component.scss create mode 100644 src/lib/components/feedback/progress-circle/progress-circle.component.ts create mode 100644 src/lib/components/feedback/skeleton-loader/index.ts create mode 100644 src/lib/components/feedback/skeleton-loader/skeleton-loader.component.scss create mode 100644 src/lib/components/feedback/skeleton-loader/skeleton-loader.component.ts create mode 100644 src/lib/components/feedback/snackbar/index.ts create mode 100644 src/lib/components/feedback/snackbar/snackbar.component.scss create mode 100644 src/lib/components/feedback/snackbar/snackbar.component.ts create mode 100644 src/lib/components/feedback/status-badge.component.scss create mode 100644 src/lib/components/feedback/status-badge.component.ts create mode 100644 src/lib/components/feedback/theme-switcher.component.scss create mode 100644 src/lib/components/feedback/theme-switcher.component.ts create mode 100644 src/lib/components/feedback/toast/index.ts create mode 100644 src/lib/components/feedback/toast/toast.component.scss create mode 100644 src/lib/components/feedback/toast/toast.component.ts create mode 100644 src/lib/components/forms/autocomplete/autocomplete.component.scss create mode 100644 src/lib/components/forms/autocomplete/autocomplete.component.ts create mode 100644 src/lib/components/forms/autocomplete/index.ts create mode 100644 src/lib/components/forms/checkbox/checkbox.component.scss create mode 100644 src/lib/components/forms/checkbox/checkbox.component.ts create mode 100644 src/lib/components/forms/checkbox/index.ts create mode 100644 src/lib/components/forms/color-picker/color-picker.component.scss create mode 100644 src/lib/components/forms/color-picker/color-picker.component.ts create mode 100644 src/lib/components/forms/color-picker/index.ts create mode 100644 src/lib/components/forms/date-picker/date-picker.component.scss create mode 100644 src/lib/components/forms/date-picker/date-picker.component.ts create mode 100644 src/lib/components/forms/date-picker/index.ts create mode 100644 src/lib/components/forms/file-upload/file-upload.component.scss create mode 100644 src/lib/components/forms/file-upload/file-upload.component.ts create mode 100644 src/lib/components/forms/file-upload/index.ts create mode 100644 src/lib/components/forms/form-field/form-field.component.scss create mode 100644 src/lib/components/forms/form-field/form-field.component.ts create mode 100644 src/lib/components/forms/form-field/index.ts create mode 100644 src/lib/components/forms/index.ts create mode 100644 src/lib/components/forms/input/index.ts create mode 100644 src/lib/components/forms/input/input-wrapper.component.ts create mode 100644 src/lib/components/forms/input/text-input.component.scss create mode 100644 src/lib/components/forms/input/text-input.component.ts create mode 100644 src/lib/components/forms/input/textarea.component.scss create mode 100644 src/lib/components/forms/input/textarea.component.ts create mode 100644 src/lib/components/forms/radio/index.ts create mode 100644 src/lib/components/forms/radio/radio-button.component.scss create mode 100644 src/lib/components/forms/radio/radio-button.component.ts create mode 100644 src/lib/components/forms/radio/radio-group.component.scss create mode 100644 src/lib/components/forms/radio/radio-group.component.ts create mode 100644 src/lib/components/forms/range-slider/index.ts create mode 100644 src/lib/components/forms/range-slider/range-slider.component.scss create mode 100644 src/lib/components/forms/range-slider/range-slider.component.ts create mode 100644 src/lib/components/forms/search/index.ts create mode 100644 src/lib/components/forms/search/search-bar.component.scss create mode 100644 src/lib/components/forms/search/search-bar.component.ts create mode 100644 src/lib/components/forms/select/select.component.scss create mode 100644 src/lib/components/forms/select/select.component.ts create mode 100644 src/lib/components/forms/switch/index.ts create mode 100644 src/lib/components/forms/switch/switch.component.scss create mode 100644 src/lib/components/forms/switch/switch.component.ts create mode 100644 src/lib/components/forms/tag-input/index.ts create mode 100644 src/lib/components/forms/tag-input/tag-input.component.scss create mode 100644 src/lib/components/forms/tag-input/tag-input.component.ts create mode 100644 src/lib/components/forms/time-picker/index.ts create mode 100644 src/lib/components/forms/time-picker/time-picker.component.scss create mode 100644 src/lib/components/forms/time-picker/time-picker.component.ts create mode 100644 src/lib/components/layout/aspect-ratio/aspect-ratio.component.scss create mode 100644 src/lib/components/layout/aspect-ratio/aspect-ratio.component.ts create mode 100644 src/lib/components/layout/aspect-ratio/index.ts create mode 100644 src/lib/components/layout/bento-grid/bento-grid-item.component.ts create mode 100644 src/lib/components/layout/bento-grid/bento-grid.component.scss create mode 100644 src/lib/components/layout/bento-grid/bento-grid.component.ts create mode 100644 src/lib/components/layout/bento-grid/index.ts create mode 100644 src/lib/components/layout/box/box.component.scss create mode 100644 src/lib/components/layout/box/box.component.ts create mode 100644 src/lib/components/layout/box/index.ts create mode 100644 src/lib/components/layout/breakpoint-container/breakpoint-container.component.scss create mode 100644 src/lib/components/layout/breakpoint-container/breakpoint-container.component.ts create mode 100644 src/lib/components/layout/breakpoint-container/index.ts create mode 100644 src/lib/components/layout/center/center.component.scss create mode 100644 src/lib/components/layout/center/center.component.ts create mode 100644 src/lib/components/layout/center/index.ts create mode 100644 src/lib/components/layout/column/column.component.scss create mode 100644 src/lib/components/layout/column/column.component.ts create mode 100644 src/lib/components/layout/column/index.ts create mode 100644 src/lib/components/layout/container/container.component.scss create mode 100644 src/lib/components/layout/container/container.component.ts create mode 100644 src/lib/components/layout/container/index.ts create mode 100644 src/lib/components/layout/dashboard-shell/dashboard-shell.component.scss create mode 100644 src/lib/components/layout/dashboard-shell/dashboard-shell.component.ts create mode 100644 src/lib/components/layout/dashboard-shell/index.ts create mode 100644 src/lib/components/layout/divider/divider.component.scss create mode 100644 src/lib/components/layout/divider/divider.component.ts create mode 100644 src/lib/components/layout/divider/index.ts create mode 100644 src/lib/components/layout/feed-layout/feed-layout.component.scss create mode 100644 src/lib/components/layout/feed-layout/feed-layout.component.ts create mode 100644 src/lib/components/layout/feed-layout/index.ts create mode 100644 src/lib/components/layout/flex/flex.component.scss create mode 100644 src/lib/components/layout/flex/flex.component.ts create mode 100644 src/lib/components/layout/flex/index.ts create mode 100644 src/lib/components/layout/gallery-grid/gallery-grid.component.scss create mode 100644 src/lib/components/layout/gallery-grid/gallery-grid.component.ts create mode 100644 src/lib/components/layout/gallery-grid/index.ts create mode 100644 src/lib/components/layout/grid-container/grid-container.component.scss create mode 100644 src/lib/components/layout/grid-container/grid-container.component.ts create mode 100644 src/lib/components/layout/grid-container/index.ts create mode 100644 src/lib/components/layout/grid-system/grid-system.component.scss create mode 100644 src/lib/components/layout/grid-system/grid-system.component.ts create mode 100644 src/lib/components/layout/grid-system/index.ts create mode 100644 src/lib/components/layout/hstack/hstack.component.scss create mode 100644 src/lib/components/layout/hstack/hstack.component.ts create mode 100644 src/lib/components/layout/hstack/index.ts create mode 100644 src/lib/components/layout/index.ts create mode 100644 src/lib/components/layout/infinite-scroll-container/index.ts create mode 100644 src/lib/components/layout/infinite-scroll-container/infinite-scroll-container.component.scss create mode 100644 src/lib/components/layout/infinite-scroll-container/infinite-scroll-container.component.ts create mode 100644 src/lib/components/layout/kanban-board/index.ts create mode 100644 src/lib/components/layout/kanban-board/kanban-board.component.scss create mode 100644 src/lib/components/layout/kanban-board/kanban-board.component.ts create mode 100644 src/lib/components/layout/list-detail-layout/index.ts create mode 100644 src/lib/components/layout/list-detail-layout/list-detail-layout.component.scss create mode 100644 src/lib/components/layout/list-detail-layout/list-detail-layout.component.ts create mode 100644 src/lib/components/layout/masonry/index.ts create mode 100644 src/lib/components/layout/masonry/masonry.component.scss create mode 100644 src/lib/components/layout/masonry/masonry.component.ts create mode 100644 src/lib/components/layout/scroll-container/index.ts create mode 100644 src/lib/components/layout/scroll-container/scroll-container.component.scss create mode 100644 src/lib/components/layout/scroll-container/scroll-container.component.ts create mode 100644 src/lib/components/layout/section/index.ts create mode 100644 src/lib/components/layout/section/section.component.scss create mode 100644 src/lib/components/layout/section/section.component.ts create mode 100644 src/lib/components/layout/sidebar-layout/index.ts create mode 100644 src/lib/components/layout/sidebar-layout/sidebar-layout.component.scss create mode 100644 src/lib/components/layout/sidebar-layout/sidebar-layout.component.ts create mode 100644 src/lib/components/layout/spacer/index.ts create mode 100644 src/lib/components/layout/spacer/spacer.component.scss create mode 100644 src/lib/components/layout/spacer/spacer.component.ts create mode 100644 src/lib/components/layout/split-view/index.ts create mode 100644 src/lib/components/layout/split-view/split-view.component.scss create mode 100644 src/lib/components/layout/split-view/split-view.component.ts create mode 100644 src/lib/components/layout/stack/index.ts create mode 100644 src/lib/components/layout/stack/stack.component.scss create mode 100644 src/lib/components/layout/stack/stack.component.ts create mode 100644 src/lib/components/layout/sticky-layout/index.ts create mode 100644 src/lib/components/layout/sticky-layout/sticky-layout.component.scss create mode 100644 src/lib/components/layout/sticky-layout/sticky-layout.component.ts create mode 100644 src/lib/components/layout/supporting-pane-layout/index.ts create mode 100644 src/lib/components/layout/supporting-pane-layout/supporting-pane-layout.component.scss create mode 100644 src/lib/components/layout/supporting-pane-layout/supporting-pane-layout.component.ts create mode 100644 src/lib/components/layout/tabs-container/index.ts create mode 100644 src/lib/components/layout/tabs-container/tabs-container.component.scss create mode 100644 src/lib/components/layout/tabs-container/tabs-container.component.ts create mode 100644 src/lib/components/layout/vstack/index.ts create mode 100644 src/lib/components/layout/vstack/vstack.component.scss create mode 100644 src/lib/components/layout/vstack/vstack.component.ts create mode 100644 src/lib/components/media/index.ts create mode 100644 src/lib/components/media/video-player/index.ts create mode 100644 src/lib/components/media/video-player/video-player.component.scss create mode 100644 src/lib/components/media/video-player/video-player.component.ts create mode 100644 src/lib/components/navigation/README.md create mode 100644 src/lib/components/navigation/appbar/appbar.component.scss create mode 100644 src/lib/components/navigation/appbar/appbar.component.ts create mode 100644 src/lib/components/navigation/appbar/index.ts create mode 100644 src/lib/components/navigation/bottom-navigation/bottom-navigation.component.scss create mode 100644 src/lib/components/navigation/bottom-navigation/bottom-navigation.component.ts create mode 100644 src/lib/components/navigation/bottom-navigation/index.ts create mode 100644 src/lib/components/navigation/breadcrumb/breadcrumb.component.scss create mode 100644 src/lib/components/navigation/breadcrumb/breadcrumb.component.ts create mode 100644 src/lib/components/navigation/breadcrumb/index.ts create mode 100644 src/lib/components/navigation/index.ts create mode 100644 src/lib/components/navigation/menu/index.ts create mode 100644 src/lib/components/navigation/menu/menu-container.component.scss create mode 100644 src/lib/components/navigation/menu/menu-container.component.ts create mode 100644 src/lib/components/navigation/menu/menu-item.component.scss create mode 100644 src/lib/components/navigation/menu/menu-item.component.ts create mode 100644 src/lib/components/navigation/menu/menu-submenu.component.scss create mode 100644 src/lib/components/navigation/menu/menu-submenu.component.ts create mode 100644 src/lib/components/navigation/pagination/index.ts create mode 100644 src/lib/components/navigation/pagination/pagination.component.scss create mode 100644 src/lib/components/navigation/pagination/pagination.component.ts create mode 100644 src/lib/components/navigation/stepper/index.ts create mode 100644 src/lib/components/navigation/stepper/stepper.component.scss create mode 100644 src/lib/components/navigation/stepper/stepper.component.ts create mode 100644 src/lib/components/navigation/tab-group/index.ts create mode 100644 src/lib/components/navigation/tab-group/tab-group.component.scss create mode 100644 src/lib/components/navigation/tab-group/tab-group.component.ts create mode 100644 src/lib/components/overlays/backdrop/backdrop.component.scss create mode 100644 src/lib/components/overlays/backdrop/backdrop.component.ts create mode 100644 src/lib/components/overlays/backdrop/index.ts create mode 100644 src/lib/components/overlays/command-palette/command-palette-item.component.scss create mode 100644 src/lib/components/overlays/command-palette/command-palette-item.component.ts create mode 100644 src/lib/components/overlays/command-palette/command-palette.component.scss create mode 100644 src/lib/components/overlays/command-palette/command-palette.component.ts create mode 100644 src/lib/components/overlays/command-palette/command-palette.service.ts create mode 100644 src/lib/components/overlays/command-palette/command-palette.types.ts create mode 100644 src/lib/components/overlays/command-palette/global-keyboard.directive.ts create mode 100644 src/lib/components/overlays/command-palette/index.ts create mode 100644 src/lib/components/overlays/drawer/drawer.component.scss create mode 100644 src/lib/components/overlays/drawer/drawer.component.ts create mode 100644 src/lib/components/overlays/drawer/index.ts create mode 100644 src/lib/components/overlays/floating-toolbar/floating-toolbar.component.scss create mode 100644 src/lib/components/overlays/floating-toolbar/floating-toolbar.component.ts create mode 100644 src/lib/components/overlays/floating-toolbar/index.ts create mode 100644 src/lib/components/overlays/index.ts create mode 100644 src/lib/components/overlays/modal/index.ts create mode 100644 src/lib/components/overlays/modal/modal.component.scss create mode 100644 src/lib/components/overlays/modal/modal.component.ts create mode 100644 src/lib/components/overlays/overlay-container/index.ts create mode 100644 src/lib/components/overlays/overlay-container/overlay-container.component.scss create mode 100644 src/lib/components/overlays/overlay-container/overlay-container.component.ts create mode 100644 src/lib/components/overlays/popover/index.ts create mode 100644 src/lib/components/overlays/popover/popover.component.scss create mode 100644 src/lib/components/overlays/popover/popover.component.ts create mode 100644 src/lib/layouts/index.ts create mode 100644 src/lib/layouts/loading-state-container.component.ts create mode 100644 src/public-api.ts create mode 100644 tsconfig.lib.json create mode 100644 tsconfig.lib.prod.json create mode 100644 tsconfig.spec.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..cbf051c --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +Repository: ui-essentials diff --git a/ng-package.json b/ng-package.json new file mode 100644 index 0000000..e27c1eb --- /dev/null +++ b/ng-package.json @@ -0,0 +1,7 @@ +{ + "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", + "dest": "../../dist/ui-essentials", + "lib": { + "entryFile": "src/public-api.ts" + } +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..6ecbb94 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "ui-essentials", + "version": "0.0.1", + "peerDependencies": { + "@angular/common": "^19.2.0", + "@angular/core": "^19.2.0" + }, + "dependencies": { + "tslib": "^2.3.0" + }, + "sideEffects": false +} diff --git a/src/lib/components/buttons/button.component.scss b/src/lib/components/buttons/button.component.scss new file mode 100644 index 0000000..71e818b --- /dev/null +++ b/src/lib/components/buttons/button.component.scss @@ -0,0 +1,247 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +// Tokens available globally via main application styles + +.ui-button { + // Reset and base styles + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + border-radius: $semantic-border-radius-md; + cursor: pointer; + text-decoration: none; + outline: none; + position: relative; + overflow: hidden; + + // Typography + font-family: $semantic-typography-font-family-sans; + font-weight: $semantic-typography-font-weight-medium; + text-align: center; + text-transform: none; + letter-spacing: $semantic-typography-letter-spacing-normal; + white-space: nowrap; + + // Interaction states + user-select: none; + -webkit-tap-highlight-color: transparent; + box-sizing: border-box; + + // Transitions + transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1); + + // Size variants + &--small { + height: $semantic-spacing-9; // 2.25rem + padding: 0 $semantic-spacing-3; // 0.75rem + font-size: $semantic-typography-font-size-sm; + line-height: $semantic-typography-line-height-tight; + min-width: $semantic-spacing-16; // 4rem + } + + &--medium { + height: $semantic-spacing-11; // 2.75rem + padding: 0 $semantic-spacing-4; // 1rem + font-size: $semantic-typography-font-size-md; + line-height: $semantic-typography-line-height-normal; + min-width: $semantic-spacing-20; // 5rem + } + + &--large { + height: $semantic-spacing-12; // 3rem + a bit + padding: 0 $semantic-spacing-6; // 1.5rem + font-size: $semantic-typography-font-size-lg; + line-height: $semantic-typography-line-height-normal; + min-width: $semantic-spacing-24; // 6rem + } + + // Filled variant (primary) + &--filled { + background-color: $semantic-color-interactive-primary; + color: $semantic-color-on-brand-primary; + + &:hover:not(:disabled) { + background-color: $semantic-color-brand-primary; + transform: translateY(-1px); + box-shadow: $semantic-shadow-button-hover; + } + + &:active:not(:disabled) { + transform: scale(0.98); + box-shadow: $semantic-shadow-button-active; + } + + &:focus-visible { + outline: 2px solid $semantic-color-brand-primary; + outline-offset: 2px; + } + } + + // Tonal variant + &--tonal { + background-color: $semantic-color-container-primary; + color: $semantic-color-on-container-primary; + + &:hover:not(:disabled) { + background-color: $semantic-color-container-primary; + transform: translateY(-1px); + box-shadow: $semantic-shadow-button-hover; + filter: brightness(0.95); + } + + &:active:not(:disabled) { + transform: scale(0.98); + } + + &:focus-visible { + outline: 2px solid $semantic-color-brand-primary; + outline-offset: 2px; + } + } + + // Outlined variant + &--outlined { + background-color: transparent; + color: $semantic-color-interactive-primary; + border: $semantic-border-width-1 solid $semantic-color-border-primary; + + &:hover:not(:disabled) { + background-color: $semantic-color-container-primary; + border-color: $semantic-color-interactive-primary; + transform: translateY(-1px); + } + + &:active:not(:disabled) { + background-color: $semantic-color-container-primary; + transform: scale(0.98); + filter: brightness(0.9); + } + + &:focus-visible { + outline: 2px solid $semantic-color-brand-primary; + outline-offset: 2px; + } + } + + // States + &--disabled { + opacity: 0.38; + cursor: not-allowed; + pointer-events: none; + } + + &--loading { + cursor: wait; + + .button-content { + opacity: 0; + } + } + + &--full-width { + width: 100%; + } + + // Icon styles + .button-content { + display: flex; + align-items: center; + justify-content: center; + } + + .button-icon { + display: inline-flex; + align-items: center; + flex-shrink: 0; + + &--left { + margin-right: $semantic-spacing-2; // 0.5rem + } + + &--right { + margin-left: $semantic-spacing-2; // 0.5rem + } + } + + // Size-specific icon adjustments + &--small .button-icon { + font-size: $semantic-typography-font-size-xs; + + &--left { + margin-right: $semantic-spacing-1-5; // 0.375rem + } + + &--right { + margin-left: $semantic-spacing-1-5; // 0.375rem + } + } + + &--medium .button-icon { + font-size: $semantic-typography-font-size-sm; + + &--left { + margin-right: $semantic-spacing-2; // 0.5rem + } + + &--right { + margin-left: $semantic-spacing-2; // 0.5rem + } + } + + &--large .button-icon { + font-size: $semantic-typography-font-size-md; + + &--left { + margin-right: $semantic-spacing-2-5; // 0.625rem + } + + &--right { + margin-left: $semantic-spacing-2-5; // 0.625rem + } + } + + // Loader styles + .button-loader { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } + + .button-spinner { + width: $semantic-spacing-4; // 1rem + height: $semantic-spacing-4; // 1rem + border: 2px solid currentColor; + border-top: 2px solid transparent; + border-radius: 50%; + animation: spin 1s linear infinite; + opacity: 0.7; + } + + @keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + } + + // Ripple effect + &::after { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: radial-gradient(circle, rgba(255, 255, 255, 0.3) 0%, transparent 70%); + transform: scale(0); + opacity: 0; + pointer-events: none; + transition: transform 0.3s ease, opacity 0.3s ease; + } + + &:active:not(:disabled)::after { + transform: scale(1); + opacity: 1; + transition: none; + } +} \ No newline at end of file diff --git a/src/lib/components/buttons/button.component.ts b/src/lib/components/buttons/button.component.ts new file mode 100644 index 0000000..7d61a68 --- /dev/null +++ b/src/lib/components/buttons/button.component.ts @@ -0,0 +1,75 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { IconDefinition } from '@fortawesome/fontawesome-svg-core'; + +export type ButtonVariant = 'filled' | 'tonal' | 'outlined'; +export type ButtonSize = 'small' | 'medium' | 'large'; +export type IconPosition = 'left' | 'right'; + +@Component({ + selector: 'ui-button', + standalone: true, + imports: [CommonModule, FontAwesomeModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + `, + styleUrl: './button.component.scss' +}) +export class ButtonComponent { + @Input() variant: ButtonVariant = 'filled'; + @Input() size: ButtonSize = 'medium'; + @Input() disabled: boolean = false; + @Input() loading: boolean = false; + @Input() type: 'button' | 'submit' | 'reset' = 'button'; + @Input() fullWidth: boolean = false; + @Input() class: string = ''; + @Input() icon?: IconDefinition; + @Input() iconPosition: IconPosition = 'left'; + + @Output() clicked = new EventEmitter(); + + get buttonClasses(): string { + return [ + 'ui-button', + `ui-button--${this.variant}`, + `ui-button--${this.size}`, + this.disabled ? 'ui-button--disabled' : '', + this.loading ? 'ui-button--loading' : '', + this.fullWidth ? 'ui-button--full-width' : '', + this.icon ? 'ui-button--with-icon' : '', + this.icon ? `ui-button--icon-${this.iconPosition}` : '', + this.class + ].filter(Boolean).join(' '); + } + + handleClick(event: Event): void { + if (!this.disabled && !this.loading) { + this.clicked.emit(event); + } + } +} \ No newline at end of file diff --git a/src/lib/components/buttons/fab-menu/fab-menu.component.scss b/src/lib/components/buttons/fab-menu/fab-menu.component.scss new file mode 100644 index 0000000..fc59ee6 --- /dev/null +++ b/src/lib/components/buttons/fab-menu/fab-menu.component.scss @@ -0,0 +1,475 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-fab-menu { + position: relative; + display: inline-flex; + z-index: $semantic-z-index-dropdown; + + // Position variants + &--bottom-right { + position: fixed; + bottom: $semantic-spacing-layout-section-md; + right: $semantic-spacing-layout-section-md; + } + + &--bottom-left { + position: fixed; + bottom: $semantic-spacing-layout-section-md; + left: $semantic-spacing-layout-section-md; + } + + &--top-right { + position: fixed; + top: $semantic-spacing-layout-section-md; + right: $semantic-spacing-layout-section-md; + } + + &--top-left { + position: fixed; + top: $semantic-spacing-layout-section-md; + left: $semantic-spacing-layout-section-md; + } + + // Size variants + &--sm { + .ui-fab-menu__trigger { + width: $semantic-sizing-button-height-sm; + height: $semantic-sizing-button-height-sm; + + .ui-fab-menu__trigger-icon { + width: $semantic-sizing-icon-button; + height: $semantic-sizing-icon-button; + font-size: map-get($semantic-typography-body-small, font-size); + } + } + + .ui-fab-menu__item { + min-height: $semantic-sizing-button-height-sm; + padding: $semantic-spacing-component-xs $semantic-spacing-component-sm; + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + } + } + + &--md { + .ui-fab-menu__trigger { + width: $semantic-sizing-button-height-md; + height: $semantic-sizing-button-height-md; + + .ui-fab-menu__trigger-icon { + width: $semantic-sizing-icon-button; + height: $semantic-sizing-icon-button; + font-size: map-get($semantic-typography-body-medium, font-size); + } + } + + .ui-fab-menu__item { + min-height: $semantic-sizing-button-height-md; + padding: $semantic-spacing-component-sm $semantic-spacing-component-md; + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + } + } + + &--lg { + .ui-fab-menu__trigger { + width: $semantic-sizing-button-height-lg; + height: $semantic-sizing-button-height-lg; + + .ui-fab-menu__trigger-icon { + width: $semantic-sizing-icon-navigation; + height: $semantic-sizing-icon-navigation; + font-size: map-get($semantic-typography-body-large, font-size); + } + } + + .ui-fab-menu__item { + min-height: $semantic-sizing-button-height-lg; + padding: $semantic-spacing-component-md $semantic-spacing-component-lg; + font-family: map-get($semantic-typography-body-large, font-family); + font-size: map-get($semantic-typography-body-large, font-size); + font-weight: map-get($semantic-typography-body-large, font-weight); + line-height: map-get($semantic-typography-body-large, line-height); + } + } + + // Direction variants + &--up { + .ui-fab-menu__items { + flex-direction: column-reverse; + bottom: 100%; + margin-bottom: $semantic-spacing-component-sm; + } + + .ui-fab-menu__item { + transform-origin: center bottom; + } + } + + &--down { + .ui-fab-menu__items { + flex-direction: column; + top: 100%; + margin-top: $semantic-spacing-component-sm; + } + + .ui-fab-menu__item { + transform-origin: center top; + } + } + + &--left { + .ui-fab-menu__items { + flex-direction: row-reverse; + right: 100%; + margin-right: $semantic-spacing-component-sm; + align-items: center; + } + + .ui-fab-menu__item { + transform-origin: right center; + } + } + + &--right { + .ui-fab-menu__items { + flex-direction: row; + left: 100%; + margin-left: $semantic-spacing-component-sm; + align-items: center; + } + + .ui-fab-menu__item { + transform-origin: left center; + } + } + + // State variants + &--disabled { + .ui-fab-menu__trigger { + opacity: $semantic-opacity-disabled; + cursor: not-allowed; + pointer-events: none; + } + } + + &--animating { + pointer-events: none; + } +} + +.ui-fab-menu__trigger { + position: relative; + display: flex; + align-items: center; + justify-content: center; + border: $semantic-border-width-1 solid transparent; + border-radius: $semantic-border-radius-full; + cursor: pointer; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + box-shadow: $semantic-shadow-elevation-2; + overflow: hidden; + + // Color variants + &--primary { + background: $semantic-color-primary; + color: $semantic-color-on-primary; + border-color: $semantic-color-primary; + + &:hover:not([disabled]) { + box-shadow: $semantic-shadow-elevation-3; + } + } + + &--secondary { + background: $semantic-color-secondary; + color: $semantic-color-on-secondary; + border-color: $semantic-color-secondary; + + &:hover:not([disabled]) { + box-shadow: $semantic-shadow-elevation-3; + } + } + + &--success { + background: $semantic-color-success; + color: $semantic-color-on-success; + border-color: $semantic-color-success; + + &:hover:not([disabled]) { + box-shadow: $semantic-shadow-elevation-3; + } + } + + &--danger { + background: $semantic-color-danger; + color: $semantic-color-on-danger; + border-color: $semantic-color-danger; + + &:hover:not([disabled]) { + box-shadow: $semantic-shadow-elevation-3; + } + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + + &:active:not([disabled]) { + transform: scale(0.95); + box-shadow: $semantic-shadow-elevation-1; + } + + &--active { + transform: rotate(45deg); + } + + .ui-fab-menu__trigger-icon { + display: flex; + align-items: center; + justify-content: center; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + + &--close { + transform: rotate(-45deg); + } + } + + .ui-fab-menu__trigger-ripple { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border-radius: inherit; + overflow: hidden; + + &::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 0; + height: 0; + border-radius: 50%; + background: currentColor; + transform: translate(-50%, -50%); + opacity: 0; + transition: all $semantic-motion-duration-normal $semantic-motion-easing-ease; + } + + .ui-fab-menu__trigger:active & { + &::after { + width: 100%; + height: 100%; + opacity: $semantic-opacity-subtle; + } + } + } +} + +.ui-fab-menu__items { + position: absolute; + display: flex; + gap: $semantic-spacing-component-xs; + pointer-events: none; + z-index: 1; + + .ui-fab-menu--open & { + pointer-events: auto; + } +} + +.ui-fab-menu__item { + position: relative; + display: flex; + align-items: center; + gap: $semantic-spacing-component-xs; + border: $semantic-border-width-1 solid $semantic-color-border-primary; + border-radius: $semantic-border-radius-md; + background: $semantic-color-surface-primary; + color: $semantic-color-text-primary; + cursor: pointer; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + box-shadow: $semantic-shadow-elevation-1; + transform: scale(0); + opacity: 0; + overflow: hidden; + white-space: nowrap; + + .ui-fab-menu--open & { + transform: scale(1); + opacity: 1; + + @for $i from 1 through 10 { + &:nth-child(#{$i}) { + transition-delay: #{($i - 1) * 50ms}; + } + } + } + + .ui-fab-menu--animating:not(.ui-fab-menu--open) & { + transition-delay: 0ms; + } + + // Color variants + &--primary { + background: $semantic-color-primary; + color: $semantic-color-on-primary; + border-color: $semantic-color-primary; + } + + &--secondary { + background: $semantic-color-secondary; + color: $semantic-color-on-secondary; + border-color: $semantic-color-secondary; + } + + &--success { + background: $semantic-color-success; + color: $semantic-color-on-success; + border-color: $semantic-color-success; + } + + &--danger { + background: $semantic-color-danger; + color: $semantic-color-on-danger; + border-color: $semantic-color-danger; + } + + &:hover:not([disabled]) { + box-shadow: $semantic-shadow-elevation-2; + transform: scale(1.05); + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + + &:active:not([disabled]) { + transform: scale(0.98); + box-shadow: $semantic-shadow-elevation-1; + } + + &--disabled { + opacity: $semantic-opacity-disabled; + cursor: not-allowed; + pointer-events: none; + } + + .ui-fab-menu__item-icon { + display: flex; + align-items: center; + justify-content: center; + width: $semantic-sizing-icon-inline; + height: $semantic-sizing-icon-inline; + flex-shrink: 0; + } + + .ui-fab-menu__item-label { + font-weight: $semantic-typography-font-weight-medium; + } + + .ui-fab-menu__item-ripple { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border-radius: inherit; + overflow: hidden; + + &::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 0; + height: 0; + border-radius: 50%; + background: currentColor; + transform: translate(-50%, -50%); + opacity: 0; + transition: all $semantic-motion-duration-normal $semantic-motion-easing-ease; + } + + .ui-fab-menu__item:active & { + &::after { + width: 100%; + height: 100%; + opacity: $semantic-opacity-subtle; + } + } + } +} + +.ui-fab-menu__backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: $semantic-color-backdrop; + backdrop-filter: blur(2px); + z-index: -1; + opacity: 0; + transition: opacity $semantic-motion-duration-fast $semantic-motion-easing-ease; + + .ui-fab-menu--open & { + opacity: 1; + } +} + +// Responsive design +@media (max-width: 768px) { + .ui-fab-menu { + &--bottom-right, + &--bottom-left, + &--top-right, + &--top-left { + margin: $semantic-spacing-component-sm; + } + } + + .ui-fab-menu__item { + .ui-fab-menu__item-label { + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + } + } +} + +// High contrast mode support +@media (prefers-contrast: high) { + .ui-fab-menu__trigger, + .ui-fab-menu__item { + border-width: $semantic-border-width-2; + } +} + +// Reduced motion support +@media (prefers-reduced-motion: reduce) { + .ui-fab-menu__trigger, + .ui-fab-menu__item, + .ui-fab-menu__backdrop, + .ui-fab-menu__trigger-icon, + .ui-fab-menu__trigger-ripple::after, + .ui-fab-menu__item-ripple::after { + transition-duration: 0ms; + animation-duration: 0ms; + } + + .ui-fab-menu__item { + .ui-fab-menu--open & { + transition-delay: 0ms; + } + } +} \ No newline at end of file diff --git a/src/lib/components/buttons/fab-menu/fab-menu.component.ts b/src/lib/components/buttons/fab-menu/fab-menu.component.ts new file mode 100644 index 0000000..82b808f --- /dev/null +++ b/src/lib/components/buttons/fab-menu/fab-menu.component.ts @@ -0,0 +1,285 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation, HostListener, ElementRef, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +export type FabMenuSize = 'sm' | 'md' | 'lg'; +export type FabMenuPosition = 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left'; +export type FabMenuVariant = 'primary' | 'secondary' | 'success' | 'danger'; +export type FabMenuDirection = 'up' | 'down' | 'left' | 'right' | 'auto'; + +export interface FabMenuItem { + id: string; + label: string; + icon?: string; + disabled?: boolean; + variant?: FabMenuVariant; +} + +@Component({ + selector: 'ui-fab-menu', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ + + @if (isOpen) { + + } + + + + + + @if (isOpen && backdrop) { + + } +
+ `, + styleUrl: './fab-menu.component.scss' +}) +export class FabMenuComponent { + @Input() size: FabMenuSize = 'md'; + @Input() variant: FabMenuVariant = 'primary'; + @Input() position: FabMenuPosition = 'bottom-right'; + @Input() direction: FabMenuDirection = 'auto'; + @Input() menuItems: FabMenuItem[] = []; + @Input() disabled = false; + @Input() triggerIcon?: string; + @Input() closeIcon?: string; + @Input() triggerLabel = 'Open menu'; + @Input() backdrop = true; + @Input() closeOnItemClick = true; + @Input() closeOnOutsideClick = true; + + @Output() opened = new EventEmitter(); + @Output() closed = new EventEmitter(); + @Output() itemClicked = new EventEmitter<{item: FabMenuItem, event: Event}>(); + + private elementRef = inject(ElementRef); + + isOpen = false; + isAnimating = false; + + get actualDirection(): FabMenuDirection { + if (this.direction !== 'auto') return this.direction; + + // Auto-determine direction based on position + switch (this.position) { + case 'bottom-right': + case 'bottom-left': + return 'up'; + case 'top-right': + case 'top-left': + return 'down'; + default: + return 'up'; + } + } + + getFabMenuClasses(): string { + return [ + 'ui-fab-menu', + `ui-fab-menu--${this.size}`, + `ui-fab-menu--${this.variant}`, + `ui-fab-menu--${this.position}`, + `ui-fab-menu--${this.actualDirection}`, + this.isOpen ? 'ui-fab-menu--open' : '', + this.disabled ? 'ui-fab-menu--disabled' : '', + this.isAnimating ? 'ui-fab-menu--animating' : '' + ].filter(Boolean).join(' '); + } + + getTriggerClasses(): string { + return [ + 'ui-fab-menu__trigger', + `ui-fab-menu__trigger--${this.size}`, + `ui-fab-menu__trigger--${this.variant}`, + this.isOpen ? 'ui-fab-menu__trigger--active' : '' + ].filter(Boolean).join(' '); + } + + getItemClasses(item: FabMenuItem): string { + const variant = item.variant || this.variant; + return [ + 'ui-fab-menu__item', + `ui-fab-menu__item--${variant}`, + item.disabled ? 'ui-fab-menu__item--disabled' : '' + ].filter(Boolean).join(' '); + } + + @HostListener('document:click', ['$event']) + onDocumentClick(event: Event): void { + const target = event.target as Node; + if (this.closeOnOutsideClick && this.isOpen && target && !this.elementRef.nativeElement.contains(target)) { + this.close(); + } + } + + @HostListener('keydown.escape') + onEscapePress(): void { + if (this.isOpen) { + this.close(); + } + } + + toggle(event?: Event): void { + if (this.disabled) return; + + if (this.isOpen) { + this.close(); + } else { + this.open(); + } + } + + open(): void { + if (this.disabled || this.isOpen) return; + + this.isAnimating = true; + this.isOpen = true; + this.opened.emit(); + + // Focus first menu item after animation + setTimeout(() => { + this.isAnimating = false; + this.focusFirstMenuItem(); + }, 200); + } + + close(): void { + if (!this.isOpen) return; + + this.isAnimating = true; + this.isOpen = false; + this.closed.emit(); + + setTimeout(() => { + this.isAnimating = false; + }, 200); + } + + handleItemClick(item: FabMenuItem, event: Event): void { + if (item.disabled || this.disabled) return; + + this.itemClicked.emit({ item, event }); + + if (this.closeOnItemClick) { + this.close(); + } + } + + handleTriggerKeydown(event: KeyboardEvent): void { + switch (event.key) { + case 'Enter': + case ' ': + event.preventDefault(); + this.toggle(); + break; + case 'ArrowUp': + if (this.actualDirection === 'up') { + event.preventDefault(); + if (!this.isOpen) this.open(); + } + break; + case 'ArrowDown': + if (this.actualDirection === 'down') { + event.preventDefault(); + if (!this.isOpen) this.open(); + } + break; + } + } + + handleItemKeydown(item: FabMenuItem, event: KeyboardEvent): void { + switch (event.key) { + case 'Enter': + case ' ': + event.preventDefault(); + this.handleItemClick(item, event); + break; + case 'ArrowUp': + event.preventDefault(); + this.focusPreviousItem(event.target as HTMLElement); + break; + case 'ArrowDown': + event.preventDefault(); + this.focusNextItem(event.target as HTMLElement); + break; + case 'Tab': + if (event.shiftKey) { + this.focusPreviousItem(event.target as HTMLElement); + } else { + this.focusNextItem(event.target as HTMLElement); + } + break; + } + } + + private focusFirstMenuItem(): void { + const firstItem = this.elementRef.nativeElement.querySelector('.ui-fab-menu__item:not([disabled])'); + if (firstItem) { + firstItem.focus(); + } + } + + private focusNextItem(currentElement: HTMLElement): void { + const items = Array.from(this.elementRef.nativeElement.querySelectorAll('.ui-fab-menu__item:not([disabled])')); + const currentIndex = items.indexOf(currentElement); + const nextIndex = (currentIndex + 1) % items.length; + (items[nextIndex] as HTMLElement).focus(); + } + + private focusPreviousItem(currentElement: HTMLElement): void { + const items = Array.from(this.elementRef.nativeElement.querySelectorAll('.ui-fab-menu__item:not([disabled])')); + const currentIndex = items.indexOf(currentElement); + const previousIndex = currentIndex === 0 ? items.length - 1 : currentIndex - 1; + (items[previousIndex] as HTMLElement).focus(); + } +} \ No newline at end of file diff --git a/src/lib/components/buttons/fab-menu/index.ts b/src/lib/components/buttons/fab-menu/index.ts new file mode 100644 index 0000000..9c6f57d --- /dev/null +++ b/src/lib/components/buttons/fab-menu/index.ts @@ -0,0 +1 @@ +export * from './fab-menu.component'; \ No newline at end of file diff --git a/src/lib/components/buttons/fab.component.scss b/src/lib/components/buttons/fab.component.scss new file mode 100644 index 0000000..d763710 --- /dev/null +++ b/src/lib/components/buttons/fab.component.scss @@ -0,0 +1,303 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; +.ui-fab { + // Reset and base styles + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + border-radius: 50%; + cursor: pointer; + outline: none; + position: relative; + overflow: hidden; + + // Interaction states + user-select: none; + -webkit-tap-highlight-color: transparent; + box-sizing: border-box; + + // Transitions + transition: all $semantic-motion-duration-normal $semantic-motion-easing-ease-out; + + // Size variants + &--small { + width: $semantic-spacing-10; // 2.5rem + height: $semantic-spacing-10; // 2.5rem + box-shadow: $semantic-shadow-card-hover; + + .fab-icon { + font-size: $semantic-typography-font-size-lg; // 1.125rem + } + + .fab-touch-overlay { + width: $semantic-spacing-4; // 1rem + height: $semantic-spacing-4; // 1rem + top: $semantic-spacing-3; // 0.75rem + left: $semantic-spacing-3; // 0.75rem + border-radius: $semantic-border-radius-sm; + } + + .fab-spinner { + width: $semantic-spacing-4; // 1rem + height: $semantic-spacing-4; // 1rem + border-width: $semantic-border-width-2; // 2px (closest to 1.5px) + } + } + + &--medium { + width: $semantic-spacing-14; // 3.5rem + height: $semantic-spacing-14; // 3.5rem + box-shadow: $semantic-shadow-card-active; + + .fab-icon { + font-size: $semantic-typography-font-size-2xl; // 1.5rem + } + + .fab-touch-overlay { + width: $semantic-spacing-5; // 1.25rem + height: $semantic-spacing-5; // 1.25rem + top: $semantic-spacing-4-5; // ~1.125rem (closest available) + left: $semantic-spacing-4-5; // ~1.125rem (closest available) + border-radius: $semantic-border-radius-lg; + } + + .fab-spinner { + width: $semantic-spacing-5; // 1.25rem + height: $semantic-spacing-5; // 1.25rem + border-width: $semantic-border-width-2; + } + } + + &--large { + width: $semantic-spacing-24; // 6rem + height: $semantic-spacing-24; // 6rem + box-shadow: $semantic-shadow-card-featured; + + .fab-icon { + font-size: $semantic-typography-font-size-5xl; // 3rem (closest to 2.5rem) + } + + .fab-touch-overlay { + width: $semantic-spacing-10; // 2.5rem + height: $semantic-spacing-10; // 2.5rem + top: $semantic-spacing-7; // 1.75rem + left: $semantic-spacing-7; // 1.75rem + border-radius: $semantic-border-radius-xl; + } + + .fab-spinner { + width: $semantic-spacing-8; // 2rem + height: $semantic-spacing-8; // 2rem + border-width: $semantic-border-width-3; + } + } + + // Color variants + &--primary { + background-color: $semantic-color-interactive-primary; + color: $semantic-color-on-brand-primary; + + &:hover:not(:disabled) { + background-color: $semantic-color-brand-primary; + transform: $semantic-motion-hover-transform-scale-md; + box-shadow: $semantic-shadow-elevation-4; + } + } + + &--secondary { + background-color: $semantic-color-interactive-secondary; + color: $semantic-color-on-brand-secondary; + + &:hover:not(:disabled) { + background-color: $semantic-color-brand-secondary; + transform: $semantic-motion-hover-transform-scale-md; + box-shadow: $semantic-shadow-elevation-4; + } + } + + &--tertiary { + background-color: $semantic-color-interactive-tertiary; + color: $semantic-color-on-brand-tertiary; + + &:hover:not(:disabled) { + background-color: $semantic-color-brand-tertiary; + transform: $semantic-motion-hover-transform-scale-md; + box-shadow: $semantic-shadow-elevation-4; + } + } + + // Position variants + &--bottom-right { + position: fixed; + bottom: $semantic-spacing-8; // 2rem + right: $semantic-spacing-6; // 1.5rem + z-index: $semantic-z-index-toast; // 1080 + } + + &--bottom-left { + position: fixed; + bottom: $semantic-spacing-8; // 2rem + left: $semantic-spacing-6; // 1.5rem + z-index: $semantic-z-index-toast; // 1080 + } + + &--top-right { + position: fixed; + top: $semantic-spacing-8; // 2rem + right: $semantic-spacing-6; // 1.5rem + z-index: $semantic-z-index-toast; // 1080 + } + + &--top-left { + position: fixed; + top: $semantic-spacing-8; // 2rem + left: $semantic-spacing-6; // 1.5rem + z-index: $semantic-z-index-toast; // 1080 + } + + // States + &:active:not(:disabled) { + transform: scale(0.92); + } + + &:focus-visible { + box-shadow: + $semantic-shadow-button-focus, + $semantic-shadow-card-active; + } + + &--disabled { + opacity: $semantic-opacity-disabled; + cursor: not-allowed; + pointer-events: none; + box-shadow: $semantic-shadow-elevation-1; + } + + &--loading { + cursor: wait; + + .fab-icon { + opacity: $semantic-opacity-invisible; + } + } + + // Pulse effect + &--pulse { + overflow: visible; + + .fab-pulse { + position: absolute; + width: 100%; + height: 100%; + background-color: inherit; + border-radius: inherit; + animation: fab-pulse 2s infinite; + z-index: -1; + } + } + + @keyframes fab-pulse { + 0% { + transform: scale(1); + opacity: 1; + } + 100% { + transform: scale(1.4); + opacity: 0; + } + } + + // Expand animation + &--expanding { + animation: fab-expand 300ms cubic-bezier(0, 0, 0.2, 1); + } + + @keyframes fab-expand { + 0% { + transform: scale(1); + opacity: 1; + } + 50% { + transform: scale(1.2); + opacity: 0.8; + } + 100% { + transform: scale(1); + opacity: 1; + } + } + + // Touch overlay for ripple effect + .fab-touch-overlay { + position: absolute; + background-color: rgba(0, 0, 0, 0); + z-index: 1; + pointer-events: none; + } + + // Icon container + .fab-icon { + position: relative; + z-index: 2; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; + } + + // Loader styles + .fab-loader { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 3; + } + + .fab-spinner { + border: 2px solid rgba(255, 255, 255, 0.3); + border-top: 2px solid white; + border-radius: 50%; + animation: spin 1s linear infinite; + } + + @keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + } + + // Ripple effect + &::after { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: radial-gradient(circle, rgba(255, 255, 255, 0.3) 0%, transparent 70%); + transform: scale(0); + opacity: 0; + pointer-events: none; + transition: transform 0.3s ease, opacity 0.3s ease; + border-radius: inherit; + } + + &:active:not(:disabled)::after { + transform: scale(1); + opacity: 1; + transition: none; + } + + // Extended FAB support (for future use) + &--extended { + border-radius: $semantic-border-radius-pill; // 1.75rem equivalent + padding: 0 $semantic-spacing-4; // 1rem + width: auto; + min-width: $semantic-spacing-20; // 5rem + height: $semantic-spacing-14; // 3.5rem + + .fab-icon { + margin-right: $semantic-spacing-1-5; // 0.375rem + } + } +} \ No newline at end of file diff --git a/src/lib/components/buttons/fab.component.ts b/src/lib/components/buttons/fab.component.ts new file mode 100644 index 0000000..a126776 --- /dev/null +++ b/src/lib/components/buttons/fab.component.ts @@ -0,0 +1,79 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +export type FabSize = 'small' | 'medium' | 'large'; +export type FabVariant = 'primary' | 'secondary' | 'tertiary'; +export type FabPosition = 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left' | 'static'; + +@Component({ + selector: 'ui-fab', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + `, + styleUrl: './fab.component.scss' +}) +export class FabComponent { + @Input() variant: FabVariant = 'primary'; + @Input() size: FabSize = 'medium'; + @Input() disabled: boolean = false; + @Input() loading: boolean = false; + @Input() type: 'button' | 'submit' | 'reset' = 'button'; + @Input() position: FabPosition = 'static'; + @Input() pulse: boolean = false; + @Input() expandOnClick: boolean = false; + @Input() class: string = ''; + + @Output() clicked = new EventEmitter(); + + get fabClasses(): string { + return [ + 'ui-fab', + `ui-fab--${this.variant}`, + `ui-fab--${this.size}`, + `ui-fab--${this.position}`, + this.disabled ? 'ui-fab--disabled' : '', + this.loading ? 'ui-fab--loading' : '', + this.pulse ? 'ui-fab--pulse' : '', + this.expandOnClick ? 'ui-fab--expandable' : '', + this.class + ].filter(Boolean).join(' '); + } + + handleClick(event: Event): void { + if (!this.disabled && !this.loading) { + this.clicked.emit(event); + + if (this.expandOnClick) { + // Add animation class for expand effect + (event.target as HTMLElement).classList.add('ui-fab--expanding'); + setTimeout(() => { + (event.target as HTMLElement).classList.remove('ui-fab--expanding'); + }, 300); + } + } + } +} \ No newline at end of file diff --git a/src/lib/components/buttons/ghost-button.component.scss b/src/lib/components/buttons/ghost-button.component.scss new file mode 100644 index 0000000..a822320 --- /dev/null +++ b/src/lib/components/buttons/ghost-button.component.scss @@ -0,0 +1,141 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; +.ui-ghost-button { + // Reset and base styles + display: inline-flex; + align-items: center; + justify-content: center; + border: $semantic-border-width-1 solid transparent; + background: transparent; + cursor: pointer; + text-decoration: none; + outline: none; + position: relative; + overflow: hidden; + border-radius: $semantic-border-radius-lg; + + // Typography + font-family: $semantic-typography-font-family-sans; + font-weight: $semantic-typography-font-weight-medium; + text-align: center; + text-transform: none; + letter-spacing: $semantic-typography-letter-spacing-wide; + white-space: nowrap; + color: $semantic-color-text-secondary; + + // Interaction states + user-select: none; + -webkit-tap-highlight-color: transparent; + box-sizing: border-box; + + // Transitions + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease-out; + + // Size variants + &--small { + height: $semantic-spacing-9; // 2.25rem + padding: 0 $semantic-spacing-2; // 0.5rem + font-size: $semantic-typography-font-size-sm; + line-height: $semantic-typography-line-height-normal; + min-width: $semantic-spacing-16; // 4rem + } + + &--medium { + height: $semantic-spacing-11; // 2.75rem + padding: 0 $semantic-spacing-4; // 1rem + font-size: $semantic-typography-font-size-md; + line-height: $semantic-typography-line-height-normal; + min-width: $semantic-spacing-20; // 5rem + } + + &--large { + height: $semantic-spacing-12; // 3rem (closest to 3.25rem) + padding: 0 $semantic-spacing-6; // 1.5rem + font-size: $semantic-typography-font-size-lg; + line-height: $semantic-typography-line-height-normal; + min-width: $semantic-spacing-24; // 6rem + } + + // Hover states + &:hover:not(:disabled) { + background-color: $semantic-color-surface-container; + color: $semantic-color-text-primary; + border-color: $semantic-color-border-secondary; + transform: $semantic-motion-hover-transform-lift-sm; + box-shadow: $semantic-shadow-button-hover; + } + + &:active:not(:disabled) { + background-color: $semantic-color-surface-high; + transform: scale(0.98); + box-shadow: $semantic-shadow-none; + } + + &:focus-visible { + background-color: $semantic-color-surface-container; + border-color: $semantic-color-border-focus; + box-shadow: $semantic-shadow-button-focus; + } + + // States + &--disabled { + opacity: $semantic-opacity-disabled; + cursor: not-allowed; + pointer-events: none; + } + + &--loading { + cursor: wait; + + .ghost-button-content { + opacity: $semantic-opacity-invisible; + } + } + + &--full-width { + width: 100%; + } + + // Loader styles + .ghost-button-loader { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } + + .ghost-button-spinner { + width: $semantic-spacing-4; // 1rem + height: $semantic-spacing-4; // 1rem + border: $semantic-border-width-2 solid rgba($semantic-color-brand-primary, 0.3); + border-top: $semantic-border-width-2 solid $semantic-color-brand-primary; + border-radius: $semantic-border-radius-full; + animation: spin 1s $semantic-motion-easing-linear infinite; + } + + @keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + } + + // Ripple effect (basic implementation) + &::after { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: radial-gradient(circle, rgba($semantic-color-brand-primary, 0.08) 0%, transparent 70%); + transform: scale(0); + opacity: $semantic-opacity-invisible; + pointer-events: none; + transition: transform $semantic-motion-duration-normal $semantic-motion-easing-ease, opacity $semantic-motion-duration-normal $semantic-motion-easing-ease; + border-radius: inherit; + } + + &:active:not(:disabled)::after { + transform: scale(1); + opacity: $semantic-opacity-opaque; + transition: none; + } +} \ No newline at end of file diff --git a/src/lib/components/buttons/ghost-button.component.ts b/src/lib/components/buttons/ghost-button.component.ts new file mode 100644 index 0000000..9bffdca --- /dev/null +++ b/src/lib/components/buttons/ghost-button.component.ts @@ -0,0 +1,55 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +export type GhostButtonSize = 'small' | 'medium' | 'large'; + +@Component({ + selector: 'ui-ghost-button', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + `, + styleUrl: './ghost-button.component.scss' +}) +export class GhostButtonComponent { + @Input() size: GhostButtonSize = 'medium'; + @Input() disabled: boolean = false; + @Input() loading: boolean = false; + @Input() type: 'button' | 'submit' | 'reset' = 'button'; + @Input() fullWidth: boolean = false; + @Input() class: string = ''; + + @Output() clicked = new EventEmitter(); + + get buttonClasses(): string { + return [ + 'ui-ghost-button', + `ui-ghost-button--${this.size}`, + this.disabled ? 'ui-ghost-button--disabled' : '', + this.loading ? 'ui-ghost-button--loading' : '', + this.fullWidth ? 'ui-ghost-button--full-width' : '', + this.class + ].filter(Boolean).join(' '); + } + + handleClick(event: Event): void { + if (!this.disabled && !this.loading) { + this.clicked.emit(event); + } + } +} \ No newline at end of file diff --git a/src/lib/components/buttons/icon-button/icon-button.component.scss b/src/lib/components/buttons/icon-button/icon-button.component.scss new file mode 100644 index 0000000..e3246cf --- /dev/null +++ b/src/lib/components/buttons/icon-button/icon-button.component.scss @@ -0,0 +1,203 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-icon-button { + // Reset and base styles + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + border-radius: $semantic-border-radius-md; + cursor: pointer; + text-decoration: none; + outline: none; + position: relative; + overflow: hidden; + + // Make it square/circular + aspect-ratio: 1; + flex-shrink: 0; + + // Interaction states + user-select: none; + -webkit-tap-highlight-color: transparent; + box-sizing: border-box; + + // Transitions + transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1); + + // Size variants - square buttons with proper touch targets + &--small { + width: $semantic-spacing-component-padding-xl; // 1.5rem + height: $semantic-spacing-component-padding-xl; + border-radius: $semantic-border-radius-sm; + + .icon-button-icon { + font-size: $semantic-spacing-component-md; // 0.75rem + } + } + + &--medium { + width: $semantic-spacing-interactive-touch-target; // 2.75rem + height: $semantic-spacing-interactive-touch-target; + border-radius: $semantic-border-radius-md; + + .icon-button-icon { + font-size: $semantic-spacing-component-lg; // 1rem + } + } + + &--large { + width: $semantic-spacing-layout-section-xs; // 2rem but we need larger + height: $semantic-spacing-layout-section-xs; + border-radius: $semantic-border-radius-lg; + + .icon-button-icon { + font-size: $semantic-spacing-component-xl; // 1.5rem + } + } + + // Filled variant (primary) + &--filled { + background-color: $semantic-color-brand-primary; + color: $semantic-color-on-brand-primary; + + &:hover:not(:disabled) { + background-color: $semantic-color-brand-primary; + transform: translateY(-1px); + box-shadow: $semantic-shadow-elevation-2; + filter: brightness(1.1); + } + + &:active:not(:disabled) { + transform: scale(0.95); + box-shadow: $semantic-shadow-elevation-1; + } + + &:focus-visible { + outline: 2px solid $semantic-color-brand-primary; + outline-offset: 2px; + } + } + + // Tonal variant + &--tonal { + background-color: $semantic-color-surface-interactive; + color: $semantic-color-text-primary; + + &:hover:not(:disabled) { + background-color: $semantic-color-surface-interactive; + transform: translateY(-1px); + box-shadow: $semantic-shadow-elevation-2; + filter: brightness(0.95); + } + + &:active:not(:disabled) { + transform: scale(0.95); + } + + &:focus-visible { + outline: 2px solid $semantic-color-brand-primary; + outline-offset: 2px; + } + } + + // Outlined variant + &--outlined { + background-color: transparent; + color: $semantic-color-brand-primary; + border: $semantic-border-width-1 solid $semantic-color-border-primary; + + &:hover:not(:disabled) { + background-color: $semantic-color-surface-interactive; + border-color: $semantic-color-brand-primary; + transform: translateY(-1px); + } + + &:active:not(:disabled) { + background-color: $semantic-color-surface-interactive; + transform: scale(0.95); + filter: brightness(0.9); + } + + &:focus-visible { + outline: 2px solid $semantic-color-brand-primary; + outline-offset: 2px; + } + } + + // States + &--disabled { + opacity: $semantic-opacity-disabled; + cursor: not-allowed; + pointer-events: none; + } + + &--loading { + cursor: wait; + + .icon-button-icon { + opacity: 0; + } + } + + &--pressed { + background-color: $semantic-color-surface-selected; + color: $semantic-color-text-primary; + + &.ui-icon-button--outlined { + border-color: $semantic-color-brand-primary; + } + } + + // Icon styles + .icon-button-icon { + display: inline-flex; + align-items: center; + justify-content: center; + transition: opacity 200ms ease; + } + + // Loader styles + .icon-button-loader { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } + + .icon-button-spinner { + width: $semantic-spacing-component-lg; // 1rem + height: $semantic-spacing-component-lg; + border: 2px solid currentColor; + border-top: 2px solid transparent; + border-radius: 50%; + animation: spin 1s linear infinite; + opacity: 0.7; + } + + @keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + } + + // Ripple effect + &::after { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: radial-gradient(circle, rgba(255, 255, 255, 0.3) 0%, transparent 70%); + transform: scale(0); + opacity: 0; + pointer-events: none; + transition: transform 0.3s ease, opacity 0.3s ease; + } + + &:active:not(:disabled)::after { + transform: scale(1); + opacity: 1; + transition: none; + } +} \ No newline at end of file diff --git a/src/lib/components/buttons/icon-button/icon-button.component.ts b/src/lib/components/buttons/icon-button/icon-button.component.ts new file mode 100644 index 0000000..5bd0832 --- /dev/null +++ b/src/lib/components/buttons/icon-button/icon-button.component.ts @@ -0,0 +1,66 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { IconDefinition } from '@fortawesome/fontawesome-svg-core'; + +export type IconButtonVariant = 'filled' | 'tonal' | 'outlined'; +export type IconButtonSize = 'small' | 'medium' | 'large'; + +@Component({ + selector: 'ui-icon-button', + standalone: true, + imports: [CommonModule, FontAwesomeModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + `, + styleUrl: './icon-button.component.scss' +}) +export class IconButtonComponent { + @Input({ required: true }) icon!: IconDefinition; + @Input() variant: IconButtonVariant = 'filled'; + @Input() size: IconButtonSize = 'medium'; + @Input() disabled: boolean = false; + @Input() loading: boolean = false; + @Input() pressed: boolean = false; + @Input() type: 'button' | 'submit' | 'reset' = 'button'; + @Input() class: string = ''; + @Input() ariaLabel: string = ''; + @Input() title: string = ''; + + @Output() clicked = new EventEmitter(); + + get buttonClasses(): string { + return [ + 'ui-icon-button', + `ui-icon-button--${this.variant}`, + `ui-icon-button--${this.size}`, + this.disabled ? 'ui-icon-button--disabled' : '', + this.loading ? 'ui-icon-button--loading' : '', + this.pressed ? 'ui-icon-button--pressed' : '', + this.class + ].filter(Boolean).join(' '); + } + + handleClick(event: Event): void { + if (!this.disabled && !this.loading) { + this.clicked.emit(event); + } + } +} \ No newline at end of file diff --git a/src/lib/components/buttons/icon-button/index.ts b/src/lib/components/buttons/icon-button/index.ts new file mode 100644 index 0000000..2e70bf8 --- /dev/null +++ b/src/lib/components/buttons/icon-button/index.ts @@ -0,0 +1 @@ +export * from './icon-button.component'; \ No newline at end of file diff --git a/src/lib/components/buttons/index copy.ts b/src/lib/components/buttons/index copy.ts new file mode 100644 index 0000000..c640c61 --- /dev/null +++ b/src/lib/components/buttons/index copy.ts @@ -0,0 +1,5 @@ +export * from './simple-button.component'; +export * from './button.component'; +export * from './text-button.component'; +export * from './ghost-button.component'; +export * from './fab.component'; \ No newline at end of file diff --git a/src/lib/components/buttons/index.ts b/src/lib/components/buttons/index.ts new file mode 100644 index 0000000..7f8195c --- /dev/null +++ b/src/lib/components/buttons/index.ts @@ -0,0 +1,8 @@ +export * from './button.component'; +export * from './text-button.component'; +export * from './ghost-button.component'; +export * from './fab.component'; +export * from './fab-menu'; +export * from './simple-button.component'; +export * from './split-button'; +export * from './icon-button'; diff --git a/src/lib/components/buttons/simple-button.component.scss b/src/lib/components/buttons/simple-button.component.scss new file mode 100644 index 0000000..e686b87 --- /dev/null +++ b/src/lib/components/buttons/simple-button.component.scss @@ -0,0 +1,91 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +// Tokens available globally via main application styles + +.ui-simple-button { + // Reset and base styles + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + border-radius: $semantic-border-radius-md; + cursor: pointer; + text-decoration: none; + outline: none; + position: relative; + box-sizing: border-box; + + // Typography + font-family: $semantic-typography-font-family-sans; + font-weight: $semantic-typography-font-weight-medium; + font-size: $semantic-typography-font-size-sm; + line-height: $semantic-typography-line-height-normal; + text-align: center; + white-space: nowrap; + + // Default sizing + height: $semantic-spacing-10; // 2.5rem + padding: 0 $semantic-spacing-4; // 1rem + min-width: $semantic-spacing-20; // 5rem + + // Interaction states + user-select: none; + -webkit-tap-highlight-color: transparent; + + // Transitions + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease-out; + + // Primary variant + &--primary { + background-color: $semantic-color-interactive-primary; + color: $semantic-color-on-brand-primary; + + &:hover:not(:disabled) { + background-color: $semantic-color-brand-primary; + transform: translateY(-1px); + box-shadow: $semantic-shadow-button-hover; + } + + &:active:not(:disabled) { + transform: scale(0.98); + box-shadow: $semantic-shadow-button-active; + } + + &:focus-visible { + outline: 2px solid $semantic-color-brand-primary; + outline-offset: 2px; + } + } + + // Secondary variant + &--secondary { + background-color: $semantic-color-container-primary; + color: $semantic-color-on-container-primary; + border: $semantic-border-width-1 solid $semantic-color-border-primary; + + &:hover:not(:disabled) { + background-color: $semantic-color-container-primary; + border-color: $semantic-color-interactive-primary; + transform: translateY(-1px); + filter: brightness(0.95); + } + + &:active:not(:disabled) { + background-color: $semantic-color-container-primary; + transform: scale(0.98); + filter: brightness(0.9); + } + + &:focus-visible { + outline: 2px solid $semantic-color-brand-primary; + outline-offset: 2px; + } + } + + // Disabled state + &:disabled { + opacity: $semantic-opacity-disabled; + cursor: not-allowed; + pointer-events: none; + } +} \ No newline at end of file diff --git a/src/lib/components/buttons/simple-button.component.ts b/src/lib/components/buttons/simple-button.component.ts new file mode 100644 index 0000000..b723f02 --- /dev/null +++ b/src/lib/components/buttons/simple-button.component.ts @@ -0,0 +1,41 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'ui-simple-button', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + `, + styleUrl: './simple-button.component.scss' +}) +export class SimpleButtonComponent { + @Input() variant: 'primary' | 'secondary' = 'primary'; + @Input() disabled: boolean = false; + @Input() type: 'button' | 'submit' | 'reset' = 'button'; + + @Output() clicked = new EventEmitter(); + + get buttonClasses(): string { + return [ + 'ui-simple-button', + `ui-simple-button--${this.variant}`, + this.disabled ? 'ui-simple-button--disabled' : '' + ].filter(Boolean).join(' '); + } + + handleClick(event: Event): void { + if (!this.disabled) { + this.clicked.emit(event); + } + } +} \ No newline at end of file diff --git a/src/lib/components/buttons/split-button/index.ts b/src/lib/components/buttons/split-button/index.ts new file mode 100644 index 0000000..67fdfc6 --- /dev/null +++ b/src/lib/components/buttons/split-button/index.ts @@ -0,0 +1 @@ +export * from './split-button.component'; \ No newline at end of file diff --git a/src/lib/components/buttons/split-button/split-button.component.scss b/src/lib/components/buttons/split-button/split-button.component.scss new file mode 100644 index 0000000..ac12644 --- /dev/null +++ b/src/lib/components/buttons/split-button/split-button.component.scss @@ -0,0 +1,463 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-split-button { + // Reset and base styles + display: inline-flex; + align-items: center; + position: relative; + border-radius: $semantic-border-radius-md; + overflow: hidden; + + // Size variants + &--small { + height: $semantic-spacing-9; // 2.25rem + + .ui-split-button__primary, + .ui-split-button__dropdown { + font-size: $semantic-typography-font-size-sm; + line-height: $semantic-typography-line-height-tight; + } + } + + &--medium { + height: $semantic-spacing-11; // 2.75rem + + .ui-split-button__primary, + .ui-split-button__dropdown { + font-size: $semantic-typography-font-size-md; + line-height: $semantic-typography-line-height-normal; + } + } + + &--large { + height: $semantic-spacing-12; // 3rem + + .ui-split-button__primary, + .ui-split-button__dropdown { + font-size: $semantic-typography-font-size-lg; + line-height: $semantic-typography-line-height-normal; + } + } + + // Full width variant + &--full-width { + width: 100%; + } + + // Disabled state + &--disabled { + opacity: 0.38; + cursor: not-allowed; + pointer-events: none; + } + + // Primary button part + &__primary { + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + cursor: pointer; + text-decoration: none; + outline: none; + flex: 1; + height: 100%; // Match the container height + + // Default colors (filled variant) + background-color: $semantic-color-interactive-primary; + color: $semantic-color-on-primary; + + // Typography + font-family: $semantic-typography-font-family-sans; + font-size: $semantic-typography-font-size-md; // Default medium size + font-weight: $semantic-typography-font-weight-medium; + line-height: $semantic-typography-line-height-normal; // Default medium line-height + text-align: center; + text-transform: none; + letter-spacing: $semantic-typography-letter-spacing-normal; + white-space: nowrap; + + // Interaction states + user-select: none; + -webkit-tap-highlight-color: transparent; + box-sizing: border-box; + + // Transitions + transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1); + + // Default hover state + &:hover:not(:disabled) { + background-color: $semantic-color-primary; + transform: translateY(-1px); + box-shadow: $semantic-shadow-button-hover; + } + + &:active:not(:disabled) { + transform: scale(0.98); + box-shadow: $semantic-shadow-button-active; + } + + &:focus-visible { + outline: 2px solid $semantic-color-primary; + outline-offset: 2px; + } + + // Size-specific padding + .ui-split-button--small & { + padding: 0 $semantic-spacing-3; // 0.75rem + } + + .ui-split-button--medium & { + padding: 0 $semantic-spacing-4; // 1rem + } + + .ui-split-button--large & { + padding: 0 $semantic-spacing-6; // 1.5rem + } + } + + // Dropdown button part + &__dropdown { + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + cursor: pointer; + outline: none; + position: relative; + + // Default colors (filled variant) + background-color: $semantic-color-interactive-primary; + color: $semantic-color-on-primary; + + // Typography + font-family: $semantic-typography-font-family-sans; + font-size: $semantic-typography-font-size-md; // Default medium size + font-weight: $semantic-typography-font-weight-medium; + line-height: $semantic-typography-line-height-normal; // Default medium line-height + + // Default size (medium) - defaults only + width: $semantic-spacing-9; // Default to medium size + padding: 0 $semantic-spacing-2-5; // Default medium padding + + // Interaction states + user-select: none; + -webkit-tap-highlight-color: transparent; + box-sizing: border-box; + + // Transitions + transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1); + + // Default hover state + &:hover:not(:disabled) { + background-color: $semantic-color-primary; + transform: translateY(-1px); + box-shadow: $semantic-shadow-button-hover; + } + + &:active:not(:disabled) { + transform: scale(0.98); + box-shadow: $semantic-shadow-button-active; + } + + &:focus-visible { + outline: 2px solid $semantic-color-primary; + outline-offset: 2px; + } + + // Divider between buttons + &::before { + content: ''; + position: absolute; + left: 0; + top: 20%; + height: 60%; + width: 1px; + background-color: currentColor; + opacity: 0.3; + } + } + + // Size-specific dropdown dimensions (must come after default styles) + &--small &__dropdown { + width: $semantic-spacing-8; // 2rem - slightly smaller than height + height: $semantic-spacing-9; // Match small container height + padding: 0 $semantic-spacing-2; // 0.5rem + } + + &--medium &__dropdown { + width: $semantic-spacing-9; // 2.25rem - smaller than height + height: $semantic-spacing-11; // Match medium container height + padding: 0 $semantic-spacing-2-5; // 0.625rem + } + + &--large &__dropdown { + width: $semantic-spacing-10; // 2.5rem - smaller than height + height: $semantic-spacing-12; // Match large container height + padding: 0 $semantic-spacing-3; // 0.75rem + } + + // Content wrapper + &__content { + display: flex; + align-items: center; + justify-content: center; + } + + // Icon styles + &__icon { + display: inline-flex; + align-items: center; + flex-shrink: 0; + + &--left { + margin-right: $semantic-spacing-2; // 0.5rem + } + + &--right { + margin-left: $semantic-spacing-2; // 0.5rem + } + + // Size-specific adjustments + .ui-split-button--small & { + font-size: $semantic-typography-font-size-xs; + + &--left { + margin-right: $semantic-spacing-1-5; // 0.375rem + } + + &--right { + margin-left: $semantic-spacing-1-5; // 0.375rem + } + } + + .ui-split-button--medium & { + font-size: $semantic-typography-font-size-sm; + } + + .ui-split-button--large & { + font-size: $semantic-typography-font-size-md; + + &--left { + margin-right: $semantic-spacing-2-5; // 0.625rem + } + + &--right { + margin-left: $semantic-spacing-2-5; // 0.625rem + } + } + } + + // Dropdown arrow + &__arrow { + display: inline-flex; + align-items: center; + font-size: 0.875em; // Slightly larger for better visibility + transition: transform 200ms cubic-bezier(0.4, 0, 0.2, 1); + } + + // Dropdown menu + &__menu { + position: absolute; + top: 100%; + left: 0; + min-width: 100%; + background: $semantic-color-surface-elevated; + border: $semantic-border-width-1 solid $semantic-color-border-primary; + border-radius: $semantic-border-radius-md; + box-shadow: $semantic-shadow-dropdown; + z-index: $semantic-z-index-dropdown; + opacity: 0; + visibility: hidden; + transform: translateY(-$semantic-spacing-2); + transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1); + + &--open { + opacity: 1; + visibility: visible; + transform: translateY(0); + } + } + + // Menu item + &__menu-item { + display: flex; + align-items: center; + width: 100%; + padding: $semantic-spacing-3 $semantic-spacing-4; + border: none; + background: transparent; + color: $semantic-color-text-primary; + font-family: $semantic-typography-font-family-sans; + font-size: $semantic-typography-font-size-sm; + font-weight: $semantic-typography-font-weight-normal; + text-align: left; + cursor: pointer; + transition: background-color 200ms cubic-bezier(0.4, 0, 0.2, 1); + + &:hover { + background: $semantic-color-surface-container; + } + + &:first-child { + border-top-left-radius: $semantic-border-radius-md; + border-top-right-radius: $semantic-border-radius-md; + } + + &:last-child { + border-bottom-left-radius: $semantic-border-radius-md; + border-bottom-right-radius: $semantic-border-radius-md; + } + } + + // Filled variant (primary) + &--filled { + .ui-split-button__primary, + .ui-split-button__dropdown { + background-color: $semantic-color-interactive-primary; + color: $semantic-color-on-primary; + + &:hover:not(:disabled) { + background-color: $semantic-color-primary; + transform: translateY(-1px); + box-shadow: $semantic-shadow-button-hover; + } + + &:active:not(:disabled) { + transform: scale(0.98); + box-shadow: $semantic-shadow-button-active; + } + + &:focus-visible { + outline: 2px solid $semantic-color-primary; + outline-offset: 2px; + } + } + + .ui-split-button__dropdown:hover .ui-split-button__arrow { + transform: rotate(180deg); + } + } + + // Tonal variant + &--tonal { + .ui-split-button__primary, + .ui-split-button__dropdown { + background-color: $semantic-color-surface-container; + color: $semantic-color-text-primary; + + &:hover:not(:disabled) { + background-color: $semantic-color-surface-container; + transform: translateY(-1px); + box-shadow: $semantic-shadow-button-hover; + filter: brightness(0.95); + } + + &:active:not(:disabled) { + transform: scale(0.98); + } + + &:focus-visible { + outline: 2px solid $semantic-color-primary; + outline-offset: 2px; + } + } + + .ui-split-button__dropdown:hover .ui-split-button__arrow { + transform: rotate(180deg); + } + } + + // Outlined variant + &--outlined { + border: $semantic-border-width-1 solid $semantic-color-border-primary; + + .ui-split-button__primary, + .ui-split-button__dropdown { + background-color: transparent; + color: $semantic-color-interactive-primary; + + &:hover:not(:disabled) { + background-color: $semantic-color-surface-container; + transform: translateY(-1px); + } + + &:active:not(:disabled) { + background-color: $semantic-color-surface-container; + transform: scale(0.98); + filter: brightness(0.9); + } + + &:focus-visible { + outline: 2px solid $semantic-color-primary; + outline-offset: 2px; + } + } + + .ui-split-button__dropdown:hover .ui-split-button__arrow { + transform: rotate(180deg); + } + } + + // Loading state + &--loading { + .ui-split-button__primary { + cursor: wait; + + .ui-split-button__content { + opacity: 0; + } + } + + .ui-split-button__dropdown { + pointer-events: none; + opacity: 0.5; + } + } + + // Loader + &__loader { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } + + &__spinner { + width: $semantic-spacing-4; // 1rem + height: $semantic-spacing-4; // 1rem + border: 2px solid currentColor; + border-top: 2px solid transparent; + border-radius: 50%; + animation: spin 1s linear infinite; + opacity: 0.7; + } + + @keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + } + + // Ripple effect + .ui-split-button__primary::after, + .ui-split-button__dropdown::after { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: radial-gradient(circle, rgba(255, 255, 255, 0.3) 0%, transparent 70%); + transform: scale(0); + opacity: 0; + pointer-events: none; + transition: transform 0.3s ease, opacity 0.3s ease; + } + + .ui-split-button__primary:active:not(:disabled)::after, + .ui-split-button__dropdown:active:not(:disabled)::after { + transform: scale(1); + opacity: 1; + transition: none; + } +} \ No newline at end of file diff --git a/src/lib/components/buttons/split-button/split-button.component.ts b/src/lib/components/buttons/split-button/split-button.component.ts new file mode 100644 index 0000000..787ced9 --- /dev/null +++ b/src/lib/components/buttons/split-button/split-button.component.ts @@ -0,0 +1,169 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation, HostListener } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { IconDefinition } from '@fortawesome/fontawesome-svg-core'; +import { faChevronDown } from '@fortawesome/free-solid-svg-icons'; + +export type SplitButtonVariant = 'filled' | 'tonal' | 'outlined'; +export type SplitButtonSize = 'small' | 'medium' | 'large'; +export type SplitButtonIconPosition = 'left' | 'right'; + +export interface SplitButtonMenuItem { + label: string; + value?: any; + icon?: IconDefinition; + disabled?: boolean; +} + +@Component({ + selector: 'ui-split-button', + standalone: true, + imports: [CommonModule, FontAwesomeModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ + + + + + + + + @if (menuItems && menuItems.length > 0) { + + } +
+ `, + styleUrl: './split-button.component.scss' +}) +export class SplitButtonComponent { + @Input() variant: SplitButtonVariant = 'filled'; + @Input() size: SplitButtonSize = 'medium'; + @Input() disabled: boolean = false; + @Input() loading: boolean = false; + @Input() type: 'button' | 'submit' | 'reset' = 'button'; + @Input() fullWidth: boolean = false; + @Input() class: string = ''; + @Input() icon?: IconDefinition; + @Input() iconPosition: SplitButtonIconPosition = 'left'; + @Input() menuItems: SplitButtonMenuItem[] = []; + + @Output() primaryClicked = new EventEmitter(); + @Output() menuItemClicked = new EventEmitter<{ event: Event; item: SplitButtonMenuItem }>(); + @Output() dropdownToggled = new EventEmitter(); + + isMenuOpen = false; + faChevronDown = faChevronDown; + + @HostListener('document:click', ['$event']) + onDocumentClick(event: Event): void { + const target = event.target as Element; + const splitButton = target.closest('.ui-split-button'); + + if (!splitButton || !splitButton.contains(target)) { + this.closeMenu(); + } + } + + @HostListener('document:keydown', ['$event']) + onDocumentKeydown(event: KeyboardEvent): void { + if (event.key === 'Escape' && this.isMenuOpen) { + this.closeMenu(); + } + } + + handlePrimaryClick(event: Event): void { + if (!this.disabled && !this.loading) { + this.primaryClicked.emit(event); + this.closeMenu(); + } + } + + handleDropdownClick(event: Event): void { + if (!this.disabled && !this.loading) { + event.stopPropagation(); + this.toggleMenu(); + } + } + + handleMenuItemClick(event: Event, item: SplitButtonMenuItem): void { + if (!item.disabled) { + event.stopPropagation(); + this.menuItemClicked.emit({ event, item }); + this.closeMenu(); + } + } + + private toggleMenu(): void { + this.isMenuOpen = !this.isMenuOpen; + this.dropdownToggled.emit(this.isMenuOpen); + } + + private closeMenu(): void { + if (this.isMenuOpen) { + this.isMenuOpen = false; + this.dropdownToggled.emit(false); + } + } +} \ No newline at end of file diff --git a/src/lib/components/buttons/text-button.component.scss b/src/lib/components/buttons/text-button.component.scss new file mode 100644 index 0000000..f7a999d --- /dev/null +++ b/src/lib/components/buttons/text-button.component.scss @@ -0,0 +1,132 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; +.ui-text-button { + // Reset and base styles + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + cursor: pointer; + text-decoration: none; + outline: none; + position: relative; + overflow: hidden; + + // Typography + font-family: $semantic-typography-font-family-sans; + font-weight: $semantic-typography-font-weight-medium; + text-align: center; + text-transform: none; + letter-spacing: $semantic-typography-letter-spacing-wide; + white-space: nowrap; + color: $semantic-color-interactive-primary; + + // Interaction states + user-select: none; + -webkit-tap-highlight-color: transparent; + box-sizing: border-box; + border-radius: $semantic-border-radius-sm; + + // Transitions + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease-out; + + // Size variants + &--small { + height: $semantic-spacing-8; // 2rem + padding: 0 $semantic-spacing-1; // 0.25rem + font-size: $semantic-typography-font-size-sm; + line-height: $semantic-typography-line-height-normal; + min-width: $semantic-spacing-12; // 3rem + } + + &--medium { + height: $semantic-spacing-10; // 2.5rem + padding: 0 $semantic-spacing-2; // 0.5rem + font-size: $semantic-typography-font-size-md; + line-height: $semantic-typography-line-height-normal; + min-width: $semantic-spacing-16; // 4rem + } + + &--large { + height: $semantic-spacing-12; // 3rem + padding: 0 $semantic-spacing-3; // 0.75rem + font-size: $semantic-typography-font-size-lg; + line-height: $semantic-typography-line-height-normal; + min-width: $semantic-spacing-20; // 5rem + } + + // Hover states + &:hover:not(:disabled) { + background-color: rgba($semantic-color-brand-primary, 0.08); + color: $semantic-color-brand-primary; + } + + &:active:not(:disabled) { + background-color: rgba($semantic-color-brand-primary, 0.12); + transform: scale(0.98); + } + + &:focus-visible { + background-color: rgba($semantic-color-brand-primary, 0.08); + box-shadow: $semantic-shadow-button-focus; + } + + // States + &--disabled { + opacity: $semantic-opacity-disabled; + cursor: not-allowed; + pointer-events: none; + } + + &--loading { + cursor: wait; + + .text-button-content { + opacity: $semantic-opacity-invisible; + } + } + + // Loader styles + .text-button-loader { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } + + .text-button-spinner { + width: $semantic-spacing-3-5; // 0.875rem + height: $semantic-spacing-3-5; // 0.875rem + border: $semantic-border-width-2 solid rgba($semantic-color-brand-primary, 0.3); // closest to 1.5px + border-top: $semantic-border-width-2 solid $semantic-color-brand-primary; + border-radius: $semantic-border-radius-full; + animation: spin 1s $semantic-motion-easing-linear infinite; + } + + @keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + } + + // Ripple effect (basic implementation) + &::after { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: radial-gradient(circle, rgba($semantic-color-brand-primary, 0.12) 0%, transparent 70%); + transform: scale(0); + opacity: $semantic-opacity-invisible; + pointer-events: none; + transition: transform $semantic-motion-duration-normal $semantic-motion-easing-ease, opacity $semantic-motion-duration-normal $semantic-motion-easing-ease; + border-radius: inherit; + } + + &:active:not(:disabled)::after { + transform: scale(1); + opacity: $semantic-opacity-opaque; + transition: none; + } +} \ No newline at end of file diff --git a/src/lib/components/buttons/text-button.component.ts b/src/lib/components/buttons/text-button.component.ts new file mode 100644 index 0000000..03dc103 --- /dev/null +++ b/src/lib/components/buttons/text-button.component.ts @@ -0,0 +1,53 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +export type TextButtonSize = 'small' | 'medium' | 'large'; + +@Component({ + selector: 'ui-text-button', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + `, + styleUrl: './text-button.component.scss' +}) +export class TextButtonComponent { + @Input() size: TextButtonSize = 'medium'; + @Input() disabled: boolean = false; + @Input() loading: boolean = false; + @Input() type: 'button' | 'submit' | 'reset' = 'button'; + @Input() class: string = ''; + + @Output() clicked = new EventEmitter(); + + get buttonClasses(): string { + return [ + 'ui-text-button', + `ui-text-button--${this.size}`, + this.disabled ? 'ui-text-button--disabled' : '', + this.loading ? 'ui-text-button--loading' : '', + this.class + ].filter(Boolean).join(' '); + } + + handleClick(event: Event): void { + if (!this.disabled && !this.loading) { + this.clicked.emit(event); + } + } +} \ No newline at end of file diff --git a/src/lib/components/data-display/accordion/accordion.component.scss b/src/lib/components/data-display/accordion/accordion.component.scss new file mode 100644 index 0000000..8dc7aa3 --- /dev/null +++ b/src/lib/components/data-display/accordion/accordion.component.scss @@ -0,0 +1,279 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-accordion { + // Core Structure + display: flex; + flex-direction: column; + position: relative; + width: 100%; + box-sizing: border-box; + + // Base styles + font-family: $semantic-typography-font-family-sans; + + // Default styles (md variant as default) + .ui-accordion__header { + padding: $semantic-spacing-component-sm $semantic-spacing-component-md; + font-size: $semantic-typography-font-size-md; + } + + .ui-accordion__content { + padding: $semantic-spacing-component-sm $semantic-spacing-component-md; + font-size: $semantic-typography-font-size-md; + } + + // Size variants + &--sm { + .ui-accordion__header { + padding: $semantic-spacing-component-xs $semantic-spacing-component-sm; + font-size: $semantic-typography-font-size-sm; + } + + .ui-accordion__content { + padding: $semantic-spacing-component-xs $semantic-spacing-component-sm; + font-size: $semantic-typography-font-size-sm; + } + } + + &--md { + .ui-accordion__header { + padding: $semantic-spacing-component-sm $semantic-spacing-component-md; + font-size: $semantic-typography-font-size-md; + } + + .ui-accordion__content { + padding: $semantic-spacing-component-sm $semantic-spacing-component-md; + font-size: $semantic-typography-font-size-md; + } + } + + &--lg { + .ui-accordion__header { + padding: $semantic-spacing-component-md $semantic-spacing-component-lg; + font-size: $semantic-typography-font-size-lg; + } + + .ui-accordion__content { + padding: $semantic-spacing-component-md $semantic-spacing-component-lg; + font-size: $semantic-typography-font-size-lg; + } + } + + // Variant styles + &--elevated { + .ui-accordion__item { + background: $semantic-color-surface-primary; + border: $semantic-border-width-1 solid $semantic-color-border-subtle; + border-radius: $semantic-border-radius-md; + box-shadow: $semantic-shadow-elevation-1; + margin-bottom: $semantic-spacing-component-xs; + + &:last-child { + margin-bottom: 0; + } + } + } + + &--outlined { + .ui-accordion__item { + background: $semantic-color-surface-primary; + border: $semantic-border-width-1 solid $semantic-color-border-primary; + border-radius: $semantic-border-radius-md; + margin-bottom: $semantic-spacing-component-xs; + + &:last-child { + margin-bottom: 0; + } + } + } + + &--filled { + .ui-accordion__item { + background: $semantic-color-surface-secondary; + border: $semantic-border-width-1 solid $semantic-color-surface-secondary; + border-radius: $semantic-border-radius-md; + margin-bottom: $semantic-spacing-component-xs; + + &:last-child { + margin-bottom: 0; + } + } + } + + // State variants + &--disabled { + opacity: 0.38; + cursor: not-allowed; + pointer-events: none; + } + + // Individual accordion item + &__item { + position: relative; + overflow: hidden; + transition: all $semantic-motion-duration-fast $semantic-easing-standard; + + &--expanded { + .ui-accordion__header { + .ui-accordion__icon { + transform: rotate(180deg); + } + } + } + + &--disabled { + opacity: 0.38; + cursor: not-allowed; + + .ui-accordion__header { + cursor: not-allowed; + pointer-events: none; + } + } + } + + // Header/trigger area + &__header { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + background: transparent; + border: none; + cursor: pointer; + color: $semantic-color-text-primary; + font-weight: $semantic-typography-font-weight-medium; + text-align: left; + transition: all $semantic-motion-duration-fast $semantic-easing-standard; + + &:hover:not(:disabled) { + background: $semantic-color-surface-secondary; + } + + &:focus-visible { + outline: 2px solid $semantic-color-primary; + outline-offset: 2px; + } + + &:active:not(:disabled) { + background: $semantic-color-surface-elevated; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.38; + } + } + + // Header content area + &__header-content { + flex: 1; + display: flex; + align-items: center; + gap: $semantic-spacing-component-sm; + } + + // Icon container + &__icon { + display: flex; + align-items: center; + justify-content: center; + width: $semantic-sizing-icon-inline; + height: $semantic-sizing-icon-inline; + color: $semantic-color-text-secondary; + transition: transform $semantic-motion-duration-fast $semantic-easing-standard; + flex-shrink: 0; + } + + // Content area + &__content { + overflow: hidden; + background: $semantic-color-surface-primary; + color: $semantic-color-text-primary; + line-height: $semantic-typography-line-height-relaxed; + + // Animation states + &--collapsed { + max-height: 0; + padding-top: 0; + padding-bottom: 0; + transition: + max-height $semantic-motion-duration-normal $semantic-easing-standard, + padding $semantic-motion-duration-normal $semantic-easing-standard; + } + + &--expanded { + max-height: 1000px; // Large enough for most content + transition: max-height $semantic-motion-duration-normal $semantic-easing-standard; + } + + &--expanding { + transition: + max-height $semantic-motion-duration-normal $semantic-easing-standard, + padding $semantic-motion-duration-normal $semantic-easing-standard; + } + } + + // Content inner wrapper for proper padding animation + &__content-inner { + padding-top: $semantic-spacing-component-xs; + } + + // Multiple expand mode styling + &--multiple { + .ui-accordion__item:not(:last-child) { + border-bottom: $semantic-border-width-1 solid $semantic-color-border-subtle; + } + } + + // Dark mode support + :host-context(.dark-theme) & { + .ui-accordion__item { + background: $semantic-color-surface-elevated; + border-color: $semantic-color-border-secondary; + } + + .ui-accordion__header { + color: $semantic-color-text-primary; + + &:hover:not(:disabled) { + background: $semantic-color-surface-secondary; + } + } + + .ui-accordion__content { + background: $semantic-color-surface-elevated; + color: $semantic-color-text-primary; + } + } + + // Responsive design + @media (max-width: $semantic-breakpoint-md - 1) { + &--lg { + .ui-accordion__header { + padding: $semantic-spacing-component-sm $semantic-spacing-component-md; + font-size: $semantic-typography-font-size-md; + } + + .ui-accordion__content { + padding: $semantic-spacing-component-sm $semantic-spacing-component-md; + font-size: $semantic-typography-font-size-md; + } + } + } + + @media (max-width: $semantic-breakpoint-sm - 1) { + &--md, + &--lg { + .ui-accordion__header { + padding: $semantic-spacing-component-xs $semantic-spacing-component-sm; + font-size: $semantic-typography-font-size-sm; + } + + .ui-accordion__content { + padding: $semantic-spacing-component-xs $semantic-spacing-component-sm; + font-size: $semantic-typography-font-size-sm; + } + } + } +} \ No newline at end of file diff --git a/src/lib/components/data-display/accordion/accordion.component.ts b/src/lib/components/data-display/accordion/accordion.component.ts new file mode 100644 index 0000000..7ff2d37 --- /dev/null +++ b/src/lib/components/data-display/accordion/accordion.component.ts @@ -0,0 +1,370 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation, ContentChildren, QueryList, AfterContentInit, OnDestroy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Subject, takeUntil } from 'rxjs'; + +export type AccordionSize = 'sm' | 'md' | 'lg'; +export type AccordionVariant = 'elevated' | 'outlined' | 'filled'; + +export interface AccordionItem { + id: string; + title: string; + content?: string; + disabled?: boolean; + expanded?: boolean; +} + +export interface AccordionExpandEvent { + item: AccordionItem; + index: number; + expanded: boolean; +} + +@Component({ + selector: 'ui-accordion-item', + standalone: true, + imports: [CommonModule], + template: ` +
+ + + +
+ + @if (expanded || !collapseContent) { +
+ + @if (!hasContent && content) { +

{{ content }}

+ } +
+ } +
+
+ ` +}) +export class AccordionItemComponent { + @Input() id!: string; + @Input() title!: string; + @Input() content?: string; + @Input() disabled = false; + @Input() expanded = false; + @Input() collapseContent = true; // Whether to remove content from DOM when collapsed + @Input() hasHeaderContent = false; + @Input() hasContent = false; + + @Output() expandedChange = new EventEmitter(); + @Output() itemToggle = new EventEmitter(); + + get headerId(): string { + return `accordion-header-${this.id}`; + } + + get contentId(): string { + return `accordion-content-${this.id}`; + } + + handleToggle(): void { + if (!this.disabled) { + this.expanded = !this.expanded; + this.expandedChange.emit(this.expanded); + this.itemToggle.emit({ + item: { + id: this.id, + title: this.title, + content: this.content, + disabled: this.disabled, + expanded: this.expanded + }, + index: 0, // Will be set by parent accordion + expanded: this.expanded + }); + } + } + + handleKeydown(event: KeyboardEvent): void { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + this.handleToggle(); + } + } +} + +@Component({ + selector: 'ui-accordion', + standalone: true, + imports: [CommonModule, AccordionItemComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ + @if (items && items.length > 0) { + @for (item of items; track item.id; let i = $index) { + + + } + } @else { + + } +
+ `, + styleUrl: './accordion.component.scss' +}) +export class AccordionComponent implements AfterContentInit, OnDestroy { + @Input() size: AccordionSize = 'md'; + @Input() variant: AccordionVariant = 'elevated'; + @Input() disabled = false; + @Input() multiple = false; // Allow multiple items to be expanded simultaneously + @Input() collapseContent = true; // Whether to remove content from DOM when collapsed + @Input() items: AccordionItem[] = []; // Programmatic items + @Input() expandedItems: string[] = []; // IDs of initially expanded items + + @Output() itemToggle = new EventEmitter(); + @Output() expandedItemsChange = new EventEmitter(); + + @ContentChildren(AccordionItemComponent) accordionItems!: QueryList; + + private destroy$ = new Subject(); + + getComponentClasses(): string { + const classes = [ + 'ui-accordion', + `ui-accordion--${this.size}`, + `ui-accordion--${this.variant}` + ]; + + if (this.disabled) { + classes.push('ui-accordion--disabled'); + } + + if (this.multiple) { + classes.push('ui-accordion--multiple'); + } + + return classes.join(' '); + } + + ngAfterContentInit(): void { + // Set up keyboard navigation between accordion items + this.setupKeyboardNavigation(); + + // Initialize expanded states + this.initializeExpandedStates(); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + private setupKeyboardNavigation(): void { + this.accordionItems.changes + .pipe(takeUntil(this.destroy$)) + .subscribe(() => { + this.accordionItems.forEach((item, index) => { + // Update index for event emission + const originalToggle = item.handleToggle.bind(item); + item.handleToggle = () => { + originalToggle(); + // Emit with correct index + const event = { + item: { + id: item.id, + title: item.title, + content: item.content, + disabled: item.disabled, + expanded: item.expanded + }, + index, + expanded: item.expanded + }; + this.handleItemToggle(event, index); + }; + }); + }); + } + + private initializeExpandedStates(): void { + if (this.items.length > 0) { + // Initialize programmatic items + this.items.forEach(item => { + if (this.expandedItems.includes(item.id)) { + item.expanded = true; + } + }); + } + } + + handleItemToggle(event: AccordionExpandEvent, index: number): void { + event.index = index; + + if (!this.multiple && event.expanded) { + // Close other items if not in multiple mode + this.closeOtherItems(event.item.id); + } + + // Update expanded items list + this.updateExpandedItems(event.item.id, event.expanded); + + // Emit events + this.itemToggle.emit(event); + this.expandedItemsChange.emit([...this.expandedItems]); + } + + private closeOtherItems(exceptId: string): void { + if (this.items.length > 0) { + // Close other programmatic items + this.items.forEach(item => { + if (item.id !== exceptId && item.expanded) { + item.expanded = false; + } + }); + } + + // Close other content-projected items + this.accordionItems.forEach(item => { + if (item.id !== exceptId && item.expanded) { + item.expanded = false; + item.expandedChange.emit(false); + } + }); + } + + private updateExpandedItems(itemId: string, expanded: boolean): void { + if (expanded) { + if (!this.expandedItems.includes(itemId)) { + this.expandedItems.push(itemId); + } + } else { + const index = this.expandedItems.indexOf(itemId); + if (index > -1) { + this.expandedItems.splice(index, 1); + } + } + } + + // Public methods for programmatic control + expandItem(itemId: string): void { + const item = this.items.find(i => i.id === itemId); + const accordionItem = this.accordionItems.find(i => i.id === itemId); + + if (item && !item.disabled) { + if (!this.multiple) { + this.closeAllItems(); + } + item.expanded = true; + this.updateExpandedItems(itemId, true); + } + + if (accordionItem && !accordionItem.disabled) { + if (!this.multiple) { + this.closeAllItems(); + } + accordionItem.expanded = true; + accordionItem.expandedChange.emit(true); + this.updateExpandedItems(itemId, true); + } + } + + collapseItem(itemId: string): void { + const item = this.items.find(i => i.id === itemId); + const accordionItem = this.accordionItems.find(i => i.id === itemId); + + if (item) { + item.expanded = false; + this.updateExpandedItems(itemId, false); + } + + if (accordionItem) { + accordionItem.expanded = false; + accordionItem.expandedChange.emit(false); + this.updateExpandedItems(itemId, false); + } + } + + expandAll(): void { + if (this.multiple) { + this.items.forEach(item => { + if (!item.disabled) { + item.expanded = true; + this.updateExpandedItems(item.id, true); + } + }); + + this.accordionItems.forEach(item => { + if (!item.disabled) { + item.expanded = true; + item.expandedChange.emit(true); + this.updateExpandedItems(item.id, true); + } + }); + } + } + + collapseAll(): void { + this.items.forEach(item => { + item.expanded = false; + this.updateExpandedItems(item.id, false); + }); + + this.accordionItems.forEach(item => { + item.expanded = false; + item.expandedChange.emit(false); + this.updateExpandedItems(item.id, false); + }); + } + + private closeAllItems(): void { + this.items.forEach(item => { + item.expanded = false; + }); + + this.accordionItems.forEach(item => { + item.expanded = false; + item.expandedChange.emit(false); + }); + + this.expandedItems = []; + } +} \ No newline at end of file diff --git a/src/lib/components/data-display/accordion/index.ts b/src/lib/components/data-display/accordion/index.ts new file mode 100644 index 0000000..9a739bc --- /dev/null +++ b/src/lib/components/data-display/accordion/index.ts @@ -0,0 +1,2 @@ +export { AccordionComponent, AccordionItemComponent } from './accordion.component'; +export type { AccordionSize, AccordionVariant, AccordionItem, AccordionExpandEvent } from './accordion.component'; \ No newline at end of file diff --git a/src/lib/components/data-display/avatar/avatar.component.scss b/src/lib/components/data-display/avatar/avatar.component.scss new file mode 100644 index 0000000..8d6963b --- /dev/null +++ b/src/lib/components/data-display/avatar/avatar.component.scss @@ -0,0 +1,244 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; +.skyui-avatar { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background-color: $semantic-color-surface-secondary; + color: $semantic-color-text-secondary; + box-sizing: border-box; + flex-shrink: 0; + user-select: none; + cursor: pointer; + + // Size variants based on semantic sizing tokens + &--size-xs { + width: 24px; + height: 24px; + + .skyui-avatar__initials { + font-size: calc(24px * 0.4); + font-weight: 500; + } + + .skyui-avatar__icon { + font-size: calc(24px * 0.6); + } + } + + &--size-sm { + width: 32px; + height: 32px; + + .skyui-avatar__initials { + font-size: calc(32px * 0.4); + font-weight: 500; + } + + .skyui-avatar__icon { + font-size: calc(32px * 0.6); + } + } + + &--size-md { + width: 40px; + height: 40px; + + .skyui-avatar__initials { + font-size: calc(40px * 0.4); + font-weight: 500; + } + + .skyui-avatar__icon { + font-size: calc(40px * 0.6); + } + } + + &--size-lg { + width: 48px; + height: 48px; + + .skyui-avatar__initials { + font-size: calc(48px * 0.4); + font-weight: 600; + } + + .skyui-avatar__icon { + font-size: calc(48px * 0.6); + } + } + + &--size-xl { + width: 64px; + height: 64px; + + .skyui-avatar__initials { + font-size: calc(64px * 0.35); + font-weight: 600; + } + + .skyui-avatar__icon { + font-size: calc(64px * 0.5); + } + } + + &--size-xxl { + width: 80px; + height: 80px; + + .skyui-avatar__initials { + font-size: calc(80px * 0.35); + font-weight: 600; + } + + .skyui-avatar__icon { + font-size: calc(80px * 0.5); + } + } + + // Loading state + &--loading { + background-color: $semantic-color-surface-disabled; + + .skyui-avatar__loading { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + } + + .loading-spinner { + width: 50%; + height: 50%; + border: 2px solid $semantic-color-border-subtle; + border-top: 2px solid $semantic-color-brand-primary; + border-radius: 50%; + animation: spin 1s linear infinite; + } + } + + // Avatar image + &__image { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 50%; + overflow: hidden; + } + + // Fallback container + &__fallback { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background-color: $semantic-color-container-primary; + color: $semantic-color-on-container-primary; + border-radius: 50%; + overflow: hidden; + } + + // Initials text + &__initials { + line-height: 1; + text-align: center; + letter-spacing: -0.02em; + } + + // Icon + &__icon { + display: flex; + align-items: center; + justify-content: center; + } + + // Status indicator + &__status { + position: absolute; + bottom: 0; + right: 0; + width: 25%; + height: 25%; + min-width: 8px; + min-height: 8px; + border-radius: 50%; + border: 2px solid $semantic-color-surface-primary; + box-sizing: border-box; + + &--online { + background-color: $semantic-color-success; + } + + &--offline { + background-color: $semantic-color-text-tertiary; + } + + &--away { + background-color: $semantic-color-warning; + } + + &--busy { + background-color: $semantic-color-danger; + } + } + + // Badge indicator positioning + &__badge { + position: absolute; + top: -4px; + right: -4px; + } +} + +// Animation for loading spinner +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +// Focus and hover states for interactive avatars +.skyui-avatar { + &[role="button"]:focus-visible, + &[tabindex]:focus-visible { + outline: 2px solid $semantic-color-brand-primary; + outline-offset: 2px; + } + + &[role="button"]:hover, + &[tabindex]:hover { + transform: scale(1.05); + transition: transform $semantic-duration-fast $semantic-easing-standard; + } +} + +// High contrast mode support +@media (prefers-contrast: high) { + .skyui-avatar { + border: $semantic-border-width-1 solid $semantic-color-border-primary; + + &__status { + border-width: $semantic-border-width-3; + } + } +} + +// Reduced motion support +@media (prefers-reduced-motion: reduce) { + .skyui-avatar { + &[role="button"]:hover, + &[tabindex]:hover { + transition: none; + } + + .loading-spinner { + animation: none; + } + } +} \ No newline at end of file diff --git a/src/lib/components/data-display/avatar/avatar.component.ts b/src/lib/components/data-display/avatar/avatar.component.ts new file mode 100644 index 0000000..bbca1f4 --- /dev/null +++ b/src/lib/components/data-display/avatar/avatar.component.ts @@ -0,0 +1,108 @@ +import { Component, Input, ChangeDetectionStrategy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { faUser } from '@fortawesome/free-solid-svg-icons'; +import { BadgeComponent } from '../badge/badge.component'; + +export type AvatarSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl'; + +@Component({ + selector: 'ui-avatar', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, FontAwesomeModule, BadgeComponent], + template: ` +
+ + @if (loading) { +
+ +
+ } @else { + @if (imageUrl && !imageError) { + + } @else { +
+ @if (computedInitials) { + + } @else { + + } +
+ } + } + + @if (status) { +
+
+ } + + @if (badge !== undefined && badge !== null) { + + {{ badge }} + + } +
+ `, + styleUrl: './avatar.component.scss' +}) +export class AvatarComponent { + @Input() imageUrl?: string; + @Input() name?: string; + @Input() initials?: string; + @Input() size: AvatarSize = 'md'; + @Input() altText?: string; + @Input() ariaLabel?: string; + @Input() loading: boolean = false; + @Input() status?: 'online' | 'offline' | 'away' | 'busy'; + @Input() statusLabel?: string; + @Input() badge?: string | number; + @Input() badgeLabel?: string; + + faUser = faUser; + imageError = false; + + onImageError(): void { + this.imageError = true; + } + + onImageLoad(): void { + this.imageError = false; + } + + // Auto-generate initials from name if not provided + get computedInitials(): string { + if (this.initials) { + return this.initials.slice(0, 2).toUpperCase(); + } + + if (this.name) { + const nameParts = this.name.trim().split(/\s+/); + if (nameParts.length >= 2) { + return (nameParts[0][0] + nameParts[nameParts.length - 1][0]).toUpperCase(); + } else if (nameParts[0]) { + return nameParts[0].slice(0, 2).toUpperCase(); + } + } + + return ''; + } +} \ No newline at end of file diff --git a/src/lib/components/data-display/avatar/index.ts b/src/lib/components/data-display/avatar/index.ts new file mode 100644 index 0000000..1638b86 --- /dev/null +++ b/src/lib/components/data-display/avatar/index.ts @@ -0,0 +1 @@ +export * from './avatar.component'; \ No newline at end of file diff --git a/src/lib/components/data-display/badge/badge.component.scss b/src/lib/components/data-display/badge/badge.component.scss new file mode 100644 index 0000000..c1a9077 --- /dev/null +++ b/src/lib/components/data-display/badge/badge.component.scss @@ -0,0 +1,176 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; +/** + * ========================================================================== + * BADGE COMPONENT STYLES + * ========================================================================== + * Material Design 3 inspired badge component with design token integration. + * Supports multiple variants, sizes, shapes, and dot mode for notifications. + * ========================================================================== + */ + + +// Tokens available globally via main application styles + +.ui-badge { + display: inline-flex; + align-items: center; + justify-content: center; + font-weight: $semantic-typography-font-weight-medium; + line-height: 1; + text-align: center; + white-space: nowrap; + vertical-align: middle; + user-select: none; + box-sizing: border-box; + transition: all $semantic-duration-short $semantic-easing-standard; + background-color: aqua; + + // Default size (md) + min-width: $semantic-sizing-icon-navigation; + height: $semantic-sizing-icon-navigation; + padding: 0 $semantic-spacing-component-xs; + font-size: $semantic-typography-font-size-xs; + border-radius: $semantic-border-radius-full; + + // Default variant styling + background-color: $semantic-color-surface-secondary; + color: $semantic-color-text-primary; + border: $semantic-border-width-1 solid $semantic-color-border-subtle; + + // Size variants + &[data-size="xs"] { + min-width: $semantic-typography-icon-small-size; + height: $semantic-typography-icon-small-size; + padding: 0 calc($semantic-spacing-component-xs / 2); + font-size: $semantic-typography-font-size-xs; + } + + &[data-size="sm"] { + min-width: $semantic-sizing-icon-button; + height: $semantic-sizing-icon-button; + padding: 0 $semantic-spacing-component-xs; + font-size: $semantic-typography-font-size-xs; + } + + &[data-size="md"] { + min-width: $semantic-sizing-icon-navigation; + height: $semantic-sizing-icon-navigation; + padding: 0 $semantic-spacing-component-xs; + font-size: $semantic-typography-font-size-xs; + } + + &[data-size="lg"] { + min-width: $semantic-sizing-icon-header; + height: $semantic-sizing-icon-header; + padding: 0 $semantic-spacing-component-sm; + font-size: $semantic-typography-font-size-sm; + } + + // Shape variants + &[data-shape="pill"] { + border-radius: $semantic-border-radius-full; + } + + &[data-shape="rounded"] { + border-radius: $semantic-border-radius-md; + } + + &[data-shape="square"] { + border-radius: $semantic-border-radius-sm; + } + + // Dot mode - small notification dot + &[data-dot="true"] { + min-width: 0; + width: 8px; + height: 8px; + padding: 0; + border-radius: 50%; + border: 2px solid $semantic-color-surface-primary; + + &[data-size="xs"] { + width: 6px; + height: 6px; + } + + &[data-size="sm"] { + width: 8px; + height: 8px; + } + + &[data-size="md"] { + width: 10px; + height: 10px; + } + + &[data-size="lg"] { + width: 12px; + height: 12px; + } + } + + // Color variants + &[data-variant="default"] { + background-color: $semantic-color-surface-secondary; + color: $semantic-color-text-primary; + border-color: $semantic-color-border-subtle; + } + + &[data-variant="primary"] { + background-color: $semantic-color-brand-primary; + color: $semantic-color-text-inverse; + border-color: $semantic-color-brand-primary; + } + + &[data-variant="secondary"] { + background-color: $semantic-color-brand-secondary; + color: $semantic-color-text-inverse; + border-color: $semantic-color-brand-secondary; + } + + &[data-variant="success"] { + background-color: $semantic-color-success; + color: $semantic-color-text-inverse; + border-color: $semantic-color-success; + } + + &[data-variant="warning"] { + background-color: $semantic-color-warning; + color: $semantic-color-text-inverse; + border-color: $semantic-color-warning; + } + + &[data-variant="danger"] { + background-color: $semantic-color-danger; + color: $semantic-color-text-inverse; + border-color: $semantic-color-danger; + } + + &[data-variant="info"] { + background-color: $semantic-color-info; + color: $semantic-color-text-inverse; + border-color: $semantic-color-info; + } + + // Hover states for interactive badges + &:hover { + transform: translateY(-1px); + box-shadow: $semantic-shadow-elevation-1; + } + + // Focus states for accessibility + &:focus { + outline: 2px solid $semantic-color-focus-ring; + outline-offset: 2px; + } + + // Responsive adjustments + @media (max-width: calc($semantic-breakpoint-sm - 1px)) { + &[data-size="lg"] { + min-width: $semantic-sizing-icon-navigation; + height: $semantic-sizing-icon-navigation; + padding: 0 $semantic-spacing-component-xs; + font-size: $semantic-typography-font-size-xs; + } + } +} \ No newline at end of file diff --git a/src/lib/components/data-display/badge/badge.component.ts b/src/lib/components/data-display/badge/badge.component.ts new file mode 100644 index 0000000..99952aa --- /dev/null +++ b/src/lib/components/data-display/badge/badge.component.ts @@ -0,0 +1,37 @@ +import { Component, Input, ChangeDetectionStrategy } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +export type BadgeVariant = 'default' | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info'; +export type BadgeSize = 'xs' | 'sm' | 'md' | 'lg'; +export type BadgeShape = 'pill' | 'rounded' | 'square'; + +@Component({ + selector: 'ui-badge', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + @if (!isDot) { + + } + + `, + styleUrls: ['./badge.component.scss'] +}) +export class BadgeComponent { + @Input() variant: BadgeVariant = 'default'; + @Input() size: BadgeSize = 'md'; + @Input() shape: BadgeShape = 'pill'; + @Input() isDot: boolean = false; + @Input() ariaLabel?: string; + @Input() title?: string; +} \ No newline at end of file diff --git a/src/lib/components/data-display/badge/index.ts b/src/lib/components/data-display/badge/index.ts new file mode 100644 index 0000000..216ebbb --- /dev/null +++ b/src/lib/components/data-display/badge/index.ts @@ -0,0 +1 @@ +export * from './badge.component'; \ No newline at end of file diff --git a/src/lib/components/data-display/card/card.component.scss b/src/lib/components/data-display/card/card.component.scss new file mode 100644 index 0000000..864e5d6 --- /dev/null +++ b/src/lib/components/data-display/card/card.component.scss @@ -0,0 +1,400 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +// Tokens available globally via main application styles + +.ui-card { + position: relative; + width: 100%; + box-sizing: border-box; + margin-right: $semantic-spacing-grid-gap-xs; + margin-bottom: $semantic-spacing-grid-gap-xs; + overflow: hidden; + display: flex; + flex-direction: column; + + // Base styles + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + transition: all $semantic-duration-fast $semantic-easing-standard; + + // Size variants - following 8px grid increments + &--sm { + min-height: $semantic-sizing-card-height-sm; + + .card-content-layer { + padding: $semantic-spacing-component-padding-sm; + } + + .card-header { + padding-bottom: $semantic-spacing-component-sm; + } + + .card-footer { + padding-top: $semantic-spacing-component-sm; + } + } + + &--md { + min-height: $semantic-sizing-card-height-md; + + .card-content-layer { + padding: $semantic-spacing-component-padding-lg; + } + + .card-header { + padding-bottom: $semantic-spacing-component-md; + } + + .card-footer { + padding-top: $semantic-spacing-component-md; + } + } + + &--lg { + min-height: $semantic-sizing-card-height-lg; + + .card-content-layer { + padding: $semantic-spacing-component-padding-xl; + } + + .card-header { + padding-bottom: $semantic-spacing-component-lg; + } + + .card-footer { + padding-top: $semantic-spacing-component-lg; + } + } + + // Elevation variants - Material Design shadow levels + &--elevation-none { + box-shadow: none; + } + + &--elevation-sm { + box-shadow: $semantic-shadow-elevation-1; + } + + &--elevation-md { + box-shadow: $semantic-shadow-elevation-2; + } + + &--elevation-lg { + box-shadow: $semantic-shadow-elevation-3; + } + + &--elevation-xl { + box-shadow: $semantic-shadow-elevation-4; + } + + // Radius variants - consistent scale + &--radius-none { + border-radius: 0; + } + + &--radius-sm { + border-radius: $semantic-border-radius-sm; + } + + &--radius-md { + border-radius: $semantic-border-radius-md; + } + + &--radius-lg { + border-radius: $semantic-border-radius-lg; + } + + &--radius-full { + border-radius: $semantic-border-radius-full; + aspect-ratio: 1; + } + + // Variant styles - Material Design 3 inspired colors + &--elevated { + background-color: $semantic-color-surface-primary; + color: $semantic-color-text-primary; + border: none; + + &:hover:not(.ui-card--disabled) { + transform: translateY(-2px); + box-shadow: $semantic-shadow-card-hover; + } + } + + &--filled { + background-color: $semantic-color-surface-secondary; + color: $semantic-color-text-primary; + border: $semantic-border-width-1 solid $semantic-color-border-primary; + + &:hover:not(.ui-card--disabled) { + background-color: $semantic-color-surface-elevated; + } + } + + &--outlined { + background-color: transparent; + color: $semantic-color-text-primary; + border: $semantic-border-width-1 solid $semantic-color-border-secondary; + + &:hover:not(.ui-card--disabled) { + border-color: $semantic-color-border-focus; + background-color: $semantic-color-surface-elevated; + } + } + + // Clickable state + &--clickable { + cursor: pointer; + user-select: none; + + &:active:not(.ui-card--disabled) { + transform: scale(0.98); // Subtle press effect + } + + &:focus-visible { + outline: 2px solid $semantic-color-border-focus; + outline-offset: 2px; + } + } + + // Disabled state + &--disabled { + opacity: 0.38; // Material Design disabled opacity + cursor: not-allowed; + pointer-events: none; + } + + // Glass effect using CSS variables and semantic approach + &--glass { + position: relative; + isolation: isolate; + backdrop-filter: blur(var(--glass-blur-md, 8px)); + -webkit-backdrop-filter: blur(var(--glass-blur-md, 8px)); + border: 1px solid var(--glass-border-color, rgba(255, 255, 255, 0.2)); + transition: all $semantic-duration-short $semantic-easing-standard; + + .card-content-layer { + position: relative; + z-index: 2; + } + + // Glass variant styles using CSS variables - fixed syntax + &.ui-card--glass-translucent { + background: rgba(var(--glass-background-base, 255, 255, 255), var(--glass-opacity-translucent, 0.1)); + backdrop-filter: blur(var(--glass-blur-sm, 4px)); + -webkit-backdrop-filter: blur(var(--glass-blur-sm, 4px)); + } + + &.ui-card--glass-light { + background: rgba(var(--glass-background-base, 255, 255, 255), var(--glass-opacity-light, 0.3)); + backdrop-filter: blur(var(--glass-blur-md, 8px)); + -webkit-backdrop-filter: blur(var(--glass-blur-md, 8px)); + } + + &.ui-card--glass-medium { + background: rgba(var(--glass-background-base, 255, 255, 255), var(--glass-opacity-medium, 0.5)); + backdrop-filter: blur(var(--glass-blur-md, 8px)); + -webkit-backdrop-filter: blur(var(--glass-blur-md, 8px)); + } + + &.ui-card--glass-heavy { + background: rgba(var(--glass-background-base, 255, 255, 255), var(--glass-opacity-heavy, 0.7)); + backdrop-filter: blur(var(--glass-blur-lg, 16px)); + -webkit-backdrop-filter: blur(var(--glass-blur-lg, 16px)); + } + + &.ui-card--glass-frosted { + background: rgba(var(--glass-background-base, 255, 255, 255), var(--glass-opacity-frosted, 0.85)); + backdrop-filter: blur(var(--glass-blur-xl, 24px)); + -webkit-backdrop-filter: blur(var(--glass-blur-xl, 24px)); + } + + // Interactive glass states + &.ui-card--clickable:hover:not(.ui-card--disabled) { + transform: translateY(-2px); + box-shadow: $semantic-shadow-elevation-3; + } + + &.ui-card--clickable:active:not(.ui-card--disabled) { + transform: scale(0.98); + } + + // Disabled state for glass cards + &.ui-card--disabled { + backdrop-filter: blur(var(--glass-blur-xs, 2px)); + -webkit-backdrop-filter: blur(var(--glass-blur-xs, 2px)); + opacity: 0.6; + } + } + + // With background layer + &--with-background { + .card-background-layer { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + box-sizing: border-box; + transform: translateZ(0); + z-index: 0; + } + + .card-content-layer { + position: relative; + z-index: 1; + background: $semantic-color-surface-primary; + color: $semantic-color-text-primary; + } + } +} + +// Card content structure +.card-content-layer { + display: flex; + flex-direction: column; + height: 100%; + flex: 1; + color: inherit; +} + +.card-header { + border-bottom: $semantic-border-width-1 solid $semantic-color-border-secondary; + + h1, h2, h3, h4, h5, h6 { + margin: 0; + color: $semantic-color-text-primary; + } + + p { + margin: 0; + color: $semantic-color-text-secondary; + } +} + +.card-body { + flex: 1; + color: inherit; + + // Typography reset + > *:first-child { + margin-top: 0; + } + + > *:last-child { + margin-bottom: 0; + } + + p { + color: $semantic-color-text-primary; + } +} + +.card-footer { + border-top: $semantic-border-width-1 solid $semantic-color-border-secondary; + display: flex; + justify-content: flex-end; + align-items: center; + gap: $semantic-spacing-component-sm; +} + +// Glass overlay for enhanced glass effect +.card-glass-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(145deg, + rgba(255, 255, 255, 0.1) 0%, + rgba(255, 255, 255, 0.05) 50%, + rgba(255, 255, 255, 0.1) 100%); + pointer-events: none; + z-index: 1; + border-radius: inherit; +} + +// Ripple effect for clickable cards +.card-ripple-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + overflow: hidden; + pointer-events: none; + z-index: 3; + + &::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 0; + height: 0; + border-radius: 50%; + background: rgba(120, 53, 255, 0.2); // Brand color ripple + transform: translate(-50%, -50%) scale(0); + opacity: 0; + transition: transform 0.3s ease, opacity 0.3s ease; + } + + .ui-card--clickable:active & { + &::after { + transform: translate(-50%, -50%) scale(2); + opacity: 1; + transition: none; + } + } +} + +// Hover effects based on elevation +.ui-card:hover:not(.ui-card--disabled) { + &.ui-card--elevation-sm { + box-shadow: $semantic-shadow-elevation-2; + } + + &.ui-card--elevation-md { + box-shadow: $semantic-shadow-elevation-3; + } + + &.ui-card--elevation-lg { + box-shadow: $semantic-shadow-elevation-4; + } +} + +// Dark mode support - Remove problematic dark mode overrides +// Semantic tokens should handle dark mode automatically + +// Responsive adaptations +@media (max-width: ($semantic-breakpoint-md - 1)) { + .ui-card { + margin-right: 0; + + &--lg { + .card-content-layer { + padding: $semantic-spacing-component-padding-lg; + } + } + + .card-footer { + flex-direction: column; + gap: $semantic-spacing-component-md; + } + } +} + +// Accessibility +@media (prefers-reduced-motion: reduce) { + .ui-card { + transition: none; + + &:hover, + &:active { + transform: none; + } + } + + .card-ripple-overlay::after { + transition: none; + } +} \ No newline at end of file diff --git a/src/lib/components/data-display/card/card.component.ts b/src/lib/components/data-display/card/card.component.ts new file mode 100644 index 0000000..f5f50e6 --- /dev/null +++ b/src/lib/components/data-display/card/card.component.ts @@ -0,0 +1,99 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +export type CardVariant = 'elevated' | 'filled' | 'outlined'; +export type CardSize = 'sm' | 'md' | 'lg'; +export type CardElevation = 'none' | 'sm' | 'md' | 'lg' | 'xl'; +export type CardRadius = 'none' | 'sm' | 'md' | 'lg' | 'full'; +export type GlassVariant = 'translucent' | 'light' | 'medium' | 'heavy' | 'frosted'; + +@Component({ + selector: 'ui-card', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ @if (hasBackgroundLayer) { +
+ +
+ } + +
+ @if (hasHeader) { +
+ +
+ } + +
+ +
+ + @if (hasFooter) { + + } +
+ + @if (glass) { +
+ } + + @if (clickable && !disabled) { +
+ } +
+ `, + styleUrl: './card.component.scss' +}) +export class CardComponent { + @Input() variant: CardVariant = 'elevated'; + @Input() size: CardSize = 'md'; + @Input() elevation: CardElevation = 'sm'; + @Input() radius: CardRadius = 'md'; + @Input() disabled: boolean = false; + @Input() clickable: boolean = false; + @Input() glass: boolean = false; + @Input() glassVariant: GlassVariant = 'medium'; + @Input() backgroundImage?: string; + @Input() hasBackgroundLayer: boolean = false; + @Input() hasHeader: boolean = false; + @Input() hasFooter: boolean = false; + @Input() class: string = ''; + + @Output() cardClick = new EventEmitter(); + + get cardClasses(): string { + return [ + 'ui-card', + `ui-card--${this.variant}`, + `ui-card--${this.size}`, + `ui-card--elevation-${this.elevation}`, + `ui-card--radius-${this.radius}`, + this.disabled ? 'ui-card--disabled' : '', + this.clickable ? 'ui-card--clickable' : '', + this.glass ? 'ui-card--glass' : '', + this.glass ? `ui-card--glass-${this.glassVariant}` : '', + this.hasBackgroundLayer ? 'ui-card--with-background' : '', + this.class + ].filter(Boolean).join(' '); + } + + handleClick(event: Event): void { + if (this.clickable && !this.disabled) { + this.cardClick.emit(event); + } + } +} \ No newline at end of file diff --git a/src/lib/components/data-display/card/index.ts b/src/lib/components/data-display/card/index.ts new file mode 100644 index 0000000..0904481 --- /dev/null +++ b/src/lib/components/data-display/card/index.ts @@ -0,0 +1 @@ +export * from './card.component'; \ No newline at end of file diff --git a/src/lib/components/data-display/carousel/carousel.component.scss b/src/lib/components/data-display/carousel/carousel.component.scss new file mode 100644 index 0000000..6d822db --- /dev/null +++ b/src/lib/components/data-display/carousel/carousel.component.scss @@ -0,0 +1,375 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +@mixin font-properties($font-map) { + @if type-of($font-map) == 'map' { + font-family: map-get($font-map, 'font-family'); + font-size: map-get($font-map, 'font-size'); + line-height: map-get($font-map, 'line-height'); + font-weight: map-get($font-map, 'font-weight'); + letter-spacing: map-get($font-map, 'letter-spacing'); + } @else { + font: $font-map; + } +} + +.ui-carousel { + position: relative; + width: 100%; + overflow: hidden; + border-radius: 12px; + background: $semantic-color-surface-container; + + &__container { + position: relative; + width: 100%; + height: 400px; // Default height + overflow: hidden; + } + + &__track { + display: flex; + width: 100%; + height: 100%; + transition: transform $semantic-duration-medium $semantic-easing-standard; + } + + &__slide { + flex: 0 0 100%; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + opacity: 1; + transform: scale(1); + transition: opacity $semantic-duration-medium $semantic-easing-standard, + transform $semantic-duration-medium $semantic-easing-standard; + + &--active { + opacity: 1; + transform: scale(1); + } + + .ui-carousel__image-container, + .ui-carousel__card, + .ui-carousel__content-slide { + width: 100%; + height: 100%; + } + } + + &__image-container { + position: relative; + width: 100%; + height: 100%; + + .ui-carousel__image { + width: 100%; + height: 100%; + object-fit: cover; + } + + .ui-carousel__image-overlay { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: linear-gradient(transparent, rgba(0, 0, 0, 0.7)); + padding: $semantic-spacing-component-lg; + color: white; + + .ui-carousel__title { + @include font-properties($semantic-typography-heading-h3); + margin-bottom: $semantic-spacing-component-sm; + } + + .ui-carousel__subtitle { + @include font-properties($semantic-typography-body-medium); + opacity: 0.9; + } + } + } + + &__card { + background: $semantic-color-surface-container; + border-radius: 8px; + overflow: hidden; + box-shadow: $semantic-shadow-elevation-2; + + .ui-carousel__card-image { + width: 100%; + height: 60%; + object-fit: cover; + } + + .ui-carousel__card-content { + padding: $semantic-spacing-component-lg; + height: 40%; + display: flex; + flex-direction: column; + justify-content: center; + + .ui-carousel__title { + @include font-properties($semantic-typography-heading-h4); + margin-bottom: $semantic-spacing-component-sm; + } + + .ui-carousel__subtitle { + @include font-properties($semantic-typography-body-medium); + color: $semantic-color-text-secondary; + margin-bottom: $semantic-spacing-component-sm; + } + + .ui-carousel__content { + @include font-properties($semantic-typography-body-small); + color: $semantic-color-text-secondary; + } + } + } + + &__content-slide { + padding: $semantic-spacing-component-lg; + text-align: center; + color: $semantic-color-text-primary; + display: flex; + flex-direction: column; + justify-content: center; + + .ui-carousel__title { + @include font-properties($semantic-typography-heading-h3); + margin-bottom: $semantic-spacing-component-md; + } + + .ui-carousel__subtitle { + @include font-properties($semantic-typography-body-medium); + color: $semantic-color-text-secondary; + margin-bottom: $semantic-spacing-component-md; + } + + .ui-carousel__content { + @include font-properties($semantic-typography-body-medium); + color: $semantic-color-text-secondary; + } + } + + &__nav { + position: absolute; + top: 50%; + transform: translateY(-50%); + z-index: 2; + background: rgba(255, 255, 255, 0.9); + border: none; + border-radius: 50%; + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + box-shadow: $semantic-shadow-elevation-3; + transition: all $semantic-motion-duration-fast $semantic-easing-standard; + + &:hover { + background: white; + transform: translateY(-50%) scale(1.1); + } + + &:focus { + outline: 2px solid $semantic-color-primary; + outline-offset: 2px; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: translateY(-50%); + } + + &--prev { + left: $semantic-spacing-component-md; + } + + &--next { + right: $semantic-spacing-component-md; + } + + .ui-carousel__nav-icon { + width: 20px; + height: 20px; + color: $semantic-color-text-primary; + } + } + + &__indicators { + position: absolute; + bottom: $semantic-spacing-component-md; + left: 50%; + transform: translateX(-50%); + display: flex; + gap: $semantic-spacing-component-xs; + z-index: 2; + + &--dots .ui-carousel__indicator { + width: 12px; + height: 12px; + border-radius: 50%; + } + + &--bars .ui-carousel__indicator { + width: 24px; + height: 4px; + border-radius: 2px; + } + + &--thumbnails .ui-carousel__indicator { + width: 48px; + height: 32px; + border-radius: 4px; + overflow: hidden; + } + } + + &__indicator { + border: none; + background: rgba(255, 255, 255, 0.5); + cursor: pointer; + transition: all $semantic-duration-medium $semantic-easing-standard; + + &--active { + background: $semantic-color-primary; + transform: scale(1.2); + } + + &:hover { + background: rgba(255, 255, 255, 0.8); + } + + &:focus { + outline: 2px solid $semantic-color-primary; + outline-offset: 2px; + } + + .ui-carousel__indicator-dot, + .ui-carousel__indicator-bar { + display: block; + width: 100%; + height: 100%; + border-radius: inherit; + } + + .ui-carousel__indicator-thumbnail { + width: 100%; + height: 100%; + object-fit: cover; + } + } + + // Size variants + &--sm { + .ui-carousel__container { + height: 250px; + } + } + + &--md { + .ui-carousel__container { + height: 400px; + } + } + + &--lg { + .ui-carousel__container { + height: 600px; + } + } + + // Transition effects + &--fade { + .ui-carousel__track { + // For fade transition, we need to position slides absolutely + position: relative; + } + + .ui-carousel__slide { + position: absolute; + top: 0; + left: 0; + right: 0; + opacity: 0; + transition: opacity $semantic-duration-medium $semantic-easing-standard; + flex: none; // Override the flex property + + &--active { + opacity: 1; + z-index: 1; + } + } + } + + &--scale { + .ui-carousel__track { + position: relative; + } + + .ui-carousel__slide { + position: absolute; + top: 0; + left: 0; + right: 0; + transform: scale(0.8); + opacity: 0.3; + transition: transform $semantic-duration-medium $semantic-easing-standard, + opacity $semantic-duration-medium $semantic-easing-standard; + flex: none; // Override the flex property + + &--active { + transform: scale(1); + opacity: 1; + z-index: 1; + } + } + } + + &--slide { + // Default slide behavior - already handled by track transform + .ui-carousel__track { + transition: transform $semantic-duration-medium $semantic-easing-standard; + } + } + + // Disabled state + &--disabled { + opacity: 0.6; + pointer-events: none; + } + + // Responsive design + @media (max-width: 768px) { + &__container { + height: 300px; + } + + &--sm .ui-carousel__container { + height: 200px; + } + + &--lg .ui-carousel__container { + height: 400px; + } + + &__nav { + width: 40px; + height: 40px; + + .ui-carousel__nav-icon { + width: 16px; + height: 16px; + } + } + + &__card .ui-carousel__card-content, + &__content-slide { + padding: $semantic-spacing-component-md; + } + } +} \ No newline at end of file diff --git a/src/lib/components/data-display/carousel/carousel.component.ts b/src/lib/components/data-display/carousel/carousel.component.ts new file mode 100644 index 0000000..e01caa5 --- /dev/null +++ b/src/lib/components/data-display/carousel/carousel.component.ts @@ -0,0 +1,277 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation, signal, computed } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +export type CarouselSize = 'sm' | 'md' | 'lg'; +export type CarouselVariant = 'card' | 'image' | 'content'; +export type CarouselIndicatorStyle = 'dots' | 'bars' | 'thumbnails' | 'none'; +export type CarouselTransition = 'slide' | 'fade' | 'scale'; + +export interface CarouselItem { + id: string | number; + imageUrl?: string; + title?: string; + subtitle?: string; + content?: string; + thumbnail?: string; +} + +@Component({ + selector: 'ui-carousel', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` + + `, + styleUrl: './carousel.component.scss' +}) +export class CarouselComponent { + @Input() items: CarouselItem[] = []; + @Input() size: CarouselSize = 'md'; + @Input() variant: CarouselVariant = 'image'; + @Input() transition: CarouselTransition = 'slide'; + @Input() indicatorStyle: CarouselIndicatorStyle = 'dots'; + @Input() disabled = false; + @Input() autoplay = false; + @Input() autoplayInterval = 3000; + @Input() loop = true; + @Input() showNavigation = true; + @Input() showIndicators = true; + @Input() ariaLabel = 'Image carousel'; + @Input() initialIndex = 0; + + @Output() slideChange = new EventEmitter(); + @Output() itemClick = new EventEmitter<{item: CarouselItem, index: number}>(); + + private _currentIndex = signal(this.initialIndex); + private _autoplayTimer?: number; + + currentIndex = this._currentIndex.asReadonly(); + + getComponentClasses(): string { + const classes = [ + 'ui-carousel', + `ui-carousel--${this.size}`, + `ui-carousel--${this.variant}`, + `ui-carousel--${this.transition}` + ]; + + if (this.disabled) { + classes.push('ui-carousel--disabled'); + } + + if (this.autoplay) { + classes.push('ui-carousel--autoplay'); + } + + return classes.join(' '); + } + + getIndicatorClasses(): string { + const classes = ['ui-carousel__indicators']; + + if (this.indicatorStyle) { + classes.push(`ui-carousel__indicators--${this.indicatorStyle}`); + } + + return classes.join(' '); + } + + ngOnInit(): void { + this._currentIndex.set(this.initialIndex); + if (this.autoplay && !this.disabled) { + this.startAutoplay(); + } + } + + ngOnDestroy(): void { + this.stopAutoplay(); + } + + goToSlide(index: number): void { + if (this.disabled || index < 0 || index >= this.items.length) { + return; + } + + this._currentIndex.set(index); + this.slideChange.emit(index); + this.resetAutoplay(); + } + + goToNext(): void { + const nextIndex = this.loop && this.currentIndex() === this.items.length - 1 + ? 0 + : Math.min(this.currentIndex() + 1, this.items.length - 1); + this.goToSlide(nextIndex); + } + + goToPrevious(): void { + const prevIndex = this.loop && this.currentIndex() === 0 + ? this.items.length - 1 + : Math.max(this.currentIndex() - 1, 0); + this.goToSlide(prevIndex); + } + + private startAutoplay(): void { + this.stopAutoplay(); + this._autoplayTimer = window.setInterval(() => { + this.goToNext(); + }, this.autoplayInterval); + } + + private stopAutoplay(): void { + if (this._autoplayTimer) { + clearInterval(this._autoplayTimer); + this._autoplayTimer = undefined; + } + } + + private resetAutoplay(): void { + if (this.autoplay && !this.disabled) { + this.stopAutoplay(); + this.startAutoplay(); + } + } +} \ No newline at end of file diff --git a/src/lib/components/data-display/carousel/index.ts b/src/lib/components/data-display/carousel/index.ts new file mode 100644 index 0000000..7a3d383 --- /dev/null +++ b/src/lib/components/data-display/carousel/index.ts @@ -0,0 +1 @@ +export * from './carousel.component'; \ No newline at end of file diff --git a/src/lib/components/data-display/chip/chip.component.scss b/src/lib/components/data-display/chip/chip.component.scss new file mode 100644 index 0000000..8d49a58 --- /dev/null +++ b/src/lib/components/data-display/chip/chip.component.scss @@ -0,0 +1,451 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; +.ui-chip { + display: inline-flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + position: relative; + vertical-align: top; + white-space: nowrap; + overflow: hidden; + margin-right: 0.75rem; + margin-bottom: 0.5rem; + + // Prevent text selection + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + + // Base typography + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + font-weight: 500; + text-decoration: none; + outline: none; + + // Transitions + transition: all $semantic-duration-fast $semantic-easing-standard; + + // Size variants + &--sm { + height: 24px; + font-size: 0.75rem; + line-height: 1; + + .chip-label { + padding: 0 0.5rem; + } + + .chip-leading-icon, + .chip-trailing-icon { + width: 18px; + height: 18px; + font-size: 0.75rem; + margin: 0 0.25rem; + } + + .chip-avatar { + width: 18px; + height: 18px; + margin: 0 0.25rem 0 0.125rem; + + img { + width: 18px; + height: 18px; + border-radius: 9px; + } + } + + &.ui-chip--with-leading-icon .chip-label, + &.ui-chip--with-avatar .chip-label { + padding-left: 0.25rem; + } + + &.ui-chip--with-trailing-icon .chip-label, + &.ui-chip--removable .chip-label { + padding-right: 0.25rem; + } + } + + &--md { + height: 32px; + font-size: 0.875rem; + line-height: 1; + + .chip-label { + padding: 0 0.75rem; + } + + .chip-leading-icon, + .chip-trailing-icon { + width: 24px; + height: 24px; + font-size: 1rem; + margin: 0 0.25rem; + } + + .chip-avatar { + width: 24px; + height: 24px; + margin: 0 0.25rem 0 0.125rem; + + img { + width: 24px; + height: 24px; + border-radius: 12px; + } + } + + &.ui-chip--with-leading-icon .chip-label, + &.ui-chip--with-avatar .chip-label { + padding-left: 0.5rem; + } + + &.ui-chip--with-trailing-icon .chip-label, + &.ui-chip--removable .chip-label { + padding-right: 0.5rem; + } + } + + &--lg { + height: 40px; + font-size: 1rem; + line-height: 1; + + .chip-label { + padding: 0 1rem; + } + + .chip-leading-icon, + .chip-trailing-icon { + width: 28px; + height: 28px; + font-size: 1.125rem; + margin: 0 0.375rem; + } + + .chip-avatar { + width: 32px; + height: 32px; + margin: 0 0.375rem 0 0.25rem; + + img { + width: 32px; + height: 32px; + border-radius: 16px; + } + } + + &.ui-chip--with-leading-icon .chip-label, + &.ui-chip--with-avatar .chip-label { + padding-left: 0.5rem; + } + + &.ui-chip--with-trailing-icon .chip-label, + &.ui-chip--removable .chip-label { + padding-right: 0.5rem; + } + } + + // Radius variants + &--radius-none { + border-radius: 0; + } + + &--radius-sm { + border-radius: 0.25rem; + } + + &--radius-md { + border-radius: 0.375rem; + } + + &--radius-lg { + border-radius: 0.5rem; + } + + &--radius-capsule { + border-radius: 50px; + } + + // Variant styles + &--filled { + background-color: $semantic-color-surface-elevated; + color: $semantic-color-text-primary; + border: 1px solid transparent; + + &:hover:not(.ui-chip--disabled) { + background-color: $semantic-color-surface-selected; + transform: translateY(-1px); + } + + &:active:not(.ui-chip--disabled) { + transform: scale(0.95); + } + + &.ui-chip--selected { + background-color: $semantic-color-primary; + color: $semantic-color-text-inverse; + + &:hover:not(.ui-chip--disabled) { + background-color: $semantic-color-primary-hover; + } + } + } + + &--outlined { + background-color: transparent; + color: $semantic-color-primary; + border: 1px solid $semantic-color-border-primary; + + &:hover:not(.ui-chip--disabled) { + background-color: $semantic-color-surface-elevated; + border-color: $semantic-color-primary; + transform: translateY(-1px); + } + + &:active:not(.ui-chip--disabled) { + background-color: $semantic-color-surface-primary; + transform: scale(0.95); + } + + &.ui-chip--selected { + background-color: $semantic-color-surface-primary; + border-color: $semantic-color-primary; + + &:hover:not(.ui-chip--disabled) { + background-color: $semantic-color-surface-elevated; + } + } + } + + &--elevated { + background-color: $semantic-color-surface-primary; + color: $semantic-color-text-primary; + border: none; + box-shadow: $semantic-shadow-elevation-1; + + &:hover:not(.ui-chip--disabled) { + box-shadow: $semantic-shadow-elevation-2; + transform: translateY(-1px); + } + + &:active:not(.ui-chip--disabled) { + box-shadow: $semantic-shadow-elevation-1; + transform: scale(0.95); + } + + &.ui-chip--selected { + background-color: $semantic-color-primary; + color: $semantic-color-text-inverse; + box-shadow: $semantic-shadow-elevation-2; + + &:hover:not(.ui-chip--disabled) { + background-color: $semantic-color-primary-hover; + box-shadow: $semantic-shadow-elevation-3; + } + } + } + + // States + &--clickable { + cursor: pointer; + + &:focus-visible { + outline: 2px solid $semantic-color-border-focus; + outline-offset: 2px; + } + } + + &--disabled { + opacity: 0.38; + cursor: not-allowed; + pointer-events: none; + } + + &--selected { + // Selected styles are handled in variant sections above + } + + // Icon and avatar styles + .chip-leading-icon, + .chip-trailing-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + } + + .chip-trailing-icon { + cursor: pointer; + border-radius: 50%; + transition: background-color $semantic-duration-fast ease; + + &:hover { + background-color: rgba(0, 0, 0, 0.1); + } + + &:focus-visible { + outline: 1px solid $semantic-color-border-focus; + outline-offset: 1px; + } + } + + .chip-avatar { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + overflow: hidden; + + img { + object-fit: cover; + border: 1px solid rgba(0, 0, 0, 0.1); + } + } + + .chip-label { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + min-width: 0; + text-align: center; + } +} + +// Dark mode support +@media (prefers-color-scheme: dark) { + .ui-chip { + &--filled { + background-color: hsl(279, 14%, 25%); + color: hsl(0, 0%, 90%); + + &:hover:not(.ui-chip--disabled) { + background-color: hsl(279, 14%, 30%); + } + + &.ui-chip--selected { + background-color: hsl(258, 100%, 47%); + color: white; + + &:hover:not(.ui-chip--disabled) { + background-color: hsl(258, 100%, 52%); + } + } + } + + &--outlined { + background-color: transparent; + color: hsl(258, 100%, 67%); + border-color: hsl(279, 14%, 45%); + + &:hover:not(.ui-chip--disabled) { + background-color: hsl(279, 14%, 15%); + border-color: hsl(258, 100%, 67%); + } + + &:active:not(.ui-chip--disabled) { + background-color: hsl(279, 14%, 20%); + } + + &.ui-chip--selected { + background-color: hsl(279, 14%, 20%); + border-color: hsl(258, 100%, 67%); + + &:hover:not(.ui-chip--disabled) { + background-color: hsl(279, 14%, 25%); + } + } + } + + &--elevated { + background-color: hsl(279, 14%, 15%); + color: hsl(0, 0%, 90%); + + &:hover:not(.ui-chip--disabled) { + background-color: hsl(279, 14%, 20%); + } + + &.ui-chip--selected { + background-color: hsl(258, 100%, 47%); + color: white; + + &:hover:not(.ui-chip--disabled) { + background-color: hsl(258, 100%, 52%); + } + } + } + + .chip-trailing-icon:hover { + background-color: rgba(255, 255, 255, 0.1); + } + + .chip-avatar img { + border-color: rgba(255, 255, 255, 0.2); + } + } +} + +// Accessibility +@media (prefers-reduced-motion: reduce) { + .ui-chip { + transition: none; + + &:hover, + &:active { + transform: none; + } + } +} + +// Chip group/set container utility +.ui-chip-set { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: flex-start; + + &--row { + flex-wrap: nowrap; + overflow-x: auto; + scroll-padding-left: 1rem; + scroll-snap-type: x mandatory; + + .ui-chip { + scroll-snap-align: center; + flex-shrink: 0; + } + + &::-webkit-scrollbar { + height: 0; + background: transparent; + } + + scrollbar-width: none; + -ms-overflow-style: none; + } + + &--no-scroll { + overflow-x: visible; + } +} + +// Responsive +@media (max-width: 768px) { + .ui-chip { + margin-right: 0.5rem; + margin-bottom: 0.375rem; + + &--lg { + height: 36px; + font-size: 0.875rem; + + .chip-label { + padding: 0 0.75rem; + } + } + } +} \ No newline at end of file diff --git a/src/lib/components/data-display/chip/chip.component.ts b/src/lib/components/data-display/chip/chip.component.ts new file mode 100644 index 0000000..c6930bd --- /dev/null +++ b/src/lib/components/data-display/chip/chip.component.ts @@ -0,0 +1,105 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { IconDefinition } from '@fortawesome/fontawesome-svg-core'; + +export type ChipVariant = 'filled' | 'outlined' | 'elevated'; +export type ChipSize = 'sm' | 'md' | 'lg'; +export type ChipRadius = 'none' | 'sm' | 'md' | 'lg' | 'capsule'; + +@Component({ + selector: 'ui-chip', + standalone: true, + imports: [CommonModule, FontAwesomeModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ @if (leadingIcon) { +
+ +
+ } + + @if (avatar) { +
+ +
+ } + +
+ {{ label }} +
+ + @if (removable && closeIcon) { +
+ +
+ } + + @if (trailingIcon && !removable) { +
+ +
+ } +
+ `, + styleUrl: './chip.component.scss' +}) +export class ChipComponent { + @Input() label: string = ''; + @Input() variant: ChipVariant = 'filled'; + @Input() size: ChipSize = 'md'; + @Input() radius: ChipRadius = 'md'; + @Input() disabled: boolean = false; + @Input() selected: boolean = false; + @Input() clickable: boolean = false; + @Input() removable: boolean = false; + @Input() leadingIcon?: IconDefinition; + @Input() trailingIcon?: IconDefinition; + @Input() closeIcon?: IconDefinition; + @Input() avatar?: string; + @Input() avatarAlt?: string; + @Input() class: string = ''; + + @Output() chipClick = new EventEmitter(); + @Output() chipRemove = new EventEmitter(); + + get chipClasses(): string { + return [ + 'ui-chip', + `ui-chip--${this.variant}`, + `ui-chip--${this.size}`, + `ui-chip--radius-${this.radius}`, + this.disabled ? 'ui-chip--disabled' : '', + this.selected ? 'ui-chip--selected' : '', + this.clickable ? 'ui-chip--clickable' : '', + this.removable ? 'ui-chip--removable' : '', + this.leadingIcon ? 'ui-chip--with-leading-icon' : '', + this.trailingIcon ? 'ui-chip--with-trailing-icon' : '', + this.avatar ? 'ui-chip--with-avatar' : '', + this.class + ].filter(Boolean).join(' '); + } + + handleClick(event: Event): void { + if (!this.disabled && this.clickable) { + this.chipClick.emit(event); + } + } + + handleRemove(event: Event): void { + event.stopPropagation(); + if (!this.disabled) { + this.chipRemove.emit(event); + } + } +} \ No newline at end of file diff --git a/src/lib/components/data-display/chip/index.ts b/src/lib/components/data-display/chip/index.ts new file mode 100644 index 0000000..bfb6f60 --- /dev/null +++ b/src/lib/components/data-display/chip/index.ts @@ -0,0 +1 @@ +export * from './chip.component'; \ No newline at end of file diff --git a/src/lib/components/data-display/image-container/image-container.component.scss b/src/lib/components/data-display/image-container/image-container.component.scss new file mode 100644 index 0000000..1921b9e --- /dev/null +++ b/src/lib/components/data-display/image-container/image-container.component.scss @@ -0,0 +1,314 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +// Tokens available globally via main application styles + +.ui-image-container { + // Core Structure + position: relative; + display: inline-block; + overflow: hidden; + + // Layout & Aspect Ratio + aspect-ratio: var(--aspect-ratio, 1/1); + + // Visual Design + background: $semantic-color-surface-secondary; + border: $semantic-border-width-1 solid $semantic-color-border-primary; + border-radius: $semantic-border-radius-md; + + // Transitions + transition: all $semantic-duration-fast $semantic-easing-standard; + + // Size Variants + &--size-sm { + min-width: $semantic-sizing-card-width-sm * 0.5; // 8rem + max-width: $semantic-sizing-card-width-sm; // 16rem + min-height: $semantic-sizing-card-height-sm * 0.5; // 4rem + } + + &--size-md { + min-width: $semantic-sizing-card-width-md * 0.5; // 10rem + max-width: $semantic-sizing-card-width-md; // 20rem + min-height: $semantic-sizing-card-height-md * 0.5; // 6rem + } + + &--size-lg { + min-width: $semantic-sizing-card-width-lg * 0.5; // 12rem + max-width: $semantic-sizing-card-width-lg; // 24rem + min-height: $semantic-sizing-card-height-lg * 0.5; // 8rem + } + + &--size-xl { + min-width: $semantic-sizing-card-width-lg * 0.75; // 18rem + max-width: $semantic-sizing-content-narrow; // 42rem + min-height: $semantic-sizing-card-height-lg * 0.75; // 12rem + } + + // Aspect Ratio Variants + &--aspect-1-1 { aspect-ratio: 1/1; } + &--aspect-4-3 { aspect-ratio: 4/3; } + &--aspect-16-9 { aspect-ratio: 16/9; } + &--aspect-3-2 { aspect-ratio: 3/2; } + &--aspect-2-1 { aspect-ratio: 2/1; } + &--aspect-3-4 { aspect-ratio: 3/4; } + &--aspect-9-16 { aspect-ratio: 9/16; } + + // Shape Variants + &--shape-square { + border-radius: $semantic-border-radius-sm; + } + + &--shape-rounded { + border-radius: $semantic-border-radius-md; + } + + &--shape-circle { + border-radius: $semantic-border-radius-full; + aspect-ratio: 1/1; + } + + // State Variants + &--loading { + cursor: wait; + } + + &--error { + border-color: $semantic-color-border-error; + border-style: dashed; + } + + &--lazy { + background: $semantic-color-surface-secondary; + + &:not(.ui-image-container--loaded) { + animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; + } + } + + // Interactive States + &:not(.ui-image-container--error) { + &:hover { + box-shadow: $semantic-shadow-elevation-2; + transform: translateY(-1px); + border-color: $semantic-color-border-primary; + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus-ring; + outline-offset: 2px; + } + + &:active { + transform: translateY(0); + box-shadow: $semantic-shadow-elevation-1; + } + } + + // Image Element + &__image { + width: 100%; + height: 100%; + display: block; + + // Object Fit Variants + &--fit-contain { object-fit: contain; } + &--fit-cover { object-fit: cover; } + &--fit-fill { object-fit: fill; } + &--fit-scale-down { object-fit: scale-down; } + &--fit-none { object-fit: none; } + + // Smooth appearance transition + opacity: 0; + transition: opacity $semantic-duration-medium $semantic-easing-standard; + + .ui-image-container--loaded & { + opacity: 1; + } + } + + // Loading State + &__loading { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: $semantic-color-surface-secondary; + z-index: 2; + } + + &__spinner { + width: $semantic-sizing-icon-navigation; + height: $semantic-sizing-icon-navigation; + border: 2px solid $semantic-color-border-primary; + border-top: 2px solid $semantic-color-interactive-primary; + border-radius: $semantic-border-radius-full; + animation: spin 1s linear infinite; + } + + // Error State + &__error { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: $semantic-spacing-component-xs; + background: $semantic-color-surface-secondary; + color: $semantic-color-danger; + z-index: 2; + padding: $semantic-spacing-component-sm; + text-align: center; + } + + &__error-icon { + color: $semantic-color-text-secondary; + + svg { + width: $semantic-sizing-icon-navigation; + height: $semantic-sizing-icon-navigation; + } + } + + &__error-text { + font-size: $semantic-typography-font-size-xs; + color: $semantic-color-text-secondary; + font-weight: $semantic-typography-font-weight-medium; + } + + // Overlay + &__overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.4); + opacity: 0; + transition: opacity $semantic-duration-fast $semantic-easing-standard; + z-index: 3; + + .ui-image-container:hover & { + opacity: 1; + } + } + + // Caption + &__caption { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: linear-gradient(transparent, rgba(0, 0, 0, 0.7)); + color: white; + padding: $semantic-spacing-component-md $semantic-spacing-component-sm $semantic-spacing-component-sm; + font-size: $semantic-typography-font-size-sm; + line-height: $semantic-typography-line-height-tight; + z-index: 4; + } + + // Dark Mode Support + :host-context(.dark-theme) & { + background: $semantic-color-surface-primary; + border-color: $semantic-color-border-subtle; + + &__loading { + background: $semantic-color-surface-primary; + } + + &__spinner { + border-color: $semantic-color-border-subtle; + border-top-color: $semantic-color-interactive-primary; + } + } + + // Responsive Design + @media (max-width: $semantic-sizing-breakpoint-tablet - 1) { + &--size-xl { + max-width: $semantic-sizing-card-width-lg; + } + + &--size-lg { + max-width: $semantic-sizing-card-width-md; + } + } + + @media (max-width: $semantic-sizing-breakpoint-mobile - 1) { + // Mobile adjustments + &__caption { + font-size: $semantic-typography-font-size-xs; + padding: $semantic-spacing-component-xs; + } + + &__error-text { + font-size: $semantic-typography-font-size-xs; + } + } + + // Accessibility - Reduced motion + @media (prefers-reduced-motion: reduce) { + transition: none; + + &__image { + transition: none; + } + + &__overlay { + transition: none; + } + + &--lazy:not(.ui-image-container--loaded) { + animation: none; + } + + &__spinner { + animation: none; + } + } +} + +// Animation Keyframes +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +// Utility classes for content projection +.ui-image-container { + [slot='error'] { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: $semantic-spacing-component-xs; + padding: $semantic-spacing-component-sm; + text-align: center; + height: 100%; + color: $semantic-color-danger; + } + + [slot='overlay'] { + display: flex; + align-items: center; + justify-content: center; + color: white; + font-weight: $semantic-typography-font-weight-medium; + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5); + } + + [slot='caption'] { + font-size: $semantic-typography-font-size-sm; + line-height: $semantic-typography-line-height-tight; + } +} \ No newline at end of file diff --git a/src/lib/components/data-display/image-container/image-container.component.ts b/src/lib/components/data-display/image-container/image-container.component.ts new file mode 100644 index 0000000..14f5fc6 --- /dev/null +++ b/src/lib/components/data-display/image-container/image-container.component.ts @@ -0,0 +1,217 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation, ElementRef, OnInit, OnDestroy, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +export type ImageContainerSize = 'sm' | 'md' | 'lg' | 'xl'; +export type ImageContainerAspectRatio = '1/1' | '4/3' | '16/9' | '3/2' | '2/1' | '3/4' | '9/16'; +export type ImageContainerObjectFit = 'contain' | 'cover' | 'fill' | 'scale-down' | 'none'; +export type ImageContainerShape = 'square' | 'rounded' | 'circle'; + +@Component({ + selector: 'ui-image-container', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ + @if (loading && !isLoaded) { + + } + + @if (hasError) { + + } @else { + + } + + @if (overlay) { +
+ +
+ } + + @if (caption) { +
+ + {{ caption }} + +
+ } +
+ `, + styleUrl: './image-container.component.scss' +}) +export class ImageContainerComponent implements OnInit, OnDestroy { + @Input() src!: string; + @Input() alt = ''; + @Input() size: ImageContainerSize = 'md'; + @Input() aspectRatio: ImageContainerAspectRatio = '1/1'; + @Input() objectFit: ImageContainerObjectFit = 'cover'; + @Input() shape: ImageContainerShape = 'rounded'; + @Input() lazy = true; + @Input() loading = false; + @Input() placeholder?: string; + @Input() caption?: string; + @Input() overlay = false; + @Input() ariaLabel?: string; + @Input() errorAlt?: string; + + // Advanced image attributes + @Input() srcset?: string; + @Input() sizes?: string; + @Input() crossorigin?: 'anonymous' | 'use-credentials'; + @Input() referrerPolicy?: 'no-referrer' | 'no-referrer-when-downgrade' | 'origin' | 'origin-when-cross-origin' | 'same-origin' | 'strict-origin' | 'strict-origin-when-cross-origin' | 'unsafe-url'; + + @Output() imageLoaded = new EventEmitter(); + @Output() imageError = new EventEmitter(); + @Output() imageClick = new EventEmitter(); + @Output() loadingChange = new EventEmitter(); + + private elementRef = inject(ElementRef); + private intersectionObserver?: IntersectionObserver; + + hasError = false; + isLoaded = false; + currentSrc = ''; + + ngOnInit(): void { + this.initializeImage(); + if (this.lazy && this.supportsIntersectionObserver()) { + this.setupLazyLoading(); + } else { + this.loadImage(); + } + } + + ngOnDestroy(): void { + if (this.intersectionObserver) { + this.intersectionObserver.disconnect(); + } + } + + private initializeImage(): void { + this.currentSrc = this.placeholder || this.src; + this.hasError = false; + this.isLoaded = false; + } + + private setupLazyLoading(): void { + if (!this.supportsIntersectionObserver()) { + this.loadImage(); + return; + } + + this.intersectionObserver = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + this.loadImage(); + this.intersectionObserver?.unobserve(entry.target); + } + }); + }, + { + rootMargin: '50px 0px', + threshold: 0.01 + } + ); + + this.intersectionObserver.observe(this.elementRef.nativeElement); + } + + private loadImage(): void { + if (this.src && !this.isLoaded && !this.hasError) { + this.currentSrc = this.src; + this.loading = true; + this.loadingChange.emit(true); + } + } + + private supportsIntersectionObserver(): boolean { + return 'IntersectionObserver' in window; + } + + handleLoadStart(): void { + this.loading = true; + this.loadingChange.emit(true); + } + + handleLoad(event: Event): void { + this.loading = false; + this.isLoaded = true; + this.hasError = false; + this.loadingChange.emit(false); + this.imageLoaded.emit(event); + } + + handleError(event: Event): void { + this.loading = false; + this.hasError = true; + this.isLoaded = false; + this.loadingChange.emit(false); + this.imageError.emit(event); + } + + handleClick(event: MouseEvent): void { + if (!this.hasError) { + this.imageClick.emit(event); + } + } + + // Method to retry loading failed images + retryLoad(): void { + this.hasError = false; + this.isLoaded = false; + this.loadImage(); + } + + getContainerClasses(): string { + const aspectClass = `ui-image-container--aspect-${this.aspectRatio.replace('/', '-')}`; + const sizeClass = `ui-image-container--size-${this.size}`; + const shapeClass = `ui-image-container--shape-${this.shape}`; + + return `ui-image-container ${sizeClass} ${aspectClass} ${shapeClass}`; + } + + getImageClasses(): string { + const fitClass = `ui-image-container__image--fit-${this.objectFit}`; + return `ui-image-container__image ${fitClass}`; + } +} \ No newline at end of file diff --git a/src/lib/components/data-display/image-container/index.ts b/src/lib/components/data-display/image-container/index.ts new file mode 100644 index 0000000..b7b3a8c --- /dev/null +++ b/src/lib/components/data-display/image-container/index.ts @@ -0,0 +1 @@ +export * from './image-container.component'; \ No newline at end of file diff --git a/src/lib/components/data-display/index.ts b/src/lib/components/data-display/index.ts new file mode 100644 index 0000000..0512996 --- /dev/null +++ b/src/lib/components/data-display/index.ts @@ -0,0 +1,17 @@ +export * from './accordion'; +export * from './avatar'; +export * from './badge'; +export * from './card'; +export * from './carousel'; +export * from './chip'; +export * from './image-container'; +export * from './list'; +export * from './progress'; +export * from './table'; +export * from './timeline'; +export * from './tooltip'; +export * from './transfer-list'; +export * from './tree-view'; +// Selectively export from feedback to avoid ProgressBarComponent conflict +export { StatusBadgeComponent } from '../feedback'; +export type { StatusBadgeVariant, StatusBadgeSize, StatusBadgeShape } from '../feedback'; diff --git a/src/lib/components/data-display/list/README.md b/src/lib/components/data-display/list/README.md new file mode 100644 index 0000000..1ef3912 --- /dev/null +++ b/src/lib/components/data-display/list/README.md @@ -0,0 +1,223 @@ +# List Components + +Comprehensive list component system built with Angular 19+ and semantic design tokens. Features multiple variants, sizes, and interactive states for maximum flexibility. + +## Components + +### `ListItemComponent` +Individual list item with support for avatars, media, icons, and multiple text lines. + +### `ListContainerComponent` +Container for list items with elevation, spacing, and scrolling capabilities. + +### `ListExamplesComponent` +Demonstration component showcasing all variants and usage patterns. + +## Features + +- **Multiple Sizes**: `sm`, `md`, `lg` +- **Line Variants**: One, two, or three lines of text +- **Content Types**: Text-only, avatar, media, icon +- **Interactive States**: Hover, focus, selected, disabled +- **Container Options**: Elevation, spacing, scrolling, rounded corners +- **Text Overflow**: Automatic ellipsis handling +- **Accessibility**: Full ARIA support and keyboard navigation +- **Responsive**: Mobile-first design with breakpoint adjustments + +## Usage + +### Basic Text List + +```typescript +import { ListItemComponent, ListContainerComponent } from './shared/components'; + +// In your component: +items = [ + { primary: 'Inbox' }, + { primary: 'Starred' }, + { primary: 'Sent' } +]; +``` + +```html + + @for (item of items; track item.primary) { + + } + +``` + +### Avatar List with Two Lines + +```typescript +avatarItems = [ + { + primary: 'John Doe', + secondary: 'Software Engineer', + avatarSrc: 'https://example.com/avatar.jpg', + avatarAlt: 'John Doe' + } +]; +``` + +```html + + @for (item of avatarItems; track item.primary) { + + + + } + +``` + +### Media List with Three Lines + +```typescript +mediaItems = [ + { + primary: 'Angular Course', + secondary: 'Complete guide to modern development', + tertiary: 'Duration: 2h 30m • Updated: Today', + mediaSrc: 'https://example.com/thumb.jpg', + mediaAlt: 'Course thumbnail' + } +]; +``` + +```html + + @for (item of mediaItems; track item.primary) { + +
+ + 4.8 +
+
+ } +
+``` + +### Scrollable List + +```html + + @for (item of longList; track item.id) { + + } + +``` + +## API + +### ListItemComponent Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `data` | `ListItemData` | required | Item content and configuration | +| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Item height and spacing | +| `lines` | `'one' \| 'two' \| 'three'` | `'one'` | Number of text lines | +| `variant` | `'text' \| 'avatar' \| 'media'` | `'text'` | Content type | +| `interactive` | `boolean` | `true` | Enable hover/focus states | +| `divider` | `boolean` | `false` | Show bottom border | + +### ListContainerComponent Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `elevation` | `'none' \| 'sm' \| 'md' \| 'lg'` | `'none'` | Shadow elevation | +| `spacing` | `'none' \| 'xs' \| 'sm' \| 'md' \| 'lg'` | `'sm'` | Item spacing | +| `scrollable` | `boolean` | `false` | Enable vertical scrolling | +| `maxHeight` | `string?` | - | Maximum container height | +| `fadeIndicator` | `boolean` | `true` | Show fade at scroll bottom | +| `dense` | `boolean` | `false` | Reduce spacing between items | +| `rounded` | `boolean` | `false` | Round container corners | +| `ariaLabel` | `string?` | - | Accessibility label | + +### ListItemData Interface + +```typescript +interface ListItemData { + primary: string; // Main text (required) + secondary?: string; // Secondary text + tertiary?: string; // Tertiary text (three-line only) + avatarSrc?: string; // Avatar image URL + avatarAlt?: string; // Avatar alt text + mediaSrc?: string; // Media image URL + mediaAlt?: string; // Media alt text + icon?: string; // Icon class name + disabled?: boolean; // Disabled state + selected?: boolean; // Selected state +} +``` + +## Slots + +### Trailing Content +Use the `slot="trailing"` attribute to add content to the right side of list items: + +```html + + + +``` + +## Design Tokens + +The components use semantic design tokens for consistent styling: + +- **Colors**: `$semantic-color-*` +- **Spacing**: `$semantic-spacing-*` +- **Shadows**: `$semantic-shadow-*` +- **Borders**: `$semantic-border-radius-*` + +## Accessibility + +- Full ARIA support with proper roles and labels +- Keyboard navigation support +- Focus management and indicators +- Screen reader friendly +- High contrast mode support +- Reduced motion support + +## Responsive Design + +- Mobile-first approach +- Breakpoint-specific adjustments +- Touch-friendly tap targets +- Optimized spacing for different screen sizes + +## Examples + +Import and use the `ListExamplesComponent` to see all variants in action: + +```typescript +import { ListExamplesComponent } from './shared/components'; +``` + +```html + +``` \ No newline at end of file diff --git a/src/lib/components/data-display/list/index.ts b/src/lib/components/data-display/list/index.ts new file mode 100644 index 0000000..aac01ec --- /dev/null +++ b/src/lib/components/data-display/list/index.ts @@ -0,0 +1,23 @@ +// ========================================================================== +// LIST COMPONENTS +// ========================================================================== +// Comprehensive list component system with multiple variants and sizes +// Built with Angular 19+ and semantic design tokens +// ========================================================================== + +export * from './list-item.component'; +export * from './list-container.component'; +export * from './list-examples.component'; + +// Re-export types for convenience +export type { + ListItemSize, + ListItemLines, + ListItemVariant, + ListItemData +} from './list-item.component'; + +export type { + ListContainerElevation, + ListContainerSpacing +} from './list-container.component'; \ No newline at end of file diff --git a/src/lib/components/data-display/list/list-container.component.scss b/src/lib/components/data-display/list/list-container.component.scss new file mode 100644 index 0000000..01d0292 --- /dev/null +++ b/src/lib/components/data-display/list/list-container.component.scss @@ -0,0 +1,263 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +// Tokens available globally via main application styles + +// ========================================================================== +// LIST CONTAINER COMPONENT +// ========================================================================== +// Container component for list items with elevation, spacing, and scrolling +// Provides consistent layout and styling using semantic design tokens +// ========================================================================== + +.list-container { + display: flex; + flex-direction: column; + width: 100%; + box-sizing: border-box; + background-color: $semantic-color-surface-primary; + position: relative; + overflow: hidden; + + // ========================================================================== + // ELEVATION VARIANTS + // ========================================================================== + + &--elevation-none { + box-shadow: none; + } + + &--elevation-sm { + box-shadow: $semantic-shadow-elevation-1; + } + + &--elevation-md { + box-shadow: $semantic-shadow-elevation-3; + } + + &--elevation-lg { + box-shadow: $semantic-shadow-elevation-4; + } + + // ========================================================================== + // SPACING VARIANTS + // ========================================================================== + + &--spacing-none { + padding: 0; + + .list-item:not(:last-child) { + margin-bottom: 0; + } + } + + &--spacing-xs { + padding: $semantic-spacing-component-xs 0; + + .list-item:not(:last-child) { + margin-bottom: $semantic-spacing-component-xs; + } + } + + &--spacing-sm { + padding: $semantic-spacing-component-sm 0; + + .list-item:not(:last-child) { + margin-bottom: $semantic-spacing-component-sm; + } + } + + &--spacing-md { + padding: $semantic-spacing-component-md 0; + + .list-item:not(:last-child) { + margin-bottom: $semantic-spacing-component-md; + } + } + + &--spacing-lg { + padding: $semantic-spacing-component-lg 0; + + .list-item:not(:last-child) { + margin-bottom: $semantic-spacing-component-lg; + } + } + + // ========================================================================== + // LAYOUT MODIFIERS + // ========================================================================== + + &--scrollable { + overflow-y: auto; + -webkit-overflow-scrolling: touch; + + // Custom scrollbar styling + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background: $semantic-color-surface-secondary; + border-radius: 3px; + } + + &::-webkit-scrollbar-thumb { + background: $semantic-color-border-primary; + border-radius: 3px; + + &:hover { + background: $semantic-color-border-secondary; + } + } + + // Firefox scrollbar + scrollbar-width: thin; + scrollbar-color: $semantic-color-border-primary $semantic-color-surface-secondary; + } + + &--max-height { + height: var(--list-max-height); + max-height: var(--list-max-height); + } + + &--dense { + &.list-container--spacing-xs .list-item:not(:last-child) { + margin-bottom: $semantic-spacing-micro-tight; + } + + &.list-container--spacing-sm .list-item:not(:last-child) { + margin-bottom: $semantic-spacing-component-xs; + } + + &.list-container--spacing-md .list-item:not(:last-child) { + margin-bottom: $semantic-spacing-component-sm; + } + + &.list-container--spacing-lg .list-item:not(:last-child) { + margin-bottom: $semantic-spacing-component-md; + } + } + + &--rounded { + border-radius: $semantic-border-radius-md; + overflow: hidden; + + .list-item:first-child { + border-top-left-radius: $semantic-border-radius-md; + border-top-right-radius: $semantic-border-radius-md; + } + + .list-item:last-child { + border-bottom-left-radius: $semantic-border-radius-md; + border-bottom-right-radius: $semantic-border-radius-md; + } + } +} + +// ========================================================================== +// FADE INDICATOR +// ========================================================================== + +.list-container__fade-bottom { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 24px; + background: linear-gradient( + to bottom, + transparent 0%, + $semantic-color-surface-primary 100% + ); + pointer-events: none; + opacity: 0; + transition: opacity 0.2s ease-in-out; + + .list-container--scrollable:not(.list-container--at-bottom) & { + opacity: 1; + } +} + +// ========================================================================== +// DYNAMIC HEIGHT SETUP +// ========================================================================== + +// JavaScript will set this CSS custom property +.list-container--max-height { + --list-max-height: 400px; +} + +// ========================================================================== +// RESPONSIVE ADJUSTMENTS +// ========================================================================== + +@media (max-width: 768px) { + .list-container { + &--spacing-md { + padding: $semantic-spacing-component-sm 0; + + .list-item:not(:last-child) { + margin-bottom: $semantic-spacing-component-sm; + } + } + + &--spacing-lg { + padding: $semantic-spacing-component-md 0; + + .list-item:not(:last-child) { + margin-bottom: $semantic-spacing-component-md; + } + } + } +} + +// ========================================================================== +// ACCESSIBILITY ENHANCEMENTS +// ========================================================================== + +.list-container { + &:focus-within { + outline: 2px solid $semantic-color-border-focus; + outline-offset: 2px; + } +} + +// ========================================================================== +// ANIMATION UTILITIES +// ========================================================================== + +.list-container { + // Smooth height transitions when content changes + transition: height 0.2s ease-in-out; +} + +// List item enter/leave animations +@keyframes list-item-enter { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes list-item-leave { + from { + opacity: 1; + transform: translateY(0); + } + to { + opacity: 0; + transform: translateY(-8px); + } +} + +// Add these classes via JavaScript for dynamic content +.list-item-enter { + animation: list-item-enter 0.2s ease-out; +} + +.list-item-leave { + animation: list-item-leave 0.2s ease-out; +} \ No newline at end of file diff --git a/src/lib/components/data-display/list/list-container.component.ts b/src/lib/components/data-display/list/list-container.component.ts new file mode 100644 index 0000000..be11390 --- /dev/null +++ b/src/lib/components/data-display/list/list-container.component.ts @@ -0,0 +1,52 @@ +import { Component, Input, ChangeDetectionStrategy } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +export type ListContainerElevation = 'none' | 'sm' | 'md' | 'lg'; +export type ListContainerSpacing = 'none' | 'xs' | 'sm' | 'md' | 'lg'; + +@Component({ + selector: 'ui-list-container', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule], + template: ` +
+ + + + @if (scrollable && fadeIndicator) { +
+ } +
+ `, + styleUrls: ['./list-container.component.scss'] +}) +export class ListContainerComponent { + @Input() elevation: ListContainerElevation = 'none'; + @Input() spacing: ListContainerSpacing = 'sm'; + @Input() scrollable = false; + @Input() maxHeight?: string; + @Input() fadeIndicator = true; + @Input() dense = false; + @Input() rounded = false; + @Input() ariaLabel?: string; + + getContainerClasses(): string { + const classes = [ + `list-container--elevation-${this.elevation}`, + `list-container--spacing-${this.spacing}` + ]; + + if (this.scrollable) classes.push('list-container--scrollable'); + if (this.dense) classes.push('list-container--dense'); + if (this.rounded) classes.push('list-container--rounded'); + if (this.maxHeight) classes.push('list-container--max-height'); + + return classes.join(' '); + } +} \ No newline at end of file diff --git a/src/lib/components/data-display/list/list-examples.component.scss b/src/lib/components/data-display/list/list-examples.component.scss new file mode 100644 index 0000000..aeb2e77 --- /dev/null +++ b/src/lib/components/data-display/list/list-examples.component.scss @@ -0,0 +1,234 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +// Tokens available globally via main application styles + +// ========================================================================== +// LIST EXAMPLES COMPONENT +// ========================================================================== +// Demonstration component showcasing all list variants and capabilities +// ========================================================================== + +.list-examples { + padding: $semantic-spacing-component-xl; + max-width: 1200px; + margin: 0 auto; + + h2 { + font-size: 2rem; + font-weight: 600; + color: $semantic-color-text-primary; + margin-bottom: $semantic-spacing-layout-lg; + text-align: center; + } +} + +.example-section { + margin-bottom: $semantic-spacing-layout-xl; + + h3 { + font-size: 1.5rem; + font-weight: 500; + color: $semantic-color-text-primary; + margin-bottom: $semantic-spacing-component-xl; + padding-bottom: $semantic-spacing-component-sm; + border-bottom: 2px solid $semantic-color-border-secondary; + } +} + +.example-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: $semantic-spacing-component-xl; + margin-bottom: $semantic-spacing-layout-md; +} + +.example-item { + h4 { + font-size: 1rem; + font-weight: 500; + color: $semantic-color-text-secondary; + margin-bottom: $semantic-spacing-component-md; + text-align: center; + } +} + +.example-single { + max-width: 600px; + margin: 0 auto; +} + +// ========================================================================== +// TRAILING SLOT COMPONENTS +// ========================================================================== + +.action-button { + background: none; + border: none; + color: $semantic-color-text-tertiary; + cursor: pointer; + padding: $semantic-spacing-component-xs; + border-radius: $semantic-border-radius-sm; + transition: all 0.15s ease-in-out; + + &:hover { + background-color: $semantic-color-surface-interactive; + color: $semantic-color-text-secondary; + } + + &:focus { + outline: 2px solid $semantic-color-border-focus; + outline-offset: 2px; + } + + i { + font-size: 14px; + } +} + +.timestamp { + font-size: 0.75rem; + color: $semantic-color-text-tertiary; + font-weight: 400; +} + +.play-button { + width: 32px; + height: 32px; + border-radius: 50%; + background-color: $semantic-color-interactive-primary; + color: $semantic-color-on-brand-primary; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s ease-in-out; + + &:hover { + transform: scale(1.05); + box-shadow: $semantic-shadow-elevation-1; + } + + &:focus { + outline: 2px solid $semantic-color-border-focus; + outline-offset: 2px; + } + + i { + font-size: 12px; + margin-left: 2px; // Optical alignment for play icon + } +} + +.rating { + display: flex; + align-items: center; + gap: $semantic-spacing-micro-tight; + color: $semantic-color-warning; + font-size: 0.875rem; + font-weight: 500; + + i { + font-size: 14px; + } +} + +.select-button { + background-color: $semantic-color-interactive-primary; + color: $semantic-color-on-brand-primary; + border: none; + padding: $semantic-spacing-component-xs $semantic-spacing-component-sm; + border-radius: $semantic-border-radius-sm; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease-in-out; + + &:hover { + background-color: $semantic-color-interactive-secondary; + } + + &:focus { + outline: 2px solid $semantic-color-border-focus; + outline-offset: 2px; + } +} + +.text-success { + color: $semantic-color-success; +} + +// ========================================================================== +// RESPONSIVE DESIGN +// ========================================================================== + +@media (max-width: 768px) { + .list-examples { + padding: $semantic-spacing-component-lg; + + h2 { + font-size: 1.5rem; + } + } + + .example-grid { + grid-template-columns: 1fr; + gap: $semantic-spacing-component-lg; + } + + .example-section { + margin-bottom: $semantic-spacing-layout-md; + + h3 { + font-size: 1.25rem; + } + } +} + +@media (max-width: 480px) { + .list-examples { + padding: $semantic-spacing-component-md; + } + + .example-grid { + gap: $semantic-spacing-component-md; + } +} + +// ========================================================================== +// ACCESSIBILITY ENHANCEMENTS +// ========================================================================== + +// Focus indicators for better keyboard navigation +.list-examples { + button:focus, + .action-button:focus, + .play-button:focus, + .select-button:focus { + outline: 2px solid $semantic-color-border-focus; + outline-offset: 2px; + border-radius: $semantic-border-radius-sm; + } +} + +// High contrast mode support +@media (prefers-contrast: high) { + .action-button, + .play-button, + .select-button { + border: 1px solid $semantic-color-border-primary; + } +} + +// Reduced motion support +@media (prefers-reduced-motion: reduce) { + .action-button, + .play-button, + .select-button { + transition: none; + } + + .play-button:hover { + transform: none; + } +} \ No newline at end of file diff --git a/src/lib/components/data-display/list/list-examples.component.ts b/src/lib/components/data-display/list/list-examples.component.ts new file mode 100644 index 0000000..92a5f94 --- /dev/null +++ b/src/lib/components/data-display/list/list-examples.component.ts @@ -0,0 +1,374 @@ +import { Component, ChangeDetectionStrategy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ListItemComponent, ListItemData } from './list-item.component'; +import { ListContainerComponent } from './list-container.component'; + +@Component({ + selector: 'ui-list-examples', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, ListItemComponent, ListContainerComponent], + template: ` +
+

List Component Examples

+ + +
+

Text Lists - Different Sizes

+ +
+
+

Small (sm)

+ + @for (item of basicTextItems; track item.primary) { + + } + +
+ +
+

Medium (md)

+ + @for (item of basicTextItems; track item.primary) { + + } + +
+ +
+

Large (lg)

+ + @for (item of basicTextItems; track item.primary) { + + } + +
+
+
+ + +
+

Multi-line Text Lists

+ +
+
+

Two Lines

+ + @for (item of twoLineItems; track item.primary) { + + } + +
+ +
+

Three Lines

+ + @for (item of threeLineItems; track item.primary) { + + } + +
+
+
+ + +
+

Lists with Avatars

+ +
+
+

Avatar + One Line

+ + @for (item of avatarItems; track item.primary) { + + + + } + +
+ +
+

Avatar + Two Lines

+ + @for (item of avatarTwoLineItems; track item.primary) { + + 2h + + } + +
+
+
+ + +
+

Lists with Media

+ +
+
+

Media + Two Lines

+ + @for (item of mediaItems; track item.primary) { + + + + } + +
+ +
+

Media + Three Lines

+ + @for (item of mediaThreeLineItems; track item.primary) { + +
+ + 4.8 +
+
+ } +
+
+
+
+ + +
+

Scrollable List

+ +
+ + @for (item of longList; track item.primary) { + + @if (item.selected) { + + } + + } + +
+
+ + +
+

Interactive States

+ +
+ + @for (item of interactiveItems; track item.primary) { + + @if (item.selected) { + + } @else { + + } + + } + +
+
+
+ `, + styleUrls: ['./list-examples.component.scss'] +}) +export class ListExamplesComponent { + basicTextItems: ListItemData[] = [ + { primary: 'Inbox' }, + { primary: 'Starred' }, + { primary: 'Sent' }, + { primary: 'Drafts' }, + { primary: 'Archive' } + ]; + + twoLineItems: ListItemData[] = [ + { + primary: 'Meeting with Design Team', + secondary: 'Discuss new component library architecture and implementation' + }, + { + primary: 'Code Review - Authentication', + secondary: 'Review pull request #247 for OAuth integration updates' + }, + { + primary: 'Performance Optimization', + secondary: 'Analyze bundle size and implement lazy loading strategies' + } + ]; + + threeLineItems: ListItemData[] = [ + { + primary: 'Angular 19 Migration', + secondary: 'Upgrade project to latest Angular version with new control flow', + tertiary: 'Estimated completion: Next Sprint' + }, + { + primary: 'Design System Documentation', + secondary: 'Create comprehensive documentation for all components', + tertiary: 'Priority: High - Needed for team onboarding' + } + ]; + + avatarItems: ListItemData[] = [ + { + primary: 'John Doe', + avatarSrc: 'https://api.dicebear.com/7.x/avataaars/svg?seed=john', + avatarAlt: 'John Doe avatar' + }, + { + primary: 'Jane Smith', + avatarSrc: 'https://api.dicebear.com/7.x/avataaars/svg?seed=jane', + avatarAlt: 'Jane Smith avatar' + }, + { + primary: 'Mike Johnson', + avatarSrc: 'https://api.dicebear.com/7.x/avataaars/svg?seed=mike', + avatarAlt: 'Mike Johnson avatar' + } + ]; + + avatarTwoLineItems: ListItemData[] = [ + { + primary: 'Sarah Wilson', + secondary: 'Hey! How\'s the new component library coming along?', + avatarSrc: 'https://api.dicebear.com/7.x/avataaars/svg?seed=sarah', + avatarAlt: 'Sarah Wilson avatar' + }, + { + primary: 'Alex Chen', + secondary: 'The design tokens look great. Ready for review.', + avatarSrc: 'https://api.dicebear.com/7.x/avataaars/svg?seed=alex', + avatarAlt: 'Alex Chen avatar' + } + ]; + + mediaItems: ListItemData[] = [ + { + primary: 'Angular Fundamentals', + secondary: 'Complete guide to modern Angular development', + mediaSrc: 'https://picsum.photos/80/80?random=1', + mediaAlt: 'Angular course thumbnail' + }, + { + primary: 'TypeScript Deep Dive', + secondary: 'Advanced TypeScript patterns and best practices', + mediaSrc: 'https://picsum.photos/80/80?random=2', + mediaAlt: 'TypeScript course thumbnail' + } + ]; + + mediaThreeLineItems: ListItemData[] = [ + { + primary: 'Design Systems at Scale', + secondary: 'Building maintainable design systems for large organizations', + tertiary: 'Duration: 2h 30m • Updated: Today', + mediaSrc: 'https://picsum.photos/80/80?random=3', + mediaAlt: 'Design systems course' + }, + { + primary: 'Component Architecture', + secondary: 'Scalable component patterns in modern web applications', + tertiary: 'Duration: 1h 45m • Updated: Yesterday', + mediaSrc: 'https://picsum.photos/80/80?random=4', + mediaAlt: 'Component architecture course' + } + ]; + + longList: ListItemData[] = Array.from({ length: 20 }, (_, i) => ({ + primary: `Contact ${i + 1}`, + secondary: `contact${i + 1}@example.com`, + avatarSrc: `https://api.dicebear.com/7.x/avataaars/svg?seed=contact${i}`, + avatarAlt: `Contact ${i + 1} avatar`, + selected: i === 3 || i === 7 || i === 12 + })); + + interactiveItems: ListItemData[] = [ + { + primary: 'Enable Notifications', + secondary: 'Get notified about important updates', + avatarSrc: 'https://api.dicebear.com/7.x/avataaars/svg?seed=notify', + selected: false + }, + { + primary: 'Dark Mode', + secondary: 'Switch to dark theme', + avatarSrc: 'https://api.dicebear.com/7.x/avataaars/svg?seed=dark', + selected: true + }, + { + primary: 'Auto-save', + secondary: 'Automatically save your work', + avatarSrc: 'https://api.dicebear.com/7.x/avataaars/svg?seed=save', + selected: false, + disabled: true + } + ]; +} \ No newline at end of file diff --git a/src/lib/components/data-display/list/list-item.component.scss b/src/lib/components/data-display/list/list-item.component.scss new file mode 100644 index 0000000..1633ba4 --- /dev/null +++ b/src/lib/components/data-display/list/list-item.component.scss @@ -0,0 +1,360 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +// Tokens available globally via main application styles + +// ========================================================================== +// LIST ITEM COMPONENT +// ========================================================================== +// Comprehensive list item component with multiple variants and sizes +// Uses semantic design tokens for consistent styling across the design system +// ========================================================================== + +.list-item { + display: flex; + align-items: stretch; + width: 100%; + box-sizing: border-box; + position: relative; + user-select: none; + background-color: $semantic-color-surface-primary; + color: $semantic-color-text-primary; + + // Interactive states + &--interactive { + cursor: pointer; + transition: background-color 0.15s ease-in-out; + + &:hover:not(.list-item--disabled) { + background-color: $semantic-color-surface-interactive; + } + + &:focus-within:not(.list-item--disabled) { + outline: 2px solid $semantic-color-border-focus; + outline-offset: -2px; + } + } + + // States + &--selected { + background-color: $semantic-color-container-primary; + color: $semantic-color-on-container-primary; + } + + &--disabled { + opacity: 0.6; + cursor: not-allowed; + color: $semantic-color-text-disabled; + } + + &--divider { + border-bottom: 1px solid $semantic-color-border-secondary; + } + + // ========================================================================== + // SIZE VARIANTS + // ========================================================================== + + &--sm { + min-height: 40px; + + &.list-item--one-line { height: 40px; } + &.list-item--two-line { min-height: 56px; } + &.list-item--three-line { min-height: 72px; } + } + + &--md { + min-height: 48px; + + &.list-item--one-line { height: 48px; } + &.list-item--two-line { min-height: 64px; } + &.list-item--three-line { min-height: 80px; } + } + + &--lg { + min-height: 56px; + + &.list-item--one-line { height: 56px; } + &.list-item--two-line { min-height: 72px; } + &.list-item--three-line { min-height: 88px; } + } +} + +// ========================================================================== +// LAYOUT ELEMENTS +// ========================================================================== + +.list-item__avatar, +.list-item__media, +.list-item__icon { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.list-item__avatar { + .list-item--sm & { + width: 32px; + padding: $semantic-spacing-component-sm; + } + + .list-item--md & { + width: 40px; + padding: $semantic-spacing-component-md; + } + + .list-item--lg & { + width: 48px; + padding: $semantic-spacing-component-lg; + } +} + +.list-item__avatar-image { + width: 100%; + height: 100%; + border-radius: 50%; + object-fit: cover; + + .list-item--sm & { + width: 24px; + height: 24px; + } + + .list-item--md & { + width: 32px; + height: 32px; + } + + .list-item--lg & { + width: 40px; + height: 40px; + } +} + +.list-item__media { + .list-item--sm & { + width: 48px; + padding: $semantic-spacing-component-sm; + } + + .list-item--md & { + width: 56px; + padding: $semantic-spacing-component-md; + } + + .list-item--lg & { + width: 64px; + padding: $semantic-spacing-component-lg; + } +} + +.list-item__media-image { + width: 100%; + height: auto; + border-radius: $semantic-border-radius-sm; + object-fit: cover; + + .list-item--sm & { + width: 40px; + height: 40px; + } + + .list-item--md & { + width: 48px; + height: 48px; + } + + .list-item--lg & { + width: 56px; + height: 56px; + } +} + +.list-item__icon { + .list-item--sm & { + width: 32px; + padding: $semantic-spacing-component-sm; + } + + .list-item--md & { + width: 40px; + padding: $semantic-spacing-component-md; + } + + .list-item--lg & { + width: 48px; + padding: $semantic-spacing-component-lg; + } + + i { + color: $semantic-color-text-secondary; + + .list-item--sm & { font-size: 16px; } + .list-item--md & { font-size: 20px; } + .list-item--lg & { font-size: 24px; } + } +} + +.list-item__content { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + justify-content: center; + padding: $semantic-spacing-component-sm $semantic-spacing-component-md; + gap: $semantic-spacing-micro-tight; + + .list-item--sm & { + padding: $semantic-spacing-component-xs $semantic-spacing-component-sm; + } + + .list-item--lg & { + padding: $semantic-spacing-component-md $semantic-spacing-component-lg; + } +} + +.list-item__primary { + font-size: 1rem; + font-weight: 500; + line-height: 1.25; + color: inherit; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + .list-item--sm & { + font-size: 0.875rem; + } + + .list-item--lg & { + font-size: 1.125rem; + } +} + +.list-item__secondary { + font-size: 0.875rem; + font-weight: 400; + line-height: 1.25; + color: $semantic-color-text-secondary; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + .list-item--sm & { + font-size: 0.75rem; + } + + .list-item--lg & { + font-size: 1rem; + } +} + +.list-item__tertiary { + font-size: 0.75rem; + font-weight: 400; + line-height: 1.25; + color: $semantic-color-text-tertiary; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + .list-item--sm & { + font-size: 0.6875rem; + } + + .list-item--lg & { + font-size: 0.875rem; + } +} + +.list-item__trailing { + flex-shrink: 0; + display: flex; + align-items: center; + padding-right: $semantic-spacing-component-md; + + .list-item--sm & { + padding-right: $semantic-spacing-component-sm; + } + + .list-item--lg & { + padding-right: $semantic-spacing-component-lg; + } + + &:empty { + display: none; + } +} + +// ========================================================================== +// LINE VARIANTS - Content layout adjustments +// ========================================================================== + +.list-item--one-line { + .list-item__content { + justify-content: center; + } +} + +.list-item--two-line { + .list-item__content { + justify-content: center; + gap: $semantic-spacing-micro-tight; + } +} + +.list-item--three-line { + .list-item__content { + justify-content: flex-start; + padding-top: $semantic-spacing-component-sm; + padding-bottom: $semantic-spacing-component-sm; + gap: $semantic-spacing-micro-tight; + + .list-item--sm & { + padding-top: $semantic-spacing-component-xs; + padding-bottom: $semantic-spacing-component-xs; + } + + .list-item--lg & { + padding-top: $semantic-spacing-component-md; + padding-bottom: $semantic-spacing-component-md; + } + } +} + +// ========================================================================== +// VARIANT SPECIFIC STYLES +// ========================================================================== + +.list-item--avatar { + .list-item__content { + padding-left: $semantic-spacing-component-xs; + } +} + +.list-item--media { + .list-item__content { + padding-left: $semantic-spacing-component-xs; + } +} + +// ========================================================================== +// RESPONSIVE ADJUSTMENTS +// ========================================================================== + +@media (max-width: 768px) { + .list-item { + &--lg { + min-height: 48px; + + &.list-item--one-line { height: 48px; } + &.list-item--two-line { min-height: 64px; } + &.list-item--three-line { min-height: 80px; } + } + } + + .list-item__content { + padding-left: $semantic-spacing-component-sm; + padding-right: $semantic-spacing-component-sm; + } +} \ No newline at end of file diff --git a/src/lib/components/data-display/list/list-item.component.ts b/src/lib/components/data-display/list/list-item.component.ts new file mode 100644 index 0000000..9754be8 --- /dev/null +++ b/src/lib/components/data-display/list/list-item.component.ts @@ -0,0 +1,109 @@ +import { Component, Input, ChangeDetectionStrategy, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +export type ListItemSize = 'sm' | 'md' | 'lg'; +export type ListItemLines = 'one' | 'two' | 'three'; +export type ListItemVariant = 'text' | 'avatar' | 'media'; + +export interface ListItemData { + primary: string; + secondary?: string; + tertiary?: string; + avatarSrc?: string; + avatarAlt?: string; + mediaSrc?: string; + mediaAlt?: string; + icon?: string; + disabled?: boolean; + selected?: boolean; +} + +@Component({ + selector: 'ui-list-item', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule], + template: ` +
+ + @if (variant === 'avatar' && data.avatarSrc) { +
+ +
+ } + + @if (variant === 'media' && data.mediaSrc) { +
+ +
+ } + + @if (data.icon) { +
+ +
+ } + + +
+ @if (lines === 'one') { +
{{ data.primary }}
+ } + + @if (lines === 'two') { +
{{ data.primary }}
+
{{ data.secondary }}
+ } + + @if (lines === 'three') { +
{{ data.primary }}
+
{{ data.secondary }}
+
{{ data.tertiary }}
+ } +
+ + +
+ +
+
+ `, + styleUrls: ['./list-item.component.scss'] +}) +export class ListItemComponent { + @Input() data!: ListItemData; + @Input() size: ListItemSize = 'md'; + @Input() lines: ListItemLines = 'one'; + @Input() variant: ListItemVariant = 'text'; + @Input() interactive = true; + @Input() divider = false; + + getItemClasses(): string { + const classes = [ + `list-item--${this.size}`, + `list-item--${this.lines}-line`, + `list-item--${this.variant}` + ]; + + if (this.interactive) classes.push('list-item--interactive'); + if (this.divider) classes.push('list-item--divider'); + if (this.data.disabled) classes.push('list-item--disabled'); + if (this.data.selected) classes.push('list-item--selected'); + + return classes.join(' '); + } +} \ No newline at end of file diff --git a/src/lib/components/data-display/progress/index.ts b/src/lib/components/data-display/progress/index.ts new file mode 100644 index 0000000..e4f9cc4 --- /dev/null +++ b/src/lib/components/data-display/progress/index.ts @@ -0,0 +1 @@ +export * from './progress-bar.component'; \ No newline at end of file diff --git a/src/lib/components/data-display/progress/progress-bar.component.scss b/src/lib/components/data-display/progress/progress-bar.component.scss new file mode 100644 index 0000000..772a2a0 --- /dev/null +++ b/src/lib/components/data-display/progress/progress-bar.component.scss @@ -0,0 +1,421 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +// Tokens available globally via main application styles + +// ========================================================================== +// PROGRESS BAR COMPONENT STYLES +// ========================================================================== +// Modern progress bar implementation using design tokens +// Follows Material Design 3 principles with semantic token system +// Includes determinate, indeterminate, and buffer progress types +// ========================================================================== + +.progress-bar-wrapper { + display: flex; + flex-direction: column; + width: 100%; + + // Size variants + &.progress-bar-wrapper--sm { + gap: $semantic-spacing-component-xs; + } + + &.progress-bar-wrapper--md { + gap: $semantic-spacing-component-sm; + } + + &.progress-bar-wrapper--lg { + gap: $semantic-spacing-component-md; + } + + // State variants + &.progress-bar-wrapper--disabled { + opacity: 0.6; + pointer-events: none; + } +} + +.progress-bar { + position: relative; + display: block; + width: 100%; + background-color: $semantic-color-surface-secondary; + border-radius: $semantic-border-radius-full; + overflow: hidden; + transition: all $semantic-duration-fast $semantic-easing-standard; + + // Size variants + &.progress-bar--sm { + height: 4px; + } + + &.progress-bar--md { + height: 6px; + } + + &.progress-bar--lg { + height: 8px; + } + + // Disabled state + &.progress-bar--disabled { + background-color: $semantic-color-surface-disabled; + } +} + +.progress-bar__fill { + position: absolute; + top: 0; + left: 0; + height: 100%; + border-radius: inherit; + transition: width $semantic-duration-medium $semantic-easing-standard; + + // Variant colors + .progress-bar-wrapper--primary & { + background-color: $semantic-color-brand-primary; + } + + .progress-bar-wrapper--secondary & { + background-color: $semantic-color-text-secondary; + } + + .progress-bar-wrapper--success & { + background-color: $semantic-color-success; + } + + .progress-bar-wrapper--warning & { + background-color: $semantic-color-warning; + } + + .progress-bar-wrapper--danger & { + background-color: $semantic-color-danger; + } + + // Disabled state + &.progress-bar__fill--disabled { + background-color: $semantic-color-surface-disabled !important; + } + + // Striped effect + &.progress-bar__fill--striped { + background-image: linear-gradient( + 45deg, + rgba(255, 255, 255, 0.15) 25%, + transparent 25%, + transparent 50%, + rgba(255, 255, 255, 0.15) 50%, + rgba(255, 255, 255, 0.15) 75%, + transparent 75%, + transparent + ); + background-size: 1rem 1rem; + } + + // Animated stripes + &.progress-bar__fill--animated { + animation: progress-bar-stripes 1s linear infinite; + } +} + +// Indeterminate progress bar +.progress-bar__fill--indeterminate { + width: 100%; + + &:before { + content: ''; + position: absolute; + background-color: inherit; + top: 0; + left: 0; + bottom: 0; + will-change: left, right; + animation: indeterminate 2.1s cubic-bezier(0.650, 0.815, 0.735, 0.395) infinite; + } + + &:after { + content: ''; + position: absolute; + background-color: inherit; + top: 0; + left: 0; + bottom: 0; + will-change: left, right; + animation: indeterminate-short 2.1s cubic-bezier(0.165, 0.840, 0.440, 1.000) infinite; + animation-delay: 1.15s; + } +} + +// Buffer progress bar +.progress-bar__buffer { + position: absolute; + top: 0; + left: 0; + height: 100%; + background-color: rgba($semantic-color-text-secondary, 0.3); + border-radius: inherit; + transition: width $semantic-duration-medium $semantic-easing-standard; + + &.progress-bar__buffer--disabled { + background-color: $semantic-color-surface-disabled; + } +} + +.progress-bar__fill--buffer { + z-index: 1; +} + +// Label and percentage +.progress-bar__label { + display: flex; + justify-content: space-between; + align-items: center; + color: $semantic-color-text-primary; + font-weight: $semantic-typography-font-weight-medium; + line-height: $semantic-typography-line-height-tight; + + // Size variants + &.progress-bar__label--sm { + font-size: $semantic-typography-font-size-sm; + margin-bottom: $semantic-spacing-component-xs; + } + + &.progress-bar__label--md { + font-size: $semantic-typography-font-size-md; + margin-bottom: $semantic-spacing-component-sm; + } + + &.progress-bar__label--lg { + font-size: $semantic-typography-font-size-lg; + margin-bottom: $semantic-spacing-component-sm; + } + + // Disabled state + &.progress-bar__label--disabled { + color: $semantic-color-text-disabled; + } +} + +.progress-bar__label-text { + flex: 1; + margin-right: $semantic-spacing-component-sm; +} + +.progress-bar__percentage { + font-weight: $semantic-typography-font-weight-bold; + color: $semantic-color-text-secondary; + font-variant-numeric: tabular-nums; + + .progress-bar__label--disabled & { + color: $semantic-color-text-disabled; + } +} + +.progress-bar__helper-text { + color: $semantic-color-text-secondary; + line-height: $semantic-typography-line-height-tight; + + // Size variants + &.progress-bar__helper-text--sm { + font-size: $semantic-typography-font-size-xs; + margin-top: $semantic-spacing-component-xs; + } + + &.progress-bar__helper-text--md { + font-size: $semantic-typography-font-size-sm; + margin-top: $semantic-spacing-component-xs; + } + + &.progress-bar__helper-text--lg { + font-size: $semantic-typography-font-size-md; + margin-top: $semantic-spacing-component-sm; + } + + // Disabled state + &.progress-bar__helper-text--disabled { + color: $semantic-color-text-disabled; + } +} + +// ========================================================================== +// ANIMATIONS +// ========================================================================== + +// Striped animation +@keyframes progress-bar-stripes { + 0% { + background-position-x: 1rem; + } + 100% { + background-position-x: 0; + } +} + +// Indeterminate animations +@keyframes indeterminate { + 0% { + left: -35%; + right: 100%; + } + 60% { + left: 100%; + right: -90%; + } + 100% { + left: 100%; + right: -90%; + } +} + +@keyframes indeterminate-short { + 0% { + left: -200%; + right: 100%; + } + 60% { + left: 107%; + right: -8%; + } + 100% { + left: 107%; + right: -8%; + } +} + +// Pulse animation for focus/hover states +@keyframes progress-bar-pulse { + 0% { + box-shadow: 0 0 0 0 rgba($semantic-color-brand-primary, 0.4); + } + 70% { + box-shadow: 0 0 0 4px rgba($semantic-color-brand-primary, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba($semantic-color-brand-primary, 0); + } +} + +// ========================================================================== +// HOVER AND FOCUS STATES +// ========================================================================== + +.progress-bar:hover:not(.progress-bar--disabled) { + transform: scaleY(1.1); + box-shadow: $semantic-shadow-elevation-1; +} + +.progress-bar:focus-visible { + outline: $semantic-border-width-2 solid $semantic-color-brand-primary; + outline-offset: 2px; +} + +// ========================================================================== +// RESPONSIVE DESIGN +// ========================================================================== + +@media (max-width: $semantic-sizing-breakpoint-tablet - 1) { + .progress-bar-wrapper { + // Slightly larger progress bars on mobile for better visibility + &.progress-bar-wrapper--sm { + .progress-bar--sm { + height: 5px; + } + } + + &.progress-bar-wrapper--md { + .progress-bar--md { + height: 7px; + } + } + + &.progress-bar-wrapper--lg { + .progress-bar--lg { + height: 9px; + } + } + } +} + +// ========================================================================== +// ACCESSIBILITY ENHANCEMENTS +// ========================================================================== + +// Reduced motion preference +@media (prefers-reduced-motion: reduce) { + .progress-bar__fill, + .progress-bar__buffer { + transition: none !important; + } + + .progress-bar__fill--animated, + .progress-bar__fill--indeterminate:before, + .progress-bar__fill--indeterminate:after { + animation: none !important; + } +} + +// High contrast mode +@media (prefers-contrast: high) { + .progress-bar { + border: $semantic-border-width-1 solid $semantic-color-text-primary; + } + + .progress-bar__fill { + border: $semantic-border-width-1 solid $semantic-color-text-primary; + border-radius: 0; + } +} + +// ========================================================================== +// THEME VARIANTS +// ========================================================================== + +// Dark theme adjustments +@media (prefers-color-scheme: dark) { + .progress-bar { + background-color: rgba($semantic-color-surface-primary, 0.1); + } + + .progress-bar__buffer { + background-color: rgba($semantic-color-text-secondary, 0.2); + } +} + +// ========================================================================== +// SPECIAL STATES AND EFFECTS +// ========================================================================== + +// Success completion effect +.progress-bar-wrapper--success { + .progress-bar__fill[style*="100%"] { + animation: progress-complete 0.6s $semantic-easing-spring; + } +} + +@keyframes progress-complete { + 0% { + transform: scaleX(1); + } + 50% { + transform: scaleX(1.02); + } + 100% { + transform: scaleX(1); + } +} + +// Error state pulsing +.progress-bar-wrapper--danger { + .progress-bar__fill { + animation: progress-error 2s ease-in-out infinite; + } +} + +@keyframes progress-error { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.8; + } +} \ No newline at end of file diff --git a/src/lib/components/data-display/progress/progress-bar.component.ts b/src/lib/components/data-display/progress/progress-bar.component.ts new file mode 100644 index 0000000..8305f1f --- /dev/null +++ b/src/lib/components/data-display/progress/progress-bar.component.ts @@ -0,0 +1,213 @@ +import { Component, Input, ChangeDetectionStrategy, signal, computed, ViewChild, ElementRef } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +export type ProgressBarSize = 'sm' | 'md' | 'lg'; +export type ProgressBarVariant = 'primary' | 'secondary' | 'success' | 'warning' | 'danger'; +export type ProgressBarType = 'determinate' | 'indeterminate' | 'buffer'; +export type ProgressBarState = 'default' | 'disabled'; + +@Component({ + selector: 'ui-progress-bar', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule], + template: ` +
+ + @if (showLabelComputed()) { +
+ {{ label }} + @if (showPercentageComputed() && type() === 'determinate') { + {{ Math.round(value()) }}% + } +
+ } + +
+ + @if (type() === 'determinate') { +
+
+ } + + @if (type() === 'indeterminate') { +
+
+ } + + @if (type() === 'buffer') { +
+
+
+
+ } + +
+ + @if (helperTextComputed()) { +
+ {{ helperTextComputed() }} +
+ } + +
+ `, + styleUrls: ['./progress-bar.component.scss'] +}) +export class ProgressBarComponent { + @ViewChild('progressElement', { static: false }) progressElement!: ElementRef; + + // Core inputs + @Input() set progress(value: number) { this._value.set(Math.max(0, Math.min(100, value))); } + @Input() set buffer(value: number) { this._bufferValue.set(Math.max(0, Math.min(100, value))); } + @Input() label: string = ''; + @Input() size: ProgressBarSize = 'md'; + @Input() variant: ProgressBarVariant = 'primary'; + @Input() progressType: ProgressBarType = 'determinate'; + @Input() state: ProgressBarState = 'default'; + + // Display options + @Input() showLabel = true; + @Input() showPercentage = true; + @Input() striped = false; + @Input() animated = false; + + // Accessibility + @Input() ariaLabel: string = ''; + @Input() helperText: string = ''; + @Input() progressId: string = `progress-${Math.random().toString(36).substr(2, 9)}`; + + // Exposed Math for template + protected readonly Math = Math; + + // Internal state + private _value = signal(0); + private _bufferValue = signal(0); + + // Computed properties + protected value = computed(() => this._value()); + protected bufferValue = computed(() => this._bufferValue()); + protected type = computed(() => this.progressType); + protected showLabelComputed = computed(() => this.label && this.showLabel); + protected showPercentageComputed = computed(() => this.showPercentage && this.progressType === 'determinate'); + protected helperTextComputed = computed(() => this.helperText); + + ngOnInit(): void { + // Initialize with default values if not set + if (this._value() === 0 && this.progressType === 'determinate') { + this._value.set(0); + } + } + + getAriaValueText(): string { + if (this.progressType === 'determinate') { + return `${Math.round(this.value())}% complete`; + } else if (this.progressType === 'indeterminate') { + return 'Loading...'; + } else { + return `${Math.round(this.value())}% of ${Math.round(this.bufferValue())}%`; + } + } + + // CSS class getters + getWrapperClasses(): string { + const classes = [ + `progress-bar-wrapper--${this.size}`, + `progress-bar-wrapper--${this.variant}`, + `progress-bar-wrapper--${this.progressType}`, + `progress-bar-wrapper--${this.state}` + ]; + + if (this.state === 'disabled') classes.push('progress-bar-wrapper--disabled'); + if (this.striped) classes.push('progress-bar-wrapper--striped'); + if (this.animated) classes.push('progress-bar-wrapper--animated'); + + return classes.join(' '); + } + + getProgressBarClasses(): string { + const classes = [ + `progress-bar--${this.size}`, + `progress-bar--${this.variant}`, + `progress-bar--${this.progressType}`, + `progress-bar--${this.state}` + ]; + + if (this.state === 'disabled') classes.push('progress-bar--disabled'); + + return classes.join(' '); + } + + getFillClasses(): string { + const classes = [`progress-bar__fill--${this.size}`]; + + if (this.striped) classes.push('progress-bar__fill--striped'); + if (this.animated) classes.push('progress-bar__fill--animated'); + if (this.state === 'disabled') classes.push('progress-bar__fill--disabled'); + + return classes.join(' '); + } + + getBufferClasses(): string { + const classes = [`progress-bar__buffer--${this.size}`]; + + if (this.state === 'disabled') classes.push('progress-bar__buffer--disabled'); + + return classes.join(' '); + } + + getLabelClasses(): string { + const classes = [`progress-bar__label--${this.size}`]; + + if (this.state === 'disabled') classes.push('progress-bar__label--disabled'); + + return classes.join(' '); + } + + getHelperTextClasses(): string { + const classes = [`progress-bar__helper-text--${this.size}`]; + + if (this.state === 'disabled') classes.push('progress-bar__helper-text--disabled'); + + return classes.join(' '); + } + + // Public methods for programmatic control + setProgress(value: number): void { + this._value.set(Math.max(0, Math.min(100, value))); + } + + setBuffer(value: number): void { + this._bufferValue.set(Math.max(0, Math.min(100, value))); + } + + reset(): void { + this._value.set(0); + this._bufferValue.set(0); + } + + complete(): void { + this._value.set(100); + } +} \ No newline at end of file diff --git a/src/lib/components/data-display/table/enhanced-table.component.scss b/src/lib/components/data-display/table/enhanced-table.component.scss new file mode 100644 index 0000000..1c87117 --- /dev/null +++ b/src/lib/components/data-display/table/enhanced-table.component.scss @@ -0,0 +1,480 @@ +@use 'ui-design-system/src/styles/semantic' as *; + +.ui-enhanced-table-container { + position: relative; + width: 100%; + background: $semantic-color-surface-primary; + border-radius: $semantic-border-card-radius; + box-shadow: $semantic-shadow-elevation-1; + overflow: hidden; + + // Variants + &--striped { + .ui-enhanced-table__row:nth-child(even), + .ui-enhanced-table__virtual-row:nth-child(even) { + background: $semantic-color-surface-secondary; + } + } + + &--bordered { + border: $semantic-border-width-1 solid $semantic-color-border-primary; + + .ui-enhanced-table__header-cell, + .ui-enhanced-table__cell, + .ui-enhanced-table__virtual-cell { + border-right: $semantic-border-width-1 solid $semantic-color-border-subtle; + } + } + + &--minimal { + box-shadow: none; + border-radius: 0; + + .ui-enhanced-table__header { + border-bottom: $semantic-border-width-1 solid $semantic-color-border-subtle; + } + } + + // Size variants + &--compact { + .ui-enhanced-table__header-cell, + .ui-enhanced-table__cell, + .ui-enhanced-table__virtual-cell { + padding: $semantic-spacing-component-xs $semantic-spacing-component-sm; + font-size: map-get($semantic-typography-body-small, font-size); + line-height: map-get($semantic-typography-body-small, line-height); + } + } + + &--comfortable { + .ui-enhanced-table__header-cell, + .ui-enhanced-table__cell, + .ui-enhanced-table__virtual-cell { + padding: $semantic-spacing-component-lg $semantic-spacing-component-md; + font-size: map-get($semantic-typography-body-large, font-size); + line-height: map-get($semantic-typography-body-large, line-height); + } + } + + // States + &--sticky-header { + .ui-enhanced-table__header { + position: sticky; + top: 0; + z-index: $semantic-z-index-dropdown; + background: $semantic-color-surface-elevated; + box-shadow: $semantic-shadow-elevation-1; + } + } + + &--loading { + pointer-events: none; + + .ui-enhanced-table__loading-overlay { + opacity: 1; + visibility: visible; + } + } + + &--virtual { + .ui-enhanced-table__scroll-container { + overflow-y: auto; + } + } +} + +// Filter Row +.ui-enhanced-table__filter-row { + display: flex; + background: $semantic-color-surface-secondary; + border-bottom: $semantic-border-width-1 solid $semantic-color-border-subtle; + padding: $semantic-spacing-component-sm 0; + gap: $semantic-spacing-component-xs; +} + +.ui-enhanced-table__filter-cell { + padding: 0 $semantic-spacing-component-sm; + flex-shrink: 0; +} + +.ui-enhanced-table__filter-input, +.ui-enhanced-table__filter-select { + width: 100%; + padding: $semantic-spacing-component-xs $semantic-spacing-component-sm; + border: $semantic-border-width-1 solid $semantic-color-border-primary; + border-radius: $semantic-border-input-radius; + background: $semantic-color-surface-primary; + color: $semantic-color-text-primary; + font-size: map-get($semantic-typography-body-small, font-size); + font-family: map-get($semantic-typography-body-small, font-family); + line-height: map-get($semantic-typography-body-small, line-height); + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + + &:focus { + outline: none; + border-color: $semantic-color-focus; + box-shadow: 0 0 0 2px $semantic-color-focus; + } + + &::placeholder { + color: $semantic-color-text-tertiary; + } +} + +// Scroll Container +.ui-enhanced-table__scroll-container { + position: relative; + overflow-x: auto; + max-height: 600px; +} + +// Header +.ui-enhanced-table__header { + background: $semantic-color-surface-elevated; + border-bottom: $semantic-border-width-2 solid $semantic-color-border-primary; +} + +.ui-enhanced-table__header-row { + display: flex; + min-width: 100%; +} + +.ui-enhanced-table__header-cell { + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + padding: $semantic-spacing-component-md; + background: $semantic-color-surface-elevated; + border-right: $semantic-border-width-1 solid $semantic-color-border-subtle; + font-weight: $semantic-typography-font-weight-semibold; + font-size: map-get($semantic-typography-body-medium, font-size); + font-family: map-get($semantic-typography-body-medium, font-family); + line-height: map-get($semantic-typography-body-medium, line-height); + color: $semantic-color-text-primary; + flex-shrink: 0; + user-select: none; + + &--sortable { + cursor: pointer; + transition: background-color $semantic-motion-duration-fast $semantic-motion-easing-ease; + + &:hover { + background: $semantic-color-surface-secondary; + } + } + + &--sorted { + background: $semantic-color-surface-secondary; + } + + &--draggable { + cursor: grab; + + &:active { + cursor: grabbing; + } + } + + &:last-child { + border-right: none; + } +} + +.ui-enhanced-table__header-content { + display: flex; + align-items: center; + gap: $semantic-spacing-component-xs; + flex: 1; +} + +.ui-enhanced-table__header-text { + font-weight: $semantic-typography-font-weight-semibold; +} + +.ui-enhanced-table__sort-indicators { + display: flex; + flex-direction: column; + gap: 2px; +} + +.ui-enhanced-table__sort-indicator { + display: inline-flex; + align-items: center; + font-size: 12px; + color: $semantic-color-primary; + font-weight: $semantic-typography-font-weight-bold; + + sup { + font-size: 8px; + margin-left: 2px; + } + + &[data-direction="asc"] { + color: $semantic-color-success; + } + + &[data-direction="desc"] { + color: $semantic-color-danger; + } +} + +// Resize Handle +.ui-enhanced-table__resize-handle { + position: absolute; + top: 0; + right: 0; + width: 4px; + height: 100%; + background: transparent; + cursor: col-resize; + z-index: 1; + + &::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 2px; + height: 20px; + background: $semantic-color-border-secondary; + opacity: 0; + transition: opacity $semantic-motion-duration-fast $semantic-motion-easing-ease; + } + + &:hover::before { + opacity: 1; + } +} + +// Virtual Viewport +.ui-enhanced-table__virtual-viewport { + position: relative; + overflow-y: auto; +} + +// Rows (Traditional and Virtual) +.ui-enhanced-table__body { + background: $semantic-color-surface-primary; +} + +.ui-enhanced-table__row, +.ui-enhanced-table__virtual-row { + display: flex; + min-width: 100%; + transition: background-color $semantic-motion-duration-fast $semantic-motion-easing-ease; + + &--hover:hover { + background: $semantic-color-surface-secondary; + } + + &--selected { + background: rgba($semantic-color-primary, 0.1); + border-left: 3px solid $semantic-color-primary; + } + + &--striped { + background: $semantic-color-surface-secondary; + } +} + +// Cells +.ui-enhanced-table__cell, +.ui-enhanced-table__virtual-cell { + display: flex; + align-items: center; + padding: $semantic-spacing-component-md; + border-bottom: $semantic-border-width-1 solid $semantic-color-border-subtle; + font-size: map-get($semantic-typography-body-medium, font-size); + font-family: map-get($semantic-typography-body-medium, font-family); + line-height: map-get($semantic-typography-body-medium, line-height); + color: $semantic-color-text-primary; + flex-shrink: 0; + overflow: hidden; +} + +.ui-enhanced-table__cell-content { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +// Empty State +.ui-enhanced-table__empty-row { + display: flex; + justify-content: center; + padding: $semantic-spacing-layout-section-lg; +} + +.ui-enhanced-table__empty-cell { + text-align: center; +} + +.ui-enhanced-table__empty-content { + display: flex; + flex-direction: column; + align-items: center; + gap: $semantic-spacing-component-md; +} + +.ui-enhanced-table__empty-text { + font-size: map-get($semantic-typography-body-large, font-size); + font-family: map-get($semantic-typography-body-large, font-family); + color: $semantic-color-text-tertiary; +} + +// Loading Overlay +.ui-enhanced-table__loading-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba($semantic-color-backdrop, 0.5); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: $semantic-spacing-component-md; + opacity: 0; + visibility: hidden; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + z-index: $semantic-z-index-overlay; + backdrop-filter: blur(2px); +} + +.ui-enhanced-table__loading-spinner { + width: 32px; + height: 32px; + border: 3px solid $semantic-color-border-subtle; + border-top: 3px solid $semantic-color-primary; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +.ui-enhanced-table__loading-text { + font-size: map-get($semantic-typography-body-medium, font-size); + font-family: map-get($semantic-typography-body-medium, font-family); + color: $semantic-color-text-primary; + font-weight: $semantic-typography-font-weight-medium; +} + +// Column Reorder Ghost +.ui-enhanced-table__column-ghost { + position: fixed; + padding: $semantic-spacing-component-sm $semantic-spacing-component-md; + background: $semantic-color-primary; + color: $semantic-color-on-primary; + border-radius: $semantic-border-input-radius; + font-size: map-get($semantic-typography-body-small, font-size); + font-family: map-get($semantic-typography-body-small, font-family); + font-weight: $semantic-typography-font-weight-medium; + box-shadow: $semantic-shadow-elevation-3; + pointer-events: none; + z-index: $semantic-z-index-modal; + transform: translate(-50%, -100%); + opacity: 0.9; +} + +// Animations +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +// Responsive Design +@media (max-width: 768px) { + .ui-enhanced-table-container { + font-size: map-get($semantic-typography-body-small, font-size); + + .ui-enhanced-table__header-cell, + .ui-enhanced-table__cell, + .ui-enhanced-table__virtual-cell { + padding: $semantic-spacing-component-sm; + } + + .ui-enhanced-table__filter-row { + padding: $semantic-spacing-component-xs 0; + } + + .ui-enhanced-table__resize-handle { + width: 8px; // Larger touch target + } + } + + .ui-enhanced-table__scroll-container { + overflow-x: scroll; + -webkit-overflow-scrolling: touch; + } +} + +// Accessibility +@media (prefers-reduced-motion: reduce) { + .ui-enhanced-table__header-cell, + .ui-enhanced-table__row, + .ui-enhanced-table__virtual-row, + .ui-enhanced-table__cell, + .ui-enhanced-table__virtual-cell, + .ui-enhanced-table__filter-input, + .ui-enhanced-table__filter-select, + .ui-enhanced-table__loading-overlay, + .ui-enhanced-table__resize-handle::before { + transition-duration: 0ms; + animation-duration: 0ms; + } + + .ui-enhanced-table__loading-spinner { + animation: none; + } +} + +// High contrast mode +@media (prefers-contrast: high) { + .ui-enhanced-table-container { + border: $semantic-border-width-2 solid $semantic-color-text-primary; + } + + .ui-enhanced-table__header-cell, + .ui-enhanced-table__cell, + .ui-enhanced-table__virtual-cell { + border-color: $semantic-color-text-primary; + } + + .ui-enhanced-table__filter-input, + .ui-enhanced-table__filter-select { + border-color: $semantic-color-text-primary; + } +} + +// Print styles +@media print { + .ui-enhanced-table-container { + box-shadow: none; + border: $semantic-border-width-1 solid $semantic-color-text-primary; + } + + .ui-enhanced-table__filter-row, + .ui-enhanced-table__loading-overlay, + .ui-enhanced-table__resize-handle, + .ui-enhanced-table__column-ghost { + display: none !important; + } + + .ui-enhanced-table__header { + background: white !important; + } + + .ui-enhanced-table__row, + .ui-enhanced-table__virtual-row { + break-inside: avoid; + } +} + +// Focus management for keyboard navigation +.ui-enhanced-table__header-cell:focus-visible, +.ui-enhanced-table__row:focus-visible, +.ui-enhanced-table__virtual-row:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: -2px; +} \ No newline at end of file diff --git a/src/lib/components/data-display/table/enhanced-table.component.ts b/src/lib/components/data-display/table/enhanced-table.component.ts new file mode 100644 index 0000000..9ae6520 --- /dev/null +++ b/src/lib/components/data-display/table/enhanced-table.component.ts @@ -0,0 +1,833 @@ +import { + Component, + Input, + Output, + EventEmitter, + ChangeDetectionStrategy, + ViewEncapsulation, + ContentChild, + TemplateRef, + ViewChild, + ElementRef, + OnInit, + OnDestroy, + AfterViewInit, + HostListener, + signal, + computed +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; + +export type EnhancedTableVariant = 'default' | 'striped' | 'bordered' | 'minimal'; +export type EnhancedTableSize = 'compact' | 'default' | 'comfortable'; +export type EnhancedTableColumnAlign = 'left' | 'center' | 'right'; +export type FilterOperator = 'equals' | 'contains' | 'startsWith' | 'endsWith' | 'gt' | 'gte' | 'lt' | 'lte' | 'range' | 'in' | 'notIn'; + +export interface EnhancedTableColumn { + key: string; + label: string; + sortable?: boolean; + filterable?: boolean; + resizable?: boolean; + draggable?: boolean; + align?: EnhancedTableColumnAlign; + width?: number; + minWidth?: number; + maxWidth?: number; + sticky?: boolean; + filterType?: 'text' | 'number' | 'date' | 'select' | 'multi-select'; + filterOptions?: Array<{label: string, value: any}>; + hidden?: boolean; +} + +export interface TableFilter { + column: string; + operator: FilterOperator; + value: any; + value2?: any; // For range filters +} + +export interface TableSort { + column: string; + direction: 'asc' | 'desc'; + priority: number; // For multi-column sorting +} + +export interface EnhancedTableSortEvent { + sorting: TableSort[]; +} + +export interface EnhancedTableFilterEvent { + filters: TableFilter[]; +} + +export interface EnhancedTableColumnResizeEvent { + column: string; + width: number; + columnWidths: Record; +} + +export interface EnhancedTableColumnReorderEvent { + fromIndex: number; + toIndex: number; + columnOrder: string[]; +} + +export interface VirtualScrollConfig { + enabled: boolean; + itemHeight: number; + buffer?: number; + trackBy?: (index: number, item: any) => any; +} + +@Component({ + selector: 'ui-enhanced-table', + standalone: true, + imports: [CommonModule, FormsModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ + + @if (showFilterRow) { +
+ @for (column of visibleColumns(); track column.key) { + @if (column.filterable) { +
+ @switch (column.filterType || 'text') { + @case ('text') { + + } + @case ('number') { + + } + @case ('select') { + + } + } +
+ } @else { +
+ } + } +
+ } + + +
+ + +
+
+ @for (column of visibleColumns(); track column.key; let colIndex = $index) { +
+
+ {{ column.label }} + + + @if (column.sortable) { +
+ @for (sort of getCurrentSorting(column.key); track sort.priority) { + + @if (sort.direction === 'asc') { + ↑ + } @else { + ↓ + } + @if (sorting().length > 1) { + {{ sort.priority + 1 }} + } + + } +
+ } +
+ + + @if (column.resizable) { +
+ } +
+ } +
+
+ + + @if (virtualScrolling.enabled) { +
+ + @if (virtualStartOffset() > 0) { +
+ } + + + @for (row of virtualRows(); track virtualScrolling.trackBy ? virtualScrolling.trackBy($index, row) : $index; let rowIndex = $index) { +
+ @for (column of visibleColumns(); track column.key) { +
+ @if (getCellTemplate(column.key)) { + + } @else { + + {{ getCellValue(row, column.key) }} + + } +
+ } +
+ } + + + @if (virtualEndOffset() > 0) { +
+ } +
+ } @else { + +
+ @if (filteredAndSortedData().length === 0 && showEmptyState) { +
+
+
+ @if (emptyTemplate) { + + } @else { +
{{ emptyMessage }}
+ } +
+
+
+ } @else { + @for (row of filteredAndSortedData(); track trackByFn ? trackByFn($index, row) : $index; let rowIndex = $index) { +
+ @for (column of visibleColumns(); track column.key) { +
+ @if (getCellTemplate(column.key)) { + + } @else { + + {{ getCellValue(row, column.key) }} + + } +
+ } +
+ } + } +
+ } +
+ + + @if (loading) { +
+
+
{{ loadingMessage }}
+
+ } + + + @if (columnReorderState.dragging) { +
+ {{ columnReorderState.draggedColumn?.label }} +
+ } +
+ `, + styleUrl: './enhanced-table.component.scss' +}) +export class EnhancedTableComponent implements OnInit, OnDestroy, AfterViewInit { + @Input() data: any[] = []; + @Input() columns: EnhancedTableColumn[] = []; + @Input() variant: EnhancedTableVariant = 'default'; + @Input() size: EnhancedTableSize = 'default'; + @Input() showFilterRow: boolean = false; + @Input() showEmptyState: boolean = true; + @Input() emptyMessage: string = 'No data available'; + @Input() loading: boolean = false; + @Input() loadingMessage: string = 'Loading...'; + @Input() hoverable: boolean = true; + @Input() selectable: boolean = false; + @Input() stickyHeader: boolean = false; + @Input() maxHeight: string = ''; + @Input() trackByFn?: (index: number, item: any) => any; + @Input() cellTemplates: Record> = {}; + @Input() class: string = ''; + + // Enhanced features + @Input() virtualScrolling: VirtualScrollConfig = { enabled: false, itemHeight: 48, buffer: 5 }; + @Input() multiSort: boolean = false; + @Input() initialSorting: TableSort[] = []; + @Input() initialFilters: TableFilter[] = []; + @Input() initialColumnWidths: Record = {}; + @Input() initialColumnOrder: string[] = []; + + @Output() rowClick = new EventEmitter<{row: any, index: number}>(); + @Output() sortChange = new EventEmitter(); + @Output() filterChange = new EventEmitter(); + @Output() columnResize = new EventEmitter(); + @Output() columnReorder = new EventEmitter(); + @Output() selectionChange = new EventEmitter(); + + @ContentChild('emptyTemplate') emptyTemplate?: TemplateRef; + @ViewChild('tableContainer') tableContainer?: ElementRef; + @ViewChild('scrollContainer') scrollContainer?: ElementRef; + + // Signals for reactive state management + sorting = signal([]); + filters = signal([]); + columnWidths = signal>({}); + columnOrder = signal([]); + selectedRows: Set = new Set(); + + // Virtual scrolling state + virtualStartIndex = signal(0); + virtualEndIndex = signal(0); + virtualViewportHeight = signal(400); + + // Column resizing state + columnResizeState = { + resizing: false, + columnKey: '', + startX: 0, + startWidth: 0 + }; + + // Column reordering state + columnReorderState = { + dragging: false, + draggedIndex: -1, + draggedColumn: null as EnhancedTableColumn | null, + ghostX: 0, + ghostY: 0 + }; + + // Computed properties + visibleColumns = computed(() => { + const order = this.columnOrder(); + const cols = this.columns.filter(col => !col.hidden); + + if (order.length === 0) { + return cols; + } + + // Sort columns by order array + return order + .map(key => cols.find(col => col.key === key)) + .filter(Boolean) as EnhancedTableColumn[]; + }); + + filteredAndSortedData = computed(() => { + let result = [...this.data]; + + // Apply filters + const currentFilters = this.filters(); + currentFilters.forEach(filter => { + result = this.applyFilter(result, filter); + }); + + // Apply sorting + const currentSorting = this.sorting(); + if (currentSorting.length > 0) { + result.sort((a, b) => { + for (const sort of currentSorting.sort((x, y) => x.priority - y.priority)) { + const valueA = this.getCellValue(a, sort.column); + const valueB = this.getCellValue(b, sort.column); + const comparison = this.compareValues(valueA, valueB); + + if (comparison !== 0) { + return sort.direction === 'desc' ? -comparison : comparison; + } + } + return 0; + }); + } + + return result; + }); + + virtualRows = computed(() => { + if (!this.virtualScrolling.enabled) return []; + + const data = this.filteredAndSortedData(); + const start = this.virtualStartIndex(); + const end = Math.min(this.virtualEndIndex(), data.length); + + return data.slice(start, end); + }); + + virtualStartOffset = computed(() => { + return this.virtualStartIndex() * this.virtualScrolling.itemHeight; + }); + + virtualEndOffset = computed(() => { + const data = this.filteredAndSortedData(); + const remainingItems = data.length - this.virtualEndIndex(); + return remainingItems > 0 ? remainingItems * this.virtualScrolling.itemHeight : 0; + }); + + ngOnInit() { + // Initialize state from inputs + this.sorting.set([...this.initialSorting]); + this.filters.set([...this.initialFilters]); + this.columnWidths.set({...this.initialColumnWidths}); + this.columnOrder.set([...this.initialColumnOrder]); + + // Set default column widths + this.initializeColumnWidths(); + } + + ngAfterViewInit() { + if (this.virtualScrolling.enabled) { + this.calculateVirtualScrolling(); + } + } + + ngOnDestroy() { + // Cleanup event listeners + document.removeEventListener('mousemove', this.onColumnResize); + document.removeEventListener('mouseup', this.onColumnResizeEnd); + document.removeEventListener('mousemove', this.onColumnReorderMove); + document.removeEventListener('mouseup', this.onColumnReorderEnd); + } + + get tableContainerClasses(): string { + return [ + 'ui-enhanced-table-container', + `ui-enhanced-table-container--${this.variant}`, + `ui-enhanced-table-container--${this.size}`, + this.stickyHeader ? 'ui-enhanced-table-container--sticky-header' : '', + this.maxHeight ? 'ui-enhanced-table-container--max-height' : '', + this.loading ? 'ui-enhanced-table-container--loading' : '', + this.virtualScrolling.enabled ? 'ui-enhanced-table-container--virtual' : '', + this.class + ].filter(Boolean).join(' '); + } + + private initializeColumnWidths(): void { + const widths = {...this.columnWidths()}; + this.columns.forEach(column => { + if (!widths[column.key]) { + widths[column.key] = column.width || 150; + } + }); + this.columnWidths.set(widths); + } + + getColumnWidth(columnKey: string): number { + return this.columnWidths()[columnKey] || 150; + } + + // Virtual scrolling methods + calculateVirtualScrolling(): void { + if (!this.scrollContainer) return; + + const container = this.scrollContainer.nativeElement; + const containerHeight = container.clientHeight; + const itemHeight = this.virtualScrolling.itemHeight; + const buffer = this.virtualScrolling.buffer || 5; + + const visibleCount = Math.ceil(containerHeight / itemHeight); + const startIndex = Math.floor(container.scrollTop / itemHeight); + const bufferedStart = Math.max(0, startIndex - buffer); + const bufferedEnd = Math.min( + this.filteredAndSortedData().length, + startIndex + visibleCount + buffer + ); + + this.virtualStartIndex.set(bufferedStart); + this.virtualEndIndex.set(bufferedEnd); + this.virtualViewportHeight.set(this.filteredAndSortedData().length * itemHeight); + } + + onVirtualScroll(event: Event): void { + this.calculateVirtualScrolling(); + } + + // Sorting methods + handleSort(column: EnhancedTableColumn, event?: MouseEvent): void { + if (!column.sortable) return; + + const currentSorting = [...this.sorting()]; + const existingIndex = currentSorting.findIndex(s => s.column === column.key); + + if (this.multiSort && event?.ctrlKey) { + // Multi-sort mode + if (existingIndex >= 0) { + const existing = currentSorting[existingIndex]; + if (existing.direction === 'asc') { + existing.direction = 'desc'; + } else { + currentSorting.splice(existingIndex, 1); + } + } else { + currentSorting.push({ + column: column.key, + direction: 'asc', + priority: currentSorting.length + }); + } + } else { + // Single sort mode + if (existingIndex >= 0) { + const existing = currentSorting[existingIndex]; + if (existing.direction === 'asc') { + existing.direction = 'desc'; + currentSorting.splice(0, currentSorting.length, existing); + } else { + currentSorting.splice(0, currentSorting.length); + } + } else { + currentSorting.splice(0, currentSorting.length, { + column: column.key, + direction: 'asc', + priority: 0 + }); + } + } + + // Update priorities + currentSorting.forEach((sort, index) => { + sort.priority = index; + }); + + this.sorting.set(currentSorting); + this.sortChange.emit({ sorting: currentSorting }); + } + + getSortInfo(columnKey: string): {active: boolean, direction?: 'asc' | 'desc', priority?: number} { + const sort = this.sorting().find(s => s.column === columnKey); + return sort ? { + active: true, + direction: sort.direction, + priority: sort.priority + } : { active: false }; + } + + getCurrentSorting(columnKey: string): TableSort[] { + return this.sorting().filter(s => s.column === columnKey); + } + + getSortAttribute(columnKey: string): string | null { + const sort = this.sorting().find(s => s.column === columnKey); + return sort ? (sort.direction === 'asc' ? 'ascending' : 'descending') : null; + } + + private compareValues(a: any, b: any): number { + if (a == null && b == null) return 0; + if (a == null) return -1; + if (b == null) return 1; + + // Handle different data types + if (typeof a === 'string' && typeof b === 'string') { + return a.localeCompare(b); + } + + if (typeof a === 'number' && typeof b === 'number') { + return a - b; + } + + if (a instanceof Date && b instanceof Date) { + return a.getTime() - b.getTime(); + } + + // Fallback to string comparison + return String(a).localeCompare(String(b)); + } + + // Filtering methods + updateFilter(columnKey: string, operator: FilterOperator, value: any): void { + const currentFilters = [...this.filters()]; + const existingIndex = currentFilters.findIndex(f => f.column === columnKey); + + if (value === '' || value == null) { + // Remove filter if value is empty + if (existingIndex >= 0) { + currentFilters.splice(existingIndex, 1); + } + } else { + const filter: TableFilter = { column: columnKey, operator, value }; + + if (existingIndex >= 0) { + currentFilters[existingIndex] = filter; + } else { + currentFilters.push(filter); + } + } + + this.filters.set(currentFilters); + this.filterChange.emit({ filters: currentFilters }); + } + + getFilterValue(columnKey: string): any { + const filter = this.filters().find(f => f.column === columnKey); + return filter?.value || ''; + } + + private applyFilter(data: any[], filter: TableFilter): any[] { + return data.filter(row => { + const cellValue = this.getCellValue(row, filter.column); + const filterValue = filter.value; + + switch (filter.operator) { + case 'equals': + return cellValue == filterValue; + case 'contains': + return String(cellValue).toLowerCase().includes(String(filterValue).toLowerCase()); + case 'startsWith': + return String(cellValue).toLowerCase().startsWith(String(filterValue).toLowerCase()); + case 'endsWith': + return String(cellValue).toLowerCase().endsWith(String(filterValue).toLowerCase()); + case 'gt': + return Number(cellValue) > Number(filterValue); + case 'gte': + return Number(cellValue) >= Number(filterValue); + case 'lt': + return Number(cellValue) < Number(filterValue); + case 'lte': + return Number(cellValue) <= Number(filterValue); + case 'range': + const numValue = Number(cellValue); + return numValue >= Number(filterValue) && numValue <= Number(filter.value2); + case 'in': + return Array.isArray(filterValue) && filterValue.includes(cellValue); + case 'notIn': + return Array.isArray(filterValue) && !filterValue.includes(cellValue); + default: + return true; + } + }); + } + + // Column resizing methods + startColumnResize = (event: MouseEvent, columnKey: string): void => { + event.preventDefault(); + event.stopPropagation(); + + this.columnResizeState = { + resizing: true, + columnKey, + startX: event.clientX, + startWidth: this.getColumnWidth(columnKey) + }; + + document.addEventListener('mousemove', this.onColumnResize); + document.addEventListener('mouseup', this.onColumnResizeEnd); + }; + + private onColumnResize = (event: MouseEvent): void => { + if (!this.columnResizeState.resizing) return; + + const deltaX = event.clientX - this.columnResizeState.startX; + const newWidth = Math.max(50, this.columnResizeState.startWidth + deltaX); + + const widths = {...this.columnWidths()}; + widths[this.columnResizeState.columnKey] = newWidth; + this.columnWidths.set(widths); + }; + + private onColumnResizeEnd = (): void => { + if (this.columnResizeState.resizing) { + this.columnResize.emit({ + column: this.columnResizeState.columnKey, + width: this.getColumnWidth(this.columnResizeState.columnKey), + columnWidths: this.columnWidths() + }); + } + + this.columnResizeState.resizing = false; + document.removeEventListener('mousemove', this.onColumnResize); + document.removeEventListener('mouseup', this.onColumnResizeEnd); + }; + + autoSizeColumn(columnKey: string): void { + // Simple auto-sizing logic - could be enhanced to measure content + const column = this.columns.find(c => c.key === columnKey); + if (column) { + const defaultWidth = Math.max(column.label.length * 8 + 40, 100); + const widths = {...this.columnWidths()}; + widths[columnKey] = defaultWidth; + this.columnWidths.set(widths); + } + } + + // Column reordering methods + startColumnReorder = (event: MouseEvent, columnIndex: number): void => { + if (!this.visibleColumns()[columnIndex]?.draggable) return; + + event.preventDefault(); + this.columnReorderState = { + dragging: true, + draggedIndex: columnIndex, + draggedColumn: this.visibleColumns()[columnIndex], + ghostX: event.clientX, + ghostY: event.clientY + }; + + document.addEventListener('mousemove', this.onColumnReorderMove); + document.addEventListener('mouseup', this.onColumnReorderEnd); + }; + + private onColumnReorderMove = (event: MouseEvent): void => { + if (!this.columnReorderState.dragging) return; + + this.columnReorderState.ghostX = event.clientX; + this.columnReorderState.ghostY = event.clientY; + }; + + private onColumnReorderEnd = (event: MouseEvent): void => { + if (!this.columnReorderState.dragging) return; + + // Find drop target + const element = document.elementFromPoint(event.clientX, event.clientY); + const headerCell = element?.closest('.ui-enhanced-table__header-cell'); + + if (headerCell) { + const targetColumn = headerCell.getAttribute('data-column'); + const targetIndex = this.visibleColumns().findIndex(c => c.key === targetColumn); + + if (targetIndex >= 0 && targetIndex !== this.columnReorderState.draggedIndex) { + const newOrder = [...this.columnOrder()]; + const draggedKey = this.columnReorderState.draggedColumn!.key; + + // Remove dragged item and insert at new position + const currentIndex = newOrder.indexOf(draggedKey); + if (currentIndex >= 0) { + newOrder.splice(currentIndex, 1); + } + newOrder.splice(targetIndex, 0, draggedKey); + + this.columnOrder.set(newOrder); + this.columnReorder.emit({ + fromIndex: this.columnReorderState.draggedIndex, + toIndex: targetIndex, + columnOrder: newOrder + }); + } + } + + this.columnReorderState.dragging = false; + document.removeEventListener('mousemove', this.onColumnReorderMove); + document.removeEventListener('mouseup', this.onColumnReorderEnd); + }; + + // Utility methods + handleRowClick(row: any, index: number): void { + if (this.selectable) { + this.toggleRowSelection(row); + } + + this.rowClick.emit({ row, index }); + } + + toggleRowSelection(row: any): void { + if (this.selectedRows.has(row)) { + this.selectedRows.delete(row); + } else { + this.selectedRows.add(row); + } + + this.selectionChange.emit(Array.from(this.selectedRows)); + } + + getCellValue(row: any, key: string): any { + return key.split('.').reduce((obj, prop) => obj?.[prop], row) ?? ''; + } + + getCellTemplate(columnKey: string): TemplateRef | null { + return this.cellTemplates[columnKey] || null; + } + + @HostListener('window:resize') + onWindowResize(): void { + if (this.virtualScrolling.enabled) { + setTimeout(() => this.calculateVirtualScrolling(), 0); + } + } +} \ No newline at end of file diff --git a/src/lib/components/data-display/table/index.ts b/src/lib/components/data-display/table/index.ts new file mode 100644 index 0000000..cd81ab2 --- /dev/null +++ b/src/lib/components/data-display/table/index.ts @@ -0,0 +1,3 @@ +export * from './table.component'; +export * from './enhanced-table.component'; +export * from './table-actions.component'; \ No newline at end of file diff --git a/src/lib/components/data-display/table/table-actions.component.scss b/src/lib/components/data-display/table/table-actions.component.scss new file mode 100644 index 0000000..fbcf088 --- /dev/null +++ b/src/lib/components/data-display/table/table-actions.component.scss @@ -0,0 +1,400 @@ +@use 'ui-design-system/src/styles/semantic' as *; +.ui-table-actions { + display: flex; + align-items: center; + gap: 0.25rem; + position: relative; + + // Size variants + &--small { + gap: 0.125rem; + } + + &--medium { + gap: 0.25rem; + } + + &--large { + gap: 0.375rem; + } + + // Layout modifiers + &--compact { + gap: 0.125rem; + + .ui-table-action { + min-width: auto; + } + } + + &--with-labels { + gap: 0.5rem; + } +} + +.ui-table-action { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.25rem; + padding: 0.375rem; + border: 1px solid transparent; + border-radius: 0.25rem; + background: transparent; + color: hsl(287, 12%, 47%); + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + font-size: 0.875rem; + font-weight: 500; + line-height: 1; + cursor: pointer; + transition: all 150ms cubic-bezier(0, 0, 0.2, 1); + min-width: 2rem; + min-height: 2rem; + position: relative; + user-select: none; + + &:hover:not(:disabled) { + background: hsl(289, 14%, 96%); + color: hsl(279, 14%, 11%); + transform: scale(1.05); + } + + &:active:not(:disabled) { + transform: scale(0.98); + } + + &:focus-visible { + outline: 2px solid hsl(258, 100%, 47%); + outline-offset: 1px; + } + + // Action variants + &--edit { + color: hsl(207, 90%, 45%); + + &:hover:not(:disabled) { + background: hsl(207, 90%, 95%); + color: hsl(207, 90%, 35%); + border-color: hsl(207, 90%, 80%); + } + } + + &--delete { + color: hsl(0, 84%, 45%); + + &:hover:not(:disabled) { + background: hsl(0, 84%, 95%); + color: hsl(0, 84%, 35%); + border-color: hsl(0, 84%, 80%); + } + } + + &--view { + color: hsl(142, 76%, 45%); + + &:hover:not(:disabled) { + background: hsl(142, 76%, 95%); + color: hsl(142, 76%, 35%); + border-color: hsl(142, 76%, 80%); + } + } + + &--download { + color: hsl(45, 93%, 45%); + + &:hover:not(:disabled) { + background: hsl(45, 93%, 95%); + color: hsl(45, 93%, 35%); + border-color: hsl(45, 93%, 80%); + } + } + + &--custom { + color: hsl(258, 100%, 47%); + + &:hover:not(:disabled) { + background: hsl(263, 100%, 95%); + color: hsl(258, 100%, 35%); + border-color: hsl(263, 100%, 80%); + } + } + + // Size variants + .ui-table-actions--small & { + padding: 0.25rem; + min-width: 1.5rem; + min-height: 1.5rem; + font-size: 0.75rem; + + .ui-table-action__icon { + font-size: 0.75rem; + } + } + + .ui-table-actions--large & { + padding: 0.5rem; + min-width: 2.5rem; + min-height: 2.5rem; + font-size: 1rem; + + .ui-table-action__icon { + font-size: 1rem; + } + } + + // States + &--disabled { + opacity: 0.4; + cursor: not-allowed; + pointer-events: none; + } + + &--active { + background: hsl(289, 14%, 90%); + color: hsl(279, 14%, 11%); + border-color: hsl(289, 14%, 80%); + } + + // More button specific styles + &--more { + color: hsl(287, 12%, 47%); + + &:hover:not(:disabled) { + background: hsl(289, 14%, 90%); + color: hsl(279, 14%, 11%); + } + + &.ui-table-action--active { + background: hsl(258, 100%, 95%); + color: hsl(258, 100%, 47%); + border-color: hsl(258, 100%, 80%); + } + } +} + +.ui-table-action__icon { + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.875rem; + line-height: 1; + flex-shrink: 0; + + // Icon-only adjustments + .ui-table-actions:not(.ui-table-actions--with-labels) & { + margin: 0; + } +} + +.ui-table-action__label { + font-size: 0.8125rem; + white-space: nowrap; + flex-shrink: 0; + + // Hide labels by default, show when --with-labels + .ui-table-actions:not(.ui-table-actions--with-labels) & { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } +} + +// Dropdown styles +.ui-table-actions__dropdown { + position: relative; + display: inline-flex; +} + +.ui-table-actions__dropdown-menu { + position: absolute; + top: calc(100% + 0.25rem); + right: 0; + min-width: 10rem; + background: hsl(286, 20%, 99%); + border: 1px solid hsl(289, 14%, 80%); + border-radius: 0.375rem; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1); + padding: 0.25rem; + z-index: 50; + animation: dropdown-appear 150ms cubic-bezier(0, 0, 0.2, 1); + + // Ensure dropdown is on top + &::before { + content: ''; + position: absolute; + top: -0.25rem; + right: 0.75rem; + width: 0; + height: 0; + border-left: 0.25rem solid transparent; + border-right: 0.25rem solid transparent; + border-bottom: 0.25rem solid hsl(289, 14%, 80%); + } + + &::after { + content: ''; + position: absolute; + top: -0.1875rem; + right: 0.8125rem; + width: 0; + height: 0; + border-left: 0.1875rem solid transparent; + border-right: 0.1875rem solid transparent; + border-bottom: 0.1875rem solid hsl(286, 20%, 99%); + } +} + +.ui-table-actions__dropdown-item { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.5rem 0.75rem; + border: none; + border-radius: 0.25rem; + background: transparent; + color: hsl(279, 14%, 11%); + font-family: inherit; + font-size: 0.875rem; + font-weight: 500; + text-align: left; + cursor: pointer; + transition: background-color 150ms ease; + + &:hover:not(:disabled) { + background: hsl(289, 14%, 96%); + } + + &:focus-visible { + outline: 2px solid hsl(258, 100%, 47%); + outline-offset: -2px; + } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } + + .ui-table-action__icon { + font-size: 0.875rem; + } + + .ui-table-action__label { + position: static; + width: auto; + height: auto; + padding: 0; + margin: 0; + overflow: visible; + clip: auto; + white-space: normal; + border: none; + } +} + +// Animation for dropdown +@keyframes dropdown-appear { + 0% { + opacity: 0; + transform: translateY(-0.25rem) scale(0.98); + } + 100% { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +// Tooltip support (when labels are hidden) +.ui-table-actions:not(.ui-table-actions--with-labels) { + .ui-table-action { + position: relative; + + &[title]:hover::after { + content: attr(title); + position: absolute; + bottom: calc(100% + 0.5rem); + left: 50%; + transform: translateX(-50%); + padding: 0.375rem 0.5rem; + background: hsl(279, 14%, 11%); + color: white; + font-size: 0.75rem; + white-space: nowrap; + border-radius: 0.25rem; + z-index: 100; + animation: tooltip-appear 150ms ease-out; + } + + &[title]:hover::before { + content: ''; + position: absolute; + bottom: calc(100% + 0.125rem); + left: 50%; + transform: translateX(-50%); + width: 0; + height: 0; + border-left: 0.25rem solid transparent; + border-right: 0.25rem solid transparent; + border-top: 0.25rem solid hsl(279, 14%, 11%); + z-index: 100; + } + } +} + +@keyframes tooltip-appear { + 0% { + opacity: 0; + transform: translateX(-50%) translateY(0.125rem); + } + 100% { + opacity: 1; + transform: translateX(-50%) translateY(0); + } +} + +// Responsive design +@media (max-width: 640px) { + .ui-table-actions { + &--with-labels .ui-table-action__label { + display: none; + } + + &--with-labels { + gap: 0.25rem; + } + } +} + +// High contrast mode +@media (prefers-contrast: high) { + .ui-table-action { + border: 1px solid; + + &:hover:not(:disabled) { + border-width: 2px; + } + } + + .ui-table-actions__dropdown-menu { + border-width: 2px; + } +} + +// Reduced motion +@media (prefers-reduced-motion: reduce) { + .ui-table-action, + .ui-table-actions__dropdown-menu, + .ui-table-actions__dropdown-item { + transition: none; + } + + .ui-table-actions__dropdown-menu { + animation: none; + } +} \ No newline at end of file diff --git a/src/lib/components/data-display/table/table-actions.component.ts b/src/lib/components/data-display/table/table-actions.component.ts new file mode 100644 index 0000000..4a3f38a --- /dev/null +++ b/src/lib/components/data-display/table/table-actions.component.ts @@ -0,0 +1,177 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +export type ActionVariant = 'edit' | 'delete' | 'view' | 'download' | 'custom'; +export type ActionSize = 'small' | 'medium' | 'large'; + +export interface TableAction { + id: string; + label: string; + variant: ActionVariant; + icon?: string; + disabled?: boolean; + hidden?: boolean; + tooltip?: string; + confirmMessage?: string; +} + +@Component({ + selector: 'ui-table-actions', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ @for (action of visibleActions; track action.id) { + + } + + @if (hasMoreActions) { +
+ + + @if (dropdownOpen) { + + } +
+ } +
+ `, + styleUrl: './table-actions.component.scss' +}) +export class TableActionsComponent { + @Input() actions: TableAction[] = []; + @Input() size: ActionSize = 'medium'; + @Input() maxVisibleActions: number = 3; + @Input() showLabels: boolean = false; + @Input() compact: boolean = false; + @Input() data: any = null; // Row data passed to action handlers + @Input() index: number = -1; // Row index + @Input() class: string = ''; + + @Output() actionClick = new EventEmitter<{action: TableAction, data: any, index: number, event: Event}>(); + + dropdownOpen = false; + + get visibleActions(): TableAction[] { + const filtered = this.actions.filter(action => !action.hidden); + return filtered.slice(0, this.maxVisibleActions); + } + + get hiddenActions(): TableAction[] { + const filtered = this.actions.filter(action => !action.hidden); + return filtered.slice(this.maxVisibleActions); + } + + get hasMoreActions(): boolean { + return this.hiddenActions.length > 0; + } + + get containerClasses(): string { + return [ + 'ui-table-actions', + `ui-table-actions--${this.size}`, + this.compact ? 'ui-table-actions--compact' : '', + this.showLabels ? 'ui-table-actions--with-labels' : '', + this.class + ].filter(Boolean).join(' '); + } + + getActionClasses(action: TableAction): string { + return [ + 'ui-table-action', + `ui-table-action--${action.variant}`, + action.disabled ? 'ui-table-action--disabled' : '', + ].filter(Boolean).join(' '); + } + + getMoreButtonClasses(): string { + return [ + `ui-table-action--${this.size}`, + this.dropdownOpen ? 'ui-table-action--active' : '' + ].filter(Boolean).join(' '); + } + + handleActionClick(action: TableAction, event: Event): void { + if (action.disabled) { + return; + } + + // Close dropdown if it's open + this.dropdownOpen = false; + + // Handle confirmation if required + if (action.confirmMessage) { + if (!confirm(action.confirmMessage)) { + return; + } + } + + this.actionClick.emit({ + action, + data: this.data, + index: this.index, + event + }); + } + + toggleDropdown(): void { + this.dropdownOpen = !this.dropdownOpen; + + if (this.dropdownOpen) { + // Close dropdown when clicking outside + setTimeout(() => { + const handleClickOutside = (event: Event) => { + const target = event.target as Element; + if (!target.closest('.ui-table-actions__dropdown')) { + this.dropdownOpen = false; + document.removeEventListener('click', handleClickOutside); + } + }; + document.addEventListener('click', handleClickOutside); + }); + } + } +} \ No newline at end of file diff --git a/src/lib/components/data-display/table/table.component.scss b/src/lib/components/data-display/table/table.component.scss new file mode 100644 index 0000000..b039708 --- /dev/null +++ b/src/lib/components/data-display/table/table.component.scss @@ -0,0 +1,441 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; +// Modern table component using design tokens +.ui-table-container { + position: relative; + width: 100%; + overflow: auto; + background: hsl(286, 20%, 99%); // Surface color + border-radius: 0.5rem; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1); + + // Sticky header container + &--sticky-header { + max-height: 400px; + + .ui-table__header { + position: sticky; + top: 0; + z-index: 10; + background: hsl(285, 9%, 87%); // Surface dim + } + } + + // Max height container + &--max-height { + max-height: var(--ui-table-max-height, 500px); + } + + // Loading state + &--loading { + pointer-events: none; + } +} + +.ui-table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + position: relative; + + // Subtle noise texture for modern brutalist feel + &::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: url('data:image/svg+xml;utf8,'); + pointer-events: none; + mix-blend-mode: overlay; + border-radius: inherit; + z-index: 1; + } + + // Size variants + &--compact { + .ui-table__header-cell, + .ui-table__body-cell { + padding: 0.5rem 0.75rem; + font-size: 0.875rem; + } + } + + &--default { + .ui-table__header-cell, + .ui-table__body-cell { + padding: 0.75rem 1rem; + font-size: 0.875rem; + } + } + + &--comfortable { + .ui-table__header-cell, + .ui-table__body-cell { + padding: 1rem 1.25rem; + font-size: 1rem; + } + } + + // Style variants + &--bordered { + border: 1px solid hsl(289, 14%, 80%); + + .ui-table__header-cell, + .ui-table__body-cell { + border-right: 1px solid hsl(289, 14%, 80%); + + &:last-child { + border-right: none; + } + } + + .ui-table__body-row { + border-bottom: 1px solid hsl(289, 14%, 80%); + } + } + + &--minimal { + .ui-table__header { + border-bottom: 2px solid hsl(258, 100%, 47%); + } + + .ui-table__body-row { + border-bottom: none; + + &:not(:last-child) { + border-bottom: 1px solid hsl(289, 14%, 90%); + } + } + } + + // Hover effects + &--hoverable { + .ui-table__body-row { + transition: background-color 150ms cubic-bezier(0, 0, 0.2, 1); + cursor: pointer; + + &:hover { + background: hsl(289, 14%, 96%); + position: relative; + z-index: 2; + } + } + } + + // Selection + &--selectable { + .ui-table__body-row { + cursor: pointer; + + &--selected { + background: hsl(263, 100%, 95%); + border-left: 3px solid hsl(258, 100%, 47%); + } + } + } +} + +// Header styles +.ui-table__header { + background: hsl(285, 9%, 87%); + border-bottom: 2px solid hsl(287, 12%, 59%); + position: relative; + z-index: 5; +} + +.ui-table__header-row { + // No additional styles needed +} + +.ui-table__header-cell { + text-align: left; + font-weight: 700; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: hsl(279, 14%, 11%); + position: relative; + white-space: nowrap; + user-select: none; + + // Alignment variants + &--left { + text-align: left; + } + + &--center { + text-align: center; + } + + &--right { + text-align: right; + } + + // Sortable headers + &--sortable { + cursor: pointer; + transition: background-color 150ms ease; + + &:hover { + background: hsl(285, 9%, 82%); + } + + &--sorted { + color: hsl(258, 100%, 47%); + background: hsl(263, 100%, 95%); + } + } + + // Sticky columns + &--sticky { + position: sticky; + left: 0; + background: inherit; + z-index: 6; + box-shadow: 2px 0 4px rgba(0, 0, 0, 0.1); + } +} + +.ui-table__header-content { + display: flex; + align-items: center; + gap: 0.5rem; + min-height: 1.5rem; +} + +.ui-table__header-text { + flex: 1; +} + +.ui-table__sort-indicator { + font-size: 0.875rem; + color: hsl(258, 100%, 47%); + font-weight: bold; + opacity: 0.8; + + &[data-direction="asc"] { + opacity: 1; + } + + &[data-direction="desc"] { + opacity: 1; + } +} + +// Body styles +.ui-table__body { + position: relative; + z-index: 2; +} + +.ui-table__body-row { + transition: all 150ms cubic-bezier(0, 0, 0.2, 1); + border-bottom: 1px solid hsl(289, 14%, 90%); + + &:last-child { + border-bottom: none; + } + + // Striped variant + &--striped { + background: hsl(289, 14%, 98%); + } + + // Selection state + &--selected { + background: hsl(263, 100%, 95%); + box-shadow: inset 3px 0 0 hsl(258, 100%, 47%); + } +} + +.ui-table__body-cell { + color: hsl(279, 14%, 11%); + font-weight: 400; + line-height: 1.5; + position: relative; + + // Alignment variants + &--left { + text-align: left; + } + + &--center { + text-align: center; + } + + &--right { + text-align: right; + } + + // Sticky columns + &--sticky { + position: sticky; + left: 0; + background: inherit; + z-index: 3; + box-shadow: 2px 0 4px rgba(0, 0, 0, 0.05); + + .ui-table__body-row:hover & { + background: hsl(289, 14%, 96%); + } + + .ui-table__body-row--selected & { + background: hsl(263, 100%, 95%); + } + + .ui-table__body-row--striped & { + background: hsl(289, 14%, 98%); + } + } +} + +.ui-table__cell-content { + display: block; + width: 100%; + word-break: break-word; +} + +// Empty state +.ui-table__empty-row { + background: transparent; + + &:hover { + background: transparent !important; + } +} + +.ui-table__empty-cell { + padding: 3rem 1rem; + text-align: center; + border-bottom: none; +} + +.ui-table__empty-content { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; +} + +.ui-table__empty-text { + color: hsl(287, 12%, 47%); + font-size: 1rem; + font-weight: 500; +} + +// Loading overlay +.ui-table__loading-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.9); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1rem; + z-index: 20; + backdrop-filter: blur(2px); +} + +.ui-table__loading-spinner { + width: 2rem; + height: 2rem; + border: 3px solid hsl(289, 14%, 80%); + border-top: 3px solid hsl(258, 100%, 47%); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +.ui-table__loading-text { + color: hsl(287, 12%, 47%); + font-size: 0.875rem; + font-weight: 500; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +// Responsive design +@media (max-width: 768px) { + .ui-table-container { + border-radius: 0; + margin: 0 -1rem; + } + + .ui-table { + &--compact { + .ui-table__header-cell, + .ui-table__body-cell { + padding: 0.375rem 0.5rem; + font-size: 0.8125rem; + } + } + + &--default { + .ui-table__header-cell, + .ui-table__body-cell { + padding: 0.5rem 0.625rem; + font-size: 0.8125rem; + } + } + } + + .ui-table__header-cell, + .ui-table__body-cell { + &:first-child { + padding-left: 1rem; + } + + &:last-child { + padding-right: 1rem; + } + } +} + +// Focus management +.ui-table__header-cell--sortable:focus-visible { + outline: 2px solid hsl(258, 100%, 47%); + outline-offset: -2px; +} + +// Print styles +@media print { + .ui-table-container { + box-shadow: none; + border: 1px solid #000; + } + + .ui-table { + &::before { + display: none; + } + } + + .ui-table__loading-overlay { + display: none; + } +} + +// High contrast mode +@media (prefers-contrast: high) { + .ui-table { + border: 2px solid; + } + + .ui-table__header-cell, + .ui-table__body-cell { + border: 1px solid; + } + + .ui-table__body-row:hover { + background: highlight; + color: highlighttext; + } +} \ No newline at end of file diff --git a/src/lib/components/data-display/table/table.component.ts b/src/lib/components/data-display/table/table.component.ts new file mode 100644 index 0000000..9f0131d --- /dev/null +++ b/src/lib/components/data-display/table/table.component.ts @@ -0,0 +1,246 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ContentChild, TemplateRef } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +export type TableVariant = 'default' | 'striped' | 'bordered' | 'minimal'; +export type TableSize = 'compact' | 'default' | 'comfortable'; +export type TableColumnAlign = 'left' | 'center' | 'right'; + +export interface TableColumn { + key: string; + label: string; + sortable?: boolean; + align?: TableColumnAlign; + width?: string; + sticky?: boolean; +} + +export interface TableSortEvent { + column: string; + direction: 'asc' | 'desc' | null; +} + +@Component({ + selector: 'ui-table', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ + + @if (showHeader && columns.length > 0) { + + + @for (column of columns; track column.key) { + + } + + + } + + + + @if (data.length === 0 && showEmptyState) { + + + + } @else { + @for (row of data; track trackByFn ? trackByFn($index, row) : $index; let rowIndex = $index) { + + @for (column of columns; track column.key) { + + } + + } + } + +
+
+ {{ column.label }} + @if (column.sortable && sortColumn === column.key) { + + @if (sortDirection === 'asc') { + ↑ + } @else if (sortDirection === 'desc') { + ↓ + } + + } +
+
+
+ @if (emptyTemplate) { + + } @else { +
{{ emptyMessage }}
+ } +
+
+ @if (getCellTemplate(column.key)) { + + } @else { + + {{ getCellValue(row, column.key) }} + + } +
+ + + @if (loading) { +
+
+
{{ loadingMessage }}
+
+ } +
+ `, + styleUrl: './table.component.scss' +}) +export class TableComponent { + @Input() data: any[] = []; + @Input() columns: TableColumn[] = []; + @Input() variant: TableVariant = 'default'; + @Input() size: TableSize = 'default'; + @Input() showHeader: boolean = true; + @Input() showEmptyState: boolean = true; + @Input() emptyMessage: string = 'No data available'; + @Input() loading: boolean = false; + @Input() loadingMessage: string = 'Loading...'; + @Input() hoverable: boolean = true; + @Input() selectable: boolean = false; + @Input() stickyHeader: boolean = false; + @Input() maxHeight: string = ''; + @Input() sortColumn: string = ''; + @Input() sortDirection: 'asc' | 'desc' | null = null; + @Input() trackByFn?: (index: number, item: any) => any; + @Input() cellTemplates: Record> = {}; + @Input() class: string = ''; + + @Output() rowClick = new EventEmitter<{row: any, index: number}>(); + @Output() sort = new EventEmitter(); + @Output() selectionChange = new EventEmitter(); + + @ContentChild('emptyTemplate') emptyTemplate?: TemplateRef; + + selectedRows: Set = new Set(); + + get tableContainerClasses(): string { + return [ + 'ui-table-container', + this.stickyHeader ? 'ui-table-container--sticky-header' : '', + this.maxHeight ? 'ui-table-container--max-height' : '', + this.loading ? 'ui-table-container--loading' : '', + this.class + ].filter(Boolean).join(' '); + } + + get tableClasses(): string { + return [ + 'ui-table', + `ui-table--${this.variant}`, + `ui-table--${this.size}`, + this.hoverable ? 'ui-table--hoverable' : '', + this.selectable ? 'ui-table--selectable' : '', + ].filter(Boolean).join(' '); + } + + getHeaderCellClasses(column: TableColumn): string { + return [ + 'ui-table__header-cell', + `ui-table__header-cell--${column.align || 'left'}`, + column.sortable ? 'ui-table__header-cell--sortable' : '', + column.sticky ? 'ui-table__header-cell--sticky' : '', + this.sortColumn === column.key ? 'ui-table__header-cell--sorted' : '' + ].filter(Boolean).join(' '); + } + + getBodyCellClasses(column: TableColumn, row: any): string { + return [ + 'ui-table__body-cell', + `ui-table__body-cell--${column.align || 'left'}`, + column.sticky ? 'ui-table__body-cell--sticky' : '' + ].filter(Boolean).join(' '); + } + + getRowClasses(row: any, index: number): string { + return [ + 'ui-table__body-row', + this.selectedRows.has(row) ? 'ui-table__body-row--selected' : '', + index % 2 === 1 && this.variant === 'striped' ? 'ui-table__body-row--striped' : '' + ].filter(Boolean).join(' '); + } + + handleSort(column: TableColumn): void { + if (!column.sortable) return; + + let newDirection: 'asc' | 'desc' | null; + + if (this.sortColumn === column.key) { + // Toggle through: asc -> desc -> null -> asc + if (this.sortDirection === 'asc') { + newDirection = 'desc'; + } else if (this.sortDirection === 'desc') { + newDirection = null; + } else { + newDirection = 'asc'; + } + } else { + newDirection = 'asc'; + } + + this.sort.emit({ + column: column.key, + direction: newDirection + }); + } + + handleRowClick(row: any, index: number): void { + if (this.selectable) { + this.toggleRowSelection(row); + } + + this.rowClick.emit({ row, index }); + } + + toggleRowSelection(row: any): void { + if (this.selectedRows.has(row)) { + this.selectedRows.delete(row); + } else { + this.selectedRows.add(row); + } + + this.selectionChange.emit(Array.from(this.selectedRows)); + } + + getCellValue(row: any, key: string): any { + return key.split('.').reduce((obj, prop) => obj?.[prop], row) ?? ''; + } + + getCellTemplate(columnKey: string): TemplateRef | null { + return this.cellTemplates[columnKey] || null; + } + + getSortAttribute(columnKey: string): string | null { + if (this.sortColumn === columnKey) { + return this.sortDirection === 'asc' ? 'ascending' : 'descending'; + } + return null; + } +} \ No newline at end of file diff --git a/src/lib/components/data-display/timeline/index.ts b/src/lib/components/data-display/timeline/index.ts new file mode 100644 index 0000000..95a9f12 --- /dev/null +++ b/src/lib/components/data-display/timeline/index.ts @@ -0,0 +1 @@ +export * from './timeline.component'; \ No newline at end of file diff --git a/src/lib/components/data-display/timeline/timeline.component.scss b/src/lib/components/data-display/timeline/timeline.component.scss new file mode 100644 index 0000000..972d45f --- /dev/null +++ b/src/lib/components/data-display/timeline/timeline.component.scss @@ -0,0 +1,331 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-timeline { + // Core Structure + display: flex; + flex-direction: column; + position: relative; + + // Layout & Spacing + padding: $semantic-spacing-component-md; + + // Typography + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + color: $semantic-color-text-primary; + + // Size Variants + &--sm { + padding: $semantic-spacing-component-xs; + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + + .ui-timeline__item { + padding-bottom: $semantic-spacing-content-line-tight; + } + + .ui-timeline__marker { + width: $semantic-sizing-icon-inline; + height: $semantic-sizing-icon-inline; + } + + .ui-timeline__content { + margin-left: $semantic-spacing-component-xs; + } + } + + &--md { + .ui-timeline__item { + padding-bottom: $semantic-spacing-component-sm; + } + + .ui-timeline__marker { + width: $semantic-sizing-icon-button; + height: $semantic-sizing-icon-button; + } + + .ui-timeline__content { + margin-left: $semantic-spacing-component-sm; + } + } + + &--lg { + padding: $semantic-spacing-component-lg; + font-family: map-get($semantic-typography-body-large, font-family); + font-size: map-get($semantic-typography-body-large, font-size); + font-weight: map-get($semantic-typography-body-large, font-weight); + line-height: map-get($semantic-typography-body-large, line-height); + + .ui-timeline__item { + padding-bottom: $semantic-spacing-component-md; + } + + .ui-timeline__marker { + width: $semantic-sizing-icon-navigation; + height: $semantic-sizing-icon-navigation; + } + + .ui-timeline__content { + margin-left: $semantic-spacing-component-md; + } + } + + // Timeline Item + &__item { + display: flex; + align-items: flex-start; + position: relative; + padding-bottom: $semantic-spacing-component-sm; + + &:not(:last-child) { + &::after { + content: ''; + position: absolute; + left: calc($semantic-sizing-icon-button / 2 - $semantic-border-width-1 / 2); + top: calc($semantic-sizing-icon-button + $semantic-spacing-content-line-tight); + width: $semantic-border-width-1; + height: calc(100% - $semantic-sizing-icon-button - $semantic-spacing-content-line-tight); + background: $semantic-color-border-secondary; + } + } + + &--completed { + .ui-timeline__marker { + background: $semantic-color-success; + border-color: $semantic-color-success; + + &::after { + content: '✓'; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: $semantic-color-on-success; + font-size: $semantic-typography-font-size-xs; + font-weight: $semantic-typography-font-weight-bold; + } + } + } + + &--active { + .ui-timeline__marker { + background: $semantic-color-primary; + border-color: $semantic-color-primary; + box-shadow: $semantic-shadow-elevation-2; + + &::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 50%; + height: 50%; + background: $semantic-color-on-primary; + border-radius: $semantic-border-radius-full; + transform: translate(-50%, -50%); + } + } + + .ui-timeline__content { + color: $semantic-color-text-primary; + } + + .ui-timeline__title { + color: $semantic-color-primary; + font-weight: $semantic-typography-font-weight-semibold; + } + } + + &--pending { + .ui-timeline__marker { + background: $semantic-color-surface-primary; + border-color: $semantic-color-border-secondary; + } + + .ui-timeline__content { + color: $semantic-color-text-secondary; + } + } + + &--error { + .ui-timeline__marker { + background: $semantic-color-danger; + border-color: $semantic-color-danger; + + &::after { + content: '×'; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: $semantic-color-on-danger; + font-size: $semantic-typography-font-size-sm; + font-weight: $semantic-typography-font-weight-bold; + } + } + + .ui-timeline__title { + color: $semantic-color-danger; + } + } + } + + // Timeline Marker + &__marker { + flex-shrink: 0; + width: $semantic-sizing-icon-button; + height: $semantic-sizing-icon-button; + border: $semantic-border-width-2 solid $semantic-color-border-primary; + border-radius: $semantic-border-radius-full; + background: $semantic-color-surface-primary; + position: relative; + z-index: 1; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + } + + // Timeline Content + &__content { + flex: 1; + margin-left: $semantic-spacing-component-sm; + // Remove padding-top for better alignment with marker center + } + + &__title { + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: $semantic-typography-font-weight-semibold; + line-height: map-get($semantic-typography-body-medium, line-height); + color: $semantic-color-text-primary; + margin-bottom: $semantic-spacing-content-line-tight; + } + + &__description { + color: $semantic-color-text-secondary; + margin-bottom: $semantic-spacing-content-line-tight; + } + + &__timestamp { + font-family: map-get($semantic-typography-caption, font-family); + font-size: map-get($semantic-typography-caption, font-size); + font-weight: map-get($semantic-typography-caption, font-weight); + line-height: map-get($semantic-typography-caption, line-height); + color: $semantic-color-text-tertiary; + } + + // Color Variants + &--primary { + .ui-timeline__item:not(.ui-timeline__item--completed):not(.ui-timeline__item--error) { + .ui-timeline__marker { + border-color: $semantic-color-primary; + } + + &.ui-timeline__item--active .ui-timeline__marker { + background: $semantic-color-primary; + } + } + } + + &--secondary { + .ui-timeline__item:not(.ui-timeline__item--completed):not(.ui-timeline__item--error) { + .ui-timeline__marker { + border-color: $semantic-color-secondary; + } + + &.ui-timeline__item--active .ui-timeline__marker { + background: $semantic-color-secondary; + } + + &.ui-timeline__item--active .ui-timeline__title { + color: $semantic-color-secondary; + } + } + } + + &--success { + .ui-timeline__item:not(.ui-timeline__item--completed):not(.ui-timeline__item--error) { + .ui-timeline__marker { + border-color: $semantic-color-success; + } + + &.ui-timeline__item--active .ui-timeline__marker { + background: $semantic-color-success; + } + + &.ui-timeline__item--active .ui-timeline__title { + color: $semantic-color-success; + } + } + } + + // Orientation Variants + &--horizontal { + flex-direction: row; + align-items: flex-start; + + .ui-timeline__item { + flex-direction: column; + align-items: center; + text-align: center; + padding-bottom: 0; + padding-right: $semantic-spacing-component-md; + + &:not(:last-child) { + &::after { + left: calc(100% - $semantic-spacing-component-md / 2); + top: calc($semantic-sizing-icon-button / 2 - $semantic-border-width-1 / 2); + width: $semantic-spacing-component-md; + height: $semantic-border-width-1; + } + } + } + + .ui-timeline__content { + margin-left: 0; + margin-top: $semantic-spacing-content-line-tight; + padding-top: 0; + } + } + + // Responsive Design + @media (max-width: ($semantic-breakpoint-md - 1)) { + padding: $semantic-spacing-component-sm; + + &--horizontal { + flex-direction: column; + + .ui-timeline__item { + flex-direction: row; + text-align: left; + padding-right: 0; + padding-bottom: $semantic-spacing-component-sm; + + &:not(:last-child) { + &::after { + left: calc($semantic-sizing-icon-button / 2 - $semantic-border-width-1 / 2); + top: calc($semantic-sizing-icon-button + $semantic-spacing-content-line-tight); + width: $semantic-border-width-1; + height: calc(100% - $semantic-sizing-icon-button - $semantic-spacing-content-line-tight); + } + } + } + + .ui-timeline__content { + margin-left: $semantic-spacing-component-sm; + margin-top: 0; + // Remove padding-top for better alignment + } + } + } + + @media (max-width: ($semantic-breakpoint-sm - 1)) { + padding: $semantic-spacing-component-xs; + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + } +} \ No newline at end of file diff --git a/src/lib/components/data-display/timeline/timeline.component.ts b/src/lib/components/data-display/timeline/timeline.component.ts new file mode 100644 index 0000000..8129d98 --- /dev/null +++ b/src/lib/components/data-display/timeline/timeline.component.ts @@ -0,0 +1,115 @@ +import { Component, Input, ChangeDetectionStrategy, ViewEncapsulation } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +export type TimelineSize = 'sm' | 'md' | 'lg'; +export type TimelineVariant = 'primary' | 'secondary' | 'success'; +export type TimelineOrientation = 'vertical' | 'horizontal'; +export type TimelineItemStatus = 'pending' | 'active' | 'completed' | 'error'; + +export interface TimelineItem { + id: string; + title: string; + description?: string; + timestamp?: string; + status: TimelineItemStatus; +} + +@Component({ + selector: 'ui-timeline', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ + @for (item of items; track item.id) { +
+ +
+
+ +
+ @if (item.title) { +
+ {{ item.title }} +
+ } + + @if (item.description) { +
+ {{ item.description }} +
+ } + + @if (item.timestamp) { +
+ {{ item.timestamp }} +
+ } +
+
+ } +
+ `, + styleUrl: './timeline.component.scss' +}) +export class TimelineComponent { + @Input() items: TimelineItem[] = []; + @Input() size: TimelineSize = 'md'; + @Input() variant: TimelineVariant = 'primary'; + @Input() orientation: TimelineOrientation = 'vertical'; + @Input() ariaLabel?: string; + + getComponentClasses(): string { + const classes = [ + 'ui-timeline', + `ui-timeline--${this.size}`, + `ui-timeline--${this.variant}`, + `ui-timeline--${this.orientation}` + ]; + + return classes.join(' '); + } + + getItemClasses(item: TimelineItem): string { + const classes = [ + 'ui-timeline__item', + `ui-timeline__item--${item.status}` + ]; + + return classes.join(' '); + } + + getItemAriaLabel(item: TimelineItem): string { + const status = this.getStatusLabel(item.status); + const title = item.title || 'Timeline item'; + const timestamp = item.timestamp ? `, ${item.timestamp}` : ''; + return `${title}, ${status}${timestamp}`; + } + + private getStatusLabel(status: TimelineItemStatus): string { + switch (status) { + case 'completed': + return 'completed'; + case 'active': + return 'in progress'; + case 'pending': + return 'pending'; + case 'error': + return 'error'; + default: + return status; + } + } +} \ No newline at end of file diff --git a/src/lib/components/data-display/tooltip/index.ts b/src/lib/components/data-display/tooltip/index.ts new file mode 100644 index 0000000..f48e8ff --- /dev/null +++ b/src/lib/components/data-display/tooltip/index.ts @@ -0,0 +1 @@ +export * from './tooltip.component'; \ No newline at end of file diff --git a/src/lib/components/data-display/tooltip/tooltip.component.scss b/src/lib/components/data-display/tooltip/tooltip.component.scss new file mode 100644 index 0000000..c34c954 --- /dev/null +++ b/src/lib/components/data-display/tooltip/tooltip.component.scss @@ -0,0 +1,189 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-tooltip-wrapper { + position: relative; + display: inline-block; +} + +.ui-tooltip-trigger { + display: inherit; + width: inherit; + height: inherit; +} + +.ui-tooltip { + position: absolute; + z-index: $semantic-z-index-tooltip; + background: $semantic-color-surface-elevated; + border: $semantic-border-width-1 solid $semantic-color-border-subtle; + border-radius: $semantic-border-radius-md; + box-shadow: $semantic-shadow-elevation-3; + pointer-events: none; + opacity: 1; + transform: scale(1); + animation: tooltip-fade-in $semantic-motion-duration-fast $semantic-easing-standard; + + // Size variants + &--sm { + max-width: 200px; + + .ui-tooltip__content { + padding: $semantic-spacing-component-xs; + font-size: $semantic-typography-font-size-xs; + } + } + + &--md { + max-width: 250px; + + .ui-tooltip__content { + padding: $semantic-spacing-component-sm; + font-size: $semantic-typography-font-size-sm; + } + } + + &--lg { + max-width: 300px; + + .ui-tooltip__content { + padding: $semantic-spacing-component-md; + font-size: $semantic-typography-font-size-sm; + } + } + + // Position variants + &--top { + bottom: 100%; + left: 50%; + transform: translateX(-50%) translateY(-8px); + margin-bottom: $semantic-spacing-component-xs; + + .ui-tooltip__arrow { + top: 100%; + left: 50%; + transform: translateX(-50%); + border-top-color: $semantic-color-surface-elevated; + border-bottom: none; + } + } + + &--bottom { + top: 100%; + left: 50%; + transform: translateX(-50%) translateY(8px); + margin-top: $semantic-spacing-component-xs; + + .ui-tooltip__arrow { + bottom: 100%; + left: 50%; + transform: translateX(-50%); + border-bottom-color: $semantic-color-surface-elevated; + border-top: none; + } + } + + &--left { + right: 100%; + top: 50%; + transform: translateY(-50%) translateX(-8px); + margin-right: $semantic-spacing-component-xs; + + .ui-tooltip__arrow { + left: 100%; + top: 50%; + transform: translateY(-50%); + border-left-color: $semantic-color-surface-elevated; + border-right: none; + } + } + + &--right { + left: 100%; + top: 50%; + transform: translateY(-50%) translateX(8px); + margin-left: $semantic-spacing-component-xs; + + .ui-tooltip__arrow { + right: 100%; + top: 50%; + transform: translateY(-50%); + border-right-color: $semantic-color-surface-elevated; + border-left: none; + } + } + + &__content { + color: $semantic-color-text-primary; + line-height: 1.4; + word-wrap: break-word; + text-align: center; + } + + &__arrow { + position: absolute; + width: 0; + height: 0; + border: 6px solid transparent; + + // Default arrow styling + border-top: 6px solid $semantic-color-surface-elevated; + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1)); + } + + // Dark mode support + :host-context(.dark-theme) & { + background: $semantic-color-surface-secondary; + border-color: $semantic-color-border-primary; + + .ui-tooltip__content { + color: $semantic-color-text-primary; + } + + .ui-tooltip__arrow { + border-top-color: $semantic-color-surface-secondary; + + &::after { + border-top-color: $semantic-color-surface-secondary; + } + } + + &--bottom .ui-tooltip__arrow { + border-bottom-color: $semantic-color-surface-secondary; + border-top: none; + } + + &--left .ui-tooltip__arrow { + border-left-color: $semantic-color-surface-secondary; + border-right: none; + } + + &--right .ui-tooltip__arrow { + border-right-color: $semantic-color-surface-secondary; + border-left: none; + } + } + + // Responsive adjustments + @media (max-width: $semantic-breakpoint-sm - 1) { + &--sm { max-width: 150px; } + &--md { max-width: 200px; } + &--lg { max-width: 250px; } + + .ui-tooltip__content { + padding: $semantic-spacing-component-xs; + font-size: $semantic-typography-font-size-xs; + } + } +} + +// Animation keyframes +@keyframes tooltip-fade-in { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} \ No newline at end of file diff --git a/src/lib/components/data-display/tooltip/tooltip.component.ts b/src/lib/components/data-display/tooltip/tooltip.component.ts new file mode 100644 index 0000000..62d03de --- /dev/null +++ b/src/lib/components/data-display/tooltip/tooltip.component.ts @@ -0,0 +1,152 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation, OnDestroy, ElementRef, ViewChild, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +type TooltipPosition = 'top' | 'bottom' | 'left' | 'right'; +type TooltipTrigger = 'hover' | 'click' | 'focus'; +type TooltipSize = 'sm' | 'md' | 'lg'; + +@Component({ + selector: 'ui-tooltip', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ + +
+ +
+ + + @if (isVisible()) { +
+ +
+ {{ text }} +
+ +
+
+ } +
+ `, + styleUrl: './tooltip.component.scss' +}) +export class TooltipComponent implements OnDestroy { + @ViewChild('tooltipElement') tooltipElement?: ElementRef; + + @Input() text = ''; + @Input() position: TooltipPosition = 'top'; + @Input() trigger: TooltipTrigger = 'hover'; + @Input() size: TooltipSize = 'md'; + @Input() disabled = false; + @Input() delay = 500; + + @Output() tooltipShow = new EventEmitter(); + @Output() tooltipHide = new EventEmitter(); + + protected isVisible = signal(false); + private showTimeout?: number; + private hideTimeout?: number; + + ngOnDestroy(): void { + this.clearTimeouts(); + } + + handleMouseEnter(): void { + if (this.trigger === 'hover' && !this.disabled) { + this.scheduleShow(); + } + } + + handleMouseLeave(): void { + if (this.trigger === 'hover') { + this.scheduleHide(); + } + } + + handleClick(event: MouseEvent): void { + if (this.trigger === 'click' && !this.disabled) { + event.preventDefault(); + this.toggle(); + } + } + + handleFocus(): void { + if (this.trigger === 'focus' && !this.disabled) { + this.show(); + } + } + + handleBlur(): void { + if (this.trigger === 'focus') { + this.hide(); + } + } + + private scheduleShow(): void { + this.clearTimeouts(); + this.showTimeout = window.setTimeout(() => { + this.show(); + }, this.delay); + } + + private scheduleHide(): void { + this.clearTimeouts(); + this.hideTimeout = window.setTimeout(() => { + this.hide(); + }, 100); + } + + private show(): void { + if (!this.disabled && !this.isVisible()) { + this.isVisible.set(true); + this.tooltipShow.emit(); + } + } + + private hide(): void { + if (this.isVisible()) { + this.isVisible.set(false); + this.tooltipHide.emit(); + } + } + + private toggle(): void { + if (this.isVisible()) { + this.hide(); + } else { + this.show(); + } + } + + private clearTimeouts(): void { + if (this.showTimeout) { + clearTimeout(this.showTimeout); + this.showTimeout = undefined; + } + if (this.hideTimeout) { + clearTimeout(this.hideTimeout); + this.hideTimeout = undefined; + } + } +} \ No newline at end of file diff --git a/src/lib/components/data-display/transfer-list/index.ts b/src/lib/components/data-display/transfer-list/index.ts new file mode 100644 index 0000000..00a4138 --- /dev/null +++ b/src/lib/components/data-display/transfer-list/index.ts @@ -0,0 +1 @@ +export * from './transfer-list.component'; \ No newline at end of file diff --git a/src/lib/components/data-display/transfer-list/transfer-list.component.scss b/src/lib/components/data-display/transfer-list/transfer-list.component.scss new file mode 100644 index 0000000..f7be166 --- /dev/null +++ b/src/lib/components/data-display/transfer-list/transfer-list.component.scss @@ -0,0 +1,481 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-transfer-list { + display: flex; + align-items: flex-start; + gap: $semantic-spacing-component-lg; + width: 100%; + + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + + &--sm { + gap: $semantic-spacing-component-sm; + + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + } + + &--lg { + gap: $semantic-spacing-component-xl; + + font-family: map-get($semantic-typography-body-large, font-family); + font-size: map-get($semantic-typography-body-large, font-size); + font-weight: map-get($semantic-typography-body-large, font-weight); + line-height: map-get($semantic-typography-body-large, line-height); + } + + &--disabled { + opacity: $semantic-opacity-disabled; + pointer-events: none; + } + + // Transfer List Panel + &__panel { + flex: 1; + display: flex; + flex-direction: column; + background: $semantic-color-surface-primary; + border: $semantic-border-width-1 solid $semantic-color-border-primary; + border-radius: $semantic-border-card-radius; + box-shadow: $semantic-shadow-elevation-1; + min-width: 0; + + &--source { + order: 1; + } + + &--target { + order: 3; + } + } + + // Panel Header + &__header { + padding: $semantic-spacing-component-md; + border-bottom: $semantic-border-width-1 solid $semantic-color-border-secondary; + background: $semantic-color-surface-secondary; + border-radius: $semantic-border-card-radius $semantic-border-card-radius 0 0; + } + + &__title { + margin: 0 0 $semantic-spacing-component-sm 0; + + font-family: map-get($semantic-typography-heading-h4, font-family); + font-size: map-get($semantic-typography-heading-h4, font-size); + font-weight: map-get($semantic-typography-heading-h4, font-weight); + line-height: map-get($semantic-typography-heading-h4, line-height); + color: $semantic-color-text-primary; + } + + // Search + &__search { + position: relative; + margin-bottom: $semantic-spacing-component-sm; + } + + &__search-icon { + position: absolute; + left: $semantic-spacing-interactive-input-padding-x; + top: 50%; + transform: translateY(-50%); + color: $semantic-color-text-tertiary; + z-index: 1; + } + + &__search-input { + width: 100%; + height: $semantic-sizing-input-height-md; + padding: $semantic-spacing-interactive-input-padding-y + calc($semantic-spacing-interactive-input-padding-x * 3) + $semantic-spacing-interactive-input-padding-y + calc($semantic-spacing-interactive-input-padding-x * 2.5); + box-sizing: border-box; + + background: $semantic-color-surface-primary; + border: $semantic-border-width-1 solid $semantic-color-border-primary; + border-radius: $semantic-border-input-radius; + + font-family: map-get($semantic-typography-input, font-family); + font-size: map-get($semantic-typography-input, font-size); + font-weight: map-get($semantic-typography-input, font-weight); + line-height: map-get($semantic-typography-input, line-height); + color: $semantic-color-text-primary; + + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + + &::placeholder { + color: $semantic-color-text-tertiary; + } + + &:focus { + outline: none; + border-color: $semantic-color-border-focus; + box-shadow: 0 0 0 2px $semantic-color-focus; + } + + &:disabled { + background: $semantic-color-surface-secondary; + color: $semantic-color-text-disabled; + cursor: not-allowed; + } + } + + &__search-clear { + position: absolute; + right: $semantic-spacing-interactive-input-padding-x; + top: 50%; + transform: translateY(-50%); + + width: $semantic-sizing-touch-minimum; + height: $semantic-sizing-touch-minimum; + padding: 0; + + background: transparent; + border: none; + border-radius: $semantic-border-radius-sm; + + color: $semantic-color-text-secondary; + cursor: pointer; + + display: flex; + align-items: center; + justify-content: center; + + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + + &:hover:not(:disabled) { + background: $semantic-color-surface-secondary; + color: $semantic-color-text-primary; + } + + &:focus { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + + &:disabled { + color: $semantic-color-text-disabled; + cursor: not-allowed; + } + } + + // Select All + &__select-all { + display: flex; + align-items: center; + gap: $semantic-spacing-component-xs; + cursor: pointer; + + font-family: map-get($semantic-typography-label, font-family); + font-size: map-get($semantic-typography-label, font-size); + font-weight: map-get($semantic-typography-label, font-weight); + line-height: map-get($semantic-typography-label, line-height); + color: $semantic-color-text-secondary; + + input[type="checkbox"] { + margin: 0; + } + + &:hover { + color: $semantic-color-text-primary; + } + } + + // List Container + &__list-container { + flex: 1; + overflow: hidden; + display: flex; + flex-direction: column; + } + + &__list { + flex: 1; + overflow-y: auto; + padding: $semantic-spacing-component-xs; + min-height: 200px; + max-height: 400px; + + &:focus { + outline: 2px solid $semantic-color-focus; + outline-offset: -2px; + } + } + + // List Items + &__item { + display: flex; + align-items: center; + gap: $semantic-spacing-component-xs; + padding: $semantic-spacing-component-sm; + margin-bottom: $semantic-spacing-component-xs; + + background: $semantic-color-surface-primary; + border: $semantic-border-width-1 solid transparent; + border-radius: $semantic-border-radius-sm; + + cursor: pointer; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + + &:last-child { + margin-bottom: 0; + } + + &:hover:not(&--disabled) { + background: $semantic-color-surface-secondary; + border-color: $semantic-color-border-secondary; + } + + &:focus { + outline: 2px solid $semantic-color-focus; + outline-offset: -2px; + } + + &--selected { + background: $semantic-color-surface-elevated; + border-color: $semantic-color-primary; + color: $semantic-color-text-primary; + } + + &--disabled { + opacity: $semantic-opacity-disabled; + cursor: not-allowed; + } + } + + &__checkbox { + margin: 0; + flex-shrink: 0; + } + + &__item-label { + flex: 1; + min-width: 0; + word-wrap: break-word; + } + + &__empty { + padding: $semantic-spacing-component-lg; + text-align: center; + color: $semantic-color-text-tertiary; + + font-family: map-get($semantic-typography-caption, font-family); + font-size: map-get($semantic-typography-caption, font-size); + font-weight: map-get($semantic-typography-caption, font-weight); + line-height: map-get($semantic-typography-caption, line-height); + } + + // Controls + &__controls { + order: 2; + display: flex; + flex-direction: column; + gap: $semantic-spacing-component-sm; + align-items: center; + justify-content: center; + padding: $semantic-spacing-component-md 0; + flex-shrink: 0; + } + + &__control-btn { + width: $semantic-sizing-button-height-md; + height: $semantic-sizing-button-height-md; + padding: 0; + + background: $semantic-color-primary; + border: $semantic-border-width-1 solid $semantic-color-primary; + border-radius: $semantic-border-button-radius; + + color: $semantic-color-on-primary; + cursor: pointer; + + display: flex; + align-items: center; + justify-content: center; + + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + box-shadow: $semantic-shadow-button-rest; + + &:hover:not(:disabled) { + box-shadow: $semantic-shadow-button-hover; + transform: translateY(-1px); + } + + &:focus { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + + &:active:not(:disabled) { + transform: translateY(0); + box-shadow: $semantic-shadow-button-rest; + } + + &:disabled { + background: $semantic-color-surface-secondary; + border-color: $semantic-color-border-secondary; + color: $semantic-color-text-disabled; + cursor: not-allowed; + box-shadow: none; + } + } + + // Footer + &__footer { + padding: $semantic-spacing-component-sm $semantic-spacing-component-md; + border-top: $semantic-border-width-1 solid $semantic-color-border-secondary; + background: $semantic-color-surface-secondary; + + font-family: map-get($semantic-typography-caption, font-family); + font-size: map-get($semantic-typography-caption, font-size); + font-weight: map-get($semantic-typography-caption, font-weight); + line-height: map-get($semantic-typography-caption, line-height); + color: $semantic-color-text-secondary; + + text-align: center; + } + + // Size Variants + &--sm { + .ui-transfer-list__panel { + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + } + + .ui-transfer-list__header { + padding: $semantic-spacing-component-sm; + } + + .ui-transfer-list__title { + font-family: map-get($semantic-typography-heading-h5, font-family); + font-size: map-get($semantic-typography-heading-h5, font-size); + font-weight: map-get($semantic-typography-heading-h5, font-weight); + line-height: map-get($semantic-typography-heading-h5, line-height); + } + + .ui-transfer-list__search-input { + height: $semantic-sizing-input-height-sm; + } + + .ui-transfer-list__item { + padding: $semantic-spacing-component-xs; + } + + .ui-transfer-list__control-btn { + width: $semantic-sizing-button-height-sm; + height: $semantic-sizing-button-height-sm; + } + + .ui-transfer-list__footer { + padding: $semantic-spacing-component-xs $semantic-spacing-component-sm; + } + } + + &--lg { + .ui-transfer-list__panel { + font-family: map-get($semantic-typography-body-large, font-family); + font-size: map-get($semantic-typography-body-large, font-size); + font-weight: map-get($semantic-typography-body-large, font-weight); + line-height: map-get($semantic-typography-body-large, line-height); + } + + .ui-transfer-list__header { + padding: $semantic-spacing-component-lg; + } + + .ui-transfer-list__title { + font-family: map-get($semantic-typography-heading-h3, font-family); + font-size: map-get($semantic-typography-heading-h3, font-size); + font-weight: map-get($semantic-typography-heading-h3, font-weight); + line-height: map-get($semantic-typography-heading-h3, line-height); + } + + .ui-transfer-list__search-input { + height: $semantic-sizing-input-height-lg; + } + + .ui-transfer-list__item { + padding: $semantic-spacing-component-md; + } + + .ui-transfer-list__control-btn { + width: $semantic-sizing-button-height-lg; + height: $semantic-sizing-button-height-lg; + } + + .ui-transfer-list__footer { + padding: $semantic-spacing-component-md $semantic-spacing-component-lg; + } + } + + // Responsive Design + @media (max-width: 768px) { + flex-direction: column; + gap: $semantic-spacing-component-md; + + .ui-transfer-list__controls { + order: 2; + flex-direction: row; + padding: $semantic-spacing-component-sm; + } + + .ui-transfer-list__panel { + &--source { + order: 1; + } + + &--target { + order: 3; + } + } + + .ui-transfer-list__list { + max-height: 300px; + } + } + + @media (max-width: 480px) { + gap: $semantic-spacing-component-sm; + + .ui-transfer-list__header { + padding: $semantic-spacing-component-sm; + } + + .ui-transfer-list__title { + font-family: map-get($semantic-typography-heading-h5, font-family); + font-size: map-get($semantic-typography-heading-h5, font-size); + font-weight: map-get($semantic-typography-heading-h5, font-weight); + line-height: map-get($semantic-typography-heading-h5, line-height); + } + + .ui-transfer-list__search-input { + height: $semantic-sizing-input-height-sm; + padding: $semantic-spacing-interactive-input-padding-y + calc($semantic-spacing-interactive-input-padding-x * 2.5) + $semantic-spacing-interactive-input-padding-y + calc($semantic-spacing-interactive-input-padding-x * 2); + box-sizing: border-box; + } + + .ui-transfer-list__item { + padding: $semantic-spacing-component-xs; + } + + .ui-transfer-list__control-btn { + width: $semantic-sizing-button-height-sm; + height: $semantic-sizing-button-height-sm; + } + + .ui-transfer-list__list { + max-height: 250px; + min-height: 150px; + } + } +} \ No newline at end of file diff --git a/src/lib/components/data-display/transfer-list/transfer-list.component.ts b/src/lib/components/data-display/transfer-list/transfer-list.component.ts new file mode 100644 index 0000000..5e5dcc5 --- /dev/null +++ b/src/lib/components/data-display/transfer-list/transfer-list.component.ts @@ -0,0 +1,629 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation, signal, computed } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { + faChevronRight, + faChevronLeft, + faAnglesRight, + faAnglesLeft, + faSearch, + faTimes +} from '@fortawesome/free-solid-svg-icons'; + +export interface TransferListItem { + id: string | number; + label: string; + disabled?: boolean; + data?: any; +} + +export type TransferListSize = 'sm' | 'md' | 'lg'; + +@Component({ + selector: 'ui-transfer-list', + standalone: true, + imports: [CommonModule, FormsModule, FontAwesomeModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ +
+
+

{{ sourceTitle }}

+ @if (showSearch) { + + } + @if (showSelectAll) { + + } +
+ +
+
+ @if (filteredSourceItems().length === 0) { +
+ @if (sourceSearchTerm()) { + No items match your search + } @else { + No items available + } +
+ } @else { + @for (item of filteredSourceItems(); track item.id) { +
+ @if (showCheckboxes) { + + } + {{ item.label }} +
+ } + } +
+
+ + +
+ + +
+ + + @if (showMoveAll) { + + } + + @if (showMoveAll) { + + } + + +
+ + +
+
+

{{ targetTitle }}

+ @if (showSearch) { + + } + @if (showSelectAll) { + + } +
+ +
+
+ @if (filteredTargetItems().length === 0) { +
+ @if (targetSearchTerm()) { + No items match your search + } @else { + No items selected + } +
+ } @else { + @for (item of filteredTargetItems(); track item.id) { +
+ @if (showCheckboxes) { + + } + {{ item.label }} +
+ } + } +
+
+ + +
+
+ `, + styleUrl: './transfer-list.component.scss' +}) +export class TransferListComponent { + // FontAwesome icons + faChevronRight = faChevronRight; + faChevronLeft = faChevronLeft; + faAnglesRight = faAnglesRight; + faAnglesLeft = faAnglesLeft; + faSearch = faSearch; + faTimes = faTimes; + + // Component inputs + @Input() sourceItems: TransferListItem[] = []; + @Input() targetItems: TransferListItem[] = []; + @Input() sourceTitle = 'Available'; + @Input() targetTitle = 'Selected'; + @Input() size: TransferListSize = 'md'; + @Input() disabled = false; + @Input() showSearch = true; + @Input() showSelectAll = true; + @Input() showCheckboxes = true; + @Input() showMoveAll = true; + @Input() searchPlaceholder = 'Search items...'; + @Input() ariaLabel?: string; + + // Component outputs + @Output() sourceChange = new EventEmitter(); + @Output() targetChange = new EventEmitter(); + @Output() itemMove = new EventEmitter<{ + item: TransferListItem; + from: 'source' | 'target'; + to: 'source' | 'target'; + }>(); + + // Internal signals for reactive state management + private sourceItemsSignal = signal([]); + private targetItemsSignal = signal([]); + private selectedSourceIds = signal>(new Set()); + private selectedTargetIds = signal>(new Set()); + + // Search terms + sourceSearchTerm = signal(''); + targetSearchTerm = signal(''); + + // Computed filtered items + filteredSourceItems = computed(() => { + const items = this.sourceItemsSignal(); + const searchTerm = this.sourceSearchTerm().toLowerCase().trim(); + + if (!searchTerm) return items; + + return items.filter(item => + item.label.toLowerCase().includes(searchTerm) + ); + }); + + filteredTargetItems = computed(() => { + const items = this.targetItemsSignal(); + const searchTerm = this.targetSearchTerm().toLowerCase().trim(); + + if (!searchTerm) return items; + + return items.filter(item => + item.label.toLowerCase().includes(searchTerm) + ); + }); + + // Computed selected items + selectedSourceItems = computed(() => { + const selectedIds = this.selectedSourceIds(); + return this.sourceItemsSignal().filter(item => selectedIds.has(item.id)); + }); + + selectedTargetItems = computed(() => { + const selectedIds = this.selectedTargetIds(); + return this.targetItemsSignal().filter(item => selectedIds.has(item.id)); + }); + + ngOnInit() { + this.sourceItemsSignal.set([...this.sourceItems]); + this.targetItemsSignal.set([...this.targetItems]); + } + + ngOnChanges() { + this.sourceItemsSignal.set([...this.sourceItems]); + this.targetItemsSignal.set([...this.targetItems]); + } + + // Search methods + updateSourceSearch() { + // Clear selection when searching to avoid confusion + this.selectedSourceIds.set(new Set()); + } + + updateTargetSearch() { + // Clear selection when searching to avoid confusion + this.selectedTargetIds.set(new Set()); + } + + clearSourceSearch() { + this.sourceSearchTerm.set(''); + this.selectedSourceIds.set(new Set()); + } + + clearTargetSearch() { + this.targetSearchTerm.set(''); + this.selectedTargetIds.set(new Set()); + } + + // Selection methods + isSourceItemSelected(id: string | number): boolean { + return this.selectedSourceIds().has(id); + } + + isTargetItemSelected(id: string | number): boolean { + return this.selectedTargetIds().has(id); + } + + toggleSourceItem(item: TransferListItem) { + if (this.disabled || item.disabled) return; + + const currentSelected = new Set(this.selectedSourceIds()); + if (currentSelected.has(item.id)) { + currentSelected.delete(item.id); + } else { + currentSelected.add(item.id); + } + this.selectedSourceIds.set(currentSelected); + } + + toggleTargetItem(item: TransferListItem) { + if (this.disabled || item.disabled) return; + + const currentSelected = new Set(this.selectedTargetIds()); + if (currentSelected.has(item.id)) { + currentSelected.delete(item.id); + } else { + currentSelected.add(item.id); + } + this.selectedTargetIds.set(currentSelected); + } + + // Select all methods + getSourceSelectedCount(): number { + return this.selectedSourceIds().size; + } + + getTargetSelectedCount(): number { + return this.selectedTargetIds().size; + } + + isAllSourceSelected(): boolean { + const availableItems = this.filteredSourceItems().filter(item => !item.disabled); + return availableItems.length > 0 && + availableItems.every(item => this.selectedSourceIds().has(item.id)); + } + + isAllTargetSelected(): boolean { + const availableItems = this.filteredTargetItems().filter(item => !item.disabled); + return availableItems.length > 0 && + availableItems.every(item => this.selectedTargetIds().has(item.id)); + } + + isSourceIndeterminate(): boolean { + const availableItems = this.filteredSourceItems().filter(item => !item.disabled); + const selectedCount = availableItems.filter(item => this.selectedSourceIds().has(item.id)).length; + return selectedCount > 0 && selectedCount < availableItems.length; + } + + isTargetIndeterminate(): boolean { + const availableItems = this.filteredTargetItems().filter(item => !item.disabled); + const selectedCount = availableItems.filter(item => this.selectedTargetIds().has(item.id)).length; + return selectedCount > 0 && selectedCount < availableItems.length; + } + + toggleAllSource(event: Event) { + if (this.disabled) return; + + const target = event.target as HTMLInputElement; + const availableItems = this.filteredSourceItems().filter(item => !item.disabled); + + if (target.checked) { + const newSelected = new Set(this.selectedSourceIds()); + availableItems.forEach(item => newSelected.add(item.id)); + this.selectedSourceIds.set(newSelected); + } else { + const newSelected = new Set(this.selectedSourceIds()); + availableItems.forEach(item => newSelected.delete(item.id)); + this.selectedSourceIds.set(newSelected); + } + } + + toggleAllTarget(event: Event) { + if (this.disabled) return; + + const target = event.target as HTMLInputElement; + const availableItems = this.filteredTargetItems().filter(item => !item.disabled); + + if (target.checked) { + const newSelected = new Set(this.selectedTargetIds()); + availableItems.forEach(item => newSelected.add(item.id)); + this.selectedTargetIds.set(newSelected); + } else { + const newSelected = new Set(this.selectedTargetIds()); + availableItems.forEach(item => newSelected.delete(item.id)); + this.selectedTargetIds.set(newSelected); + } + } + + // Transfer methods + moveSelectedToTarget() { + if (this.disabled) return; + + const itemsToMove = this.selectedSourceItems(); + if (itemsToMove.length === 0) return; + + const newSourceItems = this.sourceItemsSignal().filter( + item => !this.selectedSourceIds().has(item.id) + ); + const newTargetItems = [...this.targetItemsSignal(), ...itemsToMove]; + + this.sourceItemsSignal.set(newSourceItems); + this.targetItemsSignal.set(newTargetItems); + this.selectedSourceIds.set(new Set()); + + // Emit events + this.sourceChange.emit(newSourceItems); + this.targetChange.emit(newTargetItems); + + itemsToMove.forEach(item => { + this.itemMove.emit({ item, from: 'source', to: 'target' }); + }); + } + + moveSelectedToSource() { + if (this.disabled) return; + + const itemsToMove = this.selectedTargetItems(); + if (itemsToMove.length === 0) return; + + const newTargetItems = this.targetItemsSignal().filter( + item => !this.selectedTargetIds().has(item.id) + ); + const newSourceItems = [...this.sourceItemsSignal(), ...itemsToMove]; + + this.sourceItemsSignal.set(newSourceItems); + this.targetItemsSignal.set(newTargetItems); + this.selectedTargetIds.set(new Set()); + + // Emit events + this.sourceChange.emit(newSourceItems); + this.targetChange.emit(newTargetItems); + + itemsToMove.forEach(item => { + this.itemMove.emit({ item, from: 'target', to: 'source' }); + }); + } + + moveAllToTarget() { + if (this.disabled) return; + + const itemsToMove = this.filteredSourceItems().filter(item => !item.disabled); + if (itemsToMove.length === 0) return; + + const newSourceItems = this.sourceItemsSignal().filter(item => item.disabled); + const newTargetItems = [...this.targetItemsSignal(), ...itemsToMove]; + + this.sourceItemsSignal.set(newSourceItems); + this.targetItemsSignal.set(newTargetItems); + this.selectedSourceIds.set(new Set()); + + // Emit events + this.sourceChange.emit(newSourceItems); + this.targetChange.emit(newTargetItems); + + itemsToMove.forEach(item => { + this.itemMove.emit({ item, from: 'source', to: 'target' }); + }); + } + + moveAllToSource() { + if (this.disabled) return; + + const itemsToMove = this.filteredTargetItems().filter(item => !item.disabled); + if (itemsToMove.length === 0) return; + + const newTargetItems = this.targetItemsSignal().filter(item => item.disabled); + const newSourceItems = [...this.sourceItemsSignal(), ...itemsToMove]; + + this.sourceItemsSignal.set(newSourceItems); + this.targetItemsSignal.set(newTargetItems); + this.selectedTargetIds.set(new Set()); + + // Emit events + this.sourceChange.emit(newSourceItems); + this.targetChange.emit(newTargetItems); + + itemsToMove.forEach(item => { + this.itemMove.emit({ item, from: 'target', to: 'source' }); + }); + } + + // Keyboard navigation + handleSourceKeydown(event: KeyboardEvent) { + this.handleListKeydown(event, this.filteredSourceItems(), 'source'); + } + + handleTargetKeydown(event: KeyboardEvent) { + this.handleListKeydown(event, this.filteredTargetItems(), 'target'); + } + + handleItemKeydown(event: KeyboardEvent, item: TransferListItem, list: 'source' | 'target') { + if (event.key === ' ' || event.key === 'Enter') { + event.preventDefault(); + if (list === 'source') { + this.toggleSourceItem(item); + } else { + this.toggleTargetItem(item); + } + } + } + + private handleListKeydown(event: KeyboardEvent, items: TransferListItem[], list: 'source' | 'target') { + const currentFocus = document.activeElement as HTMLElement; + const listItems = Array.from(currentFocus.parentElement?.querySelectorAll('.ui-transfer-list__item[tabindex="0"]') || []) as HTMLElement[]; + + if (listItems.length === 0) return; + + const currentIndex = listItems.indexOf(currentFocus); + let nextIndex = currentIndex; + + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + nextIndex = Math.min(currentIndex + 1, listItems.length - 1); + break; + case 'ArrowUp': + event.preventDefault(); + nextIndex = Math.max(currentIndex - 1, 0); + break; + case 'Home': + event.preventDefault(); + nextIndex = 0; + break; + case 'End': + event.preventDefault(); + nextIndex = listItems.length - 1; + break; + } + + if (nextIndex !== currentIndex) { + listItems[nextIndex]?.focus(); + } + } +} \ No newline at end of file diff --git a/src/lib/components/data-display/tree-view/index.ts b/src/lib/components/data-display/tree-view/index.ts new file mode 100644 index 0000000..ae3ea06 --- /dev/null +++ b/src/lib/components/data-display/tree-view/index.ts @@ -0,0 +1 @@ +export * from './tree-view.component'; \ No newline at end of file diff --git a/src/lib/components/data-display/tree-view/tree-view.component.scss b/src/lib/components/data-display/tree-view/tree-view.component.scss new file mode 100644 index 0000000..653f69c --- /dev/null +++ b/src/lib/components/data-display/tree-view/tree-view.component.scss @@ -0,0 +1,225 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-tree-view { + // Core Structure + display: block; + position: relative; + + // Layout & Spacing + padding: $semantic-spacing-component-sm; + + // Visual Design + background: $semantic-color-surface-primary; + border: $semantic-border-width-1 solid $semantic-color-border-subtle; + border-radius: $semantic-border-card-radius; + + // Typography + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + color: $semantic-color-text-primary; + + // Size Variants + &--sm { + padding: $semantic-spacing-component-xs; + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + } + + &--md { + padding: $semantic-spacing-component-sm; + // Typography already set above for medium + } + + &--lg { + padding: $semantic-spacing-component-md; + font-family: map-get($semantic-typography-body-large, font-family); + font-size: map-get($semantic-typography-body-large, font-size); + font-weight: map-get($semantic-typography-body-large, font-weight); + line-height: map-get($semantic-typography-body-large, line-height); + } + + // Color Variants + &--primary { + border-color: $semantic-color-primary; + + .ui-tree-view__node--selected { + background: $semantic-color-primary; + color: $semantic-color-on-primary; + } + } + + &--secondary { + border-color: $semantic-color-secondary; + + .ui-tree-view__node--selected { + background: $semantic-color-secondary; + color: $semantic-color-on-secondary; + } + } + + // Tree Node + &__node { + display: flex; + align-items: center; + padding: $semantic-spacing-content-line-tight; + margin-bottom: 2px; + cursor: pointer; + border-radius: $semantic-border-radius-sm; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + + // Node states + &:hover { + background: $semantic-color-surface-secondary; + } + + &--selected { + background: $semantic-color-surface-elevated; + color: $semantic-color-text-primary; + font-weight: $semantic-typography-font-weight-medium; + } + + &--disabled { + opacity: $semantic-opacity-disabled; + cursor: not-allowed; + pointer-events: none; + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + } + + // Tree Node Content + &__node-content { + display: flex; + align-items: center; + flex: 1; + min-width: 0; // Allow text truncation + } + + // Expand/Collapse Button + &__expand-button { + display: flex; + align-items: center; + justify-content: center; + width: $semantic-sizing-touch-minimum; + height: $semantic-sizing-touch-minimum; + margin-right: $semantic-spacing-component-xs; + border: none; + background: transparent; + border-radius: $semantic-border-radius-sm; + cursor: pointer; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + + &:hover { + background: $semantic-color-surface-secondary; + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + + &--expanded { + transform: rotate(90deg); + } + + &--leaf { + cursor: default; + color: $semantic-color-text-secondary; + font-size: $semantic-typography-font-size-lg; + + &:hover { + background: transparent; + } + } + } + + // Node Icon + &__node-icon { + width: $semantic-sizing-icon-inline; + height: $semantic-sizing-icon-inline; + margin-right: $semantic-spacing-component-xs; + color: $semantic-color-text-secondary; + + .ui-tree-view__node--selected & { + color: inherit; + } + } + + // Node Label + &__node-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + // Children Container + &__children { + margin-left: $semantic-spacing-layout-section-sm; + border-left: $semantic-border-width-1 solid $semantic-color-border-subtle; + padding-left: $semantic-spacing-component-sm; + + &--hidden { + display: none; + } + } + + // Loading State + &__loading { + display: flex; + align-items: center; + justify-content: center; + padding: $semantic-spacing-component-md; + color: $semantic-color-text-secondary; + } + + // Empty State + &__empty { + display: flex; + align-items: center; + justify-content: center; + padding: $semantic-spacing-component-lg; + color: $semantic-color-text-secondary; + font-style: italic; + } + + // Root level adjustments + & > .ui-tree-view__node { + &:first-child { + margin-top: 0; + } + + &:last-child { + margin-bottom: 0; + } + } + + // Responsive Design + @media (max-width: ($semantic-breakpoint-md - 1)) { + padding: $semantic-spacing-component-xs; + + .ui-tree-view__children { + margin-left: $semantic-spacing-component-md; + padding-left: $semantic-spacing-component-xs; + } + } + + @media (max-width: ($semantic-breakpoint-sm - 1)) { + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + + .ui-tree-view__node { + padding: $semantic-spacing-content-line-tight $semantic-spacing-component-xs; + } + } +} \ No newline at end of file diff --git a/src/lib/components/data-display/tree-view/tree-view.component.ts b/src/lib/components/data-display/tree-view/tree-view.component.ts new file mode 100644 index 0000000..765a90f --- /dev/null +++ b/src/lib/components/data-display/tree-view/tree-view.component.ts @@ -0,0 +1,297 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +export interface TreeNode { + id: string; + label: string; + icon?: string; + children?: TreeNode[]; + expanded?: boolean; + selected?: boolean; + disabled?: boolean; + data?: any; +} + +type TreeViewSize = 'sm' | 'md' | 'lg'; +type TreeViewVariant = 'primary' | 'secondary'; + +@Component({ + selector: 'ui-tree-node', + standalone: true, + imports: [CommonModule, TreeNodeComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ + @if (hasChildren) { + + } @else { +
+ • +
+ } + +
+ @if (showIcons && node.icon) { + + {{ node.icon }} + + } + + + {{ node.label }} + +
+
+ + @if (hasChildren && node.expanded) { +
+ @for (childNode of node.children; track childNode.id) { + + + } +
+ } + ` +}) +export class TreeNodeComponent { + @Input() node!: TreeNode; + @Input() level = 0; + @Input() showIcons = true; + @Input() multiSelect = false; + + @Output() nodeToggled = new EventEmitter<{ node: TreeNode; expanded: boolean }>(); + @Output() nodeSelected = new EventEmitter<{ node: TreeNode; selected: boolean }>(); + + get hasChildren(): boolean { + return !!(this.node.children && this.node.children.length > 0); + } + + handleNodeClick(): void { + if (!this.node.disabled) { + this.nodeSelected.emit({ + node: this.node, + selected: !this.node.selected + }); + } + } + + handleToggleClick(event: Event): void { + event.stopPropagation(); + if (!this.node.disabled && this.hasChildren) { + this.nodeToggled.emit({ + node: this.node, + expanded: !this.node.expanded + }); + } + } + + handleKeydown(event: KeyboardEvent): void { + if (this.node.disabled) return; + + switch (event.key) { + case 'Enter': + case ' ': + event.preventDefault(); + this.handleNodeClick(); + break; + case 'ArrowRight': + if (this.hasChildren && !this.node.expanded) { + event.preventDefault(); + this.handleToggleClick(event); + } + break; + case 'ArrowLeft': + if (this.hasChildren && this.node.expanded) { + event.preventDefault(); + this.handleToggleClick(event); + } + break; + } + } +} + +@Component({ + selector: 'ui-tree-view', + standalone: true, + imports: [CommonModule, TreeNodeComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ + @if (loading) { +
+ Loading... +
+ } @else if (!nodes || nodes.length === 0) { +
+ {{ emptyMessage }} +
+ } @else { + @for (node of nodes; track node.id) { +
+ @if (renderTreeNode) { + + + } @else { + + + } +
+ } + } +
+ `, + styleUrl: './tree-view.component.scss' +}) +export class TreeViewComponent { + @Input() nodes: TreeNode[] = []; + @Input() size: TreeViewSize = 'md'; + @Input() variant: TreeViewVariant = 'primary'; + @Input() loading = false; + @Input() showIcons = true; + @Input() multiSelect = false; + @Input() expandAll = false; + @Input() emptyMessage = 'No items to display'; + @Input() ariaLabel = 'Tree view'; + @Input() renderTreeNode?: any; // Custom template reference + + @Output() nodeToggled = new EventEmitter<{ node: TreeNode; expanded: boolean }>(); + @Output() nodeSelected = new EventEmitter<{ node: TreeNode; selected: boolean }>(); + @Output() nodesChanged = new EventEmitter(); + + handleToggle = (event: { node: TreeNode; expanded: boolean }): void => { + event.node.expanded = event.expanded; + this.nodeToggled.emit(event); + this.nodesChanged.emit(this.nodes); + }; + + handleSelect = (event: { node: TreeNode; selected: boolean }): void => { + if (!this.multiSelect) { + // Single select - clear all other selections + this.clearAllSelections(this.nodes); + } + + event.node.selected = event.selected; + this.nodeSelected.emit(event); + this.nodesChanged.emit(this.nodes); + }; + + private clearAllSelections(nodes: TreeNode[]): void { + for (const node of nodes) { + node.selected = false; + if (node.children) { + this.clearAllSelections(node.children); + } + } + } + + // Public API methods + expandAllNodes(): void { + this.setAllExpanded(this.nodes, true); + this.nodesChanged.emit(this.nodes); + } + + collapseAllNodes(): void { + this.setAllExpanded(this.nodes, false); + this.nodesChanged.emit(this.nodes); + } + + getSelectedNodes(): TreeNode[] { + return this.findSelectedNodes(this.nodes); + } + + selectNode(nodeId: string): void { + const node = this.findNodeById(this.nodes, nodeId); + if (node && !node.disabled) { + this.handleSelect({ node, selected: true }); + } + } + + private setAllExpanded(nodes: TreeNode[], expanded: boolean): void { + for (const node of nodes) { + if (node.children && node.children.length > 0) { + node.expanded = expanded; + this.setAllExpanded(node.children, expanded); + } + } + } + + private findSelectedNodes(nodes: TreeNode[]): TreeNode[] { + const selected: TreeNode[] = []; + for (const node of nodes) { + if (node.selected) { + selected.push(node); + } + if (node.children) { + selected.push(...this.findSelectedNodes(node.children)); + } + } + return selected; + } + + private findNodeById(nodes: TreeNode[], id: string): TreeNode | null { + for (const node of nodes) { + if (node.id === id) { + return node; + } + if (node.children) { + const found = this.findNodeById(node.children, id); + if (found) { + return found; + } + } + } + return null; + } +} \ No newline at end of file diff --git a/src/lib/components/feedback/alert/alert.component.scss b/src/lib/components/feedback/alert/alert.component.scss new file mode 100644 index 0000000..05800c3 --- /dev/null +++ b/src/lib/components/feedback/alert/alert.component.scss @@ -0,0 +1,221 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-alert { + // Core Structure + display: flex; + position: relative; + align-items: flex-start; + + // Layout & Spacing + padding: $semantic-spacing-component-md; + margin-bottom: $semantic-spacing-component-sm; + gap: 16px; + + // Visual Design + background: $semantic-color-surface-elevated; + border: $semantic-border-width-1 solid $semantic-color-border-subtle; + border-radius: $semantic-border-card-radius; + box-shadow: $semantic-shadow-elevation-1; + + // Typography + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + color: $semantic-color-text-primary; + + // Transitions + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + + // Size Variants + &--sm { + padding: $semantic-spacing-component-sm; + gap: 12px; + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + } + + &--md { + // Default styles already applied above + } + + &--lg { + padding: $semantic-spacing-component-lg; + gap: 20px; + font-family: map-get($semantic-typography-body-large, font-family); + font-size: map-get($semantic-typography-body-large, font-size); + font-weight: map-get($semantic-typography-body-large, font-weight); + line-height: map-get($semantic-typography-body-large, line-height); + } + + // Color Variants + &--primary { + background: $semantic-color-surface-elevated; + border-color: $semantic-color-primary; + border-left-width: 4px; + + .ui-alert__icon { + color: $semantic-color-primary; + } + } + + &--success { + background: $semantic-color-surface-elevated; + border-color: $semantic-color-success; + border-left-width: 4px; + + .ui-alert__icon { + color: $semantic-color-success; + } + + .ui-alert__title { + color: $semantic-color-success; + } + } + + &--warning { + background: $semantic-color-surface-elevated; + border-color: $semantic-color-warning; + border-left-width: 4px; + + .ui-alert__icon { + color: $semantic-color-warning; + } + + .ui-alert__title { + color: $semantic-color-warning; + } + } + + &--danger { + background: $semantic-color-surface-elevated; + border-color: $semantic-color-danger; + border-left-width: 4px; + + .ui-alert__icon { + color: $semantic-color-danger; + } + + .ui-alert__title { + color: $semantic-color-danger; + } + } + + &--info { + background: $semantic-color-surface-elevated; + border-color: $semantic-color-info; + border-left-width: 4px; + + .ui-alert__icon { + color: $semantic-color-info; + } + + .ui-alert__title { + color: $semantic-color-info; + } + } + + // BEM Elements + &__icon { + flex-shrink: 0; + color: $semantic-color-text-secondary; + font-size: 1.25rem; // Increased from $semantic-sizing-icon-inline for better visibility + margin-top: 2px; // Slight optical alignment + } + + &__content { + flex: 1; + min-width: 0; // Prevents flex overflow + } + + &__title { + margin: 0 0 $semantic-spacing-content-line-tight 0; + font-family: map-get($semantic-typography-heading-h4, font-family); + font-size: map-get($semantic-typography-heading-h4, font-size); + font-weight: map-get($semantic-typography-heading-h4, font-weight); + line-height: map-get($semantic-typography-heading-h4, line-height); + color: $semantic-color-text-primary; + + &--bold { + font-weight: $semantic-typography-font-weight-semibold; + } + } + + &__message { + margin: 0; + color: $semantic-color-text-secondary; + } + + &__actions { + margin-top: $semantic-spacing-component-sm; + display: flex; + gap: $semantic-spacing-component-xs; + flex-wrap: wrap; + } + + &__dismiss { + position: absolute; + top: $semantic-spacing-component-xs; + right: $semantic-spacing-component-xs; + background: transparent; + border: none; + color: $semantic-color-text-tertiary; + cursor: pointer; + padding: $semantic-spacing-component-xs; + border-radius: $semantic-border-radius-sm; + display: flex; + align-items: center; + justify-content: center; + min-width: $semantic-sizing-touch-minimum; + min-height: $semantic-sizing-touch-minimum; + + &:hover { + background: $semantic-color-surface-secondary; + color: $semantic-color-text-secondary; + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + + &:active { + background: $semantic-color-surface-primary; + } + } + + // State Variants + &--dismissible { + padding-right: calc($semantic-spacing-component-lg + $semantic-sizing-touch-minimum); + } + + // Responsive Design + @media (max-width: $semantic-breakpoint-sm - 1) { + padding: $semantic-spacing-component-sm; + gap: $semantic-spacing-component-xs; + + &--lg { + padding: $semantic-spacing-component-md; + } + + &__title { + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + } + + &__message { + font-family: map-get($semantic-typography-caption, font-family); + font-size: map-get($semantic-typography-caption, font-size); + font-weight: map-get($semantic-typography-caption, font-weight); + line-height: map-get($semantic-typography-caption, line-height); + } + + &--dismissible { + padding-right: calc($semantic-spacing-component-md + $semantic-sizing-touch-minimum); + } + } +} \ No newline at end of file diff --git a/src/lib/components/feedback/alert/alert.component.ts b/src/lib/components/feedback/alert/alert.component.ts new file mode 100644 index 0000000..981fbea --- /dev/null +++ b/src/lib/components/feedback/alert/alert.component.ts @@ -0,0 +1,136 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { IconDefinition } from '@fortawesome/free-solid-svg-icons'; +import { + faCheckCircle, + faExclamationTriangle, + faExclamationCircle, + faInfoCircle, + faTimes +} from '@fortawesome/free-solid-svg-icons'; + +type AlertSize = 'sm' | 'md' | 'lg'; +type AlertVariant = 'primary' | 'success' | 'warning' | 'danger' | 'info'; + +@Component({ + selector: 'ui-alert', + standalone: true, + imports: [CommonModule, FontAwesomeModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ + @if (showIcon && alertIcon) { + + + } + +
+ @if (title) { +

+ {{ title }} +

+ } + +
+ +
+ + @if (actions && actions.length > 0) { +
+ +
+ } +
+ + @if (dismissible) { + + } +
+ `, + styleUrl: './alert.component.scss' +}) +export class AlertComponent { + @Input() size: AlertSize = 'md'; + @Input() variant: AlertVariant = 'primary'; + @Input() title?: string; + @Input() boldTitle = false; + @Input() showIcon = true; + @Input() dismissible = false; + @Input() dismissLabel = 'Dismiss alert'; + @Input() role = 'alert'; + @Input() ariaLive: 'polite' | 'assertive' | 'off' = 'polite'; + @Input() actions: any[] = []; + + @Output() dismissed = new EventEmitter(); + + // Icons + readonly faCheckCircle = faCheckCircle; + readonly faExclamationTriangle = faExclamationTriangle; + readonly faExclamationCircle = faExclamationCircle; + readonly faInfoCircle = faInfoCircle; + readonly faTimes = faTimes; + + // Generate unique ID for accessibility + readonly alertId = Math.random().toString(36).substr(2, 9); + + get alertIcon(): IconDefinition | null { + if (!this.showIcon) return null; + + switch (this.variant) { + case 'success': + return this.faCheckCircle; + case 'warning': + return this.faExclamationTriangle; + case 'danger': + return this.faExclamationCircle; + case 'info': + return this.faInfoCircle; + case 'primary': + default: + return this.faInfoCircle; + } + } + + handleDismiss(): void { + this.dismissed.emit(); + } + + handleDismissKeydown(event: KeyboardEvent): void { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + this.handleDismiss(); + } + } +} \ No newline at end of file diff --git a/src/lib/components/feedback/alert/index.ts b/src/lib/components/feedback/alert/index.ts new file mode 100644 index 0000000..5a36e1b --- /dev/null +++ b/src/lib/components/feedback/alert/index.ts @@ -0,0 +1 @@ +export * from './alert.component'; \ No newline at end of file diff --git a/src/lib/components/feedback/empty-state/empty-state.component.scss b/src/lib/components/feedback/empty-state/empty-state.component.scss new file mode 100644 index 0000000..d221d7b --- /dev/null +++ b/src/lib/components/feedback/empty-state/empty-state.component.scss @@ -0,0 +1,309 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-empty-state { + // Core Structure + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + position: relative; + + // Layout & Spacing + padding: $semantic-spacing-component-lg; + min-height: 200px; + + // Visual Design + background: $semantic-color-surface-primary; + border-radius: $semantic-border-card-radius; + + // Typography + color: $semantic-color-text-secondary; + + // Sizing Variants + &--sm { + min-height: 120px; + padding: $semantic-spacing-component-sm; + + .ui-empty-state__icon { + width: $semantic-sizing-icon-inline; + height: $semantic-sizing-icon-inline; + margin-bottom: $semantic-spacing-content-line-tight; + } + + .ui-empty-state__title { + font-size: $base-typography-font-size-sm; + margin-bottom: $semantic-spacing-content-line-tight; + } + + .ui-empty-state__description { + font-size: $base-typography-font-size-sm; + margin-bottom: $semantic-spacing-content-line-normal; + } + } + + &--md { + min-height: 200px; + padding: $semantic-spacing-component-md; + + .ui-empty-state__icon { + width: $semantic-sizing-icon-button; + height: $semantic-sizing-icon-button; + margin-bottom: $semantic-spacing-content-line-normal; + } + + .ui-empty-state__title { + font-size: $base-typography-font-size-lg; + margin-bottom: $semantic-spacing-content-line-normal; + } + + .ui-empty-state__description { + font-size: $base-typography-font-size-md; + margin-bottom: $semantic-spacing-content-paragraph; + } + } + + &--lg { + min-height: 300px; + padding: $semantic-spacing-component-lg; + + .ui-empty-state__icon { + width: $semantic-sizing-icon-navigation; + height: $semantic-sizing-icon-navigation; + margin-bottom: $semantic-spacing-content-paragraph; + } + + .ui-empty-state__title { + font-size: $base-typography-font-size-xl; + margin-bottom: $semantic-spacing-content-line-normal; + } + + .ui-empty-state__description { + font-size: $base-typography-font-size-lg; + margin-bottom: $semantic-spacing-content-heading; + } + } + + // Variant Styles + &--default { + .ui-empty-state__icon { + color: $semantic-color-text-tertiary; + } + } + + &--search { + .ui-empty-state__icon { + color: $semantic-color-primary; + } + + .ui-empty-state__title { + color: $semantic-color-text-primary; + } + } + + &--error { + .ui-empty-state__icon { + color: $semantic-color-error; + } + + .ui-empty-state__title { + color: $semantic-color-error; + } + } + + &--loading { + .ui-empty-state__icon { + color: $semantic-color-primary; + } + } + + &--success { + .ui-empty-state__icon { + color: $semantic-color-success; + } + + .ui-empty-state__title { + color: $semantic-color-success; + } + } +} + +.ui-empty-state__icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-bottom: $semantic-spacing-content-line-normal; + + svg, img { + width: 100%; + height: 100%; + object-fit: contain; + } +} + +.ui-empty-state__spinner { + width: 100%; + height: 100%; + border: 3px solid $semantic-color-border-subtle; + border-top: 3px solid $semantic-color-primary; + border-radius: 50%; + animation: spin $semantic-duration-medium $semantic-easing-standard infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.ui-empty-state__title { + margin: 0 0 $semantic-spacing-content-line-normal 0; + color: $semantic-color-text-primary; + font-weight: $base-typography-font-weight-semibold; + line-height: $base-typography-line-height-tight; +} + +.ui-empty-state__description { + margin: 0 0 $semantic-spacing-content-paragraph 0; + color: $semantic-color-text-secondary; + line-height: $base-typography-line-height-relaxed; + max-width: 400px; +} + +.ui-empty-state__content { + margin-bottom: $semantic-spacing-content-paragraph; + + &:empty { + display: none; + } +} + +.ui-empty-state__actions { + display: flex; + gap: $semantic-spacing-content-line-normal; + justify-content: center; + flex-wrap: wrap; +} + +.ui-empty-state__action { + display: inline-flex; + align-items: center; + justify-content: center; + padding: $semantic-spacing-component-sm $semantic-spacing-component-md; + + // Visual Design + background: $semantic-color-primary; + color: $semantic-color-on-primary; + border: $semantic-border-button-width solid $semantic-color-primary; + border-radius: $semantic-border-button-radius; + box-shadow: $semantic-shadow-button-rest; + + // Typography + font-size: $base-typography-font-size-md; + font-weight: $base-typography-font-weight-medium; + text-decoration: none; + + // Transitions + transition: all $semantic-duration-fast $semantic-easing-standard; + + cursor: pointer; + user-select: none; + + // Interactive States + &:not([disabled]) { + &:hover { + background: $semantic-color-primary; + border-color: $semantic-color-primary; + box-shadow: $semantic-shadow-button-hover; + transform: translateY(-1px); + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + + &:active { + background: $semantic-color-primary; + border-color: $semantic-color-primary; + box-shadow: $semantic-shadow-button-active; + transform: translateY(0); + } + } + + &[disabled] { + opacity: 0.38; + cursor: not-allowed; + pointer-events: none; + } +} + +// Dark Mode Support +:host-context(.dark-theme) .ui-empty-state { + background: $semantic-color-surface-secondary; +} + +// Responsive Design +@media (max-width: 768px) { + .ui-empty-state { + padding: $semantic-spacing-component-md; + min-height: 160px; + + &--lg { + min-height: 200px; + } + + .ui-empty-state__description { + max-width: 300px; + } + } +} + +@media (max-width: 480px) { + .ui-empty-state { + padding: $semantic-spacing-component-sm; + min-height: 120px; + + .ui-empty-state__icon { + width: $semantic-sizing-icon-inline; + height: $semantic-sizing-icon-inline; + } + + .ui-empty-state__title { + font-size: $base-typography-font-size-sm; + } + + .ui-empty-state__description { + font-size: $base-typography-font-size-sm; + max-width: 280px; + } + } +} + +// High contrast mode +@media (prefers-contrast: high) { + .ui-empty-state__action { + border-width: 2px; + font-weight: $base-typography-font-weight-bold; + } +} + +// Reduced motion +@media (prefers-reduced-motion: reduce) { + .ui-empty-state__spinner { + animation: none; + border-top-color: $semantic-color-primary; + } + + .ui-empty-state__action { + transition: none; + + &:hover { + transform: none; + } + + &:active { + transform: none; + } + } +} \ No newline at end of file diff --git a/src/lib/components/feedback/empty-state/empty-state.component.ts b/src/lib/components/feedback/empty-state/empty-state.component.ts new file mode 100644 index 0000000..d6c85e9 --- /dev/null +++ b/src/lib/components/feedback/empty-state/empty-state.component.ts @@ -0,0 +1,85 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +type EmptyStateSize = 'sm' | 'md' | 'lg'; +type EmptyStateVariant = 'default' | 'search' | 'error' | 'loading' | 'success'; + +@Component({ + selector: 'ui-empty-state', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ + @if (icon || variant === 'loading') { +
+ @if (variant === 'loading') { +
+ } @else if (icon) { + + } +
+ } + + @if (title) { +

{{ title }}

+ } + + @if (description) { +

{{ description }}

+ } + +
+ +
+ + @if (actionLabel) { +
+ +
+ } +
+ `, + styleUrl: './empty-state.component.scss' +}) +export class EmptyStateComponent { + @Input() size: EmptyStateSize = 'md'; + @Input() variant: EmptyStateVariant = 'default'; + @Input() icon = ''; + @Input() title = ''; + @Input() description = ''; + @Input() actionLabel = ''; + @Input() disabled = false; + @Input() role = 'region'; + @Input() ariaLabel = 'Empty state'; + + @Output() action = new EventEmitter(); + + getComponentClasses(): string { + const classes = [ + 'ui-empty-state', + `ui-empty-state--${this.size}`, + `ui-empty-state--${this.variant}` + ]; + + return classes.join(' '); + } + + handleAction(event: MouseEvent): void { + if (!this.disabled) { + this.action.emit(event); + } + } +} \ No newline at end of file diff --git a/src/lib/components/feedback/empty-state/index.ts b/src/lib/components/feedback/empty-state/index.ts new file mode 100644 index 0000000..c587e3c --- /dev/null +++ b/src/lib/components/feedback/empty-state/index.ts @@ -0,0 +1 @@ +export * from './empty-state.component'; \ No newline at end of file diff --git a/src/lib/components/feedback/index.ts b/src/lib/components/feedback/index.ts new file mode 100644 index 0000000..d4d8ad1 --- /dev/null +++ b/src/lib/components/feedback/index.ts @@ -0,0 +1,9 @@ +export * from "./alert"; +export * from "./status-badge.component"; +export * from "./skeleton-loader"; +export * from "./empty-state"; +export * from "./loading-spinner"; +export * from "./progress-circle"; +export * from "./snackbar"; +export * from "./toast"; +// export * from "./theme-switcher.component"; // Temporarily disabled due to CSS variable issues diff --git a/src/lib/components/feedback/loading-spinner/index.ts b/src/lib/components/feedback/loading-spinner/index.ts new file mode 100644 index 0000000..320a444 --- /dev/null +++ b/src/lib/components/feedback/loading-spinner/index.ts @@ -0,0 +1 @@ +export * from './loading-spinner.component'; \ No newline at end of file diff --git a/src/lib/components/feedback/loading-spinner/loading-spinner.component.scss b/src/lib/components/feedback/loading-spinner/loading-spinner.component.scss new file mode 100644 index 0000000..84adc97 --- /dev/null +++ b/src/lib/components/feedback/loading-spinner/loading-spinner.component.scss @@ -0,0 +1,248 @@ +@use 'ui-design-system/src/styles/semantic' as *; + +.ui-loading-spinner { + // Core Structure + display: inline-flex; + align-items: center; + justify-content: center; + position: relative; + flex-shrink: 0; + + // Sizing Variants + &--sm { + width: $semantic-sizing-icon-inline; + height: $semantic-sizing-icon-inline; + } + + &--md { + width: $semantic-sizing-icon-button; + height: $semantic-sizing-icon-button; + } + + &--lg { + width: $semantic-sizing-icon-navigation; + height: $semantic-sizing-icon-navigation; + } + + // Color Variants + &--primary { + color: $semantic-color-brand-primary; + } + + &--secondary { + color: $semantic-color-brand-secondary; + } + + &--accent { + color: $semantic-color-brand-accent; + } + + &--success { + color: $semantic-color-success; + } + + &--warning { + color: $semantic-color-warning; + } + + &--danger { + color: $semantic-color-danger; + } + + &--info { + color: $semantic-color-info; + } + + // Style Variants + &--spin .ui-loading-spinner__spin { + border: 2px solid $semantic-color-border-secondary; + border-top: 2px solid currentColor; + border-radius: 50%; + width: 100%; + height: 100%; + animation: ui-spinner-spin 1s ease-in-out infinite; + } + + &--dots { + .ui-loading-spinner__dots { + display: flex; + gap: 0.25rem; + + .ui-loading-spinner__dot { + width: 25%; + height: 25%; + background-color: currentColor; + border-radius: 50%; + animation: ui-spinner-dots 0.6s ease-in-out infinite; + + &:nth-child(1) { animation-delay: 0ms; } + &:nth-child(2) { animation-delay: 150ms; } + &:nth-child(3) { animation-delay: 300ms; } + } + } + } + + &--pulse .ui-loading-spinner__pulse { + width: 100%; + height: 100%; + background-color: currentColor; + border-radius: 50%; + animation: ui-spinner-pulse 1s ease-in-out infinite; + } + + &--bars { + .ui-loading-spinner__bars { + display: flex; + align-items: flex-end; + gap: 20%; + height: 100%; + + .ui-loading-spinner__bar { + width: 15%; + background-color: currentColor; + border-radius: 0.125rem; + animation: ui-spinner-bars 0.6s ease-in-out infinite; + + &:nth-child(1) { animation-delay: 0ms; } + &:nth-child(2) { animation-delay: 100ms; } + &:nth-child(3) { animation-delay: 200ms; } + } + } + } + + // State Variants + &--disabled { + opacity: 0.38; + pointer-events: none; + } + + // Speed Variants + &--slow { + .ui-loading-spinner__spin { + animation-duration: 2s; + } + + .ui-loading-spinner__dots .ui-loading-spinner__dot { + animation-duration: 1.2s; + } + + .ui-loading-spinner__pulse { + animation-duration: 2s; + } + + .ui-loading-spinner__bars .ui-loading-spinner__bar { + animation-duration: 1.2s; + } + } + + &--fast { + .ui-loading-spinner__spin { + animation-duration: 0.5s; + } + + .ui-loading-spinner__dots .ui-loading-spinner__dot { + animation-duration: 0.3s; + } + + .ui-loading-spinner__pulse { + animation-duration: 0.5s; + } + + .ui-loading-spinner__bars .ui-loading-spinner__bar { + animation-duration: 0.3s; + } + } + + // Label + &__label { + margin-left: 0.5rem; + color: $semantic-color-text-secondary; + font-size: 0.875rem; + font-weight: 500; + } + + // Screen Reader Only Text + &__sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } + + // Accessibility - Respect reduced motion preference + @media (prefers-reduced-motion: reduce) { + .ui-loading-spinner__spin, + .ui-loading-spinner__dots .ui-loading-spinner__dot, + .ui-loading-spinner__pulse, + .ui-loading-spinner__bars .ui-loading-spinner__bar { + animation: none; + } + + .ui-loading-spinner__spin { + border-top-color: transparent; + border-right-color: transparent; + border-bottom-color: transparent; + } + + .ui-loading-spinner__pulse { + opacity: 0.6; + } + } + + // High contrast mode + @media (prefers-contrast: high) { + .ui-loading-spinner__spin { + border-width: 3px; + } + } +} + +// Keyframes +@keyframes ui-spinner-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@keyframes ui-spinner-dots { + 0%, 20%, 80%, 100% { + transform: scale(1); + opacity: 1; + } + 50% { + transform: scale(1.5); + opacity: 0.7; + } +} + +@keyframes ui-spinner-pulse { + 0% { + transform: scale(1); + opacity: 1; + } + 50% { + transform: scale(1.1); + opacity: 0.5; + } + 100% { + transform: scale(1); + opacity: 1; + } +} + +@keyframes ui-spinner-bars { + 0%, 40%, 100% { + height: 40%; + } + 20% { + height: 100%; + } +} \ No newline at end of file diff --git a/src/lib/components/feedback/loading-spinner/loading-spinner.component.ts b/src/lib/components/feedback/loading-spinner/loading-spinner.component.ts new file mode 100644 index 0000000..b4d0a16 --- /dev/null +++ b/src/lib/components/feedback/loading-spinner/loading-spinner.component.ts @@ -0,0 +1,123 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +type SpinnerSize = 'sm' | 'md' | 'lg'; +type SpinnerVariant = 'primary' | 'secondary' | 'accent' | 'success' | 'warning' | 'danger' | 'info'; +type SpinnerType = 'spin' | 'dots' | 'pulse' | 'bars'; +type SpinnerSpeed = 'slow' | 'normal' | 'fast'; + +@Component({ + selector: 'ui-loading-spinner', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ + + + {{ srText || defaultSrText }} + + + + @switch (type) { + @case ('spin') { +
+ } + + @case ('dots') { +
+
+
+
+
+ } + + @case ('pulse') { +
+ } + + @case ('bars') { +
+
+
+
+
+ } + } + + + @if (label && showLabel) { + {{ label }} + } +
+ `, + styleUrl: './loading-spinner.component.scss' +}) +export class LoadingSpinnerComponent { + @Input() size: SpinnerSize = 'md'; + @Input() variant: SpinnerVariant = 'primary'; + @Input() type: SpinnerType = 'spin'; + @Input() speed: SpinnerSpeed = 'normal'; + @Input() disabled = false; + @Input() label = ''; + @Input() showLabel = false; + @Input() role = 'status'; + @Input() tabIndex = 0; + @Input() ariaLabel = ''; + @Input() srText = ''; + + @Output() clicked = new EventEmitter(); + + getComponentClasses(): string { + const classes = [ + 'ui-loading-spinner', + `ui-loading-spinner--${this.size}`, + `ui-loading-spinner--${this.variant}`, + `ui-loading-spinner--${this.type}` + ]; + + if (this.speed !== 'normal') { + classes.push(`ui-loading-spinner--${this.speed}`); + } + + if (this.disabled) { + classes.push('ui-loading-spinner--disabled'); + } + + return classes.join(' '); + } + + get defaultAriaLabel(): string { + if (this.ariaLabel) return this.ariaLabel; + return this.label ? `Loading: ${this.label}` : 'Loading'; + } + + get defaultSrText(): string { + if (this.srText) return this.srText; + return this.label ? `Loading ${this.label}...` : 'Loading...'; + } + + handleClick(event: MouseEvent): void { + if (!this.disabled) { + this.clicked.emit(event); + } + } + + handleKeydown(event: KeyboardEvent): void { + // Allow keyboard interaction if the spinner is interactive + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + this.handleClick(event as any); + } + } +} \ No newline at end of file diff --git a/src/lib/components/feedback/progress-circle/index.ts b/src/lib/components/feedback/progress-circle/index.ts new file mode 100644 index 0000000..c8bc728 --- /dev/null +++ b/src/lib/components/feedback/progress-circle/index.ts @@ -0,0 +1 @@ +export * from './progress-circle.component'; \ No newline at end of file diff --git a/src/lib/components/feedback/progress-circle/progress-circle.component.scss b/src/lib/components/feedback/progress-circle/progress-circle.component.scss new file mode 100644 index 0000000..8c46e4c --- /dev/null +++ b/src/lib/components/feedback/progress-circle/progress-circle.component.scss @@ -0,0 +1,198 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-progress-circle { + // Core Structure + display: inline-flex; + position: relative; + align-items: center; + justify-content: center; + + // Sizing variants + &--sm { + width: $semantic-sizing-icon-button; + height: $semantic-sizing-icon-button; + } + + &--md { + width: $semantic-sizing-icon-navigation; + height: $semantic-sizing-icon-navigation; + } + + &--lg { + width: $semantic-sizing-icon-header; + height: $semantic-sizing-icon-header; + } + + &--xl { + width: $semantic-sizing-icon-feature; + height: $semantic-sizing-icon-feature; + } + + // Color variants + &--primary { + .ui-progress-circle__progress { + stroke: $semantic-color-primary; + } + } + + &--secondary { + .ui-progress-circle__progress { + stroke: $semantic-color-secondary; + } + } + + &--success { + .ui-progress-circle__progress { + stroke: $semantic-color-success; + } + } + + &--warning { + .ui-progress-circle__progress { + stroke: $semantic-color-warning; + } + } + + &--danger { + .ui-progress-circle__progress { + stroke: $semantic-color-danger; + } + } + + &--info { + .ui-progress-circle__progress { + stroke: $semantic-color-info; + } + } + + // State variants + &--disabled { + opacity: 0.38; + cursor: not-allowed; + } + + &--indeterminate { + .ui-progress-circle__progress { + animation: progress-circle-spin $semantic-motion-duration-slow + $semantic-easing-standard infinite linear; + stroke-dasharray: 85, 85; + stroke-dashoffset: 0; + } + } + + // SVG elements + &__svg { + width: 100%; + height: 100%; + transform: rotate(-90deg); // Start progress from top + overflow: visible; + } + + &__track { + fill: none; + stroke: $semantic-color-border-subtle; + stroke-width: 2; + } + + &__progress { + fill: none; + stroke: $semantic-color-primary; + stroke-width: 2; + stroke-linecap: round; + transition: stroke-dashoffset $semantic-motion-duration-normal + $semantic-easing-standard; + } + + // Label content + &__label { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: $semantic-color-text-primary; + font-size: $semantic-typography-font-size-xs; + font-weight: $semantic-typography-font-weight-medium; + text-align: center; + line-height: 1; + + &--sm { + font-size: $semantic-typography-font-size-xs; + } + + &--md { + font-size: $semantic-typography-font-size-xs; + } + + &--lg { + font-size: $semantic-typography-font-size-sm; + } + + &--xl { + font-size: $semantic-typography-font-size-sm; + } + + &--disabled { + opacity: 0.38; + } + } + + // Stroke width variants + &--thin { + .ui-progress-circle__track, + .ui-progress-circle__progress { + stroke-width: 1; + } + } + + &--thick { + .ui-progress-circle__track, + .ui-progress-circle__progress { + stroke-width: 3; + } + } + + &--extra-thick { + .ui-progress-circle__track, + .ui-progress-circle__progress { + stroke-width: 4; + } + } + + // Dark mode support + :host-context(.dark-theme) & { + .ui-progress-circle__track { + stroke: $semantic-color-border-primary; + } + + .ui-progress-circle__label { + color: $semantic-color-text-primary; + } + } +} + +// Keyframe animations +@keyframes progress-circle-spin { + 0% { + transform: rotate(0deg); + } + 50% { + transform: rotate(180deg); + stroke-dasharray: 1, 200; + stroke-dashoffset: -35; + } + 100% { + transform: rotate(360deg); + stroke-dasharray: 85, 85; + stroke-dashoffset: -124; + } +} + +// Responsive design +@media (max-width: 480px) { + .ui-progress-circle { + &--xl { + width: $semantic-sizing-icon-header; + height: $semantic-sizing-icon-header; + } + } +} \ No newline at end of file diff --git a/src/lib/components/feedback/progress-circle/progress-circle.component.ts b/src/lib/components/feedback/progress-circle/progress-circle.component.ts new file mode 100644 index 0000000..cb72069 --- /dev/null +++ b/src/lib/components/feedback/progress-circle/progress-circle.component.ts @@ -0,0 +1,167 @@ +import { Component, Input, ChangeDetectionStrategy, computed, signal, ViewEncapsulation } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +export type ProgressCircleSize = 'sm' | 'md' | 'lg' | 'xl'; +export type ProgressCircleVariant = 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info'; +export type ProgressCircleStroke = 'thin' | 'default' | 'thick' | 'extra-thick'; + +@Component({ + selector: 'ui-progress-circle', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ + + + + + + + @if (!indeterminate) { + + } @else { + + } + + + + @if (showLabel) { +
+ @if (labelContent) { + {{ labelContent }} + } @else { + + } +
+ } +
+ `, + styleUrl: './progress-circle.component.scss' +}) +export class ProgressCircleComponent { + @Input() value = 0; + @Input() max = 100; + @Input() size: ProgressCircleSize = 'md'; + @Input() variant: ProgressCircleVariant = 'primary'; + @Input() stroke: ProgressCircleStroke = 'default'; + @Input() disabled = false; + @Input() indeterminate = false; + @Input() showLabel = false; + @Input() labelContent = ''; + @Input() ariaLabel = ''; + @Input() ariaValueText = ''; + + // Constants for SVG calculations + private readonly _center = 20; + private readonly _baseRadius = 16; + + // Computed properties for SVG dimensions + center = this._center; + radius = this._baseRadius; + viewBox = `0 0 ${this._center * 2} ${this._center * 2}`; + + // Computed signals + private _normalizedValue = computed(() => { + const val = Math.max(0, Math.min(this.max, this.value)); + return val; + }); + + private _percentage = computed(() => { + return this.max === 0 ? 0 : (this._normalizedValue() / this.max) * 100; + }); + + circumference = computed(() => 2 * Math.PI * this.radius); + + progressOffset = computed(() => { + const progress = this._percentage(); + return this.circumference() - (progress / 100) * this.circumference(); + }); + + svgSize = computed(() => { + const sizeMap = { + sm: 24, + md: 32, + lg: 40, + xl: 64 + }; + return sizeMap[this.size]; + }); + + strokeWidth = computed(() => { + const strokeMap = { + thin: 1, + default: 2, + thick: 3, + 'extra-thick': 4 + }; + return strokeMap[this.stroke]; + }); + + containerClasses = computed(() => { + const classes = [ + `ui-progress-circle--${this.size}`, + `ui-progress-circle--${this.variant}`, + `ui-progress-circle--${this.stroke}` + ]; + + if (this.disabled) classes.push('ui-progress-circle--disabled'); + if (this.indeterminate) classes.push('ui-progress-circle--indeterminate'); + + return classes.join(' '); + }); + + labelClasses = computed(() => { + const classes = [ + `ui-progress-circle__label--${this.size}` + ]; + + if (this.disabled) classes.push('ui-progress-circle__label--disabled'); + + return classes.join(' '); + }); + + // Utility methods + getPercentage(): number { + return this._percentage(); + } + + getValue(): number { + return this._normalizedValue(); + } + + isComplete(): boolean { + return this._normalizedValue() >= this.max; + } +} \ No newline at end of file diff --git a/src/lib/components/feedback/skeleton-loader/index.ts b/src/lib/components/feedback/skeleton-loader/index.ts new file mode 100644 index 0000000..f30995c --- /dev/null +++ b/src/lib/components/feedback/skeleton-loader/index.ts @@ -0,0 +1 @@ +export * from './skeleton-loader.component'; \ No newline at end of file diff --git a/src/lib/components/feedback/skeleton-loader/skeleton-loader.component.scss b/src/lib/components/feedback/skeleton-loader/skeleton-loader.component.scss new file mode 100644 index 0000000..3e99af8 --- /dev/null +++ b/src/lib/components/feedback/skeleton-loader/skeleton-loader.component.scss @@ -0,0 +1,293 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-skeleton-loader { + display: block; + width: 100%; + overflow: hidden; + position: relative; + background: $semantic-color-surface-variant; + + // Core structure + &::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient( + 90deg, + transparent, + rgba(255, 255, 255, 0.2), + transparent + ); + animation: shimmer 2s ease-in-out infinite; + transform: translateX(-100%); + } + + // Size variants + &--xs { + height: 0.5rem; + border-radius: 0.125rem; + } + + &--sm { + height: 0.75rem; + border-radius: 0.25rem; + } + + &--md { + height: 1rem; + border-radius: 0.375rem; + } + + &--lg { + height: 1.25rem; + border-radius: 0.5rem; + } + + &--xl { + height: 1.5rem; + border-radius: 0.5rem; + } + + // Shape variants + &--text { + height: 1.25rem; + border-radius: 0.25rem; + } + + &--heading { + height: 1.75rem; + border-radius: 0.25rem; + } + + &--avatar { + border-radius: 50%; + aspect-ratio: 1; + + &.ui-skeleton-loader--xs { width: 1rem; } + &.ui-skeleton-loader--sm { width: 1.5rem; } + &.ui-skeleton-loader--md { width: 2rem; } + &.ui-skeleton-loader--lg { width: 2.5rem; } + &.ui-skeleton-loader--xl { width: 3rem; } + } + + &--card { + height: 200px; + border-radius: 0.5rem; + } + + &--button { + height: 2.5rem; + width: 120px; + border-radius: 0.375rem; + } + + &--image { + aspect-ratio: 16/9; + border-radius: 0.375rem; + } + + &--circle { + border-radius: 50%; + aspect-ratio: 1; + } + + &--rounded { + border-radius: 0.375rem; + } + + &--square { + border-radius: 0; + } + + // Animation variants + &--pulse { + &::after { + display: none; + } + + animation: pulse 1.5s ease-in-out infinite alternate; + } + + &--wave { + &::after { + background: linear-gradient( + 90deg, + transparent 0%, + rgba(255, 255, 255, 0.4) 50%, + transparent 100% + ); + animation: wave 2s ease-in-out infinite; + } + } + + &--shimmer { + // Default shimmer animation (already applied) + } + + // Width variants + &--w-25 { width: 25%; } + &--w-50 { width: 50%; } + &--w-75 { width: 75%; } + &--w-full { width: 100%; } + + // State variants + &--loading { + opacity: 1; + } + + &--loaded { + opacity: 0; + transition: opacity 0.3s ease; + } + + // Dark mode support + :host-context(.dark-theme) & { + background: #374151; + + &::after { + background: linear-gradient( + 90deg, + transparent, + rgba(255, 255, 255, 0.1), + transparent + ); + } + } + + // High contrast mode + @media (prefers-contrast: high) { + background: #6b7280; + + &::after { + background: linear-gradient( + 90deg, + transparent, + rgba(255, 255, 255, 0.6), + transparent + ); + } + } + + // Reduced motion + @media (prefers-reduced-motion: reduce) { + &::after { + animation: none; + } + + &.ui-skeleton-loader--pulse { + animation: none; + } + } +} + +// Skeleton group for multiple skeleton items +.ui-skeleton-group { + display: flex; + flex-direction: column; + gap: 0.75rem; + + // Horizontal group + &--horizontal { + flex-direction: row; + align-items: center; + } + + // Grid group + &--grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; + } + + // Avatar with text group + &--avatar-text { + flex-direction: row; + align-items: flex-start; + gap: 0.75rem; + + .ui-skeleton-loader--avatar { + flex-shrink: 0; + } + + .ui-skeleton-content { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.5rem; + } + } + + // Card group + &--card { + .ui-skeleton-loader { + &:first-child { + height: 180px; + border-radius: 0.5rem 0.5rem 0 0; + } + + &:not(:first-child) { + margin: 0.75rem; + height: 1rem; + + &:last-child { + width: 60%; + } + } + } + } + + // Table group + &--table { + .ui-skeleton-loader { + height: 1.25rem; + + &:first-child { + width: 30%; + } + + &:nth-child(2) { + width: 50%; + } + + &:last-child { + width: 20%; + } + } + } +} + +// Animations +@keyframes shimmer { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(100%); + } +} + +@keyframes pulse { + 0% { + opacity: 1; + } + 100% { + opacity: 0.6; + } +} + +@keyframes wave { + 0% { + transform: translateX(-100%) scale(0.8); + opacity: 0; + } + 50% { + opacity: 1; + } + 100% { + transform: translateX(100%) scale(1.2); + opacity: 0; + } +} \ No newline at end of file diff --git a/src/lib/components/feedback/skeleton-loader/skeleton-loader.component.ts b/src/lib/components/feedback/skeleton-loader/skeleton-loader.component.ts new file mode 100644 index 0000000..7f0ea9e --- /dev/null +++ b/src/lib/components/feedback/skeleton-loader/skeleton-loader.component.ts @@ -0,0 +1,347 @@ +import { Component, Input, ChangeDetectionStrategy, ViewEncapsulation } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +export type SkeletonSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'; +export type SkeletonShape = 'text' | 'heading' | 'avatar' | 'card' | 'button' | 'image' | 'circle' | 'rounded' | 'square'; +export type SkeletonAnimation = 'shimmer' | 'pulse' | 'wave'; +export type SkeletonWidth = 'w-25' | 'w-50' | 'w-75' | 'w-full' | 'auto'; + +@Component({ + selector: 'ui-skeleton-loader', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+
+ `, + styleUrl: './skeleton-loader.component.scss' +}) +export class SkeletonLoaderComponent { + @Input() size: SkeletonSize = 'md'; + @Input() shape: SkeletonShape = 'text'; + @Input() animation: SkeletonAnimation = 'shimmer'; + @Input() width: SkeletonWidth = 'w-full'; + @Input() loading = true; + @Input() customWidth?: string; + @Input() customHeight?: string; + @Input() rounded = false; + @Input() circle = false; + @Input() ariaLabel = 'Loading content'; + @Input() role = 'status'; + @Input() class = ''; + + get skeletonClasses(): string { + const classes = [ + 'ui-skeleton-loader', + `ui-skeleton-loader--${this.size}`, + `ui-skeleton-loader--${this.shape}`, + `ui-skeleton-loader--${this.animation}`, + this.width !== 'auto' ? `ui-skeleton-loader--${this.width}` : '', + this.loading ? 'ui-skeleton-loader--loading' : 'ui-skeleton-loader--loaded', + this.rounded ? 'ui-skeleton-loader--rounded' : '', + this.circle ? 'ui-skeleton-loader--circle' : '', + this.class + ].filter(Boolean); + + return classes.join(' '); + } +} + +@Component({ + selector: 'ui-skeleton-group', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ +
+ `, + styleUrl: './skeleton-loader.component.scss' +}) +export class SkeletonGroupComponent { + @Input() variant: 'vertical' | 'horizontal' | 'grid' | 'avatar-text' | 'card' | 'table' = 'vertical'; + @Input() ariaLabel = 'Loading multiple items'; + @Input() role = 'status'; + @Input() class = ''; + + get groupClasses(): string { + const classes = [ + 'ui-skeleton-group', + this.variant !== 'vertical' ? `ui-skeleton-group--${this.variant}` : '', + this.class + ].filter(Boolean); + + return classes.join(' '); + } +} + +// Pre-built skeleton patterns for common use cases +@Component({ + selector: 'ui-skeleton-text-block', + standalone: true, + imports: [CommonModule, SkeletonLoaderComponent, SkeletonGroupComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + @for (line of lineArray; track $index) { + + + } + + ` +}) +export class SkeletonTextBlockComponent { + @Input() lines = 3; + @Input() size: SkeletonSize = 'md'; + @Input() animation: SkeletonAnimation = 'shimmer'; + @Input() lastLineWidth: SkeletonWidth = 'w-75'; + + get lineArray(): number[] { + return Array.from({ length: this.lines }, (_, i) => i); + } + + getLineWidth(index: number): SkeletonWidth { + return index === this.lines - 1 ? this.lastLineWidth : 'w-full'; + } +} + +@Component({ + selector: 'ui-skeleton-article', + standalone: true, + imports: [CommonModule, SkeletonLoaderComponent, SkeletonGroupComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + + + + + + + + + + @if (showImage) { + + + } + + + @for (line of contentArray; track $index) { + + + } + + ` +}) +export class SkeletonArticleComponent { + @Input() showImage = true; + @Input() contentLines = 5; + @Input() animation: SkeletonAnimation = 'shimmer'; + + get contentArray(): number[] { + return Array.from({ length: this.contentLines }, (_, i) => i); + } + + getContentLineWidth(index: number): SkeletonWidth { + if (index === this.contentLines - 1) return 'w-50'; + if (index === this.contentLines - 2) return 'w-75'; + return 'w-full'; + } +} + +@Component({ + selector: 'ui-skeleton-card', + standalone: true, + imports: [CommonModule, SkeletonLoaderComponent, SkeletonGroupComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + + @if (showImage) { + + + } + + + + + + + @for (line of contentArray; track $index) { + + + } + + + @if (showActions) { + + + + + + + } + + ` +}) +export class SkeletonCardComponent { + @Input() showImage = true; + @Input() showActions = true; + @Input() contentLines = 3; + @Input() animation: SkeletonAnimation = 'shimmer'; + + get contentArray(): number[] { + return Array.from({ length: this.contentLines }, (_, i) => i); + } + + getCardLineWidth(index: number): SkeletonWidth { + if (index === this.contentLines - 1) return 'w-50'; + return 'w-full'; + } +} + +@Component({ + selector: 'ui-skeleton-profile', + standalone: true, + imports: [CommonModule, SkeletonLoaderComponent, SkeletonGroupComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + + + + + +
+ + + + + + + + + + @if (showExtraInfo) { + + + } +
+
+ ` +}) +export class SkeletonProfileComponent { + @Input() avatarSize: SkeletonSize = 'lg'; + @Input() showExtraInfo = true; + @Input() animation: SkeletonAnimation = 'shimmer'; +} + +@Component({ + selector: 'ui-skeleton-table-row', + standalone: true, + imports: [CommonModule, SkeletonLoaderComponent, SkeletonGroupComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + @for (column of columnArray; track $index) { + + + } + + ` +}) +export class SkeletonTableRowComponent { + @Input() columns = 3; + @Input() animation: SkeletonAnimation = 'shimmer'; + + get columnArray(): number[] { + return Array.from({ length: this.columns }, (_, i) => i); + } + + getColumnWidth(index: number): SkeletonWidth { + const widths: SkeletonWidth[] = ['w-25', 'w-50', 'w-25', 'w-full']; + return widths[index % widths.length]; + } +} + +// Convenience export for all skeleton components +export const SKELETON_COMPONENTS = [ + SkeletonLoaderComponent, + SkeletonGroupComponent, + SkeletonTextBlockComponent, + SkeletonArticleComponent, + SkeletonCardComponent, + SkeletonProfileComponent, + SkeletonTableRowComponent +] as const; \ No newline at end of file diff --git a/src/lib/components/feedback/snackbar/index.ts b/src/lib/components/feedback/snackbar/index.ts new file mode 100644 index 0000000..082d25f --- /dev/null +++ b/src/lib/components/feedback/snackbar/index.ts @@ -0,0 +1 @@ +export * from './snackbar.component'; \ No newline at end of file diff --git a/src/lib/components/feedback/snackbar/snackbar.component.scss b/src/lib/components/feedback/snackbar/snackbar.component.scss new file mode 100644 index 0000000..3b9d114 --- /dev/null +++ b/src/lib/components/feedback/snackbar/snackbar.component.scss @@ -0,0 +1,235 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-snackbar { + // Core Structure + display: flex; + align-items: center; + position: fixed; + bottom: $semantic-spacing-layout-section-md; + left: 50%; + transform: translateX(-50%); + z-index: $semantic-z-index-modal; + max-width: 90vw; + width: auto; + min-width: 300px; + + // Layout & Spacing + padding: $semantic-spacing-component-md $semantic-spacing-component-lg; + gap: $semantic-spacing-component-sm; + + // Visual Design + background: $semantic-color-surface-elevated; + border: $semantic-border-width-1 solid $semantic-color-border-secondary; + border-radius: $semantic-border-radius-lg; + box-shadow: $semantic-shadow-elevation-4; + + // Typography + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + color: $semantic-color-text-primary; + + // Transitions + transition: all $semantic-motion-duration-normal $semantic-motion-easing-ease; + + // Size Variants + &--sm { + min-height: $semantic-sizing-button-height-sm; + padding: $semantic-spacing-component-xs $semantic-spacing-component-md; + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + } + + &--md { + min-height: $semantic-sizing-button-height-md; + padding: $semantic-spacing-component-md $semantic-spacing-component-lg; + } + + &--lg { + min-height: $semantic-sizing-button-height-lg; + padding: $semantic-spacing-component-lg; + font-family: map-get($semantic-typography-body-large, font-family); + font-size: map-get($semantic-typography-body-large, font-size); + font-weight: map-get($semantic-typography-body-large, font-weight); + line-height: map-get($semantic-typography-body-large, line-height); + } + + // Color Variants + &--primary { + background: $semantic-color-primary; + color: $semantic-color-on-primary; + border-color: $semantic-color-primary; + } + + &--success { + background: $semantic-color-success; + color: $semantic-color-on-success; + border-color: $semantic-color-success; + } + + &--warning { + background: $semantic-color-warning; + color: $semantic-color-on-warning; + border-color: $semantic-color-warning; + } + + &--danger { + background: $semantic-color-danger; + color: $semantic-color-on-danger; + border-color: $semantic-color-danger; + } + + &--info { + background: $semantic-color-info; + color: $semantic-color-on-info; + border-color: $semantic-color-info; + } + + // Position Variants + &--top { + top: $semantic-spacing-layout-section-md; + bottom: auto; + } + + &--left { + left: $semantic-spacing-layout-section-md; + transform: translateX(0); + } + + &--right { + right: $semantic-spacing-layout-section-md; + left: auto; + transform: translateX(0); + } + + // Animation States + &--entering { + opacity: 0; + transform: translateX(-50%) translateY(20px); + } + + &--exiting { + opacity: 0; + transform: translateX(-50%) translateY(20px); + } + + // BEM Elements + &__content { + flex: 1; + min-width: 0; + + &--multiline { + padding: $semantic-spacing-content-line-tight 0; + } + } + + &__message { + color: inherit; + margin: 0; + overflow-wrap: break-word; + } + + &__actions { + display: flex; + align-items: center; + gap: $semantic-spacing-component-xs; + margin-left: $semantic-spacing-component-sm; + flex-shrink: 0; + } + + &__action { + background: transparent; + border: none; + padding: $semantic-spacing-component-xs; + border-radius: $semantic-border-radius-sm; + cursor: pointer; + transition: background-color $semantic-motion-duration-fast $semantic-motion-easing-ease; + + // Use inherited text color with opacity for actions + color: inherit; + font-weight: $semantic-typography-font-weight-medium; + + &:hover { + background-color: rgba(255, 255, 255, 0.1); + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + + &:active { + background-color: rgba(255, 255, 255, 0.2); + } + } + + &__dismiss { + background: transparent; + border: none; + padding: $semantic-spacing-component-xs; + border-radius: $semantic-border-radius-sm; + cursor: pointer; + margin-left: $semantic-spacing-component-sm; + color: inherit; + display: flex; + align-items: center; + justify-content: center; + transition: background-color $semantic-motion-duration-fast $semantic-motion-easing-ease; + + &:hover { + background-color: rgba(255, 255, 255, 0.1); + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + + &:active { + background-color: rgba(255, 255, 255, 0.2); + } + } + + // Icon styling + &__icon { + flex-shrink: 0; + color: inherit; + font-size: $semantic-sizing-icon-inline; + } + + // Responsive Design + @media (max-width: $semantic-breakpoint-md - 1) { + bottom: $semantic-spacing-layout-section-sm; + left: $semantic-spacing-layout-section-sm; + right: $semantic-spacing-layout-section-sm; + transform: translateX(0); + max-width: none; + + &--left, + &--right { + left: $semantic-spacing-layout-section-sm; + right: $semantic-spacing-layout-section-sm; + transform: translateX(0); + } + } + + @media (max-width: $semantic-breakpoint-sm - 1) { + padding: $semantic-spacing-component-sm; + bottom: $semantic-spacing-layout-section-xs; + left: $semantic-spacing-layout-section-xs; + right: $semantic-spacing-layout-section-xs; + + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + + &__actions { + margin-left: $semantic-spacing-component-xs; + gap: $semantic-spacing-component-xs; + } + } +} \ No newline at end of file diff --git a/src/lib/components/feedback/snackbar/snackbar.component.ts b/src/lib/components/feedback/snackbar/snackbar.component.ts new file mode 100644 index 0000000..dc87881 --- /dev/null +++ b/src/lib/components/feedback/snackbar/snackbar.component.ts @@ -0,0 +1,243 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation, OnInit, OnDestroy, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { IconDefinition } from '@fortawesome/free-solid-svg-icons'; +import { + faCheckCircle, + faExclamationTriangle, + faExclamationCircle, + faInfoCircle, + faTimes +} from '@fortawesome/free-solid-svg-icons'; + +type SnackbarSize = 'sm' | 'md' | 'lg'; +type SnackbarVariant = 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info'; +type SnackbarPosition = 'bottom-center' | 'bottom-left' | 'bottom-right' | 'top-center' | 'top-left' | 'top-right'; + +export interface SnackbarAction { + label: string; + handler: () => void; + disabled?: boolean; +} + +@Component({ + selector: 'ui-snackbar', + standalone: true, + imports: [CommonModule, FontAwesomeModule], + changeDetection: ChangeDetectionStrategy.Default, + encapsulation: ViewEncapsulation.None, + template: ` +
+ + @if (showIcon && snackbarIcon) { + + + } + +
+
+ {{ message }} + +
+
+ + @if (actions && actions.length > 0) { +
+ @for (action of actions; track action.label) { + + } +
+ } + + @if (dismissible) { + + } +
+ `, + styleUrl: './snackbar.component.scss' +}) +export class SnackbarComponent implements OnInit, OnDestroy { + @Input() size: SnackbarSize = 'md'; + @Input() variant: SnackbarVariant = 'default'; + @Input() message = ''; + @Input() showIcon = false; + @Input() dismissible = true; + @Input() autoDismiss = true; + @Input() duration = 4000; // 4 seconds - shorter than toast + @Input() position: SnackbarPosition = 'bottom-center'; + @Input() dismissLabel = 'Dismiss snackbar'; + @Input() role = 'status'; + @Input() ariaLive: 'polite' | 'assertive' | 'off' = 'polite'; + @Input() actions: SnackbarAction[] = []; + @Input() isMultiline = false; + + @Output() dismissed = new EventEmitter(); + @Output() expired = new EventEmitter(); + @Output() shown = new EventEmitter(); + @Output() hidden = new EventEmitter(); + @Output() actionClicked = new EventEmitter(); + + // Icons + readonly faCheckCircle = faCheckCircle; + readonly faExclamationTriangle = faExclamationTriangle; + readonly faExclamationCircle = faExclamationCircle; + readonly faInfoCircle = faInfoCircle; + readonly faTimes = faTimes; + + // Generate unique ID for accessibility + readonly snackbarId = Math.random().toString(36).substr(2, 9); + + // State management + isEntering = false; + isExiting = false; + private autoDismissTimeout?: any; + private enterTimeout?: any; + private exitTimeout?: any; + + get snackbarIcon(): IconDefinition | null { + if (!this.showIcon) return null; + + switch (this.variant) { + case 'success': + return this.faCheckCircle; + case 'warning': + return this.faExclamationTriangle; + case 'danger': + return this.faExclamationCircle; + case 'info': + return this.faInfoCircle; + case 'primary': + case 'default': + default: + return this.faInfoCircle; + } + } + + ngOnInit(): void { + this.show(); + } + + ngOnDestroy(): void { + this.clearTimeouts(); + } + + show(): void { + this.isEntering = true; + + this.enterTimeout = setTimeout(() => { + this.isEntering = false; + this.shown.emit(); + + if (this.autoDismiss && this.duration > 0) { + this.startAutoDismissTimer(); + } + }, 300); // Animation duration + } + + hide(): void { + this.clearTimeouts(); + this.isExiting = true; + + this.exitTimeout = setTimeout(() => { + this.isExiting = false; + this.hidden.emit(); + }, 300); // Animation duration + } + + handleDismiss(): void { + this.hide(); + this.dismissed.emit(); + } + + handleDismissKeydown(event: KeyboardEvent): void { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + this.handleDismiss(); + } + } + + handleActionClick(action: SnackbarAction): void { + if (!action.disabled) { + action.handler(); + this.actionClicked.emit(action); + } + } + + private startAutoDismissTimer(): void { + if (this.autoDismissTimeout) { + clearTimeout(this.autoDismissTimeout); + } + + this.autoDismissTimeout = setTimeout(() => { + this.hide(); + this.expired.emit(); + }, this.duration); + } + + private clearTimeouts(): void { + if (this.autoDismissTimeout) { + clearTimeout(this.autoDismissTimeout); + this.autoDismissTimeout = undefined; + } + + if (this.enterTimeout) { + clearTimeout(this.enterTimeout); + this.enterTimeout = undefined; + } + + if (this.exitTimeout) { + clearTimeout(this.exitTimeout); + this.exitTimeout = undefined; + } + } + + // Pause auto-dismiss on mouse enter + onMouseEnter(): void { + if (this.autoDismissTimeout) { + clearTimeout(this.autoDismissTimeout); + } + } + + // Resume auto-dismiss on mouse leave + onMouseLeave(): void { + if (this.autoDismiss && this.duration > 0 && !this.isExiting) { + this.startAutoDismissTimer(); + } + } +} \ No newline at end of file diff --git a/src/lib/components/feedback/status-badge.component.scss b/src/lib/components/feedback/status-badge.component.scss new file mode 100644 index 0000000..b51f930 --- /dev/null +++ b/src/lib/components/feedback/status-badge.component.scss @@ -0,0 +1,253 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-status-badge { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + font-weight: 500; + line-height: 1; + text-align: center; + white-space: nowrap; + vertical-align: baseline; + transition: all 150ms cubic-bezier(0, 0, 0.2, 1); + position: relative; + + // Size variants + &--small { + padding: 0.125rem 0.375rem; + font-size: 0.625rem; + min-height: 1.125rem; + + &.ui-status-badge--with-icon .ui-status-badge__icon { + font-size: 0.625rem; + } + + &.ui-status-badge--with-dot .ui-status-badge__dot { + width: 0.375rem; + height: 0.375rem; + } + } + + &--medium { + padding: 0.25rem 0.5rem; + font-size: 0.75rem; + min-height: 1.25rem; + + &.ui-status-badge--with-icon .ui-status-badge__icon { + font-size: 0.75rem; + } + + &.ui-status-badge--with-dot .ui-status-badge__dot { + width: 0.5rem; + height: 0.5rem; + } + } + + &--large { + padding: 0.375rem 0.75rem; + font-size: 0.875rem; + min-height: 1.5rem; + + &.ui-status-badge--with-icon .ui-status-badge__icon { + font-size: 0.875rem; + } + + &.ui-status-badge--with-dot .ui-status-badge__dot { + width: 0.625rem; + height: 0.625rem; + } + } + + // Shape variants + &--rounded { + border-radius: 0.25rem; + } + + &--pill { + border-radius: 9999px; + } + + &--square { + border-radius: 0; + } + + // Style modifiers + &--uppercase { + text-transform: uppercase; + letter-spacing: 0.05em; + } + + &--bold { + font-weight: 700; + } + + // Success variant + &--success { + background: hsl(142, 76%, 90%); + color: hsl(142, 76%, 25%); + border: 1px solid hsl(142, 76%, 80%); + + &.ui-status-badge--outlined { + background: transparent; + color: hsl(142, 76%, 35%); + border-color: hsl(142, 76%, 50%); + } + + .ui-status-badge__dot { + background: hsl(142, 76%, 45%); + } + } + + // Warning variant + &--warning { + background: hsl(45, 93%, 88%); + color: hsl(45, 93%, 25%); + border: 1px solid hsl(45, 93%, 75%); + + &.ui-status-badge--outlined { + background: transparent; + color: hsl(45, 93%, 35%); + border-color: hsl(45, 93%, 50%); + } + + .ui-status-badge__dot { + background: hsl(45, 93%, 45%); + } + } + + // Danger variant + &--danger { + background: hsl(0, 84%, 90%); + color: hsl(0, 84%, 25%); + border: 1px solid hsl(0, 84%, 80%); + + &.ui-status-badge--outlined { + background: transparent; + color: hsl(0, 84%, 35%); + border-color: hsl(0, 84%, 50%); + } + + .ui-status-badge__dot { + background: hsl(0, 84%, 45%); + } + } + + // Info variant + &--info { + background: hsl(207, 90%, 88%); + color: hsl(207, 90%, 25%); + border: 1px solid hsl(207, 90%, 75%); + + &.ui-status-badge--outlined { + background: transparent; + color: hsl(207, 90%, 35%); + border-color: hsl(207, 90%, 50%); + } + + .ui-status-badge__dot { + background: hsl(207, 90%, 45%); + } + } + + // Neutral variant + &--neutral { + background: hsl(289, 14%, 90%); + color: hsl(279, 14%, 25%); + border: 1px solid hsl(289, 14%, 80%); + + &.ui-status-badge--outlined { + background: transparent; + color: hsl(279, 14%, 35%); + border-color: hsl(289, 14%, 60%); + } + + .ui-status-badge__dot { + background: hsl(287, 12%, 47%); + } + } + + // Primary variant + &--primary { + background: hsl(263, 100%, 88%); + color: hsl(260, 100%, 25%); + border: 1px solid hsl(263, 100%, 75%); + + &.ui-status-badge--outlined { + background: transparent; + color: hsl(258, 100%, 47%); + border-color: hsl(258, 100%, 47%); + } + + .ui-status-badge__dot { + background: hsl(258, 100%, 47%); + } + } + + // Secondary variant + &--secondary { + background: hsl(262, 25%, 84%); + color: hsl(256, 29%, 25%); + border: 1px solid hsl(262, 25%, 75%); + + &.ui-status-badge--outlined { + background: transparent; + color: hsl(258, 29%, 40%); + border-color: hsl(258, 29%, 40%); + } + + .ui-status-badge__dot { + background: hsl(258, 29%, 40%); + } + } +} + +.ui-status-badge__icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + line-height: 1; +} + +.ui-status-badge__dot { + border-radius: 50%; + flex-shrink: 0; +} + +.ui-status-badge__text { + flex: 1; + min-width: 0; +} + +// Hover effects for interactive badges (when used in buttons, etc.) +button .ui-status-badge, +a .ui-status-badge, +[role="button"] .ui-status-badge { + transition: all 150ms cubic-bezier(0, 0, 0.2, 1); + + &:hover { + transform: scale(1.02); + } +} + +// High contrast mode +@media (prefers-contrast: high) { + .ui-status-badge { + border-width: 2px; + font-weight: 700; + + &--outlined { + background: buttonface; + color: buttontext; + border-color: buttontext; + } + } +} + +// Reduced motion +@media (prefers-reduced-motion: reduce) { + .ui-status-badge { + transition: none; + } +} \ No newline at end of file diff --git a/src/lib/components/feedback/status-badge.component.ts b/src/lib/components/feedback/status-badge.component.ts new file mode 100644 index 0000000..3b1ae4d --- /dev/null +++ b/src/lib/components/feedback/status-badge.component.ts @@ -0,0 +1,54 @@ +import { Component, Input, ChangeDetectionStrategy } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +export type StatusBadgeVariant = 'success' | 'warning' | 'danger' | 'info' | 'neutral' | 'primary' | 'secondary'; +export type StatusBadgeSize = 'small' | 'medium' | 'large'; +export type StatusBadgeShape = 'rounded' | 'pill' | 'square'; + +@Component({ + selector: 'ui-status-badge', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + @if (icon) { + + } + @if (dot) { + + } + + + + + `, + styleUrl: './status-badge.component.scss' +}) +export class StatusBadgeComponent { + @Input() variant: StatusBadgeVariant = 'neutral'; + @Input() size: StatusBadgeSize = 'medium'; + @Input() shape: StatusBadgeShape = 'rounded'; + @Input() icon: string = ''; + @Input() dot: boolean = false; + @Input() outlined: boolean = false; + @Input() uppercase: boolean = true; + @Input() bold: boolean = false; + @Input() ariaLabel: string = ''; + @Input() class: string = ''; + + get badgeClasses(): string { + return [ + 'ui-status-badge', + `ui-status-badge--${this.variant}`, + `ui-status-badge--${this.size}`, + `ui-status-badge--${this.shape}`, + this.outlined ? 'ui-status-badge--outlined' : '', + this.uppercase ? 'ui-status-badge--uppercase' : '', + this.bold ? 'ui-status-badge--bold' : '', + this.icon ? 'ui-status-badge--with-icon' : '', + this.dot ? 'ui-status-badge--with-dot' : '', + this.class + ].filter(Boolean).join(' '); + } +} \ No newline at end of file diff --git a/src/lib/components/feedback/theme-switcher.component.scss b/src/lib/components/feedback/theme-switcher.component.scss new file mode 100644 index 0000000..27e2aa8 --- /dev/null +++ b/src/lib/components/feedback/theme-switcher.component.scss @@ -0,0 +1,579 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; +/** + * ========================================================================== + * THEME SWITCHER COMPONENT STYLES + * ========================================================================== + * Material Design 3 inspired styling for the theme switcher component. + * Uses CSS variables for runtime theming support. + * ========================================================================== + */ + + +// Tokens available globally via main application styles + + +.theme-switcher { + background-color: $semantic-color-surface-primary; + border-radius: $semantic-border-radius-lg; + padding: $semantic-spacing-component-lg; + box-shadow: $semantic-shadow-elevated; + border: 1px solid $semantic-color-border-primary; + position: relative; + max-width: 400px; + width: 100%; + + // ========================================================================== + // HEADER STYLES + // ========================================================================== + + &__header { + margin-bottom: $semantic-spacing-component-lg; + } + + &__title { + @extend .skyui-heading--h3 !optional; + color: $semantic-color-text-primary; + margin: 0 0 $semantic-spacing-component-2xs 0; + } + + &__subtitle { + @extend .skyui-text--small !optional; + color: $semantic-color-text-secondary; + margin: 0; + } + + // ========================================================================== + // SECTION STYLES + // ========================================================================== + + &__section { + margin-bottom: $semantic-spacing-component-lg; + + &:last-child { + margin-bottom: 0; + } + } + + &__section-title { + @extend .skyui-heading--h4 !optional; + color: $semantic-color-text-primary; + margin: 0 0 $semantic-spacing-component-sm 0; + } + + &__section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: $semantic-spacing-component-sm; + } + + // ========================================================================== + // DARK MODE TOGGLE STYLES + // ========================================================================== + + &__option { + display: flex; + align-items: center; + } + + &__label { + display: flex; + align-items: center; + cursor: pointer; + @extend .skyui-text--medium !optional; + color: $semantic-color-text-primary; + + &:hover { + color: $css-color-primary; + } + } + + &__checkbox { + position: absolute; + opacity: 0; + cursor: pointer; + + &:checked + &-custom { + background-color: $css-color-primary; + border-color: $css-color-primary; + + &::after { + display: block; + } + } + + &:focus + &-custom { + outline: 2px solid $css-color-primary; + outline-offset: 2px; + } + + &:disabled + &-custom { + opacity: 0.6; + cursor: not-allowed; + } + } + + &__checkbox-custom { + width: 20px; + height: 20px; + border: 2px solid $css-color-outline; + border-radius: $semantic-border-radius-sm; + margin-right: $semantic-spacing-component-sm; + position: relative; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + + &::after { + content: '✓'; + color: $css-color-on-primary; + font-size: 12px; + font-weight: bold; + display: none; + } + } + + &__text { + user-select: none; + } + + // ========================================================================== + // THEME GRID STYLES + // ========================================================================== + + .theme-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); + gap: $semantic-spacing-component-sm; + } + + .theme-card { + background-color: $semantic-color-primary-container; + border: 2px solid $semantic-color-border-primary; + border-radius: $semantic-border-radius-md; + padding: $semantic-spacing-component-sm; + cursor: pointer; + transition: all 0.2s ease; + position: relative; + text-align: center; + + &:hover:not(&--disabled) { + border-color: $css-color-primary; + box-shadow: $semantic-shadow-elevated; + transform: translateY(-1px); + } + + &--active { + border-color: $css-color-primary; + background-color: $css-color-primary-container; + } + + &--disabled { + opacity: 0.6; + cursor: not-allowed; + } + + &__preview { + margin-bottom: $semantic-spacing-component-xs; + } + + &__color-primary { + width: 100%; + height: 32px; + border-radius: $semantic-border-radius-sm; + margin-bottom: $semantic-spacing-component-2xs; + } + + &__color-details { + display: flex; + gap: $semantic-spacing-component-2xs; + } + + &__color-secondary, + &__color-tertiary { + flex: 1; + height: 16px; + border-radius: $semantic-border-radius-xs; + } + + &__name { + @extend .skyui-text--small !optional; + color: $semantic-color-text-primary; + font-weight: $semantic-typography-font-weight-medium; + display: block; + } + + &__active-indicator { + position: absolute; + top: 4px; + right: 4px; + color: $css-color-primary; + background-color: $semantic-color-surface-primary; + border-radius: 50%; + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + } + } + + // ========================================================================== + // TOGGLE BUTTON STYLES + // ========================================================================== + + &__toggle-btn { + @extend .skyui-interactive-text--button-small !optional; + background-color: transparent; + color: $css-color-primary; + border: 1px solid $css-color-primary; + border-radius: $semantic-border-radius-sm; + padding: $semantic-spacing-component-xs $semantic-spacing-component-sm; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background-color: $css-color-primary-container; + border-color: $css-color-primary; + } + + &--active { + background-color: $css-color-primary; + color: $css-color-on-primary; + + &:hover { + background-color: var(--color-primary-hover, $css-color-primary); + filter: brightness(0.9); + } + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + } + + // ========================================================================== + // CUSTOM PANEL STYLES + // ========================================================================== + + &__custom-panel { + max-height: 0; + overflow: hidden; + transition: max-height 0.3s ease; + + &--visible { + max-height: 800px; + } + } + + // ========================================================================== + // CUSTOM COLOR INPUT STYLES + // ========================================================================== + + .custom-color-input { + margin-bottom: $semantic-spacing-component-md; + + &__label { + @extend .skyui-form-text--label !optional; + display: block; + margin-bottom: $semantic-spacing-component-2xs; + color: $semantic-color-text-primary; + } + + &__container { + display: flex; + gap: $semantic-spacing-component-xs; + align-items: center; + } + + &__picker { + width: 48px; + height: 48px; + border: none; + border-radius: $semantic-border-radius-sm; + cursor: pointer; + background: none; + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + } + + &__text { + @extend .skyui-form-text--input !optional; + flex: 1; + padding: $semantic-spacing-component-sm; + border: 1px solid $css-color-outline; + border-radius: $semantic-border-radius-sm; + background-color: $semantic-color-surface-primary; + color: $semantic-color-text-primary; + font-family: $semantic-typography-font-family-mono; + + &:focus { + border-color: $css-color-primary; + outline: none; + box-shadow: 0 0 0 2px rgba($css-color-primary, 0.2); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + background-color: $semantic-color-surface-variant; + } + } + } + + // ========================================================================== + // THEME NAME INPUT STYLES + // ========================================================================== + + .custom-theme-name { + margin-bottom: $semantic-spacing-component-md; + + &__label { + @extend .skyui-form-text--label !optional; + display: block; + margin-bottom: $semantic-spacing-component-2xs; + color: $semantic-color-text-primary; + } + + &__input { + @extend .skyui-form-text--input !optional; + width: 100%; + padding: $semantic-spacing-component-sm; + border: 1px solid $css-color-outline; + border-radius: $semantic-border-radius-sm; + background-color: $semantic-color-surface-primary; + color: $semantic-color-text-primary; + + &:focus { + border-color: $css-color-primary; + outline: none; + box-shadow: 0 0 0 2px rgba($css-color-primary, 0.2); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + background-color: $semantic-color-surface-variant; + } + } + } + + // ========================================================================== + // COLOR VALIDATION STYLES + // ========================================================================== + + .color-validation { + margin-bottom: $semantic-spacing-component-md; + padding: $semantic-spacing-component-sm; + border-radius: $semantic-border-radius-sm; + + &--error { + background-color: rgba($css-color-error, 0.1); + border: 1px solid $css-color-error; + } + + &--success { + background-color: rgba($semantic-color-success, 0.1); + border: 1px solid $semantic-color-success; + } + + &__title { + @extend .skyui-text--small !optional; + font-weight: $semantic-typography-font-weight-medium; + margin-bottom: $semantic-spacing-component-2xs; + } + + &__list { + margin: 0; + padding-left: $semantic-spacing-component-md; + + li { + @extend .skyui-text--small !optional; + margin-bottom: $semantic-spacing-component-2xs; + + &:last-child { + margin-bottom: 0; + } + } + } + + &--error &__title, + &--error &__list { + color: $css-color-error; + } + + &--success &__title, + &--success &__list { + color: $semantic-color-success; + } + } + + // ========================================================================== + // COLOR PREVIEW STYLES + // ========================================================================== + + .color-preview { + margin-bottom: $semantic-spacing-component-md; + + &__title { + @extend .skyui-text--small !optional; + font-weight: $semantic-typography-font-weight-medium; + color: $semantic-color-text-primary; + margin-bottom: $semantic-spacing-component-xs; + } + + &__palette { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(80px, 1fr)); + gap: $semantic-spacing-component-2xs; + } + + &__swatch { + aspect-ratio: 1; + border-radius: $semantic-border-radius-sm; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + border: 1px solid $semantic-color-border-primary; + } + + &__swatch-label { + font-size: 10px; + font-weight: $semantic-typography-font-weight-medium; + text-transform: capitalize; + text-shadow: 0 0 2px rgba(0, 0, 0, 0.5); + } + } + + // ========================================================================== + // ACTION BUTTON STYLES + // ========================================================================== + + .custom-color-actions { + margin-bottom: $semantic-spacing-component-md; + + &__apply { + @extend .skyui-interactive-text--button-medium !optional; + width: 100%; + background-color: $css-color-primary; + color: $css-color-on-primary; + border: none; + border-radius: $semantic-border-radius-md; + padding: $semantic-spacing-component-md $semantic-spacing-component-lg; + cursor: pointer; + transition: all 0.2s ease; + font-weight: $semantic-typography-font-weight-medium; + + &:hover:not(:disabled) { + background-color: var(--color-primary-hover, $css-color-primary); + filter: brightness(0.9); + box-shadow: $semantic-shadow-elevated; + transform: translateY(-1px); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; + box-shadow: none; + } + } + } + + // ========================================================================== + // RESET BUTTON STYLES + // ========================================================================== + + &__reset-btn { + @extend .skyui-interactive-text--button-medium !optional; + width: 100%; + background-color: transparent; + color: $css-color-error; + border: 1px solid $css-color-error; + border-radius: $semantic-border-radius-md; + padding: $semantic-spacing-component-sm $semantic-spacing-component-lg; + cursor: pointer; + transition: all 0.2s ease; + + &:hover:not(:disabled) { + background-color: rgba($css-color-error, 0.1); + box-shadow: $semantic-shadow-elevated; + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + } + + // ========================================================================== + // LOADING OVERLAY STYLES + // ========================================================================== + + &__loading-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba($css-color-scrim, 0.5); + border-radius: $semantic-border-radius-lg; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + z-index: 10; + } + + &__loading-spinner { + width: 32px; + height: 32px; + border: 3px solid $semantic-color-border-primary; + border-top: 3px solid $css-color-primary; + border-radius: 50%; + animation: spin 1s linear infinite; + margin-bottom: $semantic-spacing-component-sm; + } + + &__loading-text { + @extend .skyui-text--small !optional; + color: $semantic-color-text-primary; + font-weight: $semantic-typography-font-weight-medium; + } +} + +// ========================================================================== +// ANIMATIONS +// ========================================================================== + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +// ========================================================================== +// RESPONSIVE DESIGN +// ========================================================================== + +@media (max-width: $semantic-breakpoint-sm) { + .theme-switcher { + padding: $semantic-spacing-component-md; + + .theme-grid { + grid-template-columns: repeat(auto-fit, minmax(80px, 1fr)); + } + + .color-preview { + &__palette { + grid-template-columns: repeat(auto-fit, minmax(60px, 1fr)); + } + } + } +} \ No newline at end of file diff --git a/src/lib/components/feedback/theme-switcher.component.ts b/src/lib/components/feedback/theme-switcher.component.ts new file mode 100644 index 0000000..76eb8bf --- /dev/null +++ b/src/lib/components/feedback/theme-switcher.component.ts @@ -0,0 +1,471 @@ +/** + * ========================================================================== + * THEME SWITCHER COMPONENT + * ========================================================================== + * Interactive component for runtime theme switching. + * Supports predefined themes, custom colors, and dark mode toggle. + * Provides color validation and preview functionality. + * ========================================================================== + */ + +import { Component, OnInit, OnDestroy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Subject } from 'rxjs'; +import { takeUntil, debounceTime, distinctUntilChanged } from 'rxjs/operators'; + +import { ThemeService, ThemeConfig, ThemeState } from '../../../core/services/theme.service'; + +@Component({ + selector: 'app-theme-switcher', + standalone: true, + imports: [CommonModule, FormsModule], + template: ` + + +
+ +
+

Theme Settings

+

Customize your app's appearance

+
+ + +
+
+ +
+
+ + +
+

Predefined Themes

+
+ @for (theme of availableThemes; track theme.name) { +
+
+
+
+
+
+
+
+ {{ getThemeDisplayName(theme.name) }} + @if (isThemeActive(theme.name)) { +
+ + + +
+ } +
+ } +
+
+ + +
+
+

Custom Color

+ +
+ +
+ +
+ +
+ + +
+
+ + +
+ + +
+ + + @if (colorValidation) { +
+ @if (!colorValidation.isValid) { +
+
⚠️ Issues found:
+
    + @for (warning of colorValidation.warnings; track warning) { +
  • {{ warning }}
  • + } +
+
+ } + @if (colorValidation.recommendations) { +
+
💡 Recommendations:
+
    + @for (rec of colorValidation.recommendations; track rec) { +
  • {{ rec }}
  • + } +
+
+ } +
+ } + + + @if (showColorPreview && colorValidation?.isValid) { +
+
Color Preview
+
+ @for (color of colorPreview | keyvalue; track color.key) { +
+ {{ color.key }} +
+ } +
+
+ } + + +
+ +
+
+
+ + +
+ +
+ + + @if (isApplyingTheme) { +
+
+ Applying theme... +
+ } +
+ `, + styleUrls: ['./theme-switcher.component.scss'] +}) +export class ThemeSwitcherComponent implements OnInit, OnDestroy { + private destroy$ = new Subject(); + + // Component state + currentTheme: ThemeState = { + currentTheme: 'default', + isDark: false, + primaryColor: '#6750A4', + customThemes: [] + }; + + availableThemes: ThemeConfig[] = []; + isDarkMode = false; + + // Custom color input + customColorInput = '#6750A4'; + customThemeName = 'My Theme'; + colorPreview: { [key: string]: string } = {}; + colorValidation: { isValid: boolean; warnings: string[]; recommendations?: string[]; } | null = null; + + // UI state + showCustomColorPanel = false; + showColorPreview = false; + isApplyingTheme = false; + + constructor(private themeService: ThemeService) {} + + ngOnInit(): void { + this.initializeComponent(); + this.setupSubscriptions(); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + // ========================================================================== + // INITIALIZATION METHODS + // ========================================================================== + + /** + * Initializes component state + */ + private initializeComponent(): void { + this.availableThemes = this.themeService.getAvailableThemes(); + this.currentTheme = this.themeService.getCurrentTheme(); + this.isDarkMode = this.themeService.isDarkMode(); + this.customColorInput = this.currentTheme.primaryColor; + } + + /** + * Sets up reactive subscriptions + */ + private setupSubscriptions(): void { + // Listen to theme state changes + this.themeService.themeState$ + .pipe(takeUntil(this.destroy$)) + .subscribe(themeState => { + this.currentTheme = themeState; + this.availableThemes = this.themeService.getAvailableThemes(); + }); + + // Listen to dark mode changes + this.themeService.isDark$ + .pipe(takeUntil(this.destroy$)) + .subscribe(isDark => { + this.isDarkMode = isDark; + }); + } + + // ========================================================================== + // THEME SWITCHING METHODS + // ========================================================================== + + /** + * Applies a predefined theme + * @param themeName - Name of the theme to apply + */ + onThemeChange(themeName: string): void { + if (themeName !== this.currentTheme.currentTheme) { + this.isApplyingTheme = true; + setTimeout(() => { + this.themeService.applyTheme(themeName, this.isDarkMode); + this.isApplyingTheme = false; + }, 100); + } + } + + /** + * Toggles dark mode + */ + onDarkModeToggle(): void { + this.themeService.toggleDarkMode(); + } + + /** + * Resets to default theme + */ + onResetTheme(): void { + this.isApplyingTheme = true; + setTimeout(() => { + this.themeService.resetToDefault(); + this.customColorInput = this.themeService.getCurrentTheme().primaryColor; + this.isApplyingTheme = false; + }, 100); + } + + // ========================================================================== + // CUSTOM COLOR METHODS + // ========================================================================== + + /** + * Handles custom color input changes + * @param color - Color input value + */ + onCustomColorChange(color: string): void { + this.customColorInput = color; + this.validateAndPreviewColor(color); + } + + /** + * Validates and previews a custom color + * @param color - Color to validate and preview + */ + private validateAndPreviewColor(color: string): void { + // Clear previous validation + this.colorValidation = null; + this.colorPreview = {}; + this.showColorPreview = false; + + if (!color || color.length < 4) { + return; + } + + try { + // Validate color + this.colorValidation = this.themeService.validateColor(color); + + // Generate preview if valid + if (this.colorValidation?.isValid) { + this.colorPreview = this.themeService.generateColorPreview(color); + this.showColorPreview = true; + } + } catch (error) { + this.colorValidation = { + isValid: false, + warnings: ['Invalid color format'] + }; + } + } + + /** + * Applies custom color theme + */ + onApplyCustomColor(): void { + if (!this.colorValidation?.isValid) { + return; + } + + this.isApplyingTheme = true; + setTimeout(() => { + this.themeService.applyCustomTheme( + this.customColorInput, + this.customThemeName || 'Custom Theme', + this.isDarkMode + ); + this.showCustomColorPanel = false; + this.isApplyingTheme = false; + }, 100); + } + + /** + * Toggles custom color panel + */ + onToggleCustomPanel(): void { + this.showCustomColorPanel = !this.showCustomColorPanel; + if (this.showCustomColorPanel) { + this.validateAndPreviewColor(this.customColorInput); + } + } + + // ========================================================================== + // UTILITY METHODS + // ========================================================================== + + /** + * Gets display name for theme + * @param themeName - Theme internal name + * @returns Human-readable theme name + */ + getThemeDisplayName(themeName: string): string { + const displayNames: { [key: string]: string } = { + 'default': 'Material Purple', + 'ocean': 'Ocean Blue', + 'forest': 'Forest Green', + 'sunset': 'Sunset Orange' + }; + return displayNames[themeName] || themeName; + } + + /** + * Gets CSS class for theme preview + * @param themeName - Theme name + * @returns CSS class name + */ + getThemePreviewClass(themeName: string): string { + return `theme-preview--${themeName}`; + } + + /** + * Checks if theme is currently active + * @param themeName - Theme name to check + * @returns True if theme is active + */ + isThemeActive(themeName: string): boolean { + return this.currentTheme.currentTheme === themeName; + } + + /** + * Gets color contrast text color + * @param backgroundColor - Background color + * @returns Text color (black or white) + */ + getContrastTextColor(backgroundColor: string): string { + // Simple contrast check - in production you might want to use ColorUtils.getContrast + const hex = backgroundColor.replace('#', ''); + const r = parseInt(hex.substr(0, 2), 16); + const g = parseInt(hex.substr(2, 2), 16); + const b = parseInt(hex.substr(4, 2), 16); + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + return luminance > 0.5 ? '#000000' : '#FFFFFF'; + } +} \ No newline at end of file diff --git a/src/lib/components/feedback/toast/index.ts b/src/lib/components/feedback/toast/index.ts new file mode 100644 index 0000000..dab40b7 --- /dev/null +++ b/src/lib/components/feedback/toast/index.ts @@ -0,0 +1 @@ +export * from './toast.component'; \ No newline at end of file diff --git a/src/lib/components/feedback/toast/toast.component.scss b/src/lib/components/feedback/toast/toast.component.scss new file mode 100644 index 0000000..ba43707 --- /dev/null +++ b/src/lib/components/feedback/toast/toast.component.scss @@ -0,0 +1,289 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-toast { + // Core Structure + display: flex; + position: relative; + align-items: flex-start; + width: 100%; + max-width: 400px; + + // Layout & Spacing + padding: $semantic-spacing-component-md; + gap: $semantic-spacing-component-sm; + + // Visual Design + background: $semantic-color-surface-elevated; + border: $semantic-border-width-1 solid $semantic-color-border-primary; + border-radius: $semantic-border-card-radius; + box-shadow: $semantic-shadow-elevation-3; + + // Typography + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + color: $semantic-color-text-primary; + + // Transitions + transition: all $semantic-motion-duration-normal $semantic-motion-easing-ease; + + // Size Variants + &--sm { + padding: $semantic-spacing-component-sm; + gap: $semantic-spacing-component-xs; + max-width: 320px; + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + } + + &--md { + // Default styles already applied above + } + + &--lg { + padding: $semantic-spacing-component-lg; + gap: $semantic-spacing-component-md; + max-width: 480px; + font-family: map-get($semantic-typography-body-large, font-family); + font-size: map-get($semantic-typography-body-large, font-size); + font-weight: map-get($semantic-typography-body-large, font-weight); + line-height: map-get($semantic-typography-body-large, line-height); + } + + // Color Variants + &--primary { + border-color: $semantic-color-primary; + border-left-width: 4px; + + .ui-toast__icon { + color: $semantic-color-primary; + } + + .ui-toast__title { + color: $semantic-color-primary; + } + } + + &--success { + border-color: $semantic-color-success; + border-left-width: 4px; + + .ui-toast__icon { + color: $semantic-color-success; + } + + .ui-toast__title { + color: $semantic-color-success; + } + } + + &--warning { + border-color: $semantic-color-warning; + border-left-width: 4px; + + .ui-toast__icon { + color: $semantic-color-warning; + } + + .ui-toast__title { + color: $semantic-color-warning; + } + } + + &--danger { + border-color: $semantic-color-danger; + border-left-width: 4px; + + .ui-toast__icon { + color: $semantic-color-danger; + } + + .ui-toast__title { + color: $semantic-color-danger; + } + } + + &--info { + border-color: $semantic-color-info; + border-left-width: 4px; + + .ui-toast__icon { + color: $semantic-color-info; + } + + .ui-toast__title { + color: $semantic-color-info; + } + } + + // BEM Elements + &__icon { + flex-shrink: 0; + color: $semantic-color-text-secondary; + font-size: $semantic-sizing-icon-inline; + margin-top: 2px; // Slight optical alignment + } + + &__content { + flex: 1; + min-width: 0; // Prevents flex overflow + } + + &__title { + margin: 0 0 $semantic-spacing-content-line-tight 0; + font-family: map-get($semantic-typography-label, font-family); + font-size: map-get($semantic-typography-label, font-size); + font-weight: map-get($semantic-typography-label, font-weight); + line-height: map-get($semantic-typography-label, line-height); + color: $semantic-color-text-primary; + + &--bold { + font-weight: $semantic-typography-font-weight-semibold; + } + } + + &__message { + margin: 0; + color: $semantic-color-text-secondary; + } + + &__actions { + margin-top: $semantic-spacing-component-sm; + display: flex; + gap: $semantic-spacing-component-xs; + flex-wrap: wrap; + } + + &__dismiss { + position: absolute; + top: $semantic-spacing-component-xs; + right: $semantic-spacing-component-xs; + background: transparent; + border: none; + color: $semantic-color-text-tertiary; + cursor: pointer; + padding: $semantic-spacing-component-xs; + border-radius: $semantic-border-radius-sm; + display: flex; + align-items: center; + justify-content: center; + min-width: $semantic-sizing-touch-minimum; + min-height: $semantic-sizing-touch-minimum; + + &:hover { + background: $semantic-color-surface-secondary; + color: $semantic-color-text-secondary; + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + + &:active { + background: $semantic-color-surface-primary; + } + } + + // State Variants + &--dismissible { + padding-right: calc($semantic-spacing-component-lg + $semantic-sizing-touch-minimum); + } + + // Toast-specific positioning and animations + &--entering { + animation: toast-slide-in $semantic-motion-duration-normal $semantic-motion-easing-ease forwards; + } + + &--exiting { + animation: toast-slide-out $semantic-motion-duration-normal $semantic-motion-easing-ease forwards; + } + + // Progress bar for auto-dismiss + &__progress { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 3px; + background: $semantic-color-surface-secondary; + border-radius: 0 0 $semantic-border-card-radius $semantic-border-card-radius; + overflow: hidden; + + &-bar { + height: 100%; + background: currentColor; + width: 100%; + transform-origin: left; + animation: toast-progress var(--duration) linear forwards; + } + } + + &--with-progress { + padding-bottom: calc($semantic-spacing-component-md + 3px); + } + + // Responsive Design + @media (max-width: $semantic-breakpoint-sm - 1) { + padding: $semantic-spacing-component-sm; + gap: $semantic-spacing-component-xs; + max-width: 100%; + + &--lg { + padding: $semantic-spacing-component-md; + } + + &__title { + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + } + + &__message { + font-family: map-get($semantic-typography-caption, font-family); + font-size: map-get($semantic-typography-caption, font-size); + font-weight: map-get($semantic-typography-caption, font-weight); + line-height: map-get($semantic-typography-caption, line-height); + } + + &--dismissible { + padding-right: calc($semantic-spacing-component-md + $semantic-sizing-touch-minimum); + } + } +} + +// Keyframe animations +@keyframes toast-slide-in { + from { + opacity: 0; + transform: translateX(100%); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes toast-slide-out { + from { + opacity: 1; + transform: translateX(0); + } + to { + opacity: 0; + transform: translateX(100%); + } +} + +@keyframes toast-progress { + from { + transform: scaleX(1); + } + to { + transform: scaleX(0); + } +} \ No newline at end of file diff --git a/src/lib/components/feedback/toast/toast.component.ts b/src/lib/components/feedback/toast/toast.component.ts new file mode 100644 index 0000000..c6c1eb0 --- /dev/null +++ b/src/lib/components/feedback/toast/toast.component.ts @@ -0,0 +1,238 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation, OnInit, OnDestroy, ElementRef, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { IconDefinition } from '@fortawesome/free-solid-svg-icons'; +import { + faCheckCircle, + faExclamationTriangle, + faExclamationCircle, + faInfoCircle, + faTimes +} from '@fortawesome/free-solid-svg-icons'; + +type ToastSize = 'sm' | 'md' | 'lg'; +type ToastVariant = 'primary' | 'success' | 'warning' | 'danger' | 'info'; +type ToastPosition = 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center' | 'bottom-center'; + +@Component({ + selector: 'ui-toast', + standalone: true, + imports: [CommonModule, FontAwesomeModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ + @if (showIcon && toastIcon) { + + + } + +
+ @if (title) { +

+ {{ title }} +

+ } + +
+ +
+ + @if (actions && actions.length > 0) { +
+ +
+ } +
+ + @if (dismissible) { + + } + + @if (showProgress && autoDismiss && duration > 0) { +
+
+
+
+ } +
+ `, + styleUrl: './toast.component.scss' +}) +export class ToastComponent implements OnInit, OnDestroy { + @Input() size: ToastSize = 'md'; + @Input() variant: ToastVariant = 'primary'; + @Input() title?: string; + @Input() boldTitle = false; + @Input() showIcon = true; + @Input() dismissible = true; + @Input() autoDismiss = true; + @Input() duration = 5000; // 5 seconds + @Input() showProgress = true; + @Input() position: ToastPosition = 'top-right'; + @Input() dismissLabel = 'Dismiss toast'; + @Input() role = 'alert'; + @Input() ariaLive: 'polite' | 'assertive' | 'off' = 'assertive'; + @Input() actions: any[] = []; + + @Output() dismissed = new EventEmitter(); + @Output() expired = new EventEmitter(); + @Output() shown = new EventEmitter(); + @Output() hidden = new EventEmitter(); + + // Icons + readonly faCheckCircle = faCheckCircle; + readonly faExclamationTriangle = faExclamationTriangle; + readonly faExclamationCircle = faExclamationCircle; + readonly faInfoCircle = faInfoCircle; + readonly faTimes = faTimes; + + // Generate unique ID for accessibility + readonly toastId = Math.random().toString(36).substr(2, 9); + + // State management + isEntering = false; + isExiting = false; + private autoDismissTimeout?: any; + private enterTimeout?: any; + private exitTimeout?: any; + private elementRef = inject(ElementRef); + + get toastIcon(): IconDefinition | null { + if (!this.showIcon) return null; + + switch (this.variant) { + case 'success': + return this.faCheckCircle; + case 'warning': + return this.faExclamationTriangle; + case 'danger': + return this.faExclamationCircle; + case 'info': + return this.faInfoCircle; + case 'primary': + default: + return this.faInfoCircle; + } + } + + ngOnInit(): void { + this.show(); + } + + ngOnDestroy(): void { + this.clearTimeouts(); + } + + show(): void { + this.isEntering = true; + + this.enterTimeout = setTimeout(() => { + this.isEntering = false; + this.shown.emit(); + + if (this.autoDismiss && this.duration > 0) { + this.startAutoDismissTimer(); + } + }, 300); // Animation duration + } + + hide(): void { + this.clearTimeouts(); + this.isExiting = true; + + this.exitTimeout = setTimeout(() => { + this.isExiting = false; + this.hidden.emit(); + }, 300); // Animation duration + } + + handleDismiss(): void { + this.hide(); + this.dismissed.emit(); + } + + handleDismissKeydown(event: KeyboardEvent): void { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + this.handleDismiss(); + } + } + + private startAutoDismissTimer(): void { + if (this.autoDismissTimeout) { + clearTimeout(this.autoDismissTimeout); + } + + this.autoDismissTimeout = setTimeout(() => { + this.hide(); + this.expired.emit(); + }, this.duration); + } + + private clearTimeouts(): void { + if (this.autoDismissTimeout) { + clearTimeout(this.autoDismissTimeout); + this.autoDismissTimeout = undefined; + } + + if (this.enterTimeout) { + clearTimeout(this.enterTimeout); + this.enterTimeout = undefined; + } + + if (this.exitTimeout) { + clearTimeout(this.exitTimeout); + this.exitTimeout = undefined; + } + } + + // Pause auto-dismiss on mouse enter + onMouseEnter(): void { + if (this.autoDismissTimeout) { + clearTimeout(this.autoDismissTimeout); + } + } + + // Resume auto-dismiss on mouse leave + onMouseLeave(): void { + if (this.autoDismiss && this.duration > 0 && !this.isExiting) { + this.startAutoDismissTimer(); + } + } +} \ No newline at end of file diff --git a/src/lib/components/forms/autocomplete/autocomplete.component.scss b/src/lib/components/forms/autocomplete/autocomplete.component.scss new file mode 100644 index 0000000..5eceda0 --- /dev/null +++ b/src/lib/components/forms/autocomplete/autocomplete.component.scss @@ -0,0 +1,367 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-autocomplete { + // Core Structure + position: relative; + display: inline-flex; + flex-direction: column; + width: 100%; + + // Size Variants + &--sm { + .ui-autocomplete__input-wrapper { + min-height: 40px; + } + + .ui-autocomplete__input { + font-size: 16px; + padding: 8px 12px; + } + + .ui-autocomplete__label { + font-size: 14px; + } + } + + &--md { + .ui-autocomplete__input-wrapper { + min-height: 48px; + } + + .ui-autocomplete__input { + font-size: 18px; + padding: 12px 16px; + } + + .ui-autocomplete__label { + font-size: 16px; + } + } + + &--lg { + .ui-autocomplete__input-wrapper { + min-height: 56px; + } + + .ui-autocomplete__input { + font-size: 20px; + padding: 16px 20px; + } + + .ui-autocomplete__label { + font-size: 18px; + } + } + + // Variant Styles + &--outlined { + .ui-autocomplete__input-wrapper { + background: $semantic-color-surface-primary; + border: $semantic-border-width-1 solid $semantic-color-border-primary; + border-radius: $semantic-border-radius-md; + } + + &:hover:not(.ui-autocomplete--disabled) .ui-autocomplete__input-wrapper { + border-color: $semantic-color-border-focus; + } + } + + &--filled { + .ui-autocomplete__input-wrapper { + background: $semantic-color-surface-secondary; + border: $semantic-border-width-1 solid transparent; + border-radius: $semantic-border-radius-md $semantic-border-radius-md 0 0; + border-bottom-color: $semantic-color-border-primary; + } + + &:hover:not(.ui-autocomplete--disabled) .ui-autocomplete__input-wrapper { + border-bottom-color: $semantic-color-border-focus; + } + } + + // State Variants + &--focused { + .ui-autocomplete__input-wrapper { + border-color: $semantic-color-brand-primary; + box-shadow: $semantic-shadow-button-focus; + } + + .ui-autocomplete__label { + color: $semantic-color-brand-primary; + } + } + + &--error { + .ui-autocomplete__input-wrapper { + border-color: $semantic-color-error; + } + + .ui-autocomplete__label { + color: $semantic-color-error; + } + } + + &--disabled { + opacity: 0.38; + cursor: not-allowed; + + .ui-autocomplete__input { + cursor: not-allowed; + } + } + + &--loading { + .ui-autocomplete__input { + cursor: wait; + } + } + + // Label + &__label { + display: block; + margin-bottom: 8px; + color: $semantic-color-text-secondary; + font-weight: 500; + transition: color 0.2s ease; + + &--required::after { + content: ' *'; + color: $semantic-color-error; + } + } + + // Input Wrapper + &__input-wrapper { + position: relative; + display: flex; + align-items: center; + transition: all 0.2s ease; + } + + // Input Field + &__input { + flex: 1; + border: none; + outline: none; + background: transparent; + color: $semantic-color-text-primary; + font-family: inherit; + transition: all 0.2s ease; + + &::placeholder { + color: $semantic-color-text-tertiary; + } + + &:focus { + outline: none; + } + + &:disabled { + color: $semantic-color-text-tertiary; + cursor: not-allowed; + } + } + + // Clear Button + &__clear-button { + display: flex; + align-items: center; + justify-content: center; + padding: 8px; + margin-right: 8px; + border: none; + background: transparent; + color: $semantic-color-text-secondary; + border-radius: $semantic-border-radius-sm; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background: $semantic-color-surface-elevated; + color: $semantic-color-text-primary; + } + + &:focus-visible { + outline: 2px solid $semantic-color-brand-primary; + outline-offset: 2px; + } + + &:disabled { + opacity: 0.38; + cursor: not-allowed; + } + } + + // Dropdown Container + &__dropdown { + position: absolute; + top: 100%; + left: 0; + right: 0; + z-index: 1000; + margin-top: 8px; + background: $semantic-color-surface-primary; + border: $semantic-border-width-1 solid $semantic-color-border-primary; + border-radius: $semantic-border-radius-md; + box-shadow: $semantic-shadow-elevation-3; + max-height: 240px; + overflow-y: auto; + + &--sm { + max-height: 200px; + } + + &--lg { + max-height: 280px; + } + } + + // Option Item + &__option { + display: flex; + align-items: center; + width: 100%; + padding: 12px 16px; + border: none; + background: transparent; + color: $semantic-color-text-primary; + font-family: inherit; + font-size: 18px; + text-align: left; + cursor: pointer; + transition: all 0.2s ease; + + &--sm { + padding: 8px 12px; + font-size: 16px; + } + + &--md { + padding: 12px 16px; + font-size: 18px; + } + + &--lg { + padding: 16px 20px; + font-size: 20px; + } + + &:hover { + background: $semantic-color-surface-secondary; + } + + &:focus-visible { + outline: 2px solid $semantic-color-brand-primary; + outline-offset: -2px; + } + + &--highlighted { + background: $semantic-color-surface-secondary; + color: $semantic-color-text-primary; + } + + &--selected { + background: rgba($semantic-color-brand-primary, 0.08); + color: $semantic-color-brand-primary; + font-weight: 500; + } + + &--disabled { + opacity: 0.38; + cursor: not-allowed; + + &:hover { + background: transparent; + } + } + } + + // Option Text + &__option-text { + flex: 1; + } + + // Option Secondary Text + &__option-secondary { + color: $semantic-color-text-secondary; + font-size: 12px; + margin-left: 12px; + } + + // Loading State + &__loading { + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + color: $semantic-color-text-secondary; + font-size: 14px; + } + + // No Options State + &__no-options { + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + color: $semantic-color-text-secondary; + font-size: 14px; + font-style: italic; + } + + // Helper Text + &__helper-text { + margin-top: 8px; + color: $semantic-color-text-secondary; + font-size: 12px; + } + + // Error Text + &__error-text { + margin-top: 8px; + color: $semantic-color-error; + font-size: 12px; + } + + // Full Width + &--full-width { + width: 100%; + } + + // Dark Mode Support + :host-context(.dark-theme) & { + .ui-autocomplete__input::placeholder { + color: $semantic-color-text-secondary; + } + } + + // Responsive Design + @media (max-width: 767px) { + &__dropdown { + max-height: 200px; + } + } + + @media (max-width: 575px) { + // Mobile adjustments - respect size variants + &--sm &__input { + font-size: 16px; + } + + &--md &__input { + font-size: 18px; + } + + &--lg &__input { + font-size: 20px; + } + + &__dropdown { + max-height: 180px; + } + + &__option { + padding: 12px 16px; + } + } +} \ No newline at end of file diff --git a/src/lib/components/forms/autocomplete/autocomplete.component.ts b/src/lib/components/forms/autocomplete/autocomplete.component.ts new file mode 100644 index 0000000..84b4eb0 --- /dev/null +++ b/src/lib/components/forms/autocomplete/autocomplete.component.ts @@ -0,0 +1,512 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, forwardRef, signal, ViewChild, ElementRef, HostListener } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule, ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { faTimes } from '@fortawesome/free-solid-svg-icons'; + +export type AutocompleteSize = 'sm' | 'md' | 'lg'; +export type AutocompleteVariant = 'outlined' | 'filled'; + +export interface AutocompleteOption { + value: any; + label: string; + secondaryText?: string; + disabled?: boolean; +} + +@Component({ + selector: 'ui-autocomplete', + standalone: true, + imports: [CommonModule, FormsModule, FontAwesomeModule], + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AutocompleteComponent), + multi: true + } + ], + template: ` +
+ @if (label) { + + } + +
+ + + @if (displayValue() && clearable && !disabled && !readonly) { + + } +
+ + @if (isDropdownOpen()) { +
+ @if (loading) { +
+ Loading... +
+ } @else if (filteredOptions().length === 0) { +
+ {{ noOptionsText }} +
+ } @else { + @for (option of filteredOptions(); track option.value; let index = $index) { + + } + } +
+ } + + @if (helperText && !hasError) { +
+ {{ helperText }} +
+ } + + @if (errorText && hasError) { +
+ {{ errorText }} +
+ } +
+ `, + styleUrl: './autocomplete.component.scss' +}) +export class AutocompleteComponent implements ControlValueAccessor { + @ViewChild('inputElement', { static: true }) inputElement!: ElementRef; + + // Core inputs + @Input() label: string = ''; + @Input() placeholder: string = 'Search...'; + @Input() helperText: string = ''; + @Input() errorText: string = ''; + @Input() size: AutocompleteSize = 'md'; + @Input() variant: AutocompleteVariant = 'outlined'; + + // Options and filtering + @Input() options: AutocompleteOption[] = []; + @Input() filterFn?: (option: AutocompleteOption, query: string) => boolean; + @Input() minQueryLength: number = 0; + @Input() maxOptions: number = 100; + @Input() noOptionsText: string = 'No options found'; + + // Behavior + @Input() disabled: boolean = false; + @Input() readonly: boolean = false; + @Input() required: boolean = false; + @Input() clearable: boolean = true; + @Input() loading: boolean = false; + @Input() fullWidth: boolean = false; + @Input() openOnFocus: boolean = false; + @Input() closeOnSelect: boolean = true; + + // Accessibility + @Input() ariaLabel: string = ''; + @Input() autocompleteId: string = `ui-autocomplete-${Math.random().toString(36).substr(2, 9)}`; + + // Outputs + @Output() valueChange = new EventEmitter(); + @Output() optionSelected = new EventEmitter(); + @Output() queryChange = new EventEmitter(); + @Output() focused = new EventEmitter(); + @Output() blurred = new EventEmitter(); + @Output() dropdownOpen = new EventEmitter(); + @Output() dropdownClose = new EventEmitter(); + + // Font Awesome icons + protected readonly faTimes = faTimes; + + // Internal state + private _selectedOption = signal(null); + private _query = signal(''); + private _displayValue = signal(''); + private _isFocused = signal(false); + private _isDropdownOpen = signal(false); + private _highlightedIndex = signal(-1); + private _filteredOptions = signal([]); + + // Public signals + selectedOption = this._selectedOption.asReadonly(); + query = this._query.asReadonly(); + displayValue = this._displayValue.asReadonly(); + isFocused = this._isFocused.asReadonly(); + isDropdownOpen = this._isDropdownOpen.asReadonly(); + highlightedIndex = this._highlightedIndex.asReadonly(); + filteredOptions = this._filteredOptions.asReadonly(); + + // ControlValueAccessor implementation + private onChange = (value: any) => {}; + private onTouched = () => {}; + + get hasError(): boolean { + return !!this.errorText; + } + + ngOnInit(): void { + this.updateFilteredOptions(); + } + + ngOnChanges(): void { + this.updateFilteredOptions(); + } + + // ControlValueAccessor methods + writeValue(value: any): void { + if (value) { + const option = this.options.find(opt => opt.value === value); + if (option) { + this._selectedOption.set(option); + this._displayValue.set(option.label); + this._query.set(option.label); + } else { + // If value doesn't match any option, treat as free text + this._selectedOption.set(null); + this._displayValue.set(String(value)); + this._query.set(String(value)); + } + } else { + this.clearValue(); + } + this.updateFilteredOptions(); + } + + registerOnChange(fn: (value: any) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + // Event handlers + onInput(event: Event): void { + if (this.readonly) return; + + const target = event.target as HTMLInputElement; + const value = target.value; + + this._query.set(value); + this._displayValue.set(value); + this._selectedOption.set(null); + this._highlightedIndex.set(-1); + + this.updateFilteredOptions(); + this.openDropdown(); + + this.onChange(value); + this.valueChange.emit(value); + this.queryChange.emit(value); + } + + onFocus(): void { + this._isFocused.set(true); + this.focused.emit(); + + if (this.openOnFocus) { + this.openDropdown(); + } + } + + onBlur(): void { + // Delay to allow for option selection + setTimeout(() => { + this._isFocused.set(false); + this.closeDropdown(); + this.onTouched(); + this.blurred.emit(); + }, 150); + } + + onKeyDown(event: KeyboardEvent): void { + if (this.readonly) return; + + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + this.highlightNext(); + this.openDropdown(); + break; + + case 'ArrowUp': + event.preventDefault(); + this.highlightPrevious(); + this.openDropdown(); + break; + + case 'Enter': + event.preventDefault(); + if (this.isDropdownOpen() && this.highlightedIndex() >= 0) { + const option = this.filteredOptions()[this.highlightedIndex()]; + if (option && !option.disabled) { + this.selectOption(option); + } + } + break; + + case 'Escape': + this.closeDropdown(); + this.inputElement.nativeElement.blur(); + break; + + case 'Tab': + this.closeDropdown(); + break; + } + } + + // Option selection + selectOption(option: AutocompleteOption): void { + if (option.disabled) return; + + this._selectedOption.set(option); + this._displayValue.set(option.label); + this._query.set(option.label); + this._highlightedIndex.set(-1); + + if (this.closeOnSelect) { + this.closeDropdown(); + } + + this.onChange(option.value); + this.valueChange.emit(option.value); + this.optionSelected.emit(option); + + // Return focus to input + this.inputElement.nativeElement.focus(); + } + + clearValue(): void { + this._selectedOption.set(null); + this._displayValue.set(''); + this._query.set(''); + this._highlightedIndex.set(-1); + + this.updateFilteredOptions(); + this.closeDropdown(); + + this.onChange(null); + this.valueChange.emit(null); + this.queryChange.emit(''); + + if (this.inputElement) { + this.inputElement.nativeElement.focus(); + } + } + + // Dropdown management + openDropdown(): void { + if (this.disabled || this.readonly) return; + + if (this.query().length >= this.minQueryLength || this.filteredOptions().length > 0) { + this._isDropdownOpen.set(true); + this.dropdownOpen.emit(); + } + } + + closeDropdown(): void { + this._isDropdownOpen.set(false); + this._highlightedIndex.set(-1); + this.dropdownClose.emit(); + } + + // Navigation + highlightNext(): void { + const options = this.filteredOptions(); + if (options.length === 0) return; + + const currentIndex = this.highlightedIndex(); + let nextIndex = currentIndex + 1; + + // Skip disabled options + while (nextIndex < options.length && options[nextIndex].disabled) { + nextIndex++; + } + + if (nextIndex >= options.length) { + // Wrap to first enabled option + nextIndex = 0; + while (nextIndex < options.length && options[nextIndex].disabled) { + nextIndex++; + } + } + + if (nextIndex < options.length) { + this._highlightedIndex.set(nextIndex); + } + } + + highlightPrevious(): void { + const options = this.filteredOptions(); + if (options.length === 0) return; + + const currentIndex = this.highlightedIndex(); + let prevIndex = currentIndex - 1; + + // Skip disabled options + while (prevIndex >= 0 && options[prevIndex].disabled) { + prevIndex--; + } + + if (prevIndex < 0) { + // Wrap to last enabled option + prevIndex = options.length - 1; + while (prevIndex >= 0 && options[prevIndex].disabled) { + prevIndex--; + } + } + + if (prevIndex >= 0) { + this._highlightedIndex.set(prevIndex); + } + } + + setHighlightedIndex(index: number): void { + this._highlightedIndex.set(index); + } + + // Utility methods + isSelected(option: AutocompleteOption): boolean { + const selected = this.selectedOption(); + return selected ? selected.value === option.value : false; + } + + getAriaDescribedBy(): string | null { + const ids: string[] = []; + + if (this.helperText && !this.hasError) { + ids.push(this.autocompleteId + '-helper'); + } + + if (this.errorText && this.hasError) { + ids.push(this.autocompleteId + '-error'); + } + + return ids.length > 0 ? ids.join(' ') : null; + } + + private updateFilteredOptions(): void { + const query = this.query(); + const options = this.options; + + if (query.length < this.minQueryLength) { + this._filteredOptions.set([]); + return; + } + + let filtered: AutocompleteOption[]; + + if (this.filterFn) { + filtered = options.filter(option => this.filterFn!(option, query)); + } else { + // Default filtering: case-insensitive substring match on label + const lowerQuery = query.toLowerCase(); + filtered = options.filter(option => + option.label.toLowerCase().includes(lowerQuery) || + (option.secondaryText && option.secondaryText.toLowerCase().includes(lowerQuery)) + ); + } + + // Limit results + if (filtered.length > this.maxOptions) { + filtered = filtered.slice(0, this.maxOptions); + } + + this._filteredOptions.set(filtered); + + // Reset highlighted index if out of bounds + if (this.highlightedIndex() >= filtered.length) { + this._highlightedIndex.set(-1); + } + } + + // Close dropdown when clicking outside + @HostListener('document:click', ['$event']) + onDocumentClick(event: Event): void { + const target = event.target as Element; + if (!target.closest('.ui-autocomplete')) { + this.closeDropdown(); + } + } +} \ No newline at end of file diff --git a/src/lib/components/forms/autocomplete/index.ts b/src/lib/components/forms/autocomplete/index.ts new file mode 100644 index 0000000..02025f4 --- /dev/null +++ b/src/lib/components/forms/autocomplete/index.ts @@ -0,0 +1 @@ +export * from './autocomplete.component'; \ No newline at end of file diff --git a/src/lib/components/forms/checkbox/checkbox.component.scss b/src/lib/components/forms/checkbox/checkbox.component.scss new file mode 100644 index 0000000..4906ffc --- /dev/null +++ b/src/lib/components/forms/checkbox/checkbox.component.scss @@ -0,0 +1,332 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +// Tokens available globally via main application styles + +// ========================================================================== +// CHECKBOX COMPONENT STYLES +// ========================================================================== +// Modern checkbox implementation using design tokens +// Follows Material Design 3 principles with semantic token system +// ========================================================================== + +.checkbox-wrapper { + display: flex; + align-items: flex-start; + position: relative; + cursor: pointer; + user-select: none; + transition: all $semantic-duration-fast $semantic-easing-standard; + + &:hover:not(.checkbox-wrapper--disabled) { + .checkbox-control { + border-color: $semantic-color-border-focus; + box-shadow: $semantic-shadow-input-focus; + } + } + + &:focus-within:not(.checkbox-wrapper--disabled) { + .checkbox-control { + border-color: $semantic-color-brand-primary; + box-shadow: $semantic-shadow-button-focus; + } + } + + // Disabled state + &.checkbox-wrapper--disabled { + cursor: not-allowed; + opacity: 0.6; + + .checkbox-control { + border-color: $semantic-color-border-disabled; + background-color: $semantic-color-surface-disabled; + } + + .checkbox-content { + color: $semantic-color-text-disabled; + } + } + + // Checked state + &.checkbox-wrapper--checked:not(.checkbox-wrapper--disabled) { + .checkbox-control { + background-color: $semantic-color-brand-primary; + border-color: $semantic-color-brand-primary; + } + + .checkbox-icon { + color: $semantic-color-on-brand-primary; + } + } + + // Size variants with proper spacing and alignment + &.checkbox-wrapper--sm { + gap: $semantic-spacing-component-sm; // 8px spacing + align-items: center; // Center align for single line content + + .checkbox-control { + height: 20px; + width: 20px; + } + + .checkbox-label { + font-size: $semantic-typography-font-size-sm; + line-height: $semantic-typography-line-height-normal; + } + + .checkbox-description { + font-size: $semantic-typography-font-size-xs; + line-height: $semantic-typography-line-height-tight; + } + + // If description exists, align to flex-start for multi-line content + &.checkbox-wrapper--with-description { + align-items: flex-start; + } + } + + &.checkbox-wrapper--md { + gap: $semantic-spacing-component-md; // 12px spacing + align-items: center; // Center align for single line content + + .checkbox-control { + height: 24px; + width: 24px; + } + + .checkbox-label { + font-size: $semantic-typography-font-size-md; + line-height: $semantic-typography-line-height-relaxed; + } + + .checkbox-description { + font-size: $semantic-typography-font-size-sm; + line-height: $semantic-typography-line-height-normal; + } + + // If description exists, align to flex-start for multi-line content + &.checkbox-wrapper--with-description { + align-items: flex-start; + } + } + + &.checkbox-wrapper--lg { + gap: $semantic-spacing-component-md; // 12px spacing (within range) + align-items: center; // Center align for single line content + + .checkbox-control { + height: 28px; + width: 28px; + } + + .checkbox-label { + font-size: $semantic-typography-font-size-lg; + line-height: $semantic-typography-line-height-relaxed; + } + + .checkbox-description { + font-size: $semantic-typography-font-size-md; + line-height: $semantic-typography-line-height-relaxed; + } + + // If description exists, align to flex-start for multi-line content + &.checkbox-wrapper--with-description { + align-items: flex-start; + } + } + + // Variant colors + &.checkbox-wrapper--primary.checkbox-wrapper--checked:not(.checkbox-wrapper--disabled) { + .checkbox-control { + background-color: $semantic-color-brand-primary; + border-color: $semantic-color-brand-primary; + } + } + + &.checkbox-wrapper--secondary.checkbox-wrapper--checked:not(.checkbox-wrapper--disabled) { + .checkbox-control { + background-color: $semantic-color-brand-secondary; + border-color: $semantic-color-brand-secondary; + } + } + + &.checkbox-wrapper--success.checkbox-wrapper--checked:not(.checkbox-wrapper--disabled) { + .checkbox-control { + background-color: $semantic-color-success; + border-color: $semantic-color-success; + } + + .checkbox-icon { + color: $semantic-color-on-success; + } + } + + &.checkbox-wrapper--warning.checkbox-wrapper--checked:not(.checkbox-wrapper--disabled) { + .checkbox-control { + background-color: $semantic-color-warning; + border-color: $semantic-color-warning; + } + + .checkbox-icon { + color: $semantic-color-on-warning; + } + } + + &.checkbox-wrapper--danger.checkbox-wrapper--checked:not(.checkbox-wrapper--disabled) { + .checkbox-control { + background-color: $semantic-color-danger; + border-color: $semantic-color-danger; + } + + .checkbox-icon { + color: $semantic-color-on-danger; + } + } + + // Error state + &.checkbox-wrapper--error:not(.checkbox-wrapper--disabled) { + .checkbox-control { + border-color: $semantic-color-danger; + } + + .checkbox-label { + color: $semantic-color-danger; + } + } +} + +.checkbox-input { + position: absolute; + opacity: 0; + cursor: pointer; + height: 0; + width: 0; + margin: 0; + padding: 0; +} + +.checkbox-control { + position: relative; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + background-color: $semantic-color-surface-primary; + border: $semantic-border-width-2 solid $semantic-color-border-primary; + border-radius: $semantic-border-radius-sm; + transition: all $semantic-duration-fast $semantic-easing-standard; + + // Base sizes (overridden by wrapper variants) + height: 24px; + width: 24px; +} + +.checkbox-checkmark { + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transform: scale(0.8); + transition: all $semantic-duration-fast $semantic-easing-spring; + + &.checkbox-checkmark--checked { + opacity: 1; + transform: scale(1); + } +} + +.checkbox-icon { + width: 60%; + height: 60%; + color: transparent; + transition: color $semantic-duration-fast $semantic-easing-standard; +} + +.checkbox-content { + display: flex; + flex-direction: column; + gap: $semantic-spacing-component-xs; + // Removed margin-left as it's now handled by wrapper gap +} + +.checkbox-label { + color: $semantic-color-text-primary; + font-weight: $semantic-typography-font-weight-medium; + transition: color $semantic-duration-fast $semantic-easing-standard; + + &.checkbox-label--required { + .checkbox-required-indicator { + color: $semantic-color-danger; + margin-left: 2px; + } + } +} + +.checkbox-description { + color: $semantic-color-text-secondary; + margin-top: $semantic-spacing-component-xs; + transition: color $semantic-duration-fast $semantic-easing-standard; +} + +// Content disabled state +.checkbox-content--disabled { + .checkbox-label, + .checkbox-description { + color: $semantic-color-text-disabled; + } +} + +// ========================================================================== +// RESPONSIVE DESIGN +// ========================================================================== + +@media (max-width: $semantic-breakpoint-md - 1) { + .checkbox-wrapper { + // Slightly larger touch targets on mobile + &.checkbox-wrapper--sm .checkbox-control { + height: calc(#{20px} + 4px); + width: calc(#{20px} + 4px); + } + + &.checkbox-wrapper--md .checkbox-control { + height: calc(#{24px} + 4px); + width: calc(#{24px} + 4px); + } + + &.checkbox-wrapper--lg .checkbox-control { + height: calc(#{28px} + 4px); + width: calc(#{28px} + 4px); + } + } +} + +// ========================================================================== +// ACCESSIBILITY ENHANCEMENTS +// ========================================================================== + +// Reduced motion preference +@media (prefers-reduced-motion: reduce) { + .checkbox-wrapper, + .checkbox-control, + .checkbox-checkmark, + .checkbox-icon, + .checkbox-label, + .checkbox-description { + transition: none !important; + animation: none !important; + } +} + +// High contrast mode +@media (prefers-contrast: high) { + .checkbox-wrapper { + .checkbox-control { + border-width: $semantic-border-width-3; + } + + &.checkbox-wrapper--checked:not(.checkbox-wrapper--disabled) { + .checkbox-control { + border-width: $semantic-border-width-3; + } + } + } +} \ No newline at end of file diff --git a/src/lib/components/forms/checkbox/checkbox.component.ts b/src/lib/components/forms/checkbox/checkbox.component.ts new file mode 100644 index 0000000..c79e271 --- /dev/null +++ b/src/lib/components/forms/checkbox/checkbox.component.ts @@ -0,0 +1,156 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, forwardRef, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; + +export type CheckboxSize = 'sm' | 'md' | 'lg'; +export type CheckboxVariant = 'primary' | 'secondary' | 'success' | 'warning' | 'danger'; +export type CheckboxState = 'default' | 'error' | 'disabled'; + +@Component({ + selector: 'ui-checkbox', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CheckboxComponent), + multi: true + } + ], + template: ` + + `, + styleUrls: ['./checkbox.component.scss'] +}) +export class CheckboxComponent implements ControlValueAccessor { + @Input() label: string = ''; + @Input() description: string = ''; + @Input() size: CheckboxSize = 'md'; + @Input() variant: CheckboxVariant = 'primary'; + @Input() state: CheckboxState = 'default'; + @Input() disabled = false; + @Input() required = false; + @Input() checkboxId: string = `checkbox-${Math.random().toString(36).substr(2, 9)}`; + + @Output() checkboxChange = new EventEmitter(); + @Output() checkboxFocus = new EventEmitter(); + @Output() checkboxBlur = new EventEmitter(); + + private checked = signal(false); + + // ControlValueAccessor implementation + private onChange = (value: boolean) => {}; + private onTouched = () => {}; + + isChecked(): boolean { + return this.checked(); + } + + writeValue(value: boolean): void { + this.checked.set(!!value); + } + + registerOnChange(fn: (value: boolean) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + getWrapperClasses(): string { + const classes = [ + `checkbox-wrapper--${this.size}`, + `checkbox-wrapper--${this.variant}`, + `checkbox-wrapper--${this.state}` + ]; + + if (this.disabled) classes.push('checkbox-wrapper--disabled'); + if (this.isChecked()) classes.push('checkbox-wrapper--checked'); + if (this.description) classes.push('checkbox-wrapper--with-description'); + + return classes.join(' '); + } + + getControlClasses(): string { + const classes = [ + `checkbox-control--${this.size}`, + `checkbox-control--${this.variant}`, + `checkbox-control--${this.state}` + ]; + + if (this.disabled) classes.push('checkbox-control--disabled'); + if (this.isChecked()) classes.push('checkbox-control--checked'); + + return classes.join(' '); + } + + getContentClasses(): string { + const classes = [ + `checkbox-content--${this.size}` + ]; + + if (this.disabled) classes.push('checkbox-content--disabled'); + + return classes.join(' '); + } + + onCheckboxChange(event: Event): void { + const target = event.target as HTMLInputElement; + const value = target.checked; + + this.checked.set(value); + this.onChange(value); + this.checkboxChange.emit(value); + } + + onFocus(event: FocusEvent): void { + this.checkboxFocus.emit(event); + } + + onBlur(event: FocusEvent): void { + this.onTouched(); + this.checkboxBlur.emit(event); + } +} \ No newline at end of file diff --git a/src/lib/components/forms/checkbox/index.ts b/src/lib/components/forms/checkbox/index.ts new file mode 100644 index 0000000..c0b086d --- /dev/null +++ b/src/lib/components/forms/checkbox/index.ts @@ -0,0 +1 @@ +export * from './checkbox.component'; \ No newline at end of file diff --git a/src/lib/components/forms/color-picker/color-picker.component.scss b/src/lib/components/forms/color-picker/color-picker.component.scss new file mode 100644 index 0000000..7329349 --- /dev/null +++ b/src/lib/components/forms/color-picker/color-picker.component.scss @@ -0,0 +1,362 @@ +@use 'ui-design-system/src/styles/semantic' as *; + +.ui-color-picker { + display: flex; + flex-direction: column; + position: relative; + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + color: $semantic-color-text-primary; + + &__label { + display: block; + margin-bottom: $semantic-spacing-form-field-gap; + font-family: map-get($semantic-typography-label, font-family); + font-size: map-get($semantic-typography-label, font-size); + font-weight: map-get($semantic-typography-label, font-weight); + line-height: map-get($semantic-typography-label, line-height); + color: $semantic-color-text-primary; + } + + &__required { + color: $semantic-color-danger; + margin-left: $semantic-spacing-component-xs; + } + + &__container { + display: flex; + flex-direction: column; + gap: $semantic-spacing-component-md; + padding: $semantic-spacing-component-md; + background: $semantic-color-surface-primary; + border: $semantic-border-width-1 solid $semantic-color-border-primary; + border-radius: $semantic-border-radius-md; + box-shadow: $semantic-shadow-elevation-1; + } + + &__wheel-container { + position: relative; + align-self: center; + } + + &__wheel { + display: block; + border-radius: $semantic-border-radius-full; + cursor: crosshair; + border: $semantic-border-width-1 solid $semantic-color-border-subtle; + box-shadow: $semantic-shadow-elevation-1; + transition: box-shadow $semantic-motion-duration-fast $semantic-motion-easing-ease; + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + box-shadow: $semantic-shadow-elevation-2; + } + } + + &__wheel-indicator { + position: absolute; + width: 12px; + height: 12px; + border: 2px solid $semantic-color-surface-primary; + border-radius: $semantic-border-radius-full; + background: transparent; + pointer-events: none; + box-shadow: $semantic-shadow-elevation-2; + z-index: 1; + } + + &__hue-container { + position: relative; + align-self: center; + } + + &__hue-bar { + display: block; + border-radius: $semantic-border-radius-sm; + cursor: pointer; + border: $semantic-border-width-1 solid $semantic-color-border-subtle; + box-shadow: $semantic-shadow-elevation-1; + transition: box-shadow $semantic-motion-duration-fast $semantic-motion-easing-ease; + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + box-shadow: $semantic-shadow-elevation-2; + } + } + + &__hue-indicator { + position: absolute; + top: -2px; + width: 12px; + height: calc(100% + 4px); + border: 2px solid $semantic-color-surface-primary; + border-radius: $semantic-border-radius-sm; + background: transparent; + pointer-events: none; + box-shadow: $semantic-shadow-elevation-2; + z-index: 1; + } + + &__preview-container { + align-self: center; + } + + &__preview { + width: 48px; + height: 32px; + border: $semantic-border-width-1 solid $semantic-color-border-primary; + border-radius: $semantic-border-radius-sm; + box-shadow: $semantic-shadow-elevation-1; + cursor: pointer; + } + + &__hex-container { + display: flex; + flex-direction: column; + align-items: center; + gap: $semantic-spacing-component-xs; + } + + &__hex-label { + font-family: map-get($semantic-typography-caption, font-family); + font-size: map-get($semantic-typography-caption, font-size); + font-weight: map-get($semantic-typography-caption, font-weight); + line-height: map-get($semantic-typography-caption, line-height); + color: $semantic-color-text-secondary; + text-transform: uppercase; + } + + &__hex-input { + width: 80px; + padding: $semantic-spacing-interactive-input-padding-y $semantic-spacing-interactive-input-padding-x; + background: $semantic-color-surface-secondary; + border: $semantic-border-width-1 solid $semantic-color-border-primary; + border-radius: $semantic-border-input-radius; + font-family: map-get($semantic-typography-input, font-family); + font-size: map-get($semantic-typography-input, font-size); + font-weight: map-get($semantic-typography-input, font-weight); + line-height: map-get($semantic-typography-input, line-height); + color: $semantic-color-text-primary; + text-align: center; + text-transform: uppercase; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + + &:focus { + outline: none; + border-color: $semantic-color-border-focus; + box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1); + } + + &:disabled { + background: $semantic-color-surface-container; + color: $semantic-color-text-disabled; + cursor: not-allowed; + opacity: $semantic-opacity-disabled; + } + } + + &__description { + margin-top: $semantic-spacing-component-xs; + font-family: map-get($semantic-typography-caption, font-family); + font-size: map-get($semantic-typography-caption, font-size); + font-weight: map-get($semantic-typography-caption, font-weight); + line-height: map-get($semantic-typography-caption, line-height); + color: $semantic-color-text-tertiary; + } + + // Size variants + &--sm { + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + + .ui-color-picker__container { + padding: $semantic-spacing-component-sm; + gap: $semantic-spacing-component-sm; + } + + .ui-color-picker__preview { + width: 32px; + height: 24px; + } + + .ui-color-picker__hex-input { + width: 70px; + padding: $semantic-spacing-component-xs; + } + + .ui-color-picker__wheel-indicator { + width: 8px; + height: 8px; + } + + .ui-color-picker__hue-indicator { + width: 8px; + height: calc(100% + 4px); + } + } + + &--lg { + font-family: map-get($semantic-typography-body-large, font-family); + font-size: map-get($semantic-typography-body-large, font-size); + font-weight: map-get($semantic-typography-body-large, font-weight); + line-height: map-get($semantic-typography-body-large, line-height); + + .ui-color-picker__container { + padding: $semantic-spacing-component-lg; + gap: $semantic-spacing-component-lg; + } + + .ui-color-picker__preview { + width: 64px; + height: 40px; + } + + .ui-color-picker__hex-input { + width: 90px; + padding: $semantic-spacing-component-sm; + } + + .ui-color-picker__wheel-indicator { + width: 16px; + height: 16px; + } + + .ui-color-picker__hue-indicator { + width: 16px; + height: calc(100% + 4px); + } + } + + // Color variants + &--primary { + .ui-color-picker__container { + border-color: $semantic-color-primary; + } + + .ui-color-picker__hex-input:focus { + border-color: $semantic-color-primary; + } + } + + &--secondary { + .ui-color-picker__container { + border-color: $semantic-color-secondary; + } + + .ui-color-picker__hex-input:focus { + border-color: $semantic-color-secondary; + } + } + + &--success { + .ui-color-picker__container { + border-color: $semantic-color-success; + } + + .ui-color-picker__hex-input:focus { + border-color: $semantic-color-success; + } + } + + &--danger { + .ui-color-picker__container { + border-color: $semantic-color-danger; + } + + .ui-color-picker__hex-input:focus { + border-color: $semantic-color-danger; + } + } + + // State variants + &--error { + .ui-color-picker__container { + border-color: $semantic-color-border-error; + } + + .ui-color-picker__label { + color: $semantic-color-danger; + } + + .ui-color-picker__hex-input { + border-color: $semantic-color-border-error; + } + } + + &--disabled { + opacity: $semantic-opacity-disabled; + pointer-events: none; + + .ui-color-picker__container { + background: $semantic-color-surface-container; + border-color: $semantic-color-border-subtle; + } + + .ui-color-picker__wheel, + .ui-color-picker__hue-bar { + cursor: not-allowed; + opacity: $semantic-opacity-disabled; + } + + .ui-color-picker__label { + color: $semantic-color-text-disabled; + } + } + + // Responsive design + @media (max-width: calc(#{$semantic-breakpoint-md} - 1px)) { + .ui-color-picker__container { + padding: $semantic-spacing-component-sm; + gap: $semantic-spacing-component-sm; + } + } + + @media (max-width: calc(#{$semantic-breakpoint-sm} - 1px)) { + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + + .ui-color-picker__container { + padding: $semantic-spacing-component-xs; + gap: $semantic-spacing-component-xs; + } + + .ui-color-picker__preview { + width: 40px; + height: 28px; + } + + .ui-color-picker__hex-input { + width: 75px; + } + } + + // High contrast mode + @media (prefers-contrast: high) { + .ui-color-picker__wheel, + .ui-color-picker__hue-bar { + border-width: $semantic-border-width-2; + } + + .ui-color-picker__wheel-indicator, + .ui-color-picker__hue-indicator { + border-width: 3px; + } + } + + // Reduced motion + @media (prefers-reduced-motion: reduce) { + .ui-color-picker__wheel, + .ui-color-picker__hue-bar, + .ui-color-picker__hex-input { + transition: none; + } + } +} \ No newline at end of file diff --git a/src/lib/components/forms/color-picker/color-picker.component.ts b/src/lib/components/forms/color-picker/color-picker.component.ts new file mode 100644 index 0000000..d5c7ad2 --- /dev/null +++ b/src/lib/components/forms/color-picker/color-picker.component.ts @@ -0,0 +1,613 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation, forwardRef, signal, ElementRef, ViewChild, AfterViewInit, OnDestroy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule, ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; + +export type ColorPickerSize = 'sm' | 'md' | 'lg'; +export type ColorPickerVariant = 'primary' | 'secondary' | 'success' | 'danger'; +export type ColorPickerState = 'default' | 'error' | 'disabled'; + +interface HSL { + h: number; // 0-360 + s: number; // 0-100 + l: number; // 0-100 +} + +interface RGB { + r: number; // 0-255 + g: number; // 0-255 + b: number; // 0-255 +} + +@Component({ + selector: 'ui-color-picker', + standalone: true, + imports: [CommonModule, FormsModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ColorPickerComponent), + multi: true + } + ], + template: ` +
+ + @if (label) { + + } + +
+ +
+ + +
+
+
+ + +
+ + +
+
+
+ + +
+
+
+
+ + +
+ + +
+
+ + @if (description) { +
+ {{ description }} +
+ } +
+ `, + styleUrl: './color-picker.component.scss' +}) +export class ColorPickerComponent implements ControlValueAccessor, AfterViewInit, OnDestroy { + @Input() label: string = ''; + @Input() description: string = ''; + @Input() size: ColorPickerSize = 'md'; + @Input() variant: ColorPickerVariant = 'primary'; + @Input() state: ColorPickerState = 'default'; + @Input() disabled = false; + @Input() required = false; + @Input() pickerId: string = `color-picker-${Math.random().toString(36).substr(2, 9)}`; + + @Output() colorChange = new EventEmitter(); + @Output() colorFocus = new EventEmitter(); + @Output() colorBlur = new EventEmitter(); + + @ViewChild('colorWheel') colorWheelCanvas!: ElementRef; + @ViewChild('hueBar') hueBarCanvas!: ElementRef; + @ViewChild('hexInput') hexInputElement!: ElementRef; + + protected currentColor = signal({ h: 0, s: 100, l: 50 }); + private isDraggingWheel = false; + private isDraggingHue = false; + private wheelCtx?: CanvasRenderingContext2D; + private hueCtx?: CanvasRenderingContext2D; + + // ControlValueAccessor implementation + private onChange = (value: string) => {}; + private onTouched = () => {}; + + ngAfterViewInit(): void { + this.initializeCanvases(); + this.drawColorWheel(); + this.drawHueBar(); + this.setupEventListeners(); + } + + ngOnDestroy(): void { + this.removeEventListeners(); + } + + writeValue(value: string): void { + if (value && this.isValidHexColor(value)) { + const hsl = this.hexToHsl(value); + this.currentColor.set(hsl); + this.updateCanvases(); + } + } + + registerOnChange(fn: (value: string) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + wheelSize(): number { + switch (this.size) { + case 'sm': return 120; + case 'lg': return 200; + default: return 160; + } + } + + hueBarWidth(): number { + return this.wheelSize(); + } + + hueBarHeight(): number { + switch (this.size) { + case 'sm': return 16; + case 'lg': return 24; + default: return 20; + } + } + + getWrapperClasses(): string { + const classes = [ + `ui-color-picker--${this.size}`, + `ui-color-picker--${this.variant}`, + `ui-color-picker--${this.state}` + ]; + + if (this.disabled) classes.push('ui-color-picker--disabled'); + if (this.required) classes.push('ui-color-picker--required'); + + return classes.join(' '); + } + + wheelIndicatorPosition(): { x: number, y: number } { + const size = this.wheelSize(); + const radius = size / 2; + const center = radius; + + // Convert HSL to position on wheel + const saturation = this.currentColor().s / 100; + const lightness = this.currentColor().l / 100; + + // Calculate distance from center (0 = center, 1 = edge) + const distance = saturation * (radius - 8); + + // Calculate angle for lightness (0 = top, increases clockwise) + const angle = (lightness * 360 - 90) * (Math.PI / 180); + + return { + x: center + Math.cos(angle) * distance - 6, + y: center + Math.sin(angle) * distance - 6 + }; + } + + hueIndicatorPosition(): number { + const width = this.hueBarWidth(); + return (this.currentColor().h / 360) * width - 6; + } + + getHexColor(): string { + return this.hslToHex(this.currentColor()); + } + + getWheelAriaText(): string { + const { s, l } = this.currentColor(); + return `Saturation ${Math.round(s)}%, Lightness ${Math.round(l)}%`; + } + + private initializeCanvases(): void { + if (this.colorWheelCanvas?.nativeElement) { + this.wheelCtx = this.colorWheelCanvas.nativeElement.getContext('2d') || undefined; + } + if (this.hueBarCanvas?.nativeElement) { + this.hueCtx = this.hueBarCanvas.nativeElement.getContext('2d') || undefined; + } + } + + private drawColorWheel(): void { + if (!this.wheelCtx) return; + + const size = this.wheelSize(); + const radius = size / 2; + const center = radius; + + this.wheelCtx.clearRect(0, 0, size, size); + + // Create saturation/lightness wheel with current hue + for (let x = 0; x < size; x++) { + for (let y = 0; y < size; y++) { + const dx = x - center; + const dy = y - center; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance <= radius - 8) { + const angle = Math.atan2(dy, dx); + const saturation = (distance / (radius - 8)) * 100; + const lightness = ((angle + Math.PI) / (2 * Math.PI)) * 100; + + const color = this.hslToRgb({ + h: this.currentColor().h, + s: saturation, + l: lightness + }); + + this.wheelCtx.fillStyle = `rgb(${color.r}, ${color.g}, ${color.b})`; + this.wheelCtx.fillRect(x, y, 1, 1); + } + } + } + } + + private drawHueBar(): void { + if (!this.hueCtx) return; + + const width = this.hueBarWidth(); + const height = this.hueBarHeight(); + + this.hueCtx.clearRect(0, 0, width, height); + + // Create hue gradient + const gradient = this.hueCtx.createLinearGradient(0, 0, width, 0); + for (let i = 0; i <= 360; i += 30) { + gradient.addColorStop(i / 360, `hsl(${i}, 100%, 50%)`); + } + + this.hueCtx.fillStyle = gradient; + this.hueCtx.fillRect(0, 0, width, height); + } + + private updateCanvases(): void { + this.drawColorWheel(); + // Hue bar doesn't need redrawing unless size changes + } + + private setupEventListeners(): void { + document.addEventListener('mousemove', this.handleMouseMove.bind(this)); + document.addEventListener('mouseup', this.handleMouseUp.bind(this)); + document.addEventListener('touchmove', this.handleTouchMove.bind(this), { passive: false }); + document.addEventListener('touchend', this.handleTouchEnd.bind(this)); + } + + private removeEventListeners(): void { + document.removeEventListener('mousemove', this.handleMouseMove.bind(this)); + document.removeEventListener('mouseup', this.handleMouseUp.bind(this)); + document.removeEventListener('touchmove', this.handleTouchMove.bind(this)); + document.removeEventListener('touchend', this.handleTouchEnd.bind(this)); + } + + startWheelDrag(event: MouseEvent): void { + if (this.disabled) return; + + this.isDraggingWheel = true; + this.updateWheelFromEvent(event); + event.preventDefault(); + } + + startHueDrag(event: MouseEvent): void { + if (this.disabled) return; + + this.isDraggingHue = true; + this.updateHueFromEvent(event); + event.preventDefault(); + } + + private handleMouseMove(event: MouseEvent): void { + if (this.isDraggingWheel) { + this.updateWheelFromEvent(event); + } else if (this.isDraggingHue) { + this.updateHueFromEvent(event); + } + } + + private handleMouseUp(): void { + this.isDraggingWheel = false; + this.isDraggingHue = false; + } + + private handleTouchMove(event: TouchEvent): void { + event.preventDefault(); + const touch = event.touches[0]; + if (this.isDraggingWheel) { + this.updateWheelFromEvent(touch); + } else if (this.isDraggingHue) { + this.updateHueFromEvent(touch); + } + } + + private handleTouchEnd(): void { + this.isDraggingWheel = false; + this.isDraggingHue = false; + } + + private updateWheelFromEvent(event: MouseEvent | Touch): void { + if (!this.colorWheelCanvas?.nativeElement) return; + + const rect = this.colorWheelCanvas.nativeElement.getBoundingClientRect(); + const x = event.clientX - rect.left; + const y = event.clientY - rect.top; + + const size = this.wheelSize(); + const radius = size / 2; + const center = radius; + + const dx = x - center; + const dy = y - center; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance <= radius - 8) { + const angle = Math.atan2(dy, dx); + const saturation = Math.min(100, (distance / (radius - 8)) * 100); + const lightness = ((angle + Math.PI) / (2 * Math.PI)) * 100; + + const newColor = { + ...this.currentColor(), + s: saturation, + l: lightness + }; + + this.currentColor.set(newColor); + this.emitColorChange(); + } + } + + private updateHueFromEvent(event: MouseEvent | Touch): void { + if (!this.hueBarCanvas?.nativeElement) return; + + const rect = this.hueBarCanvas.nativeElement.getBoundingClientRect(); + const x = event.clientX - rect.left; + + const width = this.hueBarWidth(); + const hue = Math.max(0, Math.min(360, (x / width) * 360)); + + const newColor = { + ...this.currentColor(), + h: hue + }; + + this.currentColor.set(newColor); + this.drawColorWheel(); // Redraw wheel with new hue + this.emitColorChange(); + } + + handleWheelKeydown(event: KeyboardEvent): void { + if (this.disabled) return; + + const step = event.shiftKey ? 10 : 1; + let { h, s, l } = this.currentColor(); + + switch (event.key) { + case 'ArrowUp': + l = Math.min(100, l + step); + break; + case 'ArrowDown': + l = Math.max(0, l - step); + break; + case 'ArrowLeft': + s = Math.max(0, s - step); + break; + case 'ArrowRight': + s = Math.min(100, s + step); + break; + default: + return; + } + + this.currentColor.set({ h, s, l }); + this.emitColorChange(); + event.preventDefault(); + } + + handleHueKeydown(event: KeyboardEvent): void { + if (this.disabled) return; + + const step = event.shiftKey ? 10 : 1; + let { h, s, l } = this.currentColor(); + + switch (event.key) { + case 'ArrowLeft': + h = (h - step + 360) % 360; + break; + case 'ArrowRight': + h = (h + step) % 360; + break; + default: + return; + } + + this.currentColor.set({ h, s, l }); + this.drawColorWheel(); + this.emitColorChange(); + event.preventDefault(); + } + + handleHexInput(event: Event): void { + const input = event.target as HTMLInputElement; + let value = input.value.trim(); + + // Add # if missing + if (value && !value.startsWith('#')) { + value = '#' + value; + } + + if (this.isValidHexColor(value)) { + const hsl = this.hexToHsl(value); + this.currentColor.set(hsl); + this.updateCanvases(); + this.emitColorChange(); + } + } + + handleHexBlur(event: FocusEvent): void { + this.onTouched(); + this.colorBlur.emit(event); + + // Reset to valid color if invalid input + const input = event.target as HTMLInputElement; + if (!this.isValidHexColor(input.value)) { + input.value = this.getHexColor(); + } + } + + private emitColorChange(): void { + const hex = this.getHexColor(); + this.onChange(hex); + this.colorChange.emit(hex); + } + + private isValidHexColor(hex: string): boolean { + return /^#[0-9A-Fa-f]{6}$/.test(hex); + } + + private hslToRgb(hsl: HSL): RGB { + const h = hsl.h / 360; + const s = hsl.s / 100; + const l = hsl.l / 100; + + let r, g, b; + + if (s === 0) { + r = g = b = l; + } else { + const hue2rgb = (p: number, q: number, t: number) => { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1/6) return p + (q - p) * 6 * t; + if (t < 1/2) return q; + if (t < 2/3) return p + (q - p) * (2/3 - t) * 6; + return p; + }; + + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + + r = hue2rgb(p, q, h + 1/3); + g = hue2rgb(p, q, h); + b = hue2rgb(p, q, h - 1/3); + } + + return { + r: Math.round(r * 255), + g: Math.round(g * 255), + b: Math.round(b * 255) + }; + } + + private rgbToHsl(rgb: RGB): HSL { + const r = rgb.r / 255; + const g = rgb.g / 255; + const b = rgb.b / 255; + + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const diff = max - min; + + let h = 0; + let s = 0; + const l = (max + min) / 2; + + if (diff !== 0) { + s = l > 0.5 ? diff / (2 - max - min) : diff / (max + min); + + switch (max) { + case r: + h = (g - b) / diff + (g < b ? 6 : 0); + break; + case g: + h = (b - r) / diff + 2; + break; + case b: + h = (r - g) / diff + 4; + break; + } + h /= 6; + } + + return { + h: Math.round(h * 360), + s: Math.round(s * 100), + l: Math.round(l * 100) + }; + } + + private hexToHsl(hex: string): HSL { + const rgb = this.hexToRgb(hex); + return this.rgbToHsl(rgb); + } + + private hslToHex(hsl: HSL): string { + const rgb = this.hslToRgb(hsl); + return this.rgbToHex(rgb); + } + + private hexToRgb(hex: string): RGB { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return { r, g, b }; + } + + private rgbToHex(rgb: RGB): string { + const toHex = (n: number) => n.toString(16).padStart(2, '0'); + return `#${toHex(rgb.r)}${toHex(rgb.g)}${toHex(rgb.b)}`.toUpperCase(); + } +} \ No newline at end of file diff --git a/src/lib/components/forms/color-picker/index.ts b/src/lib/components/forms/color-picker/index.ts new file mode 100644 index 0000000..a4241b4 --- /dev/null +++ b/src/lib/components/forms/color-picker/index.ts @@ -0,0 +1 @@ +export * from './color-picker.component'; \ No newline at end of file diff --git a/src/lib/components/forms/date-picker/date-picker.component.scss b/src/lib/components/forms/date-picker/date-picker.component.scss new file mode 100644 index 0000000..5aa763c --- /dev/null +++ b/src/lib/components/forms/date-picker/date-picker.component.scss @@ -0,0 +1,504 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-date-picker { + display: flex; + flex-direction: column; + width: 100%; + position: relative; + + // ========================================================================== + // SIZE VARIANTS + // ========================================================================== + + &--sm { + .ui-date-picker__container { + min-height: 36px; + } + + .ui-date-picker__field { + font-size: 0.875rem; + padding: $semantic-spacing-component-xs $semantic-spacing-component-sm; + } + + .ui-date-picker__label { + font-size: 0.8125rem; + margin-bottom: $semantic-spacing-component-xs; + } + } + + &--md { + .ui-date-picker__container { + min-height: 48px; + } + + .ui-date-picker__field { + font-size: 1rem; + padding: $semantic-spacing-component-sm $semantic-spacing-component-md; + } + + .ui-date-picker__label { + font-size: 0.875rem; + margin-bottom: $semantic-spacing-component-sm; + } + } + + &--lg { + .ui-date-picker__container { + min-height: 56px; + } + + .ui-date-picker__field { + font-size: 1.125rem; + padding: $semantic-spacing-component-md $semantic-spacing-component-lg; + } + + .ui-date-picker__label { + font-size: 1rem; + margin-bottom: $semantic-spacing-component-sm; + } + } + + // ========================================================================== + // VARIANT STYLES + // ========================================================================== + + &--outlined { + .ui-date-picker__container { + border: $semantic-border-width-2 solid $semantic-color-border-primary; + border-radius: $semantic-border-radius-md; + background-color: $semantic-color-surface-primary; + } + + .ui-date-picker__field { + border: none; + background: transparent; + } + } + + &--filled { + .ui-date-picker__container { + background-color: $semantic-color-surface-secondary; + border: $semantic-border-width-2 solid transparent; + border-radius: $semantic-border-radius-md; + border-bottom: 3px solid $semantic-color-border-primary; + } + + .ui-date-picker__field { + border: none; + background: transparent; + } + } + + &--underlined { + .ui-date-picker__container { + background: transparent; + border: none; + border-bottom: $semantic-border-width-2 solid $semantic-color-border-primary; + border-radius: 0; + } + + .ui-date-picker__field { + border: none; + background: transparent; + } + } + + // ========================================================================== + // STATE VARIANTS + // ========================================================================== + + &--error { + .ui-date-picker__container { + border-color: $semantic-color-error; + } + + .ui-date-picker__label { + color: $semantic-color-error; + } + } + + &--success { + .ui-date-picker__container { + border-color: $semantic-color-success; + } + } + + &--warning { + .ui-date-picker__container { + border-color: $semantic-color-warning; + } + } + + &--disabled { + opacity: 0.6; + pointer-events: none; + + .ui-date-picker__container { + background: $semantic-color-surface-secondary; + cursor: not-allowed; + } + } + + &--open { + .ui-date-picker__container { + border-color: $semantic-color-primary; + box-shadow: $semantic-shadow-input-focus; + } + } + + // ========================================================================== + // COMPONENT ELEMENTS + // ========================================================================== + + &__label { + font-weight: $semantic-typography-font-weight-medium; + color: $semantic-color-text-primary; + line-height: $semantic-typography-line-height-tight; + + &--required { + .ui-date-picker__required-indicator { + color: $semantic-color-error; + margin-left: $semantic-spacing-component-xs; + } + } + } + + &__container { + display: flex; + align-items: center; + position: relative; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease-in-out; + cursor: pointer; + + &:hover:not(.ui-date-picker--disabled &) { + border-color: $semantic-color-border-secondary; + } + + &--has-clear { + .ui-date-picker__field { + padding-right: $semantic-spacing-component-xl; + } + } + } + + &__prefix-icon { + display: flex; + align-items: center; + justify-content: center; + margin-left: $semantic-spacing-component-sm; + color: $semantic-color-text-tertiary; + pointer-events: none; + } + + &__field { + flex: 1; + border: none; + outline: none; + background: transparent; + color: $semantic-color-text-primary; + cursor: pointer; + font-family: inherit; + + &::placeholder { + color: $semantic-color-text-tertiary; + } + + &:focus { + outline: none; + } + } + + &__clear-btn { + position: absolute; + right: $semantic-spacing-component-sm; + display: flex; + align-items: center; + justify-content: center; + width: $semantic-sizing-icon-button; + height: $semantic-sizing-icon-button; + border: none; + background: transparent; + color: $semantic-color-text-tertiary; + cursor: pointer; + border-radius: $semantic-border-radius-sm; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease-in-out; + + &:hover { + background: $semantic-color-surface-secondary; + color: $semantic-color-text-secondary; + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + } + + &__helper-text { + font-size: $semantic-typography-font-size-sm; + margin-top: $semantic-spacing-component-xs; + line-height: $semantic-typography-line-height-normal; + + &--error { + color: $semantic-color-error; + } + + &--success { + color: $semantic-color-success; + } + + &--warning { + color: $semantic-color-warning; + } + + &--default { + color: $semantic-color-text-secondary; + } + } + + // ========================================================================== + // CALENDAR DROPDOWN + // ========================================================================== + + &__dropdown { + position: absolute; + top: 100%; + left: 0; + right: 0; + z-index: 1000; + margin-top: $semantic-spacing-component-xs; + background: $semantic-color-surface-primary; + border: $semantic-border-width-1 solid $semantic-color-border-primary; + border-radius: $semantic-border-radius-lg; + box-shadow: $semantic-shadow-dropdown; + padding: $semantic-spacing-component-md; + min-width: 280px; + } + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: $semantic-spacing-component-md; + } + + &__nav-btn { + display: flex; + align-items: center; + justify-content: center; + width: $semantic-sizing-icon-navigation; + height: $semantic-sizing-icon-navigation; + border: none; + background: transparent; + color: $semantic-color-text-secondary; + cursor: pointer; + border-radius: $semantic-border-radius-sm; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease-in-out; + + &:hover { + background: $semantic-color-surface-secondary; + color: $semantic-color-text-primary; + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + } + + &__month-year { + flex: 1; + text-align: center; + } + + &__month-btn { + border: none; + background: transparent; + color: $semantic-color-text-primary; + font-size: 1rem; + font-weight: $semantic-typography-font-weight-medium; + cursor: pointer; + padding: $semantic-spacing-component-xs $semantic-spacing-component-sm; + border-radius: $semantic-border-radius-sm; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease-in-out; + + &:hover { + background: $semantic-color-surface-secondary; + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + } + + &__calendar { + width: 100%; + } + + &__day-headers { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: $semantic-spacing-component-xs; + margin-bottom: $semantic-spacing-component-sm; + } + + &__day-header { + text-align: center; + font-size: $semantic-typography-font-size-sm; + font-weight: $semantic-typography-font-weight-medium; + color: $semantic-color-text-secondary; + padding: $semantic-spacing-component-xs; + } + + &__days { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: $semantic-spacing-component-xs; + } + + &__day { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: none; + background: transparent; + color: $semantic-color-text-primary; + cursor: pointer; + border-radius: $semantic-border-radius-sm; + font-size: 0.875rem; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease-in-out; + + &:hover:not(&--disabled) { + background: $semantic-color-surface-secondary; + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + + &--today { + background: $semantic-color-primary-container; + color: $semantic-color-primary; + font-weight: $semantic-typography-font-weight-medium; + } + + &--selected { + background: $semantic-color-primary; + color: $semantic-color-on-primary; + font-weight: $semantic-typography-font-weight-medium; + + &:hover { + background: $semantic-color-primary-focus; + } + } + + &--other-month { + color: $semantic-color-text-tertiary; + } + + &--disabled { + color: $semantic-color-text-disabled; + cursor: not-allowed; + opacity: 0.5; + } + } + + // ========================================================================== + // MONTH/YEAR PICKER + // ========================================================================== + + &__month-year-picker { + display: flex; + flex-direction: column; + gap: $semantic-spacing-component-md; + } + + &__months { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: $semantic-spacing-component-xs; + } + + &__month-option { + padding: $semantic-spacing-component-sm; + border: none; + background: transparent; + color: $semantic-color-text-primary; + cursor: pointer; + border-radius: $semantic-border-radius-sm; + font-size: 0.875rem; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease-in-out; + + &:hover { + background: $semantic-color-surface-secondary; + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + + &--selected { + background: $semantic-color-primary; + color: $semantic-color-on-primary; + font-weight: $semantic-typography-font-weight-medium; + + &:hover { + background: $semantic-color-primary-focus; + } + } + } + + &__year-input { + display: flex; + justify-content: center; + } + + &__year-field { + padding: $semantic-spacing-component-sm $semantic-spacing-component-md; + border: $semantic-border-width-1 solid $semantic-color-border-primary; + border-radius: $semantic-border-radius-md; + background: $semantic-color-surface-primary; + color: $semantic-color-text-primary; + font-size: 1rem; + text-align: center; + width: 80px; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease-in-out; + + &:focus { + outline: none; + border-color: $semantic-color-primary; + box-shadow: $semantic-shadow-input-focus; + } + + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } + + &[type=number] { + -moz-appearance: textfield; + } + } + + // ========================================================================== + // RESPONSIVE DESIGN + // ========================================================================== + + @media (max-width: 768px) { + &__dropdown { + position: fixed; + top: 50%; + left: 50%; + right: auto; + transform: translate(-50%, -50%); + width: 90vw; + max-width: 320px; + } + } +} \ No newline at end of file diff --git a/src/lib/components/forms/date-picker/date-picker.component.ts b/src/lib/components/forms/date-picker/date-picker.component.ts new file mode 100644 index 0000000..39bf607 --- /dev/null +++ b/src/lib/components/forms/date-picker/date-picker.component.ts @@ -0,0 +1,433 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, forwardRef, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { IconDefinition } from '@fortawesome/fontawesome-svg-core'; +import { faCalendarDays, faTimes, faChevronLeft, faChevronRight } from '@fortawesome/free-solid-svg-icons'; + +export type DatePickerSize = 'sm' | 'md' | 'lg'; +export type DatePickerVariant = 'outlined' | 'filled' | 'underlined'; +export type DatePickerState = 'default' | 'error' | 'success' | 'warning'; + +@Component({ + selector: 'ui-date-picker', + standalone: true, + imports: [CommonModule, FontAwesomeModule], + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DatePickerComponent), + multi: true + } + ], + template: ` +
+ + @if (label) { + + } + + +
+ +
+ +
+ + + + + + @if (clearable && value && !disabled) { + + } +
+ + + @if (isOpen()) { + + } + + + @if (helperText || errorMessage) { +
+ {{ state === 'error' ? errorMessage : helperText }} +
+ } +
+ `, + styleUrl: './date-picker.component.scss' +}) +export class DatePickerComponent implements ControlValueAccessor { + @Input() label: string = ''; + @Input() placeholder: string = 'Select date'; + @Input() size: DatePickerSize = 'md'; + @Input() variant: DatePickerVariant = 'outlined'; + @Input() state: DatePickerState = 'default'; + @Input() disabled = false; + @Input() required = false; + @Input() clearable = true; + @Input() helperText: string = ''; + @Input() errorMessage: string = ''; + @Input() minDate: Date | null = null; + @Input() maxDate: Date | null = null; + @Input() dateFormat: string = 'MM/dd/yyyy'; + @Input() inputId: string = `date-picker-${Math.random().toString(36).substr(2, 9)}`; + + @Output() dateChange = new EventEmitter(); + @Output() calendarOpen = new EventEmitter(); + @Output() calendarClose = new EventEmitter(); + + value: Date | null = null; + isOpen = signal(false); + currentMonth = signal(new Date().getMonth()); + currentYear = signal(new Date().getFullYear()); + showMonthView = signal(false); + + readonly faCalendarDays = faCalendarDays; + readonly faTimes = faTimes; + readonly faChevronLeft = faChevronLeft; + readonly faChevronRight = faChevronRight; + + readonly dayHeaders = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']; + readonly months = [ + { value: 0, label: 'January' }, + { value: 1, label: 'February' }, + { value: 2, label: 'March' }, + { value: 3, label: 'April' }, + { value: 4, label: 'May' }, + { value: 5, label: 'June' }, + { value: 6, label: 'July' }, + { value: 7, label: 'August' }, + { value: 8, label: 'September' }, + { value: 9, label: 'October' }, + { value: 10, label: 'November' }, + { value: 11, label: 'December' } + ]; + + readonly minYear = 1900; + readonly maxYear = 2100; + + private onChange = (value: Date | null) => {}; + private onTouched = () => {}; + + writeValue(value: Date | null): void { + this.value = value; + if (value) { + this.currentMonth.set(value.getMonth()); + this.currentYear.set(value.getFullYear()); + } + } + + registerOnChange(fn: (value: Date | null) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + get formattedValue(): string { + if (!this.value) return ''; + return this.formatDate(this.value); + } + + get calendarDays() { + const year = this.currentYear(); + const month = this.currentMonth(); + const firstDay = new Date(year, month, 1); + const lastDay = new Date(year, month + 1, 0); + const startDate = new Date(firstDay); + startDate.setDate(startDate.getDate() - firstDay.getDay()); + + const days = []; + const currentDate = new Date(startDate); + + for (let i = 0; i < 42; i++) { + const isOtherMonth = currentDate.getMonth() !== month; + const isToday = this.isSameDay(currentDate, new Date()); + const isSelected = this.value ? this.isSameDay(currentDate, this.value) : false; + const isDisabled = this.isDateDisabled(currentDate); + + days.push({ + date: new Date(currentDate), + isOtherMonth, + isToday, + isSelected, + isDisabled + }); + + currentDate.setDate(currentDate.getDate() + 1); + } + + return days; + } + + getWrapperClasses(): string { + const classes = [ + `ui-date-picker--${this.size}`, + `ui-date-picker--${this.variant}`, + `ui-date-picker--${this.state}` + ]; + + if (this.disabled) classes.push('ui-date-picker--disabled'); + if (this.isOpen()) classes.push('ui-date-picker--open'); + + return classes.join(' '); + } + + getContainerClasses(): string { + const classes: string[] = []; + if (this.clearable && this.value) classes.push('ui-date-picker__container--has-clear'); + return classes.join(' '); + } + + getHelperTextClasses(): string { + return `ui-date-picker__helper-text--${this.state}`; + } + + getMonthYearLabel(): string { + const monthName = this.months[this.currentMonth()].label; + return `${monthName} ${this.currentYear()}`; + } + + getDayAriaLabel(day: any): string { + const date = day.date; + const dayName = date.toLocaleDateString('en-US', { weekday: 'long' }); + const monthName = date.toLocaleDateString('en-US', { month: 'long' }); + return `${dayName}, ${monthName} ${date.getDate()}, ${date.getFullYear()}`; + } + + toggleCalendar(): void { + if (this.disabled) return; + + if (this.isOpen()) { + this.closeCalendar(); + } else { + this.openCalendar(); + } + } + + openCalendar(): void { + this.isOpen.set(true); + this.calendarOpen.emit(); + } + + closeCalendar(): void { + this.isOpen.set(false); + this.showMonthView.set(false); + this.onTouched(); + this.calendarClose.emit(); + } + + toggleMonthView(): void { + this.showMonthView.set(!this.showMonthView()); + } + + previousMonth(): void { + if (this.currentMonth() === 0) { + this.currentMonth.set(11); + this.currentYear.set(this.currentYear() - 1); + } else { + this.currentMonth.set(this.currentMonth() - 1); + } + } + + nextMonth(): void { + if (this.currentMonth() === 11) { + this.currentMonth.set(0); + this.currentYear.set(this.currentYear() + 1); + } else { + this.currentMonth.set(this.currentMonth() + 1); + } + } + + selectMonth(month: number): void { + this.currentMonth.set(month); + this.showMonthView.set(false); + } + + selectDate(date: Date): void { + if (this.isDateDisabled(date)) return; + + this.value = new Date(date); + this.onChange(this.value); + this.dateChange.emit(this.value); + this.closeCalendar(); + } + + clearValue(): void { + this.value = null; + this.onChange(null); + this.dateChange.emit(null); + } + + onInputKeyDown(event: KeyboardEvent): void { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + this.toggleCalendar(); + } else if (event.key === 'Escape' && this.isOpen()) { + event.preventDefault(); + this.closeCalendar(); + } + } + + onYearInput(event: Event): void { + const target = event.target as HTMLInputElement; + const year = parseInt(target.value, 10); + + if (!isNaN(year) && year >= this.minYear && year <= this.maxYear) { + this.currentYear.set(year); + } + } + + private formatDate(date: Date): string { + // Simple MM/dd/yyyy format - can be enhanced based on dateFormat input + const month = (date.getMonth() + 1).toString().padStart(2, '0'); + const day = date.getDate().toString().padStart(2, '0'); + const year = date.getFullYear(); + return `${month}/${day}/${year}`; + } + + private isSameDay(date1: Date, date2: Date): boolean { + return date1.getDate() === date2.getDate() && + date1.getMonth() === date2.getMonth() && + date1.getFullYear() === date2.getFullYear(); + } + + private isDateDisabled(date: Date): boolean { + if (this.minDate && date < this.minDate) return true; + if (this.maxDate && date > this.maxDate) return true; + return false; + } +} \ No newline at end of file diff --git a/src/lib/components/forms/date-picker/index.ts b/src/lib/components/forms/date-picker/index.ts new file mode 100644 index 0000000..d10ab33 --- /dev/null +++ b/src/lib/components/forms/date-picker/index.ts @@ -0,0 +1 @@ +export * from './date-picker.component'; \ No newline at end of file diff --git a/src/lib/components/forms/file-upload/file-upload.component.scss b/src/lib/components/forms/file-upload/file-upload.component.scss new file mode 100644 index 0000000..9c0a8e4 --- /dev/null +++ b/src/lib/components/forms/file-upload/file-upload.component.scss @@ -0,0 +1,355 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-file-upload { + position: relative; + width: 100%; + + // Size Variants + &--sm { + min-height: 36px; + + .ui-file-upload__dropzone { + padding: $semantic-spacing-component-sm; + font-size: 0.875rem; + } + } + + &--md { + min-height: 48px; + + .ui-file-upload__dropzone { + padding: $semantic-spacing-component-md; + font-size: 1rem; + } + } + + &--lg { + min-height: 56px; + + .ui-file-upload__dropzone { + padding: $semantic-spacing-component-lg; + font-size: 1.125rem; + } + } + + // Variant Styles + &--outlined { + .ui-file-upload__dropzone { + background: $semantic-color-surface-primary; + border: 1px solid $semantic-color-border-primary; + border-radius: 0.375rem; + } + } + + &--filled { + .ui-file-upload__dropzone { + background: $semantic-color-surface-secondary; + border: 1px solid transparent; + border-radius: 0.375rem; + } + } + + &--underlined { + .ui-file-upload__dropzone { + background: transparent; + border: none; + border-bottom: 1px solid $semantic-color-border-primary; + border-radius: 0; + } + } + + // State Styles + &--disabled { + opacity: 0.38; + cursor: not-allowed; + + .ui-file-upload__dropzone { + pointer-events: none; + background: $semantic-color-surface-primary; + color: $semantic-color-text-disabled; + } + } + + &--error { + .ui-file-upload__dropzone { + border-color: $semantic-color-error; + background: $semantic-color-error-container; + } + } + + &--success { + .ui-file-upload__dropzone { + border-color: $semantic-color-success; + background: $semantic-color-surface-secondary; + } + } + + &--dragover { + .ui-file-upload__dropzone { + border-color: $semantic-color-primary; + background: $semantic-color-surface-secondary; + box-shadow: $semantic-shadow-elevation-2; + transform: scale(1.02); + } + } + + // Label + &__label { + display: block; + margin-bottom: $semantic-spacing-stack-xs; + font-size: 0.875rem; + font-weight: 500; + color: $semantic-color-text-primary; + + &--required::after { + content: ' *'; + color: $semantic-color-error; + } + } + + // Dropzone + &__dropzone { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + cursor: pointer; + transition: all 0.15s $semantic-motion-easing-ease-in-out; + color: $semantic-color-text-secondary; + + &:hover:not(.ui-file-upload--disabled &) { + border-color: $semantic-color-primary; + background: $semantic-color-surface-secondary; + box-shadow: $semantic-shadow-elevation-1; + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + } + + // Hidden Input + &__input { + position: absolute; + opacity: 0; + width: 0; + height: 0; + overflow: hidden; + } + + // Upload Icon + &__icon { + font-size: 1.5rem; + margin-bottom: $semantic-spacing-stack-xs; + color: $semantic-color-text-tertiary; + } + + // Upload Text + &__text { + margin-bottom: $semantic-spacing-stack-xs; + + &-primary { + font-weight: 600; + color: $semantic-color-text-primary; + } + + &-secondary { + font-size: 0.875rem; + color: $semantic-color-text-tertiary; + } + } + + // Browse Button + &__browse-btn { + display: inline-flex; + align-items: center; + padding: $semantic-spacing-component-xs $semantic-spacing-component-sm; + background: $semantic-color-primary; + color: $semantic-color-on-primary; + border: none; + border-radius: 0.25rem; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.15s $semantic-motion-easing-ease-in-out; + margin-top: $semantic-spacing-stack-xs; + + &:hover { + background: $semantic-color-primary-hover; + box-shadow: $semantic-shadow-elevation-2; + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + } + + // File List + &__file-list { + margin-top: $semantic-spacing-stack-md; + } + + // File Item + &__file-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: $semantic-spacing-component-sm; + background: $semantic-color-surface-secondary; + border: 1px solid $semantic-color-border-subtle; + border-radius: 0.25rem; + margin-bottom: $semantic-spacing-stack-xs; + transition: all 0.15s $semantic-motion-easing-ease-in-out; + + &:hover { + box-shadow: $semantic-shadow-elevation-1; + } + + &--uploading { + opacity: 0.7; + position: relative; + overflow: hidden; + } + + &--error { + background: $semantic-color-error-container; + border-color: $semantic-color-error; + } + + &--success { + background: $semantic-color-surface-secondary; + border-color: $semantic-color-success; + } + } + + // File Info + &__file-info { + display: flex; + align-items: center; + flex: 1; + min-width: 0; + } + + &__file-icon { + font-size: 1.25rem; + margin-right: $semantic-spacing-stack-xs; + color: $semantic-color-text-tertiary; + } + + &__file-details { + min-width: 0; + flex: 1; + } + + &__file-name { + font-size: 0.875rem; + font-weight: 500; + color: $semantic-color-text-primary; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 2px; + } + + &__file-size { + font-size: 0.8125rem; + color: $semantic-color-text-tertiary; + } + + // File Actions + &__file-actions { + display: flex; + align-items: center; + gap: $semantic-spacing-stack-xs; + } + + // Remove Button + &__remove-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + background: transparent; + border: none; + border-radius: 0.25rem; + color: $semantic-color-text-tertiary; + cursor: pointer; + transition: all 0.15s $semantic-motion-easing-ease-in-out; + + &:hover { + background: $semantic-color-error-container; + color: $semantic-color-error; + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + } + + // Progress Bar + &__progress { + position: absolute; + bottom: 0; + left: 0; + height: 2px; + background: $semantic-color-primary; + border-radius: 0.125rem; + transition: width 0.3s $semantic-motion-easing-ease-in-out; + } + + // Helper Text + &__helper-text { + margin-top: $semantic-spacing-stack-xs; + font-size: 0.875rem; + + &--default { + color: $semantic-color-text-tertiary; + } + + &--error { + color: $semantic-color-error; + } + + &--success { + color: $semantic-color-success; + } + } + + // Loading State + &--loading { + .ui-file-upload__dropzone { + cursor: wait; + opacity: 0.7; + } + } + + // Responsive Design + @media (max-width: $semantic-breakpoint-md - 1) { + &__dropzone { + padding: $semantic-spacing-component-sm; + } + + &__file-name { + font-size: 0.75rem; + } + } + + @media (max-width: $semantic-breakpoint-sm - 1) { + &__dropzone { + padding: $semantic-spacing-component-xs; + } + + &__text-primary { + font-size: 0.875rem; + } + + &__text-secondary { + font-size: 0.8125rem; + } + } +} \ No newline at end of file diff --git a/src/lib/components/forms/file-upload/file-upload.component.ts b/src/lib/components/forms/file-upload/file-upload.component.ts new file mode 100644 index 0000000..e8156ff --- /dev/null +++ b/src/lib/components/forms/file-upload/file-upload.component.ts @@ -0,0 +1,420 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation, forwardRef } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { IconDefinition } from '@fortawesome/fontawesome-svg-core'; +import { faCloudUploadAlt, faFile, faImage, faFilePdf, faFileWord, faFileExcel, faFileCode, faTimes, faSpinner } from '@fortawesome/free-solid-svg-icons'; + +export type FileUploadSize = 'sm' | 'md' | 'lg'; +export type FileUploadVariant = 'outlined' | 'filled' | 'underlined'; +export type FileUploadState = 'default' | 'error' | 'success' | 'warning'; + +export interface UploadedFile { + file: File; + id: string; + name: string; + size: number; + type: string; + status: 'pending' | 'uploading' | 'completed' | 'error'; + progress?: number; + error?: string; + url?: string; +} + +@Component({ + selector: 'ui-file-upload', + standalone: true, + imports: [CommonModule, FontAwesomeModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => FileUploadComponent), + multi: true + } + ], + template: ` +
+ + @if (label) { + + } + + +
+ + +
+
+ {{ isDragOver ? 'Drop files here' : 'Drag & drop files here' }} +
+
+ @if (acceptedTypes.length > 0) { +
Accepted formats: {{ getAcceptedTypesText() }}
+ } + @if (maxFileSize) { +
Max file size: {{ formatFileSize(maxFileSize) }}
+ } + @if (maxFiles && maxFiles > 1) { +
Max {{ maxFiles }} files
+ } +
+
+ + +
+ + + + + + @if (uploadedFiles.length > 0) { +
+ @for (uploadedFile of uploadedFiles; track uploadedFile.id) { +
+
+ +
+
+ {{ uploadedFile.name }} +
+
+ {{ formatFileSize(uploadedFile.size) }} + @if (uploadedFile.status === 'error' && uploadedFile.error) { + - {{ uploadedFile.error }} + } +
+
+
+ +
+ @if (uploadedFile.status === 'uploading') { + + } @else { + + } +
+ + + @if (uploadedFile.status === 'uploading' && uploadedFile.progress !== undefined) { +
+ } +
+ } +
+ } + + + @if (helperText || errorMessage) { +
+ {{ state === 'error' ? errorMessage : helperText }} +
+ } +
+ `, + styleUrl: './file-upload.component.scss' +}) +export class FileUploadComponent implements ControlValueAccessor { + @Input() label: string = ''; + @Input() size: FileUploadSize = 'md'; + @Input() variant: FileUploadVariant = 'outlined'; + @Input() state: FileUploadState = 'default'; + @Input() disabled = false; + @Input() required = false; + @Input() multiple = false; + @Input() accept: string = ''; + @Input() acceptedTypes: string[] = []; + @Input() maxFileSize: number | null = null; // in bytes + @Input() maxFiles: number | null = null; + @Input() helperText: string = ''; + @Input() errorMessage: string = ''; + @Input() autoUpload = false; + @Input() uploadUrl: string = ''; + @Input() inputId: string = `file-upload-${Math.random().toString(36).substr(2, 9)}`; + + @Output() filesSelected = new EventEmitter(); + @Output() fileAdded = new EventEmitter(); + @Output() fileRemoved = new EventEmitter(); + @Output() uploadProgress = new EventEmitter<{file: UploadedFile, progress: number}>(); + @Output() uploadComplete = new EventEmitter(); + @Output() uploadError = new EventEmitter<{file: UploadedFile, error: string}>(); + + uploadedFiles: UploadedFile[] = []; + isDragOver = false; + + // FontAwesome icons + readonly faCloudUploadAlt = faCloudUploadAlt; + readonly faFile = faFile; + readonly faImage = faImage; + readonly faFilePdf = faFilePdf; + readonly faFileWord = faFileWord; + readonly faFileExcel = faFileExcel; + readonly faFileCode = faFileCode; + readonly faTimes = faTimes; + readonly faSpinner = faSpinner; + + // ControlValueAccessor implementation + private onChange = (value: UploadedFile[]) => {}; + private onTouched = () => {}; + + writeValue(value: UploadedFile[]): void { + this.uploadedFiles = value || []; + } + + registerOnChange(fn: (value: UploadedFile[]) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + getWrapperClasses(): string { + const classes = [ + `ui-file-upload--${this.size}`, + `ui-file-upload--${this.variant}`, + `ui-file-upload--${this.state}` + ]; + + if (this.disabled) classes.push('ui-file-upload--disabled'); + if (this.isDragOver) classes.push('ui-file-upload--dragover'); + + return classes.join(' '); + } + + getHelperTextClasses(): string { + return `ui-file-upload__helper-text--${this.state}`; + } + + openFileDialog(): void { + if (!this.disabled) { + const input = document.getElementById(this.inputId) as HTMLInputElement; + input?.click(); + } + } + + onFileSelected(event: Event): void { + const input = event.target as HTMLInputElement; + if (input.files) { + this.handleFiles(Array.from(input.files)); + // Reset input value to allow selecting the same file again + input.value = ''; + } + } + + onDragOver(event: DragEvent): void { + if (!this.disabled) { + event.preventDefault(); + event.stopPropagation(); + this.isDragOver = true; + } + } + + onDragLeave(event: DragEvent): void { + if (!this.disabled) { + event.preventDefault(); + event.stopPropagation(); + this.isDragOver = false; + } + } + + onDrop(event: DragEvent): void { + if (!this.disabled) { + event.preventDefault(); + event.stopPropagation(); + this.isDragOver = false; + + const files = event.dataTransfer?.files; + if (files) { + this.handleFiles(Array.from(files)); + } + } + } + + onKeyDown(event: KeyboardEvent): void { + if ((event.key === 'Enter' || event.key === ' ') && !this.disabled) { + event.preventDefault(); + this.openFileDialog(); + } + } + + handleFiles(files: File[]): void { + let validFiles: File[] = files; + + // Filter by accepted types + if (this.acceptedTypes.length > 0) { + validFiles = files.filter(file => this.isFileTypeAccepted(file)); + } + + // Filter by file size + if (this.maxFileSize) { + validFiles = validFiles.filter(file => file.size <= this.maxFileSize!); + } + + // Limit number of files + if (this.maxFiles) { + const remainingSlots = this.maxFiles - this.uploadedFiles.length; + validFiles = validFiles.slice(0, remainingSlots); + } + + // Create UploadedFile objects + const newUploadedFiles: UploadedFile[] = validFiles.map(file => ({ + file, + id: this.generateFileId(), + name: file.name, + size: file.size, + type: file.type, + status: 'pending', + progress: 0 + })); + + // Add to uploaded files array + if (this.multiple) { + this.uploadedFiles = [...this.uploadedFiles, ...newUploadedFiles]; + } else { + this.uploadedFiles = newUploadedFiles; + } + + // Emit events + newUploadedFiles.forEach(uploadedFile => { + this.fileAdded.emit(uploadedFile); + }); + + this.filesSelected.emit(this.uploadedFiles); + this.onChange(this.uploadedFiles); + this.onTouched(); + + // Auto upload if enabled + if (this.autoUpload && this.uploadUrl) { + newUploadedFiles.forEach(uploadedFile => { + this.uploadFile(uploadedFile); + }); + } + } + + removeFile(fileId: string): void { + const fileIndex = this.uploadedFiles.findIndex(f => f.id === fileId); + if (fileIndex > -1) { + const removedFile = this.uploadedFiles[fileIndex]; + this.uploadedFiles.splice(fileIndex, 1); + this.uploadedFiles = [...this.uploadedFiles]; // Trigger change detection + + this.fileRemoved.emit(removedFile); + this.onChange(this.uploadedFiles); + } + } + + private uploadFile(uploadedFile: UploadedFile): void { + uploadedFile.status = 'uploading'; + + const formData = new FormData(); + formData.append('file', uploadedFile.file); + + // Simulate upload progress (replace with actual upload logic) + let progress = 0; + const interval = setInterval(() => { + progress += 10; + uploadedFile.progress = progress; + this.uploadProgress.emit({ file: uploadedFile, progress }); + + if (progress >= 100) { + clearInterval(interval); + uploadedFile.status = 'completed'; + this.uploadComplete.emit(uploadedFile); + } + }, 200); + } + + private isFileTypeAccepted(file: File): boolean { + return this.acceptedTypes.some(type => { + if (type.startsWith('.')) { + return file.name.toLowerCase().endsWith(type.toLowerCase()); + } + return file.type.includes(type); + }); + } + + private generateFileId(): string { + return `file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + } + + getFileIcon(fileType: string): IconDefinition { + if (fileType.startsWith('image/')) return this.faImage; + if (fileType.includes('pdf')) return this.faFilePdf; + if (fileType.includes('word') || fileType.includes('document')) return this.faFileWord; + if (fileType.includes('excel') || fileType.includes('spreadsheet')) return this.faFileExcel; + if (fileType.includes('code') || fileType.includes('javascript') || fileType.includes('typescript')) return this.faFileCode; + return this.faFile; + } + + formatFileSize(bytes: number): string { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + } + + getAcceptedTypesText(): string { + return this.acceptedTypes.join(', ').toUpperCase(); + } +} \ No newline at end of file diff --git a/src/lib/components/forms/file-upload/index.ts b/src/lib/components/forms/file-upload/index.ts new file mode 100644 index 0000000..6581b56 --- /dev/null +++ b/src/lib/components/forms/file-upload/index.ts @@ -0,0 +1 @@ +export * from './file-upload.component'; \ No newline at end of file diff --git a/src/lib/components/forms/form-field/form-field.component.scss b/src/lib/components/forms/form-field/form-field.component.scss new file mode 100644 index 0000000..cd4de7d --- /dev/null +++ b/src/lib/components/forms/form-field/form-field.component.scss @@ -0,0 +1,317 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-form-field { + position: relative; + width: 100%; + margin-bottom: $semantic-spacing-stack-md; + + // Size Variants + &--sm { + .ui-form-field__label { + font-size: 0.8125rem; + margin-bottom: $semantic-spacing-stack-xs; + } + + .ui-form-field__helper-text { + font-size: 0.75rem; + margin-top: $semantic-spacing-stack-xs; + } + } + + &--md { + .ui-form-field__label { + font-size: 0.875rem; + margin-bottom: $semantic-spacing-stack-sm; + } + + .ui-form-field__helper-text { + font-size: 0.8125rem; + margin-top: $semantic-spacing-stack-sm; + } + } + + &--lg { + .ui-form-field__label { + font-size: 1rem; + margin-bottom: $semantic-spacing-stack-sm; + } + + .ui-form-field__helper-text { + font-size: 0.875rem; + margin-top: $semantic-spacing-stack-sm; + } + } + + // State Variants + &--error { + .ui-form-field__label { + color: $semantic-color-error; + } + + .ui-form-field__helper-text { + color: $semantic-color-error; + } + } + + &--success { + .ui-form-field__label { + color: $semantic-color-success; + } + + .ui-form-field__helper-text { + color: $semantic-color-success; + } + } + + &--warning { + .ui-form-field__label { + color: $semantic-color-warning; + } + + .ui-form-field__helper-text { + color: $semantic-color-warning; + } + } + + &--disabled { + .ui-form-field__label { + color: $semantic-color-text-disabled; + } + + .ui-form-field__helper-text { + color: $semantic-color-text-disabled; + } + } + + // Label + &__label { + display: block; + font-weight: 500; + color: $semantic-color-text-primary; + cursor: pointer; + transition: color 0.15s $semantic-motion-easing-ease-in-out; + + &--required::after { + content: ' *'; + color: $semantic-color-error; + font-weight: 400; + } + + &--optional { + &::after { + content: ' (optional)'; + color: $semantic-color-text-tertiary; + font-weight: 400; + font-size: 0.9em; + } + } + } + + // Control Container + &__control { + position: relative; + width: 100%; + } + + // Helper Text + &__helper-text { + color: $semantic-color-text-tertiary; + line-height: 1.4; + transition: color 0.15s $semantic-motion-easing-ease-in-out; + + &--error { + color: $semantic-color-error; + } + + &--success { + color: $semantic-color-success; + } + + &--warning { + color: $semantic-color-warning; + } + + &--info { + color: $semantic-color-info; + } + } + + // Validation Message + &__validation { + display: flex; + align-items: flex-start; + gap: $semantic-spacing-stack-xs; + margin-top: $semantic-spacing-stack-xs; + + &--error { + color: $semantic-color-error; + } + + &--success { + color: $semantic-color-success; + } + + &--warning { + color: $semantic-color-warning; + } + + &--info { + color: $semantic-color-info; + } + } + + &__validation-icon { + font-size: 1rem; + margin-top: 0.125rem; + flex-shrink: 0; + line-height: 1; + } + + &__validation-text { + font-size: 0.8125rem; + line-height: 1.4; + flex: 1; + } + + // Character Counter + &__char-counter { + font-size: 0.75rem; + color: $semantic-color-text-tertiary; + text-align: right; + margin-top: $semantic-spacing-stack-sm; + + &--over-limit { + color: $semantic-color-error; + } + } + + // Hint Text + &__hint { + font-size: 0.8125rem; + color: $semantic-color-text-secondary; + margin-top: $semantic-spacing-stack-xs; + display: flex; + align-items: flex-start; + gap: $semantic-spacing-stack-xs; + } + + &__hint-icon { + font-size: 1rem; + color: $semantic-color-info; + margin-top: 0.125rem; + flex-shrink: 0; + line-height: 1; + } + + // Layout Variants + &--horizontal { + display: flex; + align-items: flex-start; + gap: $semantic-spacing-stack-lg; + + .ui-form-field__label { + flex: 0 0 auto; + min-width: 120px; + margin-bottom: 0; + padding-top: $semantic-spacing-stack-xs; + } + + .ui-form-field__content { + flex: 1; + min-width: 0; + } + } + + // Content Wrapper + &__content { + width: 100%; + } + + // Focus Ring Support + &:focus-within { + .ui-form-field__label { + color: $semantic-color-primary; + } + } + + // Group Support (for radio buttons, checkboxes, etc.) + &--group { + .ui-form-field__control { + display: flex; + flex-direction: column; + gap: $semantic-spacing-stack-sm; + } + + &.ui-form-field--horizontal { + .ui-form-field__control { + flex-direction: row; + flex-wrap: wrap; + gap: $semantic-spacing-stack-md; + } + } + } + + // Compact variant for tighter layouts + &--compact { + margin-bottom: $semantic-spacing-stack-sm; + + .ui-form-field__label { + margin-bottom: $semantic-spacing-stack-xs; + } + + .ui-form-field__helper-text, + .ui-form-field__validation { + margin-top: $semantic-spacing-stack-xs; + } + } + + // Full width variant + &--full-width { + width: 100%; + + .ui-form-field__control, + .ui-form-field__control > * { + width: 100%; + } + } + + // Responsive Design + @media (max-width: $semantic-breakpoint-md - 1) { + &--horizontal { + flex-direction: column; + gap: $semantic-spacing-stack-sm; + + .ui-form-field__label { + min-width: unset; + padding-top: 0; + margin-bottom: $semantic-spacing-stack-xs; + } + } + } + + @media (max-width: $semantic-breakpoint-sm - 1) { + &--group.ui-form-field--horizontal { + .ui-form-field__control { + flex-direction: column; + gap: $semantic-spacing-stack-sm; + } + } + } + + // Animation for validation messages + .ui-form-field__validation, + .ui-form-field__helper-text { + animation: slideInUp 0.3s $semantic-motion-easing-ease-in-out; + } +} + +@keyframes slideInUp { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} \ No newline at end of file diff --git a/src/lib/components/forms/form-field/form-field.component.ts b/src/lib/components/forms/form-field/form-field.component.ts new file mode 100644 index 0000000..dc41fbc --- /dev/null +++ b/src/lib/components/forms/form-field/form-field.component.ts @@ -0,0 +1,381 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation, ContentChild, AfterContentInit, OnDestroy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { AbstractControl, FormControl, NgControl, ValidationErrors } from '@angular/forms'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { IconDefinition } from '@fortawesome/fontawesome-svg-core'; +import { faExclamationCircle, faCheckCircle, faExclamationTriangle, faInfoCircle, faQuestionCircle } from '@fortawesome/free-solid-svg-icons'; +import { Subject, takeUntil } from 'rxjs'; + +export type FormFieldSize = 'sm' | 'md' | 'lg'; +export type FormFieldState = 'default' | 'error' | 'success' | 'warning' | 'info'; +export type FormFieldLayout = 'vertical' | 'horizontal'; + +export interface ValidationMessage { + type: string; + message: string; + icon?: IconDefinition; +} + +@Component({ + selector: 'ui-form-field', + standalone: true, + imports: [CommonModule, FontAwesomeModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ @if (layout === 'horizontal') { + + @if (label) { + + } + +
+
+ +
+ + + @if (helperText && !hasValidationMessages()) { +
+ {{ helperText }} +
+ } + + + @if (hasValidationMessages()) { +
+ +
+ {{ getValidationMessage() }} +
+
+ } + + + @if (showCharacterCount && maxLength) { +
+ {{ currentLength }}/{{ maxLength }} +
+ } + + + @if (hintText) { +
+ + {{ hintText }} +
+ } +
+ } @else { + + @if (label) { + + } + +
+ +
+ + + @if (helperText && !hasValidationMessages()) { +
+ {{ helperText }} +
+ } + + + @if (hasValidationMessages()) { +
+ +
+ {{ getValidationMessage() }} +
+
+ } + + + @if (showCharacterCount && maxLength) { +
+ {{ currentLength }}/{{ maxLength }} +
+ } + + + @if (hintText) { +
+ + {{ hintText }} +
+ } + } +
+ `, + styleUrl: './form-field.component.scss' +}) +export class FormFieldComponent implements AfterContentInit, OnDestroy { + @Input() label: string = ''; + @Input() size: FormFieldSize = 'md'; + @Input() state: FormFieldState = 'default'; + @Input() layout: FormFieldLayout = 'vertical'; + @Input() required = false; + @Input() disabled = false; + @Input() showOptional = false; + @Input() helperText: string = ''; + @Input() hintText: string = ''; + @Input() errorMessage: string = ''; + @Input() successMessage: string = ''; + @Input() warningMessage: string = ''; + @Input() infoMessage: string = ''; + @Input() showCharacterCount = false; + @Input() maxLength: number | null = null; + @Input() currentLength = 0; + @Input() fieldId: string = `form-field-${Math.random().toString(36).substr(2, 9)}`; + @Input() validationMessages: { [key: string]: string } = {}; + @Input() customValidationMessages: ValidationMessage[] = []; + @Input() compact = false; + @Input() fullWidth = false; + @Input() group = false; + + @Output() stateChange = new EventEmitter(); + @Output() validationChange = new EventEmitter(); + + @ContentChild(NgControl, { static: false }) ngControl: NgControl | null = null; + + // FontAwesome icons + readonly faExclamationCircle = faExclamationCircle; + readonly faCheckCircle = faCheckCircle; + readonly faExclamationTriangle = faExclamationTriangle; + readonly faInfoCircle = faInfoCircle; + readonly faQuestionCircle = faQuestionCircle; + + private destroy$ = new Subject(); + private currentState: FormFieldState = 'default'; + + ngAfterContentInit(): void { + if (this.ngControl && this.ngControl.control) { + // Listen to control status changes + this.ngControl.control.statusChanges + .pipe(takeUntil(this.destroy$)) + .subscribe(() => { + this.updateStateFromControl(); + }); + + // Listen to value changes for character count + this.ngControl.control.valueChanges + .pipe(takeUntil(this.destroy$)) + .subscribe((value: string) => { + this.currentLength = value ? value.length : 0; + }); + + // Initial state update + this.updateStateFromControl(); + } + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + getWrapperClasses(): string { + const classes = [ + `ui-form-field--${this.size}`, + `ui-form-field--${this.getCurrentState()}`, + `ui-form-field--${this.layout}` + ]; + + if (this.disabled) classes.push('ui-form-field--disabled'); + if (this.compact) classes.push('ui-form-field--compact'); + if (this.fullWidth) classes.push('ui-form-field--full-width'); + if (this.group) classes.push('ui-form-field--group'); + + return classes.join(' '); + } + + getHelperTextClasses(): string { + const state = this.getCurrentState(); + return `ui-form-field__helper-text--${state}`; + } + + getValidationClasses(): string { + const state = this.getCurrentState(); + return `ui-form-field__validation--${state}`; + } + + getValidationIcon(): IconDefinition { + const state = this.getCurrentState(); + switch (state) { + case 'error': + return this.faExclamationCircle; + case 'success': + return this.faCheckCircle; + case 'warning': + return this.faExclamationTriangle; + case 'info': + return this.faInfoCircle; + default: + return this.faExclamationCircle; + } + } + + getCurrentState(): FormFieldState { + // Override state takes precedence + if (this.state !== 'default') { + return this.state; + } + + // Check if we have a form control + if (this.ngControl && this.ngControl.control) { + const control = this.ngControl.control; + + // Error state + if (control.invalid && (control.dirty || control.touched)) { + return 'error'; + } + + // Success state + if (control.valid && control.value && (control.dirty || control.touched)) { + return 'success'; + } + } + + // Check custom messages + if (this.errorMessage) return 'error'; + if (this.successMessage) return 'success'; + if (this.warningMessage) return 'warning'; + if (this.infoMessage) return 'info'; + + return 'default'; + } + + hasValidationMessages(): boolean { + const currentState = this.getCurrentState(); + + if (currentState === 'error') { + return !!(this.getControlErrors() || this.errorMessage); + } + + if (currentState === 'success') { + return !!this.successMessage; + } + + if (currentState === 'warning') { + return !!this.warningMessage; + } + + if (currentState === 'info') { + return !!this.infoMessage; + } + + return this.customValidationMessages.length > 0; + } + + getValidationMessage(): string { + const currentState = this.getCurrentState(); + + if (currentState === 'error') { + const controlErrors = this.getControlErrors(); + if (controlErrors) return controlErrors; + return this.errorMessage || 'Please correct the error'; + } + + if (currentState === 'success') { + return this.successMessage || 'Valid input'; + } + + if (currentState === 'warning') { + return this.warningMessage || 'Warning message'; + } + + if (currentState === 'info') { + return this.infoMessage || 'Information message'; + } + + if (this.customValidationMessages.length > 0) { + return this.customValidationMessages[0].message; + } + + return ''; + } + + isOverCharacterLimit(): boolean { + return this.maxLength ? this.currentLength > this.maxLength : false; + } + + private updateStateFromControl(): void { + const newState = this.getCurrentState(); + if (newState !== this.currentState) { + this.currentState = newState; + this.stateChange.emit(newState); + } + + if (this.ngControl && this.ngControl.control) { + this.validationChange.emit(this.ngControl.control.errors); + } + } + + private getControlErrors(): string | null { + if (!this.ngControl || !this.ngControl.control || !this.ngControl.control.errors) { + return null; + } + + const errors = this.ngControl.control.errors; + const errorKey = Object.keys(errors)[0]; // Get first error + + // Check for custom validation messages first + if (this.validationMessages[errorKey]) { + return this.validationMessages[errorKey]; + } + + // Default error messages + switch (errorKey) { + case 'required': + return 'This field is required'; + case 'email': + return 'Please enter a valid email address'; + case 'minlength': + return `Minimum length is ${errors[errorKey].requiredLength} characters`; + case 'maxlength': + return `Maximum length is ${errors[errorKey].requiredLength} characters`; + case 'min': + return `Minimum value is ${errors[errorKey].min}`; + case 'max': + return `Maximum value is ${errors[errorKey].max}`; + case 'pattern': + return 'Please enter a valid format'; + case 'url': + return 'Please enter a valid URL'; + case 'number': + return 'Please enter a valid number'; + case 'date': + return 'Please enter a valid date'; + case 'time': + return 'Please enter a valid time'; + case 'custom': + return errors[errorKey].message || 'Invalid input'; + default: + return 'Please correct this field'; + } + } +} \ No newline at end of file diff --git a/src/lib/components/forms/form-field/index.ts b/src/lib/components/forms/form-field/index.ts new file mode 100644 index 0000000..5a2d58d --- /dev/null +++ b/src/lib/components/forms/form-field/index.ts @@ -0,0 +1 @@ +export * from './form-field.component'; \ No newline at end of file diff --git a/src/lib/components/forms/index.ts b/src/lib/components/forms/index.ts new file mode 100644 index 0000000..2bffe55 --- /dev/null +++ b/src/lib/components/forms/index.ts @@ -0,0 +1,14 @@ +export * from './checkbox'; +export * from './input'; +export * from './radio'; +export * from './search'; +export * from './switch'; +export * from './select/select.component'; +export * from './autocomplete'; +export * from './date-picker'; +export * from './time-picker'; +export * from './file-upload'; +export * from './form-field'; +export * from './range-slider'; +export * from './color-picker'; +export * from './tag-input'; diff --git a/src/lib/components/forms/input/index.ts b/src/lib/components/forms/input/index.ts new file mode 100644 index 0000000..aca2de3 --- /dev/null +++ b/src/lib/components/forms/input/index.ts @@ -0,0 +1,3 @@ +export * from './text-input.component'; +export * from './textarea.component'; +export * from './input-wrapper.component'; \ No newline at end of file diff --git a/src/lib/components/forms/input/input-wrapper.component.ts b/src/lib/components/forms/input/input-wrapper.component.ts new file mode 100644 index 0000000..48e4f10 --- /dev/null +++ b/src/lib/components/forms/input/input-wrapper.component.ts @@ -0,0 +1,199 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, forwardRef, ViewChild, AfterViewInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { IconDefinition } from '@fortawesome/fontawesome-svg-core'; +import { TextInputComponent, TextInputSize, TextInputVariant, TextInputType, TextInputState } from './text-input.component'; +import { TextareaComponent, TextareaSize, TextareaVariant, TextareaState, TextareaResize } from './textarea.component'; + +export type InputWrapperMode = 'input' | 'textarea'; + +@Component({ + selector: 'ui-input-wrapper', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, TextInputComponent, TextareaComponent], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => InputWrapperComponent), + multi: true + } + ], + template: ` + @if (mode === 'input') { + + } + + @if (mode === 'textarea') { + + } + `, + styles: [` + :host { + display: block; + width: 100%; + } + `] +}) +export class InputWrapperComponent implements ControlValueAccessor, AfterViewInit { + @ViewChild('textInput') textInput?: TextInputComponent; + @ViewChild('textArea') textArea?: TextareaComponent; + @Input() mode: InputWrapperMode = 'input'; + @Input() label: string = ''; + @Input() placeholder: string = ''; + @Input() inputType: TextInputType = 'text'; + @Input() size: TextInputSize | TextareaSize = 'md'; + @Input() variant: TextInputVariant | TextareaVariant = 'outlined'; + @Input() state: TextInputState | TextareaState = 'default'; + @Input() disabled = false; + @Input() readonly = false; + @Input() required = false; + @Input() loading = false; + @Input() clearable = false; + @Input() prefixIcon: IconDefinition | null = null; + @Input() prefixText: string = ''; + @Input() suffixIcon: IconDefinition | null = null; + @Input() helperText: string = ''; + @Input() errorMessage: string = ''; + @Input() maxLength: number | null = null; + @Input() showCharacterCount = false; + @Input() autocomplete: string = ''; + @Input() min: string | number | null = null; + @Input() max: string | number | null = null; + @Input() step: string | number | null = null; + @Input() inputId: string = `input-wrapper-${Math.random().toString(36).substr(2, 9)}`; + + // Textarea-specific inputs + @Input() rows: number = 4; + @Input() cols: number | null = null; + @Input() resize: TextareaResize = 'vertical'; + @Input() autoResize = false; + + @Output() inputChange = new EventEmitter(); + @Output() inputFocus = new EventEmitter(); + @Output() inputBlur = new EventEmitter(); + @Output() keyDown = new EventEmitter(); + @Output() clear = new EventEmitter(); + + value = ''; + + // ControlValueAccessor implementation + private onChange = (value: string) => {}; + private onTouched = () => {}; + + ngAfterViewInit(): void { + // Set up the initial value and event forwarding for child components + this.updateChildComponent(); + } + + writeValue(value: string): void { + this.value = value || ''; + this.updateChildComponent(); + } + + registerOnChange(fn: (value: string) => void): void { + this.onChange = fn; + this.updateChildComponent(); + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + this.updateChildComponent(); + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + this.updateChildComponent(); + } + + private updateChildComponent(): void { + const activeComponent = this.mode === 'input' ? this.textInput : this.textArea; + if (activeComponent) { + activeComponent.writeValue(this.value); + activeComponent.registerOnChange(this.onChange); + activeComponent.registerOnTouched(this.onTouched); + activeComponent.setDisabledState(this.disabled); + } + } + + onInputChange(value: string): void { + this.value = value; + this.onChange(value); + this.inputChange.emit(value); + } + + onInputFocus(event: FocusEvent): void { + this.inputFocus.emit(event); + } + + onInputBlur(event: FocusEvent): void { + this.onTouched(); + this.inputBlur.emit(event); + } + + onKeyDown(event: KeyboardEvent): void { + this.keyDown.emit(event); + } + + onClear(): void { + this.clear.emit(); + } +} \ No newline at end of file diff --git a/src/lib/components/forms/input/text-input.component.scss b/src/lib/components/forms/input/text-input.component.scss new file mode 100644 index 0000000..3ae2b1e --- /dev/null +++ b/src/lib/components/forms/input/text-input.component.scss @@ -0,0 +1,561 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +// Tokens available globally via main application styles + +// ========================================================================== +// TEXT INPUT COMPONENT +// ========================================================================== +// Comprehensive text input component with multiple variants and sizes +// Uses semantic design tokens for consistent styling across the design system +// ========================================================================== + +.text-input-wrapper { + display: flex; + flex-direction: column; + width: 100%; + position: relative; + + // ========================================================================== + // SIZE VARIANTS + // ========================================================================== + + &--sm { + .text-input__container { + min-height: 36px; + } + + .text-input__field { + font-size: 0.875rem; + padding: $semantic-spacing-component-xs $semantic-spacing-component-sm; + } + + .text-input__label { + font-size: 0.8125rem; + margin-bottom: $semantic-spacing-component-xs; + } + } + + &--md { + .text-input__container { + min-height: 48px; + } + + .text-input__field { + font-size: 1rem; + padding: $semantic-spacing-component-sm $semantic-spacing-component-md; + } + + .text-input__label { + font-size: 0.875rem; + margin-bottom: $semantic-spacing-component-sm; + } + } + + &--lg { + .text-input__container { + min-height: 56px; + } + + .text-input__field { + font-size: 1.125rem; + padding: $semantic-spacing-component-md $semantic-spacing-component-lg; + } + + .text-input__label { + font-size: 1rem; + margin-bottom: $semantic-spacing-component-sm; + } + } + + // ========================================================================== + // VARIANT STYLES + // ========================================================================== + + &--outlined { + .text-input__container { + border: $semantic-border-width-2 solid $semantic-color-border-primary; + border-radius: $semantic-border-radius-md; + background-color: $semantic-color-surface-primary; + } + + &.text-input-wrapper--focused .text-input__container { + border-color: $semantic-color-interactive-primary; + box-shadow: 0 0 0 2px rgba($semantic-color-brand-primary, 0.12); + } + } + + &--filled { + .text-input__container { + border: none; + border-bottom: 2px solid $semantic-color-border-primary; + border-radius: $semantic-border-radius-sm $semantic-border-radius-sm 0 0; + background-color: $semantic-color-surface-secondary; + } + + &.text-input-wrapper--focused .text-input__container { + border-bottom-color: $semantic-color-interactive-primary; + background-color: $semantic-color-surface-elevated; + } + } + + &--underlined { + .text-input__container { + border: none; + border-bottom: 1px solid $semantic-color-border-primary; + border-radius: 0; + background-color: transparent; + padding: $semantic-spacing-component-xs 0; + } + + &.text-input-wrapper--focused .text-input__container { + border-bottom: 2px solid $semantic-color-interactive-primary; + } + } + + // ========================================================================== + // STATE VARIANTS + // ========================================================================== + + &--error { + .text-input__container { + border-color: $semantic-color-danger; + } + + &.text-input-wrapper--focused .text-input__container { + border-color: $semantic-color-danger; + box-shadow: 0 0 0 2px rgba($semantic-color-danger, 0.12); + } + + .text-input__label { + color: $semantic-color-danger; + } + } + + &--success { + .text-input__container { + border-color: $semantic-color-success; + } + + &.text-input-wrapper--focused .text-input__container { + border-color: $semantic-color-success; + box-shadow: 0 0 0 2px rgba($semantic-color-success, 0.12); + } + + .text-input__label { + color: $semantic-color-success; + } + } + + &--warning { + .text-input__container { + border-color: $semantic-color-warning; + } + + &.text-input-wrapper--focused .text-input__container { + border-color: $semantic-color-warning; + box-shadow: 0 0 0 2px rgba($semantic-color-warning, 0.12); + } + + .text-input__label { + color: $semantic-color-warning; + } + } + + &--disabled { + opacity: 0.6; + pointer-events: none; + + .text-input__container { + background-color: $semantic-color-surface-disabled; + border-color: $semantic-color-border-disabled; + } + + .text-input__label { + color: $semantic-color-text-disabled; + } + } + + &--readonly { + .text-input__field { + background-color: $semantic-color-surface-secondary; + cursor: default; + } + } + + &--loading { + .text-input__field { + padding-right: calc($semantic-spacing-component-lg + 24px); + } + } +} + +// ========================================================================== +// LABEL STYLES +// ========================================================================== + +.text-input__label { + display: block; + font-weight: 500; + color: $semantic-color-text-primary; + cursor: pointer; + user-select: none; + transition: color 0.2s ease-in-out; + + &--required { + .text-input__required-indicator { + color: $semantic-color-danger; + margin-left: $semantic-spacing-micro-tight; + } + } +} + +// ========================================================================== +// INPUT CONTAINER +// ========================================================================== + +.text-input__container { + display: flex; + align-items: center; + width: 100%; + box-sizing: border-box; + transition: all 0.2s ease-in-out; + position: relative; + + &:hover:not(.text-input-wrapper--disabled &) { + border-color: $semantic-color-border-secondary; + } + + &--has-prefix { + .text-input__field { + padding-left: 0; // Prefix handles spacing + } + } + + &--has-suffix { + .text-input__field { + padding-right: 0; // Suffix handles spacing + } + } +} + +// ========================================================================== +// INPUT FIELD +// ========================================================================== + +.text-input__field { + flex: 1; + border: none; + outline: none; + background: transparent; + color: $semantic-color-text-primary; + font-family: inherit; + font-weight: 400; + line-height: 1.5; + min-width: 0; + + &::placeholder { + color: $semantic-color-text-tertiary; + transition: opacity 0.2s ease-in-out; + } + + &:focus::placeholder { + opacity: 0.7; + } + + // Remove browser default styles + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } + + &[type="number"] { + -moz-appearance: textfield; + } + + // Autofill styles + &:-webkit-autofill { + -webkit-text-fill-color: $semantic-color-text-primary !important; + -webkit-box-shadow: 0 0 0 1000px $semantic-color-surface-primary inset !important; + transition: background-color 5000s ease-in-out 0s; + } + + // Remove password reveal button in Edge + &::-ms-reveal, + &::-ms-clear { + display: none; + } +} + +// ========================================================================== +// PREFIX STYLES +// ========================================================================== + +.text-input__prefix-icon, +.text-input__prefix-text { + flex-shrink: 0; + display: flex; + align-items: center; + color: $semantic-color-text-secondary; +} + +.text-input__prefix-icon { + padding-left: 12px; // 8-12px padding from left edge so icon isn't hard against side + padding-right: 12px; // 8-12px spacing between icon and text as requested + + // Size-specific spacing adjustments + .text-input-wrapper--sm & { + padding-left: 10px; // Slightly less for smaller size + padding-right: 10px; // Slightly less for smaller size + } + + .text-input-wrapper--lg & { + padding-left: 14px; // Slightly more for larger size + padding-right: 14px; // Slightly more for larger size + } + + fa-icon { + display: flex; + align-items: center; + justify-content: center; + width: 1rem; + height: 1rem; + font-size: 1rem; + + .text-input-wrapper--sm & { + width: 0.875rem; + height: 0.875rem; + font-size: 0.875rem; + } + + .text-input-wrapper--lg & { + width: 1.125rem; + height: 1.125rem; + font-size: 1.125rem; + } + } +} + +.text-input__prefix-text { + padding-left: $semantic-spacing-component-md; + padding-right: 10px; // Slightly less than icon to account for border + font-weight: 500; + font-size: 0.875rem; + white-space: nowrap; + border-right: 1px solid $semantic-color-border-secondary; + margin-right: $semantic-spacing-component-xs; + + .text-input-wrapper--sm & { + font-size: 0.8125rem; + } + + .text-input-wrapper--lg & { + font-size: 1rem; + } +} + +// ========================================================================== +// SUFFIX STYLES +// ========================================================================== + +.text-input__suffix { + flex-shrink: 0; + display: flex; + align-items: center; + gap: $semantic-spacing-component-xs; + padding-right: 12px; // 8-12px padding from right edge so icons aren't hard against side + padding-left: 12px; // Consistent spacing from text content + + // Size-specific spacing adjustments + .text-input-wrapper--sm & { + padding-left: 10px; // Slightly less for smaller size + padding-right: 10px; // Slightly less for smaller size + } + + .text-input-wrapper--lg & { + padding-left: 14px; // Slightly more for larger size + padding-right: 14px; // Slightly more for larger size + } + + &--has-content { + // Maintain consistent padding even when content is present + } +} + +.text-input__clear-btn, +.text-input__password-toggle { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: none; + border-radius: $semantic-border-radius-sm; + background: transparent; + color: $semantic-color-text-secondary; + cursor: pointer; + transition: all 0.2s ease-in-out; + + &:hover { + background-color: $semantic-color-surface-interactive; + color: $semantic-color-text-primary; + } + + &:focus { + outline: 2px solid $semantic-color-border-focus; + outline-offset: 1px; + } + + fa-icon { + display: flex; + align-items: center; + justify-content: center; + width: 0.875rem; + height: 0.875rem; + font-size: 0.875rem; + } +} + +.text-input__spinner { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + color: $semantic-color-interactive-primary; + + fa-icon { + display: flex; + align-items: center; + justify-content: center; + width: 0.875rem; + height: 0.875rem; + font-size: 0.875rem; + } +} + +.text-input__suffix-icon { + display: flex; + align-items: center; + color: $semantic-color-text-secondary; + + fa-icon { + display: flex; + align-items: center; + justify-content: center; + width: 1rem; + height: 1rem; + font-size: 1rem; + } +} + +// ========================================================================== +// HELPER TEXT AND ERROR MESSAGES +// ========================================================================== + +.text-input__helper-text { + margin-top: $semantic-spacing-component-xs; + font-size: 0.8125rem; + line-height: 1.4; + color: $semantic-color-text-secondary; + + &--error { + color: $semantic-color-danger; + } + + &--success { + color: $semantic-color-success; + } + + &--warning { + color: $semantic-color-warning; + } +} + +// ========================================================================== +// CHARACTER COUNTER +// ========================================================================== + +.text-input__char-counter { + position: absolute; + bottom: -#{$semantic-spacing-component-lg}; + right: 0; + font-size: 0.75rem; + color: $semantic-color-text-tertiary; + user-select: none; +} + +// ========================================================================== +// RESPONSIVE ADJUSTMENTS +// ========================================================================== + +@media (max-width: 768px) { + .text-input-wrapper { + &--lg { + .text-input__container { + min-height: 48px; + } + + .text-input__field { + font-size: 1rem; + padding: $semantic-spacing-component-sm $semantic-spacing-component-md; + } + } + } +} + +// ========================================================================== +// ACCESSIBILITY ENHANCEMENTS +// ========================================================================== + +.text-input__field:focus { + // Focus is handled by container border/shadow +} + +// High contrast mode support +@media (prefers-contrast: high) { + .text-input__container { + border-width: 2px; + } + + .text-input-wrapper--focused .text-input__container { + border-width: 3px; + } +} + +// Reduced motion support +@media (prefers-reduced-motion: reduce) { + .text-input__container, + .text-input__field, + .text-input__label, + .text-input__clear-btn, + .text-input__password-toggle { + transition: none; + } +} + +// ========================================================================== +// FLOATING LABEL VARIANT (OPTIONAL) +// ========================================================================== + +.text-input-wrapper--floating { + .text-input__label { + position: absolute; + top: 50%; + left: $semantic-spacing-component-md; + transform: translateY(-50%); + background-color: $semantic-color-surface-primary; + padding: 0 $semantic-spacing-component-xs; + pointer-events: none; + transition: all 0.2s ease-in-out; + z-index: 1; + } + + &.text-input-wrapper--focused .text-input__label, + &.text-input-wrapper--has-value .text-input__label { + top: 0; + transform: translateY(-50%); + font-size: 0.75rem; + color: $semantic-color-interactive-primary; + } +} \ No newline at end of file diff --git a/src/lib/components/forms/input/text-input.component.ts b/src/lib/components/forms/input/text-input.component.ts new file mode 100644 index 0000000..9055bae --- /dev/null +++ b/src/lib/components/forms/input/text-input.component.ts @@ -0,0 +1,275 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, forwardRef, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { IconDefinition } from '@fortawesome/fontawesome-svg-core'; +import { faSearch, faEnvelope, faTimes, faEye, faEyeSlash, faSpinner } from '@fortawesome/free-solid-svg-icons'; + +export type TextInputSize = 'sm' | 'md' | 'lg'; +export type TextInputVariant = 'outlined' | 'filled' | 'underlined'; +export type TextInputType = 'text' | 'email' | 'password' | 'search' | 'number' | 'tel' | 'url'; +export type TextInputState = 'default' | 'error' | 'success' | 'warning'; + +@Component({ + selector: 'ui-text-input', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, FontAwesomeModule], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TextInputComponent), + multi: true + } + ], + template: ` +
+ + @if (label) { + + } + + +
+ + @if (prefixIcon) { +
+ +
+ } + + + @if (prefixText) { +
{{ prefixText }}
+ } + + + + + +
+ + @if (clearable && value && !disabled) { + + } + + + @if (type === 'password') { + + } + + + @if (loading) { +
+ +
+ } + + + @if (suffixIcon) { +
+ +
+ } +
+
+ + + @if (helperText || errorMessage) { +
+ {{ state === 'error' ? errorMessage : helperText }} +
+ } + + + @if (maxLength && showCharacterCount) { +
+ {{ value.length }}/{{ maxLength }} +
+ } +
+ `, + styleUrls: ['./text-input.component.scss'] +}) +export class TextInputComponent implements ControlValueAccessor { + @Input() label: string = ''; + @Input() placeholder: string = ''; + @Input() type: TextInputType = 'text'; + @Input() size: TextInputSize = 'md'; + @Input() variant: TextInputVariant = 'outlined'; + @Input() state: TextInputState = 'default'; + @Input() disabled = false; + @Input() readonly = false; + @Input() required = false; + @Input() loading = false; + @Input() clearable = false; + @Input() prefixIcon: IconDefinition | null = null; + @Input() prefixText: string = ''; + @Input() suffixIcon: IconDefinition | null = null; + @Input() helperText: string = ''; + @Input() errorMessage: string = ''; + @Input() maxLength: number | null = null; + @Input() showCharacterCount = false; + @Input() autocomplete: string = ''; + @Input() min: string | number | null = null; + @Input() max: string | number | null = null; + @Input() step: string | number | null = null; + @Input() inputId: string = `text-input-${Math.random().toString(36).substr(2, 9)}`; + + @Output() inputChange = new EventEmitter(); + @Output() inputFocus = new EventEmitter(); + @Output() inputBlur = new EventEmitter(); + @Output() keyDown = new EventEmitter(); + @Output() clear = new EventEmitter(); + + value = ''; + focused = false; + passwordVisible = signal(false); + + // FontAwesome icons + readonly faSearch = faSearch; + readonly faEnvelope = faEnvelope; + readonly faTimes = faTimes; + readonly faEye = faEye; + readonly faEyeSlash = faEyeSlash; + readonly faSpinner = faSpinner; + + // ControlValueAccessor implementation + private onChange = (value: string) => {}; + private onTouched = () => {}; + + writeValue(value: string): void { + this.value = value || ''; + } + + registerOnChange(fn: (value: string) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + getWrapperClasses(): string { + const classes = [ + `text-input-wrapper--${this.size}`, + `text-input-wrapper--${this.variant}`, + `text-input-wrapper--${this.state}` + ]; + + if (this.disabled) classes.push('text-input-wrapper--disabled'); + if (this.focused) classes.push('text-input-wrapper--focused'); + if (this.readonly) classes.push('text-input-wrapper--readonly'); + if (this.loading) classes.push('text-input-wrapper--loading'); + + return classes.join(' '); + } + + getContainerClasses(): string { + const classes: string[] = []; + + if (this.prefixIcon || this.prefixText) classes.push('text-input__container--has-prefix'); + if (this.hasSuffixContent()) classes.push('text-input__container--has-suffix'); + + return classes.join(' '); + } + + getHelperTextClasses(): string { + const classes = [`text-input__helper-text--${this.state}`]; + return classes.join(' '); + } + + getInputType(): string { + if (this.type === 'password') { + return this.passwordVisible() ? 'text' : 'password'; + } + return this.type; + } + + hasSuffixContent(): boolean { + return !!( + this.clearable || + this.type === 'password' || + this.loading || + this.suffixIcon + ); + } + + onInput(event: Event): void { + const target = event.target as HTMLInputElement; + this.value = target.value; + this.onChange(this.value); + this.inputChange.emit(this.value); + } + + onFocus(event: FocusEvent): void { + this.focused = true; + this.inputFocus.emit(event); + } + + onBlur(event: FocusEvent): void { + this.focused = false; + this.onTouched(); + this.inputBlur.emit(event); + } + + onKeyDown(event: KeyboardEvent): void { + this.keyDown.emit(event); + } + + togglePasswordVisibility(): void { + this.passwordVisible.set(!this.passwordVisible()); + } + + clearValue(): void { + this.value = ''; + this.onChange(this.value); + this.inputChange.emit(this.value); + this.clear.emit(); + } +} \ No newline at end of file diff --git a/src/lib/components/forms/input/textarea.component.scss b/src/lib/components/forms/input/textarea.component.scss new file mode 100644 index 0000000..549b3b6 --- /dev/null +++ b/src/lib/components/forms/input/textarea.component.scss @@ -0,0 +1,523 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +// Tokens available globally via main application styles + +// ========================================================================== +// TEXTAREA COMPONENT +// ========================================================================== +// Comprehensive textarea component with multiple variants and sizes +// Uses semantic design tokens for consistent styling across the design system +// ========================================================================== + +.textarea-wrapper { + display: flex; + flex-direction: column; + width: 100%; + position: relative; + + // ========================================================================== + // SIZE VARIANTS + // ========================================================================== + + &--sm { + .textarea__container { + min-height: 80px; + } + + .textarea__field { + font-size: 0.875rem; + padding: $semantic-spacing-component-xs $semantic-spacing-component-sm; + line-height: 1.4; + } + + .textarea__label { + font-size: 0.8125rem; + margin-bottom: $semantic-spacing-component-xs; + } + } + + &--md { + .textarea__container { + min-height: 100px; + } + + .textarea__field { + font-size: 1rem; + padding: $semantic-spacing-component-sm $semantic-spacing-component-md; + line-height: 1.5; + } + + .textarea__label { + font-size: 0.875rem; + margin-bottom: $semantic-spacing-component-sm; + } + } + + &--lg { + .textarea__container { + min-height: 120px; + } + + .textarea__field { + font-size: 1.125rem; + padding: $semantic-spacing-component-md $semantic-spacing-component-lg; + line-height: 1.6; + } + + .textarea__label { + font-size: 1rem; + margin-bottom: $semantic-spacing-component-sm; + } + } + + // ========================================================================== + // VARIANT STYLES + // ========================================================================== + + &--outlined { + .textarea__container { + border: 2px solid $semantic-color-border-primary; + border-radius: $semantic-border-radius-md; + background-color: $semantic-color-surface-primary; + } + + &.textarea-wrapper--focused .textarea__container { + border-color: $semantic-color-interactive-primary; + box-shadow: 0 0 0 2px rgba($semantic-color-brand-primary, 0.12); + } + } + + &--filled { + .textarea__container { + border: none; + border-bottom: 2px solid $semantic-color-border-primary; + border-radius: $semantic-border-radius-sm $semantic-border-radius-sm 0 0; + background-color: $semantic-color-surface-secondary; + } + + &.textarea-wrapper--focused .textarea__container { + border-bottom-color: $semantic-color-interactive-primary; + background-color: $semantic-color-surface-elevated; + } + } + + &--underlined { + .textarea__container { + border: none; + border-bottom: 1px solid $semantic-color-border-primary; + border-radius: 0; + background-color: transparent; + padding: $semantic-spacing-component-xs 0; + } + + &.textarea-wrapper--focused .textarea__container { + border-bottom: 2px solid $semantic-color-interactive-primary; + } + } + + // ========================================================================== + // STATE VARIANTS + // ========================================================================== + + &--error { + .textarea__container { + border-color: $semantic-color-danger; + } + + &.textarea-wrapper--focused .textarea__container { + border-color: $semantic-color-danger; + box-shadow: 0 0 0 2px rgba($semantic-color-danger, 0.12); + } + + .textarea__label { + color: $semantic-color-danger; + } + } + + &--success { + .textarea__container { + border-color: $semantic-color-success; + } + + &.textarea-wrapper--focused .textarea__container { + border-color: $semantic-color-success; + box-shadow: 0 0 0 2px rgba($semantic-color-success, 0.12); + } + + .textarea__label { + color: $semantic-color-success; + } + } + + &--warning { + .textarea__container { + border-color: $semantic-color-warning; + } + + &.textarea-wrapper--focused .textarea__container { + border-color: $semantic-color-warning; + box-shadow: 0 0 0 2px rgba($semantic-color-warning, 0.12); + } + + .textarea__label { + color: $semantic-color-warning; + } + } + + &--disabled { + opacity: 0.6; + pointer-events: none; + + .textarea__container { + background-color: $semantic-color-surface-disabled; + border-color: $semantic-color-border-disabled; + } + + .textarea__label { + color: $semantic-color-text-disabled; + } + } + + &--readonly { + .textarea__field { + background-color: $semantic-color-surface-secondary; + cursor: default; + } + } + + &--loading { + .textarea__field { + padding-right: calc($semantic-spacing-component-lg + 24px); + } + } + + &--resize-none { + .textarea__field { + resize: none; + } + } + + &--resize-vertical { + .textarea__field { + resize: vertical; + } + } + + &--resize-horizontal { + .textarea__field { + resize: horizontal; + } + } + + &--resize-both { + .textarea__field { + resize: both; + } + } + + &--auto-resize { + .textarea__field { + resize: none; + overflow: hidden; + } + } +} + +// ========================================================================== +// LABEL STYLES +// ========================================================================== + +.textarea__label { + display: block; + font-weight: 500; + color: $semantic-color-text-primary; + cursor: pointer; + user-select: none; + transition: color 0.2s ease-in-out; + + &--required { + .textarea__required-indicator { + color: $semantic-color-danger; + margin-left: $semantic-spacing-micro-tight; + } + } +} + +// ========================================================================== +// TEXTAREA CONTAINER +// ========================================================================== + +.textarea__container { + display: flex; + align-items: flex-start; + width: 100%; + box-sizing: border-box; + transition: all 0.2s ease-in-out; + position: relative; + + &:hover:not(.textarea-wrapper--disabled &) { + border-color: $semantic-color-border-secondary; + } + + &--has-prefix { + .textarea__field { + padding-left: 0; + } + } + + &--has-suffix { + .textarea__field { + padding-right: 0; + } + } +} + +// ========================================================================== +// TEXTAREA FIELD +// ========================================================================== + +.textarea__field { + flex: 1; + border: none; + outline: none; + background: transparent; + color: $semantic-color-text-primary; + font-family: inherit; + font-weight: 400; + min-width: 0; + resize: vertical; + + &::placeholder { + color: $semantic-color-text-tertiary; + transition: opacity 0.2s ease-in-out; + } + + &:focus::placeholder { + opacity: 0.7; + } +} + +// ========================================================================== +// PREFIX STYLES +// ========================================================================== + +.textarea__prefix-icon { + flex-shrink: 0; + display: flex; + align-items: flex-start; + color: $semantic-color-text-secondary; + padding-left: 12px; // 8-12px padding from left edge so icon isn't hard against side + padding-right: 12px; // 8-12px spacing between icon and text + padding-top: $semantic-spacing-component-sm; + + // Size-specific spacing adjustments + .textarea-wrapper--sm & { + padding-left: 10px; // Slightly less for smaller size + padding-right: 10px; // Slightly less for smaller size + } + + .textarea-wrapper--lg & { + padding-left: 14px; // Slightly more for larger size + padding-right: 14px; // Slightly more for larger size + } + + fa-icon { + display: flex; + align-items: center; + justify-content: center; + width: 1rem; + height: 1rem; + font-size: 1rem; + + .textarea-wrapper--sm & { + width: 0.875rem; + height: 0.875rem; + font-size: 0.875rem; + } + + .textarea-wrapper--lg & { + width: 1.125rem; + height: 1.125rem; + font-size: 1.125rem; + } + } +} + +// ========================================================================== +// SUFFIX STYLES +// ========================================================================== + +.textarea__suffix { + flex-shrink: 0; + display: flex; + align-items: flex-start; + gap: $semantic-spacing-component-xs; + padding-right: 12px; // 8-12px padding from right edge so icons aren't hard against side + padding-left: 12px; // Consistent spacing from text content + padding-top: $semantic-spacing-component-sm; + + // Size-specific spacing adjustments + .textarea-wrapper--sm & { + padding-left: 10px; // Slightly less for smaller size + padding-right: 10px; // Slightly less for smaller size + } + + .textarea-wrapper--lg & { + padding-left: 14px; // Slightly more for larger size + padding-right: 14px; // Slightly more for larger size + } + + &--has-content { + // Maintain consistent padding even when content is present + } +} + +.textarea__clear-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: none; + border-radius: $semantic-border-radius-sm; + background: transparent; + color: $semantic-color-text-secondary; + cursor: pointer; + transition: all 0.2s ease-in-out; + + &:hover { + background-color: $semantic-color-surface-interactive; + color: $semantic-color-text-primary; + } + + &:focus { + outline: 2px solid $semantic-color-border-focus; + outline-offset: 1px; + } + + fa-icon { + display: flex; + align-items: center; + justify-content: center; + width: 0.875rem; + height: 0.875rem; + font-size: 0.875rem; + } +} + +.textarea__spinner { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + color: $semantic-color-interactive-primary; + + fa-icon { + display: flex; + align-items: center; + justify-content: center; + width: 0.875rem; + height: 0.875rem; + font-size: 0.875rem; + } +} + +.textarea__suffix-icon { + display: flex; + align-items: center; + color: $semantic-color-text-secondary; + + fa-icon { + display: flex; + align-items: center; + justify-content: center; + width: 1rem; + height: 1rem; + font-size: 1rem; + } +} + +// ========================================================================== +// HELPER TEXT AND ERROR MESSAGES +// ========================================================================== + +.textarea__helper-text { + margin-top: $semantic-spacing-component-xs; + font-size: 0.8125rem; + line-height: 1.4; + color: $semantic-color-text-secondary; + + &--error { + color: $semantic-color-danger; + } + + &--success { + color: $semantic-color-success; + } + + &--warning { + color: $semantic-color-warning; + } +} + +// ========================================================================== +// CHARACTER COUNTER +// ========================================================================== + +.textarea__char-counter { + position: absolute; + bottom: -#{$semantic-spacing-component-lg}; + right: 0; + font-size: 0.75rem; + color: $semantic-color-text-tertiary; + user-select: none; +} + +// ========================================================================== +// RESPONSIVE ADJUSTMENTS +// ========================================================================== + +@media (max-width: 768px) { + .textarea-wrapper { + &--lg { + .textarea__container { + min-height: 100px; + } + + .textarea__field { + font-size: 1rem; + padding: $semantic-spacing-component-sm $semantic-spacing-component-md; + } + } + } +} + +// ========================================================================== +// ACCESSIBILITY ENHANCEMENTS +// ========================================================================== + +.textarea__field:focus { + // Focus is handled by container border/shadow +} + +// High contrast mode support +@media (prefers-contrast: high) { + .textarea__container { + border-width: 2px; + } + + .textarea-wrapper--focused .textarea__container { + border-width: 3px; + } +} + +// Reduced motion support +@media (prefers-reduced-motion: reduce) { + .textarea__container, + .textarea__field, + .textarea__label, + .textarea__clear-btn { + transition: none; + } +} \ No newline at end of file diff --git a/src/lib/components/forms/input/textarea.component.ts b/src/lib/components/forms/input/textarea.component.ts new file mode 100644 index 0000000..2395b5c --- /dev/null +++ b/src/lib/components/forms/input/textarea.component.ts @@ -0,0 +1,248 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, forwardRef, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { IconDefinition } from '@fortawesome/fontawesome-svg-core'; +import { faTimes, faSpinner } from '@fortawesome/free-solid-svg-icons'; + +export type TextareaSize = 'sm' | 'md' | 'lg'; +export type TextareaVariant = 'outlined' | 'filled' | 'underlined'; +export type TextareaState = 'default' | 'error' | 'success' | 'warning'; +export type TextareaResize = 'none' | 'vertical' | 'horizontal' | 'both'; + +@Component({ + selector: 'ui-textarea', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, FontAwesomeModule], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TextareaComponent), + multi: true + } + ], + template: ` +
+ + @if (label) { + + } + + +
+ + @if (prefixIcon) { +
+ +
+ } + + + + + +
+ + @if (clearable && value && !disabled) { + + } + + + @if (loading) { +
+ +
+ } + + + @if (suffixIcon) { +
+ +
+ } +
+
+ + + @if (helperText || errorMessage) { +
+ {{ state === 'error' ? errorMessage : helperText }} +
+ } + + + @if (maxLength && showCharacterCount) { +
+ {{ value.length }}/{{ maxLength }} +
+ } +
+ `, + styleUrls: ['./textarea.component.scss'] +}) +export class TextareaComponent implements ControlValueAccessor { + @Input() label: string = ''; + @Input() placeholder: string = ''; + @Input() size: TextareaSize = 'md'; + @Input() variant: TextareaVariant = 'outlined'; + @Input() state: TextareaState = 'default'; + @Input() disabled = false; + @Input() readonly = false; + @Input() required = false; + @Input() loading = false; + @Input() clearable = false; + @Input() prefixIcon: IconDefinition | null = null; + @Input() suffixIcon: IconDefinition | null = null; + @Input() helperText: string = ''; + @Input() errorMessage: string = ''; + @Input() maxLength: number | null = null; + @Input() showCharacterCount = false; + @Input() rows: number = 4; + @Input() cols: number | null = null; + @Input() resize: TextareaResize = 'vertical'; + @Input() autoResize = false; + @Input() textareaId: string = `textarea-${Math.random().toString(36).substr(2, 9)}`; + + @Output() textareaChange = new EventEmitter(); + @Output() textareaFocus = new EventEmitter(); + @Output() textareaBlur = new EventEmitter(); + @Output() keyDown = new EventEmitter(); + @Output() clear = new EventEmitter(); + + value = ''; + focused = false; + + // FontAwesome icons + readonly faTimes = faTimes; + readonly faSpinner = faSpinner; + + // ControlValueAccessor implementation + private onChange = (value: string) => {}; + private onTouched = () => {}; + + writeValue(value: string): void { + this.value = value || ''; + } + + registerOnChange(fn: (value: string) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + getWrapperClasses(): string { + const classes = [ + `textarea-wrapper--${this.size}`, + `textarea-wrapper--${this.variant}`, + `textarea-wrapper--${this.state}`, + `textarea-wrapper--resize-${this.resize}` + ]; + + if (this.disabled) classes.push('textarea-wrapper--disabled'); + if (this.focused) classes.push('textarea-wrapper--focused'); + if (this.readonly) classes.push('textarea-wrapper--readonly'); + if (this.loading) classes.push('textarea-wrapper--loading'); + if (this.autoResize) classes.push('textarea-wrapper--auto-resize'); + + return classes.join(' '); + } + + getContainerClasses(): string { + const classes: string[] = []; + + if (this.prefixIcon) classes.push('textarea__container--has-prefix'); + if (this.hasSuffixContent()) classes.push('textarea__container--has-suffix'); + + return classes.join(' '); + } + + getHelperTextClasses(): string { + const classes = [`textarea__helper-text--${this.state}`]; + return classes.join(' '); + } + + hasSuffixContent(): boolean { + return !!( + this.clearable || + this.loading || + this.suffixIcon + ); + } + + onInput(event: Event): void { + const target = event.target as HTMLTextAreaElement; + this.value = target.value; + + if (this.autoResize) { + this.adjustHeight(target); + } + + this.onChange(this.value); + this.textareaChange.emit(this.value); + } + + onFocus(event: FocusEvent): void { + this.focused = true; + this.textareaFocus.emit(event); + } + + onBlur(event: FocusEvent): void { + this.focused = false; + this.onTouched(); + this.textareaBlur.emit(event); + } + + onKeyDown(event: KeyboardEvent): void { + this.keyDown.emit(event); + } + + clearValue(): void { + this.value = ''; + this.onChange(this.value); + this.textareaChange.emit(this.value); + this.clear.emit(); + } + + private adjustHeight(textarea: HTMLTextAreaElement): void { + textarea.style.height = 'auto'; + textarea.style.height = textarea.scrollHeight + 'px'; + } +} \ No newline at end of file diff --git a/src/lib/components/forms/radio/index.ts b/src/lib/components/forms/radio/index.ts new file mode 100644 index 0000000..e9fdf90 --- /dev/null +++ b/src/lib/components/forms/radio/index.ts @@ -0,0 +1,2 @@ +export * from './radio-button.component'; +export * from './radio-group.component'; \ No newline at end of file diff --git a/src/lib/components/forms/radio/radio-button.component.scss b/src/lib/components/forms/radio/radio-button.component.scss new file mode 100644 index 0000000..e6be000 --- /dev/null +++ b/src/lib/components/forms/radio/radio-button.component.scss @@ -0,0 +1,435 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +// Tokens available globally via main application styles + +// ========================================================================== +// RADIO BUTTON COMPONENT +// ========================================================================== +// Comprehensive radio button component with multiple variants and sizes +// Uses semantic design tokens for consistent styling across the design system +// ========================================================================== + +.radio-button-wrapper { + display: flex; + flex-direction: column; + width: 100%; + position: relative; + + // ========================================================================== + // SIZE VARIANTS + // ========================================================================== + + &--sm { + .radio-button { + width: 20px; + height: 20px; + } + + .radio-button__circle { + width: 20px; + height: 20px; + border-width: 1px; + } + + .radio-button__inner-circle { + width: 8px; + height: 8px; + top: 50%; + left: 50%; + } + + .radio-button__label { + font-size: 0.875rem; + line-height: 1.25rem; + } + + .radio-button__description { + font-size: 0.75rem; + line-height: 1rem; + } + + .radio-button-container { + gap: $semantic-spacing-component-xs; + } + } + + &--md { + .radio-button { + width: 24px; + height: 24px; + } + + .radio-button__circle { + width: 24px; + height: 24px; + border-width: 2px; + } + + .radio-button__inner-circle { + width: 10px; + height: 10px; + top: 50%; + left: 50%; + } + + .radio-button__label { + font-size: 1rem; + line-height: 1.5rem; + } + + .radio-button__description { + font-size: 0.875rem; + line-height: 1.25rem; + } + + .radio-button-container { + gap: $semantic-spacing-component-sm; + } + } + + &--lg { + .radio-button { + width: 28px; + height: 28px; + } + + .radio-button__circle { + width: 28px; + height: 28px; + border-width: 2px; + } + + .radio-button__inner-circle { + width: 12px; + height: 12px; + top: 50%; + left: 50%; + } + + .radio-button__label { + font-size: 1.125rem; + line-height: 1.75rem; + } + + .radio-button__description { + font-size: 1rem; + line-height: 1.5rem; + } + + .radio-button-container { + gap: $semantic-spacing-component-md; + } + } + + // ========================================================================== + // VARIANT STYLES + // ========================================================================== + + &--primary { + .radio-button__circle { + border-color: $semantic-color-border-primary; + } + + &.radio-button-wrapper--checked .radio-button__circle, + &.radio-button-wrapper--focused .radio-button__circle { + border-color: $semantic-color-interactive-primary; + } + + .radio-button__inner-circle--selected { + background-color: $semantic-color-interactive-primary; + } + } + + &--secondary { + .radio-button__circle { + border-color: $semantic-color-border-secondary; + } + + &.radio-button-wrapper--checked .radio-button__circle, + &.radio-button-wrapper--focused .radio-button__circle { + border-color: $semantic-color-interactive-secondary; + } + + .radio-button__inner-circle--selected { + background-color: $semantic-color-interactive-secondary; + } + } + + &--success { + .radio-button__circle { + border-color: $semantic-color-border-primary; + } + + &.radio-button-wrapper--checked .radio-button__circle, + &.radio-button-wrapper--focused .radio-button__circle { + border-color: $semantic-color-success; + } + + .radio-button__inner-circle--selected { + background-color: $semantic-color-success; + } + } + + &--warning { + .radio-button__circle { + border-color: $semantic-color-border-primary; + } + + &.radio-button-wrapper--checked .radio-button__circle, + &.radio-button-wrapper--focused .radio-button__circle { + border-color: $semantic-color-warning; + } + + .radio-button__inner-circle--selected { + background-color: $semantic-color-warning; + } + } + + &--danger { + .radio-button__circle { + border-color: $semantic-color-border-primary; + } + + &.radio-button-wrapper--checked .radio-button__circle, + &.radio-button-wrapper--focused .radio-button__circle { + border-color: $semantic-color-danger; + } + + .radio-button__inner-circle--selected { + background-color: $semantic-color-danger; + } + } + + // ========================================================================== + // STATE VARIANTS + // ========================================================================== + + &--disabled { + opacity: 0.6; + pointer-events: none; + + .radio-button__circle { + border-color: $semantic-color-border-disabled; + background-color: $semantic-color-surface-disabled; + } + + .radio-button__inner-circle--selected { + background-color: $semantic-color-text-disabled; + } + + .radio-button__label { + color: $semantic-color-text-disabled; + } + + .radio-button__description { + color: $semantic-color-text-disabled; + } + } + + &--error { + .radio-button__circle { + border-color: $semantic-color-danger; + } + + .radio-button__label { + color: $semantic-color-danger; + } + } + + &--focused { + .radio-button__circle { + box-shadow: 0 0 0 2px rgba($semantic-color-primary, 0.2); + } + } +} + +// ========================================================================== +// RADIO BUTTON CONTAINER +// ========================================================================== + +.radio-button-container { + display: flex; + align-items: flex-start; + cursor: pointer; + transition: all 0.2s ease-in-out; + + &:hover:not(.radio-button-wrapper--disabled &) { + .radio-button__circle { + border-color: $semantic-color-border-secondary; + } + } +} + +// ========================================================================== +// RADIO BUTTON +// ========================================================================== + +.radio-button { + position: relative; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.radio-button__input { + position: absolute; + opacity: 0; + width: 100%; + height: 100%; + margin: 0; + cursor: pointer; + + &:focus { + outline: none; + } +} + +.radio-button__circle { + position: relative; + border-radius: $semantic-border-radius-full; + border: 2px solid $semantic-color-border-primary; + background-color: $semantic-color-surface-primary; + transition: all 0.2s ease-in-out; + box-sizing: border-box; +} + +.radio-button__inner-circle { + position: absolute; + border-radius: $semantic-border-radius-full; + opacity: 0; + transform: translate(-50%, -50%) scale(0); + transition: all 0.2s ease-in-out; + + &--selected { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } +} + +// ========================================================================== +// CONTENT AREA +// ========================================================================== + +.radio-button-content { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; +} + +.radio-button__label { + font-weight: 500; + color: $semantic-color-text-primary; + cursor: pointer; + transition: color 0.2s ease-in-out; + margin: 0; + line-height: 1.5; +} + +.radio-button__description { + color: $semantic-color-text-secondary; + margin: 0; + margin-top: $semantic-spacing-micro-tight; + line-height: 1.4; +} + +// ========================================================================== +// HELPER TEXT AND ERROR MESSAGES +// ========================================================================== + +.radio-button__helper-text { + margin-top: $semantic-spacing-component-xs; + font-size: 0.8125rem; + line-height: 1.4; + color: $semantic-color-text-secondary; + padding-left: calc(24px + #{$semantic-spacing-component-sm}); // Align with label text + + .radio-button-wrapper--sm & { + padding-left: calc(20px + #{$semantic-spacing-component-xs}); + } + + .radio-button-wrapper--lg & { + padding-left: calc(28px + #{$semantic-spacing-component-md}); + } +} + +.radio-button__error-text { + margin-top: $semantic-spacing-component-xs; + font-size: 0.8125rem; + line-height: 1.4; + color: $semantic-color-danger; + padding-left: calc(24px + #{$semantic-spacing-component-sm}); // Align with label text + + .radio-button-wrapper--sm & { + padding-left: calc(20px + #{$semantic-spacing-component-xs}); + } + + .radio-button-wrapper--lg & { + padding-left: calc(28px + #{$semantic-spacing-component-md}); + } +} + +// ========================================================================== +// RESPONSIVE ADJUSTMENTS +// ========================================================================== + +@media (max-width: 768px) { + .radio-button-wrapper { + &--lg { + .radio-button { + width: 24px; + height: 24px; + } + + .radio-button__circle { + width: 24px; + height: 24px; + } + + .radio-button__inner-circle { + width: 10px; + height: 10px; + top: 50%; + left: 50%; + } + + .radio-button__label { + font-size: 1rem; + } + + .radio-button__description { + font-size: 0.875rem; + } + } + } +} + +// ========================================================================== +// ACCESSIBILITY ENHANCEMENTS +// ========================================================================== + +.radio-button__input:focus-visible + .radio-button__circle { + box-shadow: 0 0 0 2px $semantic-color-border-focus; + outline: none; +} + +// High contrast mode support +@media (prefers-contrast: high) { + .radio-button__circle { + border-width: 3px; + } + + .radio-button-wrapper--checked .radio-button__circle { + border-width: 3px; + } +} + +// Reduced motion support +@media (prefers-reduced-motion: reduce) { + .radio-button__circle, + .radio-button__inner-circle, + .radio-button-container { + transition: none; + } +} \ No newline at end of file diff --git a/src/lib/components/forms/radio/radio-button.component.ts b/src/lib/components/forms/radio/radio-button.component.ts new file mode 100644 index 0000000..ab76f63 --- /dev/null +++ b/src/lib/components/forms/radio/radio-button.component.ts @@ -0,0 +1,166 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, forwardRef, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; + +export type RadioButtonSize = 'sm' | 'md' | 'lg'; +export type RadioButtonVariant = 'primary' | 'secondary' | 'success' | 'warning' | 'danger'; +export type RadioButtonState = 'default' | 'error' | 'disabled'; + +export interface RadioButtonData { + value: string; + label: string; + disabled?: boolean; + description?: string; +} + +@Component({ + selector: 'ui-radio-button', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => RadioButtonComponent), + multi: true + } + ], + template: ` +
+ + + @if (helperText && !error) { +
{{ helperText }}
+ } + + @if (error) { +
{{ error }}
+ } +
+ `, + styleUrls: ['./radio-button.component.scss'] +}) +export class RadioButtonComponent implements ControlValueAccessor { + @Input() value: string = ''; + @Input() label: string = ''; + @Input() name: string = ''; + @Input() size: RadioButtonSize = 'md'; + @Input() variant: RadioButtonVariant = 'primary'; + @Input() state: RadioButtonState = 'default'; + @Input() disabled = false; + @Input() required = false; + @Input() description: string = ''; + @Input() helperText: string = ''; + @Input() error: string = ''; + @Input() radioId: string = `radio-${Math.random().toString(36).substr(2, 9)}`; + + @Output() selectionChange = new EventEmitter(); + @Output() radioFocus = new EventEmitter(); + @Output() radioBlur = new EventEmitter(); + + private selectedValue = signal(''); + private focused = signal(false); + + // ControlValueAccessor implementation + private onChange = (value: string) => {}; + private onTouched = () => {}; + + writeValue(value: string): void { + this.selectedValue.set(value || ''); + } + + registerOnChange(fn: (value: string) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + isChecked(): boolean { + return this.selectedValue() === this.value; + } + + getWrapperClasses(): string { + const classes = [ + `radio-button-wrapper--${this.size}`, + `radio-button-wrapper--${this.variant}`, + `radio-button-wrapper--${this.state}` + ]; + + if (this.disabled) classes.push('radio-button-wrapper--disabled'); + if (this.focused()) classes.push('radio-button-wrapper--focused'); + if (this.isChecked()) classes.push('radio-button-wrapper--checked'); + if (this.error) classes.push('radio-button-wrapper--error'); + if (this.description) classes.push('radio-button-wrapper--has-description'); + + return classes.join(' '); + } + + getRadioClasses(): string { + const classes: string[] = []; + + if (this.isChecked()) classes.push('radio-button--checked'); + if (this.disabled) classes.push('radio-button--disabled'); + + return classes.join(' '); + } + + getContentClasses(): string { + const classes: string[] = []; + + if (this.description) classes.push('radio-button-content--has-description'); + + return classes.join(' '); + } + + onSelectionChange(event: Event): void { + const target = event.target as HTMLInputElement; + if (target.checked) { + this.selectedValue.set(this.value); + this.onChange(this.value); + this.selectionChange.emit(this.value); + } + } + + onFocus(event: FocusEvent): void { + this.focused.set(true); + this.radioFocus.emit(event); + } + + onBlur(event: FocusEvent): void { + this.focused.set(false); + this.onTouched(); + this.radioBlur.emit(event); + } +} \ No newline at end of file diff --git a/src/lib/components/forms/radio/radio-group.component.scss b/src/lib/components/forms/radio/radio-group.component.scss new file mode 100644 index 0000000..587fd72 --- /dev/null +++ b/src/lib/components/forms/radio/radio-group.component.scss @@ -0,0 +1,272 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +// Tokens available globally via main application styles + +// ========================================================================== +// RADIO GROUP COMPONENT +// ========================================================================== +// Container component for managing groups of radio buttons +// Uses semantic design tokens for consistent styling across the design system +// ========================================================================== + +.radio-group-wrapper { + display: flex; + flex-direction: column; + width: 100%; + position: relative; + + // ========================================================================== + // SIZE VARIANTS + // ========================================================================== + + &--sm { + .radio-group__label { + font-size: 0.875rem; + margin-bottom: $semantic-spacing-component-xs; + } + + .radio-group { + gap: $semantic-spacing-component-xs; + } + + &.radio-group-wrapper--horizontal .radio-group { + gap: $semantic-spacing-component-sm; + } + } + + &--md { + .radio-group__label { + font-size: 1rem; + margin-bottom: $semantic-spacing-component-sm; + } + + .radio-group { + gap: $semantic-spacing-component-sm; + } + + &.radio-group-wrapper--horizontal .radio-group { + gap: $semantic-spacing-component-md; + } + } + + &--lg { + .radio-group__label { + font-size: 1.125rem; + margin-bottom: $semantic-spacing-component-sm; + } + + .radio-group { + gap: $semantic-spacing-component-sm; + } + + &.radio-group-wrapper--horizontal .radio-group { + gap: $semantic-spacing-component-lg; + } + } + + // ========================================================================== + // VARIANT STYLES + // ========================================================================== + + &--primary { + .radio-group__label { + color: $semantic-color-text-primary; + } + } + + &--secondary { + .radio-group__label { + color: $semantic-color-text-primary; + } + } + + &--success { + .radio-group__label { + color: $semantic-color-text-primary; + } + + &.radio-group-wrapper--error .radio-group__label { + color: $semantic-color-success; + } + } + + &--warning { + .radio-group__label { + color: $semantic-color-text-primary; + } + + &.radio-group-wrapper--error .radio-group__label { + color: $semantic-color-warning; + } + } + + &--danger { + .radio-group__label { + color: $semantic-color-text-primary; + } + + &.radio-group-wrapper--error .radio-group__label { + color: $semantic-color-danger; + } + } + + // ========================================================================== + // ORIENTATION VARIANTS + // ========================================================================== + + &--horizontal { + .radio-group { + flex-direction: row; + flex-wrap: wrap; + align-items: flex-start; + } + } + + &--vertical { + .radio-group { + flex-direction: column; + } + } + + // ========================================================================== + // STATE VARIANTS + // ========================================================================== + + &--disabled { + opacity: 0.6; + pointer-events: none; + + .radio-group__label { + color: $semantic-color-text-disabled; + } + } + + &--error { + .radio-group__label { + color: $semantic-color-danger; + } + } + + &--dense { + .radio-group { + gap: $semantic-spacing-component-xs; + } + + &.radio-group-wrapper--horizontal .radio-group { + gap: $semantic-spacing-component-sm; + } + + .radio-group__label { + margin-bottom: $semantic-spacing-component-xs; + } + } +} + +// ========================================================================== +// RADIO GROUP LABEL +// ========================================================================== + +.radio-group__label { + display: block; + font-weight: 600; + color: $semantic-color-text-primary; + line-height: 1.5; + user-select: none; + + &--required { + .radio-group__required-indicator { + color: $semantic-color-danger; + margin-left: $semantic-spacing-micro-tight; + font-weight: 400; + } + } +} + +// ========================================================================== +// RADIO GROUP CONTAINER +// ========================================================================== + +.radio-group { + display: flex; + flex-direction: column; + position: relative; + + &--vertical { + align-items: flex-start; + } + + &--horizontal { + flex-direction: row; + flex-wrap: wrap; + align-items: flex-start; + } + + &--dense { + gap: $semantic-spacing-component-xs; + } +} + +// ========================================================================== +// HELPER TEXT AND ERROR MESSAGES +// ========================================================================== + +.radio-group__helper-text { + margin-top: $semantic-spacing-component-sm; + font-size: 0.8125rem; + line-height: 1.4; + color: $semantic-color-text-secondary; +} + +.radio-group__error-text { + margin-top: $semantic-spacing-component-sm; + font-size: 0.8125rem; + line-height: 1.4; + color: $semantic-color-danger; +} + +// ========================================================================== +// RESPONSIVE ADJUSTMENTS +// ========================================================================== + +@media (max-width: 768px) { + .radio-group-wrapper { + &--horizontal { + .radio-group { + flex-direction: column; + gap: $semantic-spacing-component-sm; + } + } + + &--lg { + .radio-group__label { + font-size: 1rem; + } + } + } +} + +// ========================================================================== +// ACCESSIBILITY ENHANCEMENTS +// ========================================================================== + +.radio-group[role="radiogroup"]:focus-within { + // Focus styles are handled by individual radio buttons +} + +// High contrast mode support +@media (prefers-contrast: high) { + .radio-group__label { + font-weight: 700; + } + + .radio-group-wrapper--error .radio-group__label { + text-decoration: underline; + } +} + +// Reduced motion support +@media (prefers-reduced-motion: reduce) { + .radio-group__label { + transition: none; + } +} \ No newline at end of file diff --git a/src/lib/components/forms/radio/radio-group.component.ts b/src/lib/components/forms/radio/radio-group.component.ts new file mode 100644 index 0000000..31a9df8 --- /dev/null +++ b/src/lib/components/forms/radio/radio-group.component.ts @@ -0,0 +1,180 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, forwardRef, ContentChildren, QueryList, AfterContentInit, OnDestroy, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { Subject, takeUntil } from 'rxjs'; +import { RadioButtonComponent, RadioButtonSize, RadioButtonVariant, RadioButtonState, RadioButtonData } from './radio-button.component'; + +export type RadioGroupOrientation = 'horizontal' | 'vertical'; + +@Component({ + selector: 'ui-radio-group', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, RadioButtonComponent], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => RadioGroupComponent), + multi: true + } + ], + template: ` +
+ @if (label) { +
+ {{ label }} + @if (required) { + * + } +
+ } + +
+ @if (options && options.length > 0) { + @for (option of options; track option.value) { + + } + } @else { + + } +
+ + @if (helperText && !errorMessage) { +
{{ helperText }}
+ } + + @if (errorMessage) { +
{{ errorMessage }}
+ } +
+ `, + styleUrls: ['./radio-group.component.scss'] +}) +export class RadioGroupComponent implements ControlValueAccessor, AfterContentInit, OnDestroy { + @ContentChildren(RadioButtonComponent) radioButtons!: QueryList; + + @Input() options: RadioButtonData[] = []; + @Input() label: string = ''; + @Input() name: string = ''; + @Input() size: RadioButtonSize = 'md'; + @Input() variant: RadioButtonVariant = 'primary'; + @Input() state: RadioButtonState = 'default'; + @Input() orientation: RadioGroupOrientation = 'vertical'; + @Input() disabled = false; + @Input() required = false; + @Input() helperText: string = ''; + @Input() errorMessage: string = ''; + @Input() dense = false; + @Input() groupId: string = `radio-group-${Math.random().toString(36).substr(2, 9)}`; + + @Output() selectionChange = new EventEmitter(); + @Output() groupFocus = new EventEmitter(); + @Output() groupBlur = new EventEmitter(); + + private selectedValue = signal(''); + private destroy$ = new Subject(); + + // ControlValueAccessor implementation + private onChange = (value: string) => {}; + private onTouched = () => {}; + + ngAfterContentInit(): void { + this.syncRadioButtons(); + + // Listen for changes in radio buttons + this.radioButtons.changes.pipe(takeUntil(this.destroy$)).subscribe(() => { + this.syncRadioButtons(); + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + writeValue(value: string): void { + this.selectedValue.set(value || ''); + this.syncRadioButtons(); + } + + registerOnChange(fn: (value: string) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + this.syncRadioButtons(); + } + + private syncRadioButtons(): void { + if (this.radioButtons) { + this.radioButtons.forEach(radio => { + radio.name = this.name || this.groupId; + radio.size = this.size; + radio.variant = this.variant; + radio.state = this.state; + radio.writeValue(this.selectedValue()); + radio.setDisabledState(this.disabled); + radio.registerOnChange(this.onChange); + radio.registerOnTouched(this.onTouched); + }); + } + } + + getWrapperClasses(): string { + const classes = [ + `radio-group-wrapper--${this.size}`, + `radio-group-wrapper--${this.variant}`, + `radio-group-wrapper--${this.state}`, + `radio-group-wrapper--${this.orientation}` + ]; + + if (this.disabled) classes.push('radio-group-wrapper--disabled'); + if (this.dense) classes.push('radio-group-wrapper--dense'); + if (this.errorMessage) classes.push('radio-group-wrapper--error'); + + return classes.join(' '); + } + + getGroupClasses(): string { + const classes = [ + `radio-group--${this.orientation}` + ]; + + if (this.dense) classes.push('radio-group--dense'); + + return classes.join(' '); + } + + onSelectionChange(value: string): void { + this.selectedValue.set(value); + this.onChange(value); + this.selectionChange.emit(value); + this.syncRadioButtons(); + } + + onRadioFocus(event: FocusEvent): void { + this.groupFocus.emit(event); + } + + onRadioBlur(event: FocusEvent): void { + this.onTouched(); + this.groupBlur.emit(event); + } +} \ No newline at end of file diff --git a/src/lib/components/forms/range-slider/index.ts b/src/lib/components/forms/range-slider/index.ts new file mode 100644 index 0000000..2a60164 --- /dev/null +++ b/src/lib/components/forms/range-slider/index.ts @@ -0,0 +1 @@ +export * from './range-slider.component'; \ No newline at end of file diff --git a/src/lib/components/forms/range-slider/range-slider.component.scss b/src/lib/components/forms/range-slider/range-slider.component.scss new file mode 100644 index 0000000..c50cebd --- /dev/null +++ b/src/lib/components/forms/range-slider/range-slider.component.scss @@ -0,0 +1,383 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-range-slider { + // Core Structure + display: flex; + flex-direction: column; + position: relative; + width: 100%; + + // Sizing variants + &--sm { + .ui-range-slider__track { + height: 4px; + } + + .ui-range-slider__thumb { + width: 16px; + height: 16px; + } + + .ui-range-slider__label { + font-size: $semantic-typography-font-size-xs; + } + } + + &--md { + .ui-range-slider__track { + height: 6px; + } + + .ui-range-slider__thumb { + width: 20px; + height: 20px; + } + + .ui-range-slider__label { + font-size: $semantic-typography-font-size-xs; + } + } + + &--lg { + .ui-range-slider__track { + height: 8px; + } + + .ui-range-slider__thumb { + width: 24px; + height: 24px; + } + + .ui-range-slider__label { + font-size: $semantic-typography-font-size-sm; + } + } + + // Color variants + &--primary { + .ui-range-slider__fill { + background: $semantic-color-primary; + } + + .ui-range-slider__thumb { + border-color: $semantic-color-primary; + + &:hover { + border-color: $semantic-color-primary; + box-shadow: 0 0 0 8px rgba($semantic-color-primary, 0.1); + } + + &:focus { + border-color: $semantic-color-primary; + box-shadow: 0 0 0 4px rgba($semantic-color-primary, 0.2); + } + } + } + + &--secondary { + .ui-range-slider__fill { + background: $semantic-color-secondary; + } + + .ui-range-slider__thumb { + border-color: $semantic-color-secondary; + + &:hover { + border-color: $semantic-color-secondary; + box-shadow: 0 0 0 8px rgba($semantic-color-secondary, 0.1); + } + + &:focus { + border-color: $semantic-color-secondary; + box-shadow: 0 0 0 4px rgba($semantic-color-secondary, 0.2); + } + } + } + + &--success { + .ui-range-slider__fill { + background: $semantic-color-success; + } + + .ui-range-slider__thumb { + border-color: $semantic-color-success; + + &:hover { + border-color: $semantic-color-success; + box-shadow: 0 0 0 8px rgba($semantic-color-success, 0.1); + } + } + } + + &--warning { + .ui-range-slider__fill { + background: $semantic-color-warning; + } + + .ui-range-slider__thumb { + border-color: $semantic-color-warning; + + &:hover { + border-color: $semantic-color-warning; + box-shadow: 0 0 0 8px rgba($semantic-color-warning, 0.1); + } + } + } + + &--danger { + .ui-range-slider__fill { + background: $semantic-color-danger; + } + + .ui-range-slider__thumb { + border-color: $semantic-color-danger; + + &:hover { + border-color: $semantic-color-danger; + box-shadow: 0 0 0 8px rgba($semantic-color-danger, 0.1); + } + } + } + + // State variants + &--disabled { + opacity: 0.38; + cursor: not-allowed; + + .ui-range-slider__thumb { + cursor: not-allowed; + pointer-events: none; + } + + .ui-range-slider__input { + cursor: not-allowed; + } + } + + // Label positioning + &__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: $semantic-spacing-component-sm; + } + + &__label { + color: $semantic-color-text-primary; + font-size: $semantic-typography-font-size-sm; + font-weight: $semantic-typography-font-weight-medium; + + &--disabled { + color: $semantic-color-text-tertiary; + } + } + + &__value-display { + color: $semantic-color-text-secondary; + font-size: $semantic-typography-font-size-xs; + font-weight: $semantic-typography-font-weight-medium; + min-width: 40px; + text-align: right; + } + + // Slider container + &__container { + position: relative; + padding: $semantic-spacing-component-sm 0; + } + + // Track (background) + &__track { + position: relative; + width: 100%; + height: 6px; + background: $semantic-color-surface-secondary; + border-radius: $semantic-border-radius-xl; + overflow: hidden; + } + + // Fill (progress) + &__fill { + position: absolute; + top: 0; + left: 0; + height: 100%; + background: $semantic-color-primary; + border-radius: inherit; + transition: width $semantic-motion-duration-fast $semantic-easing-standard; + } + + // Native input (hidden but functional) + &__input { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0; + cursor: pointer; + + &:focus { + outline: none; + } + + &:disabled { + cursor: not-allowed; + } + } + + // Visual thumb + &__thumb { + position: absolute; + top: 50%; + transform: translate(-50%, -50%); + width: 20px; + height: 20px; + background: $semantic-color-surface-primary; + border: 2px solid $semantic-color-primary; + border-radius: 50%; + cursor: pointer; + transition: all $semantic-motion-duration-fast $semantic-easing-standard; + z-index: 2; + + &:hover { + box-shadow: 0 0 0 8px rgba($semantic-color-primary, 0.1); + border-color: $semantic-color-primary; + } + + &:focus, + &--focused { + box-shadow: 0 0 0 4px rgba($semantic-color-primary, 0.2); + border-color: $semantic-color-primary; + } + + &:active, + &--dragging { + box-shadow: 0 0 0 8px rgba($semantic-color-primary, 0.15); + transform: translate(-50%, -50%) scale(1.1); + } + } + + // Tick marks (optional) + &__ticks { + display: flex; + justify-content: space-between; + margin-top: $semantic-spacing-component-xs; + padding: 0 10px; // Offset for thumb width + } + + &__tick { + width: 2px; + height: 8px; + background: $semantic-color-border-subtle; + border-radius: $semantic-border-radius-xs; + + &--major { + height: 12px; + background: $semantic-color-border-primary; + } + } + + // Tick labels + &__tick-labels { + display: flex; + justify-content: space-between; + margin-top: $semantic-spacing-component-xs; + padding: 0 10px; // Offset for thumb width + } + + &__tick-label { + font-size: $semantic-typography-font-size-xs; + color: $semantic-color-text-tertiary; + text-align: center; + min-width: 20px; + } + + // Helper text + &__helper-text { + margin-top: $semantic-spacing-component-xs; + font-size: $semantic-typography-font-size-xs; + color: $semantic-color-text-secondary; + + &--error { + color: $semantic-color-danger; + } + } + + // Range variant (dual thumb) + &--range { + .ui-range-slider__fill { + left: auto; + right: auto; + } + } + + // Vertical orientation + &--vertical { + flex-direction: row; + align-items: center; + height: 200px; + width: auto; + + .ui-range-slider__container { + width: auto; + height: 100%; + padding: 0 $semantic-spacing-component-sm; + } + + .ui-range-slider__track { + width: 6px; + height: 100%; + } + + .ui-range-slider__fill { + width: 100%; + height: auto; + bottom: 0; + top: auto; + } + + .ui-range-slider__thumb { + left: 50%; + top: auto; + transform: translate(-50%, 50%); + } + + .ui-range-slider__input { + width: 100%; + height: 100%; + } + } + + // Dark mode support + :host-context(.dark-theme) & { + .ui-range-slider__track { + background: $semantic-color-surface-secondary; + } + + .ui-range-slider__thumb { + background: $semantic-color-surface-primary; + border-color: $semantic-color-primary; + } + + .ui-range-slider__label { + color: $semantic-color-text-primary; + } + } +} + +// Responsive design +@media (max-width: $semantic-breakpoint-sm - 1) { + .ui-range-slider { + &--lg { + .ui-range-slider__thumb { + width: 20px; + height: 20px; + } + + .ui-range-slider__track { + height: 6px; + } + } + } +} \ No newline at end of file diff --git a/src/lib/components/forms/range-slider/range-slider.component.ts b/src/lib/components/forms/range-slider/range-slider.component.ts new file mode 100644 index 0000000..625b74f --- /dev/null +++ b/src/lib/components/forms/range-slider/range-slider.component.ts @@ -0,0 +1,399 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, forwardRef, signal, computed, ViewChild, ElementRef, ViewEncapsulation } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule, ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; + +export type RangeSliderSize = 'sm' | 'md' | 'lg'; +export type RangeSliderVariant = 'primary' | 'secondary' | 'success' | 'warning' | 'danger'; +export type RangeSliderOrientation = 'horizontal' | 'vertical'; + +export interface RangeSliderTickMark { + value: number; + label?: string; + major?: boolean; +} + +@Component({ + selector: 'ui-range-slider', + standalone: true, + imports: [CommonModule, FormsModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => RangeSliderComponent), + multi: true + } + ], + template: ` +
+ + @if (label || showValue) { +
+ @if (label) { + + } + + @if (showValue) { + + {{ formatValue(currentValue()) }}{{ valueUnit }} + + } +
+ } + +
+ +
+ +
+
+
+ + + + + +
+
+
+ + @if (ticks && ticks.length > 0) { + +
+ @for (tick of ticks; track tick.value) { +
+
+ } +
+ + + @if (showTickLabels) { +
+ @for (tick of ticks; track tick.value) { + @if (tick.label) { +
+ {{ tick.label }} +
+ } + } +
+ } + } + + @if (helperText && !disabled) { +
+ {{ helperText }} +
+ } +
+ `, + styleUrl: './range-slider.component.scss' +}) +export class RangeSliderComponent implements ControlValueAccessor { + @ViewChild('sliderInput', { static: true }) sliderInput!: ElementRef; + @ViewChild('container', { static: true }) container!: ElementRef; + + // Core inputs + @Input() min = 0; + @Input() max = 100; + @Input() step = 1; + @Input() set value(val: number) { + if (val !== null && val !== undefined) { + this._value.set(this.normalizeValue(val)); + } + } + get value(): number { + return this.currentValue(); + } + @Input() size: RangeSliderSize = 'md'; + @Input() variant: RangeSliderVariant = 'primary'; + @Input() orientation: RangeSliderOrientation = 'horizontal'; + + // Behavior inputs + @Input() disabled = false; + @Input() required = false; + @Input() readonly = false; + + // Display options + @Input() label = ''; + @Input() showValue = false; + @Input() valueUnit = ''; + @Input() showTickLabels = false; + @Input() ticks: RangeSliderTickMark[] = []; + + // Validation and help + @Input() helperText = ''; + @Input() hasError = false; + @Input() ariaLabel = ''; + @Input() ariaValueText = ''; + @Input() sliderId = `range-slider-${Math.random().toString(36).substr(2, 9)}`; + + // Outputs + @Output() valueChange = new EventEmitter(); + @Output() sliderFocus = new EventEmitter(); + @Output() sliderBlur = new EventEmitter(); + @Output() slideStart = new EventEmitter(); + @Output() slideEnd = new EventEmitter(); + + // Internal state + private _value = signal(0); + currentValue = this._value.asReadonly(); + + private _isFocused = signal(false); + isFocused = this._isFocused.asReadonly(); + + private _isDragging = signal(false); + isDragging = this._isDragging.asReadonly(); + + // Computed properties + fillWidth = computed(() => { + const range = this.max - this.min; + const progress = (this.currentValue() - this.min) / range; + return Math.max(0, Math.min(100, progress * 100)); + }); + + thumbPosition = computed(() => { + return this.fillWidth(); + }); + + containerClasses = computed(() => { + const classes = [ + `ui-range-slider--${this.size}`, + `ui-range-slider--${this.variant}`, + `ui-range-slider--${this.orientation}` + ]; + + if (this.disabled) classes.push('ui-range-slider--disabled'); + if (this.hasError) classes.push('ui-range-slider--error'); + if (this.readonly) classes.push('ui-range-slider--readonly'); + + return classes.join(' '); + }); + + // ControlValueAccessor implementation + private onChange = (value: number) => {}; + private onTouched = () => {}; + + writeValue(value: number): void { + if (value !== null && value !== undefined) { + this._value.set(this.normalizeValue(value)); + } + } + + registerOnChange(fn: (value: number) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + // Event handlers + onInputChange(event: Event): void { + const target = event.target as HTMLInputElement; + const newValue = this.normalizeValue(parseFloat(target.value)); + this.updateValue(newValue); + } + + onFocus(event: FocusEvent): void { + this._isFocused.set(true); + this.sliderFocus.emit(event); + } + + onBlur(event: FocusEvent): void { + this._isFocused.set(false); + this.onTouched(); + this.sliderBlur.emit(event); + } + + onKeyDown(event: KeyboardEvent): void { + if (this.disabled || this.readonly) return; + + let delta = 0; + const largeStep = (this.max - this.min) * 0.1; + + switch (event.key) { + case 'ArrowRight': + case 'ArrowUp': + delta = this.step; + break; + case 'ArrowLeft': + case 'ArrowDown': + delta = -this.step; + break; + case 'PageUp': + delta = largeStep; + break; + case 'PageDown': + delta = -largeStep; + break; + case 'Home': + this.updateValue(this.min); + event.preventDefault(); + return; + case 'End': + this.updateValue(this.max); + event.preventDefault(); + return; + default: + return; + } + + event.preventDefault(); + const newValue = this.normalizeValue(this.currentValue() + delta); + this.updateValue(newValue); + } + + onThumbMouseDown(event: MouseEvent): void { + if (this.disabled || this.readonly) return; + + event.preventDefault(); + this._isDragging.set(true); + this.slideStart.emit(this.currentValue()); + + const handleMouseMove = (e: MouseEvent) => { + this.updateValueFromPosition(e.clientX); + }; + + const handleMouseUp = () => { + this._isDragging.set(false); + this.slideEnd.emit(this.currentValue()); + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + } + + onThumbTouchStart(event: TouchEvent): void { + if (this.disabled || this.readonly) return; + + event.preventDefault(); + this._isDragging.set(true); + this.slideStart.emit(this.currentValue()); + + const handleTouchMove = (e: TouchEvent) => { + if (e.touches.length > 0) { + this.updateValueFromPosition(e.touches[0].clientX); + } + }; + + const handleTouchEnd = () => { + this._isDragging.set(false); + this.slideEnd.emit(this.currentValue()); + document.removeEventListener('touchmove', handleTouchMove); + document.removeEventListener('touchend', handleTouchEnd); + }; + + document.addEventListener('touchmove', handleTouchMove, { passive: false }); + document.addEventListener('touchend', handleTouchEnd); + } + + // Utility methods + private updateValue(newValue: number): void { + const normalizedValue = this.normalizeValue(newValue); + if (normalizedValue !== this.currentValue()) { + this._value.set(normalizedValue); + this.onChange(normalizedValue); + this.valueChange.emit(normalizedValue); + + // Sync with native input + if (this.sliderInput) { + this.sliderInput.nativeElement.value = normalizedValue.toString(); + } + } + } + + private updateValueFromPosition(clientX: number): void { + if (!this.container) return; + + const rect = this.container.nativeElement.getBoundingClientRect(); + const percentage = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); + const newValue = this.min + (percentage * (this.max - this.min)); + this.updateValue(newValue); + } + + private normalizeValue(value: number): number { + // Round to nearest step + const steppedValue = Math.round((value - this.min) / this.step) * this.step + this.min; + // Clamp to min/max bounds + return Math.max(this.min, Math.min(this.max, steppedValue)); + } + + formatValue(value: number): string { + // Format the value for display (could be overridden for custom formatting) + return value.toString(); + } + + getTickPosition(tickValue: number): number { + const range = this.max - this.min; + return ((tickValue - this.min) / range) * 100; + } + + // Public API methods + setValue(value: number): void { + this.updateValue(value); + } + + getValue(): number { + return this.currentValue(); + } + + focus(): void { + if (this.sliderInput) { + this.sliderInput.nativeElement.focus(); + } + } +} \ No newline at end of file diff --git a/src/lib/components/forms/search/index.ts b/src/lib/components/forms/search/index.ts new file mode 100644 index 0000000..609f7f8 --- /dev/null +++ b/src/lib/components/forms/search/index.ts @@ -0,0 +1 @@ +export * from './search-bar.component'; \ No newline at end of file diff --git a/src/lib/components/forms/search/search-bar.component.scss b/src/lib/components/forms/search/search-bar.component.scss new file mode 100644 index 0000000..52325c8 --- /dev/null +++ b/src/lib/components/forms/search/search-bar.component.scss @@ -0,0 +1,542 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +// Tokens available globally via main application styles + +// ========================================================================== +// SEARCH BAR COMPONENT STYLES +// ========================================================================== +// Modern search bar implementation using design tokens +// Follows Material Design 3 principles with semantic token system +// ========================================================================== + +.search-bar-wrapper { + position: relative; + width: 100%; + display: flex; + flex-direction: column; + + // Size variants + &.search-bar-wrapper--sm { + .search-bar { + height: 40px; + } + } + + &.search-bar-wrapper--md { + .search-bar { + height: 48px; + } + } + + &.search-bar-wrapper--lg { + .search-bar { + height: 56px; + } + } + + // Variant styles + &.search-bar-wrapper--outlined { + .search-bar { + background-color: $semantic-color-surface-primary; + border: $semantic-border-width-1 solid $semantic-color-border-primary; + + &:hover:not(.search-bar--disabled) { + border-color: $semantic-color-border-focus; + } + + &.search-bar--focused { + border-color: $semantic-color-brand-primary; + box-shadow: $semantic-shadow-button-focus; + } + } + } + + &.search-bar-wrapper--filled { + .search-bar { + background-color: $semantic-color-surface-secondary; + border: $semantic-border-width-1 solid transparent; + + &:hover:not(.search-bar--disabled) { + background-color: $semantic-color-surface-secondary; + } + + &.search-bar--focused { + border-color: $semantic-color-brand-primary; + box-shadow: $semantic-shadow-button-focus; + } + } + } + + &.search-bar-wrapper--elevated { + .search-bar { + background-color: $semantic-color-surface-primary; + border: none; + box-shadow: $semantic-shadow-elevation-1; + + &:hover:not(.search-bar--disabled) { + box-shadow: $semantic-shadow-elevation-2; + } + + &.search-bar--focused { + box-shadow: $semantic-shadow-elevation-3; + } + } + } + + // State variants + &.search-bar-wrapper--error { + .search-bar { + border-color: $semantic-color-danger; + + &.search-bar--focused { + box-shadow: $semantic-shadow-danger; + } + } + } + + &.search-bar-wrapper--disabled { + opacity: 0.6; + + .search-bar { + background-color: $semantic-color-surface-disabled; + border-color: $semantic-color-border-disabled; + cursor: not-allowed; + } + } +} + +.search-bar { + display: flex; + align-items: center; + border-radius: $semantic-border-radius-3xl; + transition: all $semantic-duration-fast $semantic-easing-standard; + position: relative; + overflow: hidden; + + // Size-specific heights handled by wrapper + + &--disabled { + pointer-events: none; + } +} + +.search-bar__leading-icon { + display: flex; + align-items: center; + justify-content: center; + color: $semantic-color-text-secondary; + flex-shrink: 0; + + &--sm { + width: 40px; + height: 40px; + padding-left: $semantic-spacing-component-sm; + + fa-icon { + font-size: $semantic-typography-font-size-lg; + } + } + + &--md { + width: 48px; + height: 48px; + padding-left: $semantic-spacing-component-md; + + fa-icon { + font-size: $semantic-typography-font-size-2xl; + } + } + + &--lg { + width: 56px; + height: 56px; + padding-left: $semantic-spacing-component-lg; + + fa-icon { + font-size: $semantic-typography-font-size-lg; + } + } +} + +.search-bar__input-wrapper { + flex: 1; + position: relative; + height: 100%; + display: flex; + align-items: center; + + &--sm { + padding: 0 $semantic-spacing-component-xs; + } + + &--md { + padding: 0 $semantic-spacing-component-sm; + } + + &--lg { + padding: 0 $semantic-spacing-component-md; + } +} + +.search-bar__label { + position: absolute; + left: 0; + top: 50%; + transform: translateY(-50%); + color: $semantic-color-text-secondary; + font-size: $semantic-typography-font-size-md; + font-weight: $semantic-typography-font-weight-normal; + pointer-events: none; + transition: all $semantic-duration-fast $semantic-easing-standard; + z-index: 1; + + &--floating { + top: 8px; + font-size: $semantic-typography-font-size-xs; + color: $semantic-color-brand-primary; + transform: translateY(0); + } + + .search-bar__required-indicator { + color: $semantic-color-danger; + margin-left: 2px; + } +} + +.search-bar__input { + width: 100%; + height: 100%; + border: none; + background: transparent; + outline: none; + color: $semantic-color-text-primary; + font-size: $semantic-typography-font-size-md; + font-weight: $semantic-typography-font-weight-normal; + line-height: $semantic-typography-line-height-normal; + caret-color: $semantic-color-brand-primary; + + &::placeholder { + color: $semantic-color-text-secondary; + transition: color $semantic-duration-fast $semantic-easing-standard; + } + + &:focus::placeholder { + color: transparent; + } + + // Remove default search input styling + &::-webkit-search-cancel-button, + &::-webkit-search-decoration { + -webkit-appearance: none; + } + + // Size variants + &--sm { + font-size: $semantic-typography-font-size-sm; + padding: 0 $semantic-spacing-component-xs; + } + + &--md { + font-size: $semantic-typography-font-size-md; + padding: 0 $semantic-spacing-component-sm; + } + + &--lg { + font-size: $semantic-typography-font-size-lg; + padding: 0 $semantic-spacing-component-md; + } + + &--has-value { + font-weight: $semantic-typography-font-weight-medium; + } +} + +.search-bar__trailing-icons { + display: flex; + align-items: center; + gap: $semantic-spacing-component-xs; + flex-shrink: 0; + + &--sm { + padding-right: $semantic-spacing-component-sm; + } + + &--md { + padding-right: $semantic-spacing-component-md; + } + + &--lg { + padding-right: $semantic-spacing-component-lg; + } +} + +.search-bar__icon-button { + display: flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + color: $semantic-color-text-secondary; + cursor: pointer; + border-radius: $semantic-border-radius-sm; + transition: all $semantic-duration-fast $semantic-easing-standard; + + &:hover:not(:disabled) { + background-color: $semantic-color-surface-secondary; + color: $semantic-color-text-primary; + } + + &:active:not(:disabled) { + transform: scale(0.95); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + // Size variants + &--sm { + width: 32px; + height: 32px; + + fa-icon { + font-size: $semantic-typography-font-size-md; + } + } + + &--md { + width: 36px; + height: 36px; + + fa-icon { + font-size: $semantic-typography-font-size-lg; + } + } + + &--lg { + width: 40px; + height: 40px; + + fa-icon { + font-size: $semantic-typography-font-size-2xl; + } + } +} + +.search-bar__clear-button { + color: $semantic-color-text-tertiary; + + &:hover:not(:disabled) { + color: $semantic-color-danger; + background-color: rgba($semantic-color-danger, 0.1); + } +} + +.search-bar__suggestions { + position: absolute; + top: 100%; + left: 0; + right: 0; + background-color: $semantic-color-surface-primary; + border-radius: $semantic-border-radius-lg; + box-shadow: $semantic-shadow-elevation-3; + border: $semantic-border-width-1 solid $semantic-color-border-subtle; + max-height: 300px; + overflow-y: auto; + z-index: 1000; + margin-top: $semantic-spacing-component-xs; + + &--sm { + margin-top: 4px; + } + + &--md { + margin-top: 6px; + } + + &--lg { + margin-top: 8px; + } +} + +.search-bar__suggestion { + width: 100%; + display: flex; + align-items: center; + gap: $semantic-spacing-component-sm; + padding: $semantic-spacing-component-sm $semantic-spacing-component-md; + border: none; + background: transparent; + color: $semantic-color-text-primary; + cursor: pointer; + text-align: left; + transition: background-color $semantic-duration-fast $semantic-easing-standard; + + &:hover, + &--selected { + background-color: $semantic-color-surface-secondary; + } + + &:first-child { + border-top-left-radius: $semantic-border-radius-lg; + border-top-right-radius: $semantic-border-radius-lg; + } + + &:last-child { + border-bottom-left-radius: $semantic-border-radius-lg; + border-bottom-right-radius: $semantic-border-radius-lg; + } + + // Size variants + &--sm { + padding: $semantic-spacing-component-xs $semantic-spacing-component-sm; + + .search-bar__suggestion-text { + font-size: $semantic-typography-font-size-sm; + } + + .search-bar__suggestion-category { + font-size: $semantic-typography-font-size-xs; + } + } + + &--md { + padding: $semantic-spacing-component-sm $semantic-spacing-component-md; + + .search-bar__suggestion-text { + font-size: $semantic-typography-font-size-md; + } + + .search-bar__suggestion-category { + font-size: $semantic-typography-font-size-sm; + } + } + + &--lg { + padding: $semantic-spacing-component-md $semantic-spacing-component-lg; + + .search-bar__suggestion-text { + font-size: $semantic-typography-font-size-lg; + } + + .search-bar__suggestion-category { + font-size: $semantic-typography-font-size-md; + } + } +} + +.search-bar__suggestion-icon { + color: $semantic-color-text-secondary; + flex-shrink: 0; + width: 20px; + height: 20px; +} + +.search-bar__suggestion-text { + flex: 1; + font-weight: $semantic-typography-font-weight-normal; +} + +.search-bar__suggestion-category { + color: $semantic-color-text-tertiary; + font-weight: $semantic-typography-font-weight-normal; + flex-shrink: 0; +} + +.search-bar__helper-text { + margin-top: $semantic-spacing-component-xs; + padding-left: $semantic-spacing-component-md; + font-size: $semantic-typography-font-size-xs; + color: $semantic-color-text-secondary; + line-height: $semantic-typography-line-height-tight; +} + +.search-bar__error-text { + margin-top: $semantic-spacing-component-xs; + padding-left: $semantic-spacing-component-md; + font-size: $semantic-typography-font-size-xs; + color: $semantic-color-danger; + line-height: $semantic-typography-line-height-tight; + display: flex; + align-items: center; + gap: $semantic-spacing-component-xs; +} + +// ========================================================================== +// RESPONSIVE DESIGN +// ========================================================================== + +@media (max-width: $semantic-breakpoint-md - 1) { + .search-bar-wrapper { + // Larger touch targets on mobile + &.search-bar-wrapper--sm { + .search-bar { + height: 44px; + } + + .search-bar__icon-button--sm { + width: 36px; + height: 36px; + } + } + + &.search-bar-wrapper--md { + .search-bar { + height: 52px; + } + + .search-bar__icon-button--md { + width: 40px; + height: 40px; + } + } + } + + .search-bar__suggestions { + max-height: 250px; // Smaller on mobile + } +} + +// ========================================================================== +// ACCESSIBILITY ENHANCEMENTS +// ========================================================================== + +// Reduced motion preference +@media (prefers-reduced-motion: reduce) { + .search-bar, + .search-bar__label, + .search-bar__input, + .search-bar__icon-button, + .search-bar__suggestion { + transition: none !important; + animation: none !important; + } +} + +// High contrast mode +@media (prefers-contrast: high) { + .search-bar-wrapper { + &.search-bar-wrapper--outlined, + &.search-bar-wrapper--filled { + .search-bar { + border-width: $semantic-border-width-2; + } + } + } + + .search-bar__suggestions { + border-width: $semantic-border-width-2; + } +} + +// Focus visible for keyboard navigation +.search-bar__icon-button:focus-visible { + outline: $semantic-border-width-2 solid $semantic-color-brand-primary; + outline-offset: 2px; +} + +.search-bar__suggestion:focus-visible { + outline: $semantic-border-width-2 solid $semantic-color-brand-primary; + outline-offset: -2px; +} \ No newline at end of file diff --git a/src/lib/components/forms/search/search-bar.component.ts b/src/lib/components/forms/search/search-bar.component.ts new file mode 100644 index 0000000..fe4e289 --- /dev/null +++ b/src/lib/components/forms/search/search-bar.component.ts @@ -0,0 +1,397 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, forwardRef, signal, ElementRef, ViewChild } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule, ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { IconDefinition } from '@fortawesome/fontawesome-svg-core'; +import { faSearch, faTimes, faMicrophone, faCamera, faFilter } from '@fortawesome/free-solid-svg-icons'; + +export type SearchBarSize = 'sm' | 'md' | 'lg'; +export type SearchBarVariant = 'filled' | 'outlined' | 'elevated'; +export type SearchBarState = 'default' | 'error' | 'disabled'; + +export interface SearchSuggestion { + id: string; + text: string; + category?: string; + icon?: IconDefinition; +} + +@Component({ + selector: 'ui-search-bar', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, FormsModule, FontAwesomeModule], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => SearchBarComponent), + multi: true + } + ], + template: ` +
+ + + @if (suggestions.length > 0 && showSuggestions()) { +
+ @for (suggestion of suggestions; track suggestion.id; let i = $index) { + + } +
+ } + + @if (helperText && state !== 'error') { +
{{ helperText }}
+ } + + @if (errorMessage && state === 'error') { +
{{ errorMessage }}
+ } +
+ `, + styleUrls: ['./search-bar.component.scss'] +}) +export class SearchBarComponent implements ControlValueAccessor { + @ViewChild('searchInput', { static: true }) searchInput!: ElementRef; + + // Core inputs + @Input() placeholder: string = 'Search...'; + @Input() label: string = ''; + @Input() size: SearchBarSize = 'md'; + @Input() variant: SearchBarVariant = 'outlined'; + @Input() state: SearchBarState = 'default'; + + // Icon inputs + @Input() leadingIcon: IconDefinition = faSearch; + @Input() leadingIconLabel: string = ''; + @Input() trailingIcons: Array<{ + id: string; + icon: IconDefinition; + label: string; + disabled?: boolean; + }> = []; + + // Behavior inputs + @Input() disabled = false; + @Input() readonly = false; + @Input() required = false; + @Input() clearable = true; + @Input() autofocus = false; + + // Suggestions + @Input() suggestions: SearchSuggestion[] = []; + @Input() maxSuggestions = 10; + + // Accessibility + @Input() ariaLabel: string = ''; + @Input() helperText: string = ''; + @Input() errorMessage: string = ''; + @Input() searchId: string = `search-${Math.random().toString(36).substr(2, 9)}`; + + // Outputs + @Output() searchChange = new EventEmitter(); + @Output() searchSubmit = new EventEmitter(); + @Output() searchFocus = new EventEmitter(); + @Output() searchBlur = new EventEmitter(); + @Output() suggestionSelect = new EventEmitter(); + @Output() trailingIconClick = new EventEmitter<{id: string, icon: IconDefinition}>(); + + // Font Awesome icons + protected readonly faSearch = faSearch; + protected readonly faTimes = faTimes; + protected readonly faMicrophone = faMicrophone; + protected readonly faCamera = faCamera; + protected readonly faFilter = faFilter; + + // Internal state + protected searchValue = signal(''); + protected isFocused = signal(false); + protected showSuggestions = signal(false); + protected selectedSuggestionIndex = signal(-1); + + // ControlValueAccessor implementation + private onChange = (value: string) => {}; + private onTouched = () => {}; + + ngAfterViewInit(): void { + if (this.autofocus) { + this.searchInput.nativeElement.focus(); + } + } + + writeValue(value: string): void { + this.searchValue.set(value || ''); + } + + registerOnChange(fn: (value: string) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + // Event handlers + onInput(event: Event): void { + const target = event.target as HTMLInputElement; + const value = target.value; + + this.searchValue.set(value); + this.onChange(value); + this.searchChange.emit(value); + + // Show/hide suggestions based on input + this.showSuggestions.set(value.length > 0 && this.suggestions.length > 0); + this.selectedSuggestionIndex.set(-1); + } + + onFocus(event: FocusEvent): void { + this.isFocused.set(true); + this.showSuggestions.set(this.searchValue().length > 0 && this.suggestions.length > 0); + this.searchFocus.emit(event); + } + + onBlur(event: FocusEvent): void { + this.isFocused.set(false); + this.onTouched(); + + // Delay hiding suggestions to allow for suggestion clicks + setTimeout(() => { + this.showSuggestions.set(false); + this.selectedSuggestionIndex.set(-1); + }, 150); + + this.searchBlur.emit(event); + } + + onKeyDown(event: KeyboardEvent): void { + const suggestions = this.suggestions.slice(0, this.maxSuggestions); + + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + if (this.showSuggestions()) { + const nextIndex = this.selectedSuggestionIndex() + 1; + this.selectedSuggestionIndex.set(nextIndex >= suggestions.length ? 0 : nextIndex); + } + break; + + case 'ArrowUp': + event.preventDefault(); + if (this.showSuggestions()) { + const prevIndex = this.selectedSuggestionIndex() - 1; + this.selectedSuggestionIndex.set(prevIndex < 0 ? suggestions.length - 1 : prevIndex); + } + break; + + case 'Enter': + event.preventDefault(); + if (this.showSuggestions() && this.selectedSuggestionIndex() >= 0) { + this.selectSuggestion(suggestions[this.selectedSuggestionIndex()]); + } else { + this.submitSearch(); + } + break; + + case 'Escape': + this.showSuggestions.set(false); + this.selectedSuggestionIndex.set(-1); + this.searchInput.nativeElement.blur(); + break; + } + } + + clearSearch(): void { + this.searchValue.set(''); + this.onChange(''); + this.searchChange.emit(''); + this.showSuggestions.set(false); + this.searchInput.nativeElement.focus(); + } + + selectSuggestion(suggestion: SearchSuggestion): void { + this.searchValue.set(suggestion.text); + this.onChange(suggestion.text); + this.searchChange.emit(suggestion.text); + this.showSuggestions.set(false); + this.selectedSuggestionIndex.set(-1); + this.suggestionSelect.emit(suggestion); + } + + setSelectedSuggestionIndex(index: number): void { + this.selectedSuggestionIndex.set(index); + } + + submitSearch(): void { + this.searchSubmit.emit(this.searchValue()); + this.showSuggestions.set(false); + } + + onTrailingIconClick(iconData: {id: string, icon: IconDefinition, label: string, disabled?: boolean}): void { + if (!iconData.disabled) { + this.trailingIconClick.emit({id: iconData.id, icon: iconData.icon}); + } + } + + hasValue(): boolean { + return this.searchValue().length > 0; + } + + // CSS class getters + getWrapperClasses(): string { + const classes = [ + `search-bar-wrapper--${this.size}`, + `search-bar-wrapper--${this.variant}`, + `search-bar-wrapper--${this.state}` + ]; + + if (this.disabled) classes.push('search-bar-wrapper--disabled'); + if (this.readonly) classes.push('search-bar-wrapper--readonly'); + if (this.isFocused()) classes.push('search-bar-wrapper--focused'); + if (this.hasValue()) classes.push('search-bar-wrapper--has-value'); + + return classes.join(' '); + } + + getSearchBarClasses(): string { + const classes = [ + `search-bar--${this.size}`, + `search-bar--${this.variant}`, + `search-bar--${this.state}` + ]; + + if (this.disabled) classes.push('search-bar--disabled'); + if (this.readonly) classes.push('search-bar--readonly'); + if (this.isFocused()) classes.push('search-bar--focused'); + + return classes.join(' '); + } + + getLeadingIconClasses(): string { + return `search-bar__leading-icon--${this.size}`; + } + + getInputWrapperClasses(): string { + return `search-bar__input-wrapper--${this.size}`; + } + + getInputClasses(): string { + const classes = [`search-bar__input--${this.size}`]; + if (this.hasValue()) classes.push('search-bar__input--has-value'); + return classes.join(' '); + } + + getTrailingIconsClasses(): string { + return `search-bar__trailing-icons--${this.size}`; + } + + getIconButtonClasses(): string { + return `search-bar__icon-button--${this.size}`; + } + + getSuggestionsClasses(): string { + return `search-bar__suggestions--${this.size}`; + } + + getSuggestionClasses(index: number): string { + const classes = [`search-bar__suggestion--${this.size}`]; + if (this.selectedSuggestionIndex() === index) { + classes.push('search-bar__suggestion--selected'); + } + return classes.join(' '); + } +} \ No newline at end of file diff --git a/src/lib/components/forms/select/select.component.scss b/src/lib/components/forms/select/select.component.scss new file mode 100644 index 0000000..12f83b0 --- /dev/null +++ b/src/lib/components/forms/select/select.component.scss @@ -0,0 +1,205 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +// Tokens available globally via main application styles + +.ui-select { + display: flex; + flex-direction: column; + gap: $semantic-spacing-micro-tight; // 0.25rem + + &--full-width { + width: 100%; + } +} + +.select-label { + font-family: $semantic-typography-font-family-sans; + font-weight: $semantic-typography-font-weight-medium; + color: $semantic-color-text-primary; + font-size: $semantic-typography-font-size-sm; + line-height: $semantic-typography-line-height-normal; + margin-bottom: $semantic-spacing-micro-tight; // 0.25rem +} + +.select-required { + color: $semantic-color-danger; + margin-left: $semantic-spacing-micro-tight; // 0.125rem +} + +.select-container { + position: relative; + display: flex; + flex-direction: column; + gap: $semantic-spacing-micro-tight; // 0.25rem +} + +.select-field { + appearance: none; + font-family: $semantic-typography-font-family-sans; + font-weight: $semantic-typography-font-weight-normal; + background-color: $semantic-color-surface-primary; + border-radius: $semantic-border-radius-md; + transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1); + outline: none; + cursor: pointer; + padding-right: $semantic-spacing-10; // 2.5rem - space for arrow + + // Size variants + .ui-select--small & { + height: $semantic-spacing-8; // 2rem + padding: 0 $semantic-spacing-3; // 0.75rem + padding-right: $semantic-spacing-8; // 2rem + font-size: $semantic-typography-font-size-sm; + } + + .ui-select--medium & { + height: $semantic-spacing-micro-tight; // 2.5rem + padding: 0 $semantic-spacing-3; // 0.75rem + padding-right: $semantic-spacing-micro-tight; // 2.5rem + font-size: $semantic-typography-font-size-md; + } + + .ui-select--large & { + height: $semantic-spacing-12; // 3rem + padding: 0 $semantic-spacing-4; // 1rem + padding-right: $semantic-spacing-12; // 3rem + font-size: $semantic-typography-font-size-lg; + } + + // Variant styles - Outlined + .ui-select--outlined & { + border: $semantic-border-width-1 solid $semantic-color-border-primary; + background-color: transparent; + + &:hover:not(:disabled) { + border-color: $semantic-color-border-focus; + } + + &:focus { + border-color: $semantic-color-interactive-primary; + box-shadow: 0 0 0 1px $semantic-color-interactive-primary; + } + } + + // Variant styles - Filled + .ui-select--filled & { + border: none; + background-color: $semantic-color-surface-elevated; + + &:hover:not(:disabled) { + background-color: $semantic-color-surface-interactive; + } + + &:focus { + background-color: $semantic-color-surface-primary; + box-shadow: inset 0 -2px 0 $semantic-color-interactive-primary; + } + } + + // States + &:disabled { + opacity: 0.6; + cursor: not-allowed; + background-color: $semantic-color-surface-disabled; + color: $semantic-color-text-disabled; + } + + // Option styling + option { + background-color: $semantic-color-surface-primary; + color: $semantic-color-text-primary; + padding: $semantic-spacing-2; // 0.5rem + + &:disabled { + color: $semantic-color-text-disabled; + } + } + + // Error state + &--error { + .ui-select--outlined & { + border-color: $semantic-color-danger; + + &:focus { + border-color: $semantic-color-danger; + box-shadow: 0 0 0 1px $semantic-color-danger; + } + } + + .ui-select--filled & { + background-color: rgba($semantic-color-danger, 0.08); + + &:focus { + box-shadow: inset 0 -2px 0 $semantic-color-danger; + } + } + } +} + +// Custom arrow icon +.select-arrow { + position: absolute; + top: 50%; + transform: translateY(-50%); + pointer-events: none; + color: $semantic-color-text-secondary; + transition: transform 150ms ease; + + .ui-select--small & { + right: $semantic-spacing-2; // 0.5rem + + svg { + width: 10px; + height: 6px; + } + } + + .ui-select--medium & { + right: $semantic-spacing-3; // 0.75rem + + svg { + width: 12px; + height: 8px; + } + } + + .ui-select--large & { + right: $semantic-spacing-4; // 1rem + + svg { + width: 14px; + height: 10px; + } + } +} + +.select-field:focus + .select-arrow { + color: $semantic-color-interactive-primary; + transform: translateY(-50%) rotate(180deg); +} + +.select-field:disabled + .select-arrow { + color: $semantic-color-text-disabled; +} + +// Helper and error text +.select-helper-text { + font-family: $semantic-typography-font-family-sans; + font-size: $semantic-typography-font-size-xs; + color: $semantic-color-text-secondary; + line-height: $semantic-typography-line-height-normal; +} + +.select-error-text { + font-family: $semantic-typography-font-family-sans; + font-size: $semantic-typography-font-size-xs; + color: $semantic-color-danger; + line-height: $semantic-typography-line-height-normal; +} + +// Disabled state for wrapper +.ui-select--disabled { + .select-label { + color: $semantic-color-text-disabled; + } +} \ No newline at end of file diff --git a/src/lib/components/forms/select/select.component.ts b/src/lib/components/forms/select/select.component.ts new file mode 100644 index 0000000..a9159ea --- /dev/null +++ b/src/lib/components/forms/select/select.component.ts @@ -0,0 +1,151 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, forwardRef } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; + +export type SelectSize = 'small' | 'medium' | 'large'; +export type SelectVariant = 'outlined' | 'filled'; + +export interface SelectOption { + value: any; + label: string; + disabled?: boolean; +} + +@Component({ + selector: 'ui-select', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => SelectComponent), + multi: true + } + ], + template: ` +
+ @if (label) { + + } + +
+ + +
+ + + +
+ + @if (helperText && !errorText) { +
{{ helperText }}
+ } + + @if (errorText) { +
{{ errorText }}
+ } +
+
+ `, + styleUrl: './select.component.scss' +}) +export class SelectComponent implements ControlValueAccessor { + @Input() label: string = ''; + @Input() placeholder: string = ''; + @Input() helperText: string = ''; + @Input() errorText: string = ''; + @Input() size: SelectSize = 'medium'; + @Input() variant: SelectVariant = 'outlined'; + @Input() disabled: boolean = false; + @Input() required: boolean = false; + @Input() fullWidth: boolean = false; + @Input() options: SelectOption[] = []; + + @Output() valueChange = new EventEmitter(); + @Output() focused = new EventEmitter(); + @Output() blurred = new EventEmitter(); + + value: any = ''; + selectId = `ui-select-${Math.random().toString(36).substr(2, 9)}`; + + // ControlValueAccessor implementation + private onChange = (value: any) => {}; + private onTouched = () => {}; + + writeValue(value: any): void { + this.value = value; + } + + registerOnChange(fn: (value: any) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + get wrapperClasses(): string { + return [ + 'ui-select', + `ui-select--${this.size}`, + `ui-select--${this.variant}`, + this.disabled ? 'ui-select--disabled' : '', + this.errorText ? 'ui-select--error' : '', + this.fullWidth ? 'ui-select--full-width' : '' + ].filter(Boolean).join(' '); + } + + get selectClasses(): string { + return [ + 'select-field', + this.errorText ? 'select-field--error' : '' + ].filter(Boolean).join(' '); + } + + onSelectionChange(event: Event): void { + const target = event.target as HTMLSelectElement; + this.value = target.value; + this.onChange(this.value); + this.valueChange.emit(this.value); + } + + onFocus(): void { + this.focused.emit(); + } + + onBlur(): void { + this.onTouched(); + this.blurred.emit(); + } +} \ No newline at end of file diff --git a/src/lib/components/forms/switch/index.ts b/src/lib/components/forms/switch/index.ts new file mode 100644 index 0000000..4244256 --- /dev/null +++ b/src/lib/components/forms/switch/index.ts @@ -0,0 +1 @@ +export * from './switch.component'; \ No newline at end of file diff --git a/src/lib/components/forms/switch/switch.component.scss b/src/lib/components/forms/switch/switch.component.scss new file mode 100644 index 0000000..07343de --- /dev/null +++ b/src/lib/components/forms/switch/switch.component.scss @@ -0,0 +1,364 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +// Tokens available globally via main application styles + +// ========================================================================== +// SWITCH COMPONENT STYLES +// ========================================================================== +// Modern switch implementation using design tokens +// Follows Material Design 3 principles with semantic token system +// ========================================================================== + +.switch-wrapper { + display: inline-flex; + flex-direction: column; + align-items: flex-start; + + // Size variants + &.switch-wrapper--sm { + gap: $semantic-spacing-component-xs; + } + + &.switch-wrapper--md { + gap: $semantic-spacing-component-sm; + } + + &.switch-wrapper--lg { + gap: $semantic-spacing-component-md; + } + + // State variants + &.switch-wrapper--disabled { + opacity: 0.6; + pointer-events: none; + } + + &.switch-wrapper--focused { + .switch__track { + box-shadow: $semantic-shadow-button-focus; + } + } +} + +.switch { + display: inline-flex; + align-items: center; + cursor: pointer; + user-select: none; + position: relative; + + // Size-specific gaps + &.switch--sm { + gap: $semantic-spacing-component-sm; + } + + &.switch--md { + gap: $semantic-spacing-component-md; + } + + &.switch--lg { + gap: $semantic-spacing-component-lg; + } + + &.switch--disabled { + cursor: not-allowed; + } +} + +.switch__input { + position: absolute; + opacity: 0; + width: 0; + height: 0; + pointer-events: none; + + // Focus visible for keyboard navigation + &:focus-visible + .switch__track { + outline: $semantic-border-width-2 solid $semantic-color-brand-primary; + outline-offset: 2px; + } +} + +.switch__track { + position: relative; + border-radius: $semantic-border-radius-full; + transition: all $semantic-duration-fast $semantic-easing-standard; + cursor: pointer; + + // Size variants + &.switch__track--sm { + width: 32px; + height: 18px; + border: $semantic-border-width-1 solid $semantic-color-border-primary; + background-color: $semantic-color-surface-secondary; + } + + &.switch__track--md { + width: 44px; + height: 24px; + border: $semantic-border-width-1 solid $semantic-color-border-primary; + background-color: $semantic-color-surface-secondary; + } + + &.switch__track--lg { + width: 56px; + height: 32px; + border: $semantic-border-width-2 solid $semantic-color-border-primary; + background-color: $semantic-color-surface-secondary; + } + + // Checked state - variant colors + &.switch__track--checked { + border-color: $semantic-color-brand-primary; + background-color: $semantic-color-brand-primary; + + .switch-wrapper--primary & { + border-color: $semantic-color-brand-primary; + background-color: $semantic-color-brand-primary; + } + + .switch-wrapper--secondary & { + border-color: $semantic-color-text-secondary; + background-color: $semantic-color-text-secondary; + } + + .switch-wrapper--success & { + border-color: $semantic-color-success; + background-color: $semantic-color-success; + } + + .switch-wrapper--warning & { + border-color: $semantic-color-warning; + background-color: $semantic-color-warning; + } + + .switch-wrapper--danger & { + border-color: $semantic-color-danger; + background-color: $semantic-color-danger; + } + } + + // Hover states + &:hover:not(.switch__track--disabled) { + border-color: $semantic-color-border-focus; + + &.switch__track--checked { + .switch-wrapper--primary & { + background-color: $semantic-color-brand-secondary; + border-color: $semantic-color-brand-secondary; + } + + .switch-wrapper--secondary & { + opacity: 0.9; + } + + .switch-wrapper--success & { + opacity: 0.9; + } + + .switch-wrapper--warning & { + opacity: 0.9; + } + + .switch-wrapper--danger & { + opacity: 0.9; + } + } + } + + // Disabled state + &.switch__track--disabled { + background-color: $semantic-color-surface-disabled; + border-color: $semantic-color-border-disabled; + cursor: not-allowed; + opacity: 0.5; + } +} + +.switch__thumb { + position: absolute; + top: 50%; + border-radius: $semantic-border-radius-full; + background-color: $semantic-color-surface-primary; + box-shadow: $semantic-shadow-elevation-1; + transition: all $semantic-duration-fast $semantic-easing-standard; + transform: translateY(-50%); + + // Size variants - unchecked position + &.switch__thumb--sm { + width: 14px; + height: 14px; + left: 2px; + } + + &.switch__thumb--md { + width: 20px; + height: 20px; + left: 2px; + } + + &.switch__thumb--lg { + width: 26px; + height: 26px; + left: 3px; + } + + // Checked position + &.switch__thumb--checked { + &.switch__thumb--sm { + left: 16px; // 32px - 14px - 2px + } + + &.switch__thumb--md { + left: 22px; // 44px - 20px - 2px + } + + &.switch__thumb--lg { + left: 27px; // 56px - 26px - 3px + } + } + + // Hover enhancement + .switch__track:hover &:not(.switch__thumb--disabled) { + box-shadow: $semantic-shadow-elevation-2; + + &.switch__thumb--checked { + box-shadow: $semantic-shadow-elevation-3; + } + } + + // Disabled state + &.switch__thumb--disabled { + background-color: $semantic-color-surface-disabled; + box-shadow: none; + } +} + +.switch__label { + color: $semantic-color-text-primary; + font-weight: $semantic-typography-font-weight-medium; + line-height: $semantic-typography-line-height-tight; + + // Size variants + &.switch__label--sm { + font-size: $semantic-typography-font-size-sm; + } + + &.switch__label--md { + font-size: $semantic-typography-font-size-md; + } + + &.switch__label--lg { + font-size: $semantic-typography-font-size-lg; + } + + // Disabled state + &.switch__label--disabled { + color: $semantic-color-text-disabled; + } + + .switch__required-indicator { + color: $semantic-color-danger; + margin-left: 2px; + } +} + +.switch__helper-text { + font-size: $semantic-typography-font-size-xs; + color: $semantic-color-text-secondary; + line-height: $semantic-typography-line-height-tight; + margin-top: $semantic-spacing-component-xs; + + .switch-wrapper--sm & { + margin-left: 40px; // Align with switch track + } + + .switch-wrapper--md & { + margin-left: 52px; // Align with switch track + } + + .switch-wrapper--lg & { + margin-left: 64px; // Align with switch track + } +} + +// ========================================================================== +// RESPONSIVE DESIGN +// ========================================================================== + +@media (max-width: $semantic-breakpoint-md - 1) { + .switch-wrapper { + // Larger touch targets on mobile + &.switch-wrapper--sm { + .switch__track--sm { + width: 36px; + height: 20px; + } + + .switch__thumb--sm { + width: 16px; + height: 16px; + + &.switch__thumb--checked { + left: 18px; // 36px - 16px - 2px + } + } + } + + &.switch-wrapper--md { + .switch__track--md { + width: 48px; + height: 26px; + } + + .switch__thumb--md { + width: 22px; + height: 22px; + + &.switch__thumb--checked { + left: 24px; // 48px - 22px - 2px + } + } + } + } +} + +// ========================================================================== +// ACCESSIBILITY ENHANCEMENTS +// ========================================================================== + +// Reduced motion preference +@media (prefers-reduced-motion: reduce) { + .switch__track, + .switch__thumb { + transition: none !important; + } +} + +// High contrast mode +@media (prefers-contrast: high) { + .switch__track { + border-width: $semantic-border-width-2; + + &.switch__track--checked { + border-width: $semantic-border-width-2; + } + } + + .switch__thumb { + box-shadow: 0 0 0 1px $semantic-color-text-primary; + } +} + +// Focus visible for keyboard navigation +.switch__input:focus-visible + .switch__track { + outline: $semantic-border-width-2 solid $semantic-color-brand-primary; + outline-offset: 2px; +} + +// Active state for better touch feedback +.switch:active:not(.switch--disabled) { + .switch__thumb:not(.switch__thumb--disabled) { + transform: translateY(-50%) scale(1.1); + } +} \ No newline at end of file diff --git a/src/lib/components/forms/switch/switch.component.ts b/src/lib/components/forms/switch/switch.component.ts new file mode 100644 index 0000000..f4cbbea --- /dev/null +++ b/src/lib/components/forms/switch/switch.component.ts @@ -0,0 +1,205 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, forwardRef, signal, ViewChild, ElementRef } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule, ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; + +export type SwitchSize = 'sm' | 'md' | 'lg'; +export type SwitchVariant = 'primary' | 'secondary' | 'success' | 'warning' | 'danger'; +export type SwitchState = 'default' | 'disabled'; + +@Component({ + selector: 'ui-switch', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, FormsModule], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => SwitchComponent), + multi: true + } + ], + template: ` +
+ + + @if (helperText && state !== 'disabled') { +
{{ helperText }}
+ } +
+ `, + styleUrls: ['./switch.component.scss'] +}) +export class SwitchComponent implements ControlValueAccessor { + @ViewChild('switchInput', { static: true }) switchInput!: ElementRef; + + // Core inputs + @Input() label: string = ''; + @Input() size: SwitchSize = 'md'; + @Input() variant: SwitchVariant = 'primary'; + @Input() state: SwitchState = 'default'; + + // Behavior inputs + @Input() disabled = false; + @Input() required = false; + @Input() autofocus = false; + + // Accessibility + @Input() ariaLabel: string = ''; + @Input() helperText: string = ''; + @Input() switchId: string = `switch-${Math.random().toString(36).substr(2, 9)}`; + + // Outputs + @Output() switchChange = new EventEmitter(); + @Output() switchFocus = new EventEmitter(); + @Output() switchBlur = new EventEmitter(); + + // Internal state + protected isChecked = signal(false); + protected isFocused = signal(false); + + // ControlValueAccessor implementation + private onChange = (value: boolean) => {}; + private onTouched = () => {}; + + ngAfterViewInit(): void { + if (this.autofocus) { + this.switchInput.nativeElement.focus(); + } + } + + writeValue(value: boolean): void { + this.isChecked.set(!!value); + } + + registerOnChange(fn: (value: boolean) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + // Event handlers + onToggle(event: Event): void { + const target = event.target as HTMLInputElement; + const checked = target.checked; + + this.isChecked.set(checked); + this.onChange(checked); + this.switchChange.emit(checked); + } + + onFocus(event: FocusEvent): void { + this.isFocused.set(true); + this.switchFocus.emit(event); + } + + onBlur(event: FocusEvent): void { + this.isFocused.set(false); + this.onTouched(); + this.switchBlur.emit(event); + } + + onKeyDown(event: KeyboardEvent): void { + // Space or Enter can toggle the switch + if (event.key === ' ' || event.key === 'Enter') { + event.preventDefault(); + if (!this.disabled) { + const newValue = !this.isChecked(); + this.isChecked.set(newValue); + this.onChange(newValue); + this.switchChange.emit(newValue); + + // Update the actual input element + this.switchInput.nativeElement.checked = newValue; + } + } + } + + // CSS class getters + getWrapperClasses(): string { + const classes = [ + `switch-wrapper--${this.size}`, + `switch-wrapper--${this.variant}`, + `switch-wrapper--${this.state}` + ]; + + if (this.disabled) classes.push('switch-wrapper--disabled'); + if (this.isChecked()) classes.push('switch-wrapper--checked'); + if (this.isFocused()) classes.push('switch-wrapper--focused'); + + return classes.join(' '); + } + + getSwitchClasses(): string { + const classes = [ + `switch--${this.size}`, + `switch--${this.variant}`, + `switch--${this.state}` + ]; + + if (this.disabled) classes.push('switch--disabled'); + if (this.isChecked()) classes.push('switch--checked'); + if (this.isFocused()) classes.push('switch--focused'); + + return classes.join(' '); + } + + getTrackClasses(): string { + const classes = [`switch__track--${this.size}`]; + + if (this.isChecked()) classes.push('switch__track--checked'); + if (this.disabled) classes.push('switch__track--disabled'); + + return classes.join(' '); + } + + getThumbClasses(): string { + const classes = [`switch__thumb--${this.size}`]; + + if (this.isChecked()) classes.push('switch__thumb--checked'); + if (this.disabled) classes.push('switch__thumb--disabled'); + + return classes.join(' '); + } + + getLabelClasses(): string { + const classes = [`switch__label--${this.size}`]; + + if (this.disabled) classes.push('switch__label--disabled'); + + return classes.join(' '); + } +} \ No newline at end of file diff --git a/src/lib/components/forms/tag-input/index.ts b/src/lib/components/forms/tag-input/index.ts new file mode 100644 index 0000000..249923b --- /dev/null +++ b/src/lib/components/forms/tag-input/index.ts @@ -0,0 +1 @@ +export * from './tag-input.component'; \ No newline at end of file diff --git a/src/lib/components/forms/tag-input/tag-input.component.scss b/src/lib/components/forms/tag-input/tag-input.component.scss new file mode 100644 index 0000000..2e766e3 --- /dev/null +++ b/src/lib/components/forms/tag-input/tag-input.component.scss @@ -0,0 +1,197 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-tag-input { + display: flex; + flex-wrap: wrap; + align-items: center; + position: relative; + + // Layout & Spacing + padding: $semantic-spacing-interactive-input-padding-y $semantic-spacing-interactive-input-padding-x; + gap: $semantic-spacing-component-xs; + min-height: $semantic-sizing-input-height-md; + + // Visual Design + background: $semantic-color-surface-primary; + border: $semantic-border-width-1 solid $semantic-color-border-primary; + border-radius: $semantic-border-input-radius; + + // Typography + font-family: map-get($semantic-typography-input, font-family); + font-size: map-get($semantic-typography-input, font-size); + font-weight: map-get($semantic-typography-input, font-weight); + line-height: map-get($semantic-typography-input, line-height); + + // Transitions + transition: border-color $semantic-motion-duration-fast $semantic-motion-easing-ease, + box-shadow $semantic-motion-duration-fast $semantic-motion-easing-ease; + + // Size Variants + &--sm { + min-height: $semantic-sizing-input-height-sm; + padding: $semantic-spacing-component-xs; + gap: $semantic-spacing-component-xs; + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + + .ui-tag-input__input { + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + } + } + + &--md { + // Already defined above + } + + &--lg { + min-height: $semantic-sizing-input-height-lg; + padding: $semantic-spacing-component-md; + gap: $semantic-spacing-component-sm; + font-family: map-get($semantic-typography-body-large, font-family); + font-size: map-get($semantic-typography-body-large, font-size); + font-weight: map-get($semantic-typography-body-large, font-weight); + line-height: map-get($semantic-typography-body-large, line-height); + + .ui-tag-input__input { + font-family: map-get($semantic-typography-body-large, font-family); + font-size: map-get($semantic-typography-body-large, font-size); + font-weight: map-get($semantic-typography-body-large, font-weight); + line-height: map-get($semantic-typography-body-large, line-height); + } + } + + // Style Variants + &--outlined { + background: transparent; + border-color: $semantic-color-border-primary; + } + + &--filled { + background: $semantic-color-surface-secondary; + border-color: $semantic-color-border-subtle; + } + + // State Variants + &--disabled { + opacity: $semantic-opacity-disabled; + cursor: not-allowed; + pointer-events: none; + + .ui-tag-input__input { + cursor: not-allowed; + color: $semantic-color-text-disabled; + } + } + + &--error { + border-color: $semantic-color-border-error; + + &:focus-within { + border-color: $semantic-color-border-error; + box-shadow: 0 0 0 2px rgba($semantic-color-danger, 0.2); + } + } + + // Interactive States + &:not(.ui-tag-input--disabled) { + &:hover { + border-color: $semantic-color-border-focus; + } + + &:focus-within { + border-color: $semantic-color-focus; + box-shadow: 0 0 0 2px rgba($semantic-color-focus, 0.2); + } + } + + // Tags Container + &__tags { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: $semantic-spacing-component-xs; + flex: 1; + min-width: 0; + } + + // Input Element + &__input { + border: none; + background: transparent; + outline: none; + flex: 1; + min-width: 120px; + padding: 0; + margin: 0; + color: $semantic-color-text-primary; + + font-family: map-get($semantic-typography-input, font-family); + font-size: map-get($semantic-typography-input, font-size); + font-weight: map-get($semantic-typography-input, font-weight); + line-height: map-get($semantic-typography-input, line-height); + + &::placeholder { + color: $semantic-color-text-tertiary; + } + + &:disabled { + color: $semantic-color-text-disabled; + cursor: not-allowed; + } + } + + // Tag Item (using existing chip component) + &__tag { + .ui-chip { + margin: 0; + } + } + + // Max Tags Reached Indicator + &--max-reached { + .ui-tag-input__input { + display: none; + } + } + + // Focus Management + &__focus-trap { + position: absolute; + opacity: 0; + pointer-events: none; + top: -1px; + left: -1px; + } + + // Responsive Design + @media (max-width: 767px) { + .ui-tag-input__input { + min-width: 80px; + } + } +} + +// Error Message +.ui-tag-input-error { + margin-top: $semantic-spacing-component-xs; + color: $semantic-color-danger; + font-family: map-get($semantic-typography-caption, font-family); + font-size: map-get($semantic-typography-caption, font-size); + font-weight: map-get($semantic-typography-caption, font-weight); + line-height: map-get($semantic-typography-caption, line-height); +} + +// Help Text +.ui-tag-input-help { + margin-top: $semantic-spacing-component-xs; + color: $semantic-color-text-secondary; + font-family: map-get($semantic-typography-caption, font-family); + font-size: map-get($semantic-typography-caption, font-size); + font-weight: map-get($semantic-typography-caption, font-weight); + line-height: map-get($semantic-typography-caption, line-height); +} \ No newline at end of file diff --git a/src/lib/components/forms/tag-input/tag-input.component.ts b/src/lib/components/forms/tag-input/tag-input.component.ts new file mode 100644 index 0000000..ae0d69a --- /dev/null +++ b/src/lib/components/forms/tag-input/tag-input.component.ts @@ -0,0 +1,322 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation, forwardRef, ElementRef, ViewChild } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { ChipComponent } from '../../data-display/chip/chip.component'; +import { faXmark } from '@fortawesome/free-solid-svg-icons'; + +export type TagInputSize = 'sm' | 'md' | 'lg'; +export type TagInputVariant = 'outlined' | 'filled'; + +@Component({ + selector: 'ui-tag-input', + standalone: true, + imports: [CommonModule, ChipComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TagInputComponent), + multi: true + } + ], + template: ` +
+ +
+ @for (tag of tags; track tag; let i = $index) { +
+ + +
+ } +
+ + @if (!isMaxTagsReached) { + + } +
+ + @if (errorMessage) { +
+ {{ errorMessage }} +
+ } + + @if (helpText && !errorMessage) { +
+ {{ helpText }} +
+ } + `, + styleUrl: './tag-input.component.scss' +}) +export class TagInputComponent implements ControlValueAccessor { + @Input() size: TagInputSize = 'md'; + @Input() variant: TagInputVariant = 'outlined'; + @Input() disabled = false; + @Input() readonly = false; + @Input() placeholder = 'Add tags...'; + @Input() maxTags?: number; + @Input() maxTagLength?: number; + @Input() allowDuplicates = false; + @Input() trimTags = true; + @Input() separators = [',', ';', '|']; + @Input() ariaLabel = 'Tag input'; + @Input() inputAriaLabel = 'Add new tag'; + @Input() errorMessage?: string; + @Input() helpText?: string; + @Input() class = ''; + + @Output() tagAdd = new EventEmitter(); + @Output() tagRemove = new EventEmitter<{ tag: string; index: number }>(); + @Output() maxTagsReached = new EventEmitter(); + @Output() duplicateTag = new EventEmitter(); + @Output() tagValidationError = new EventEmitter<{ tag: string; error: string }>(); + + @ViewChild('inputElement') inputElement!: ElementRef; + + // FontAwesome icon + faXmark = faXmark; + + // Internal state + tags: string[] = []; + inputValue = ''; + + // ControlValueAccessor + private onChange = (value: string[]) => {}; + private onTouched = () => {}; + + get containerClasses(): string { + return [ + 'ui-tag-input', + `ui-tag-input--${this.size}`, + `ui-tag-input--${this.variant}`, + this.disabled ? 'ui-tag-input--disabled' : '', + this.errorMessage ? 'ui-tag-input--error' : '', + this.isMaxTagsReached ? 'ui-tag-input--max-reached' : '', + this.class + ].filter(Boolean).join(' '); + } + + get chipSize(): 'sm' | 'md' | 'lg' { + return this.size; + } + + get chipVariant(): 'filled' | 'outlined' { + return this.variant === 'outlined' ? 'outlined' : 'filled'; + } + + get isMaxTagsReached(): boolean { + return this.maxTags ? this.tags.length >= this.maxTags : false; + } + + get effectivePlaceholder(): string { + if (this.tags.length === 0) { + return this.placeholder; + } + if (this.isMaxTagsReached) { + return `Maximum ${this.maxTags} tags reached`; + } + return ''; + } + + get errorId(): string { + return `ui-tag-input-error-${Math.random().toString(36).substr(2, 9)}`; + } + + get helpId(): string { + return `ui-tag-input-help-${Math.random().toString(36).substr(2, 9)}`; + } + + get ariaDescribedBy(): string { + const ids: string[] = []; + if (this.errorMessage) { + ids.push(this.errorId); + } else if (this.helpText) { + ids.push(this.helpId); + } + return ids.join(' ') || ''; + } + + // ControlValueAccessor implementation + writeValue(value: string[]): void { + this.tags = Array.isArray(value) ? [...value] : []; + } + + registerOnChange(fn: (value: string[]) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + // Event handlers + handleInput(event: Event): void { + const target = event.target as HTMLInputElement; + this.inputValue = target.value; + + // Check for separators + const lastChar = this.inputValue[this.inputValue.length - 1]; + if (this.separators.includes(lastChar)) { + this.addTagFromInput(); + } + } + + handleKeydown(event: KeyboardEvent): void { + switch (event.key) { + case 'Enter': + case 'Tab': + if (this.inputValue.trim()) { + event.preventDefault(); + this.addTagFromInput(); + } + break; + + case 'Backspace': + if (!this.inputValue && this.tags.length > 0) { + event.preventDefault(); + this.removeTag(this.tags.length - 1); + } + break; + + case 'Escape': + this.inputValue = ''; + if (this.inputElement) { + this.inputElement.nativeElement.value = ''; + } + break; + } + } + + handleBlur(): void { + this.onTouched(); + if (this.inputValue.trim()) { + this.addTagFromInput(); + } + } + + handleFocus(): void { + // Focus handling if needed + } + + focusInput(): void { + if (!this.disabled && !this.readonly && !this.isMaxTagsReached && this.inputElement) { + this.inputElement.nativeElement.focus(); + } + } + + // Tag management + addTag(tag: string): boolean { + const processedTag = this.trimTags ? tag.trim() : tag; + + if (!processedTag) { + return false; + } + + // Check max length + if (this.maxTagLength && processedTag.length > this.maxTagLength) { + this.tagValidationError.emit({ + tag: processedTag, + error: `Tag exceeds maximum length of ${this.maxTagLength} characters` + }); + return false; + } + + // Check max tags + if (this.isMaxTagsReached) { + this.maxTagsReached.emit(); + return false; + } + + // Check duplicates + if (!this.allowDuplicates && this.tags.includes(processedTag)) { + this.duplicateTag.emit(processedTag); + return false; + } + + // Add tag + this.tags = [...this.tags, processedTag]; + this.onChange(this.tags); + this.tagAdd.emit(processedTag); + + return true; + } + + removeTag(index: number): void { + if (index >= 0 && index < this.tags.length) { + const removedTag = this.tags[index]; + this.tags = this.tags.filter((_, i) => i !== index); + this.onChange(this.tags); + this.tagRemove.emit({ tag: removedTag, index }); + + // Focus input after removal + setTimeout(() => this.focusInput(), 0); + } + } + + private addTagFromInput(): void { + if (this.inputValue) { + // Remove separator characters + let cleanValue = this.inputValue; + this.separators.forEach(sep => { + cleanValue = cleanValue.replace(new RegExp(`\\${sep}`, 'g'), ''); + }); + + if (this.addTag(cleanValue)) { + this.inputValue = ''; + if (this.inputElement) { + this.inputElement.nativeElement.value = ''; + } + } + } + } + + // Public methods + clear(): void { + this.tags = []; + this.inputValue = ''; + if (this.inputElement) { + this.inputElement.nativeElement.value = ''; + } + this.onChange(this.tags); + } + + addTagProgrammatically(tag: string): boolean { + return this.addTag(tag); + } + + getTagsAsString(separator = ', '): string { + return this.tags.join(separator); + } +} \ No newline at end of file diff --git a/src/lib/components/forms/time-picker/index.ts b/src/lib/components/forms/time-picker/index.ts new file mode 100644 index 0000000..20af417 --- /dev/null +++ b/src/lib/components/forms/time-picker/index.ts @@ -0,0 +1 @@ +export * from './time-picker.component'; \ No newline at end of file diff --git a/src/lib/components/forms/time-picker/time-picker.component.scss b/src/lib/components/forms/time-picker/time-picker.component.scss new file mode 100644 index 0000000..ebe912c --- /dev/null +++ b/src/lib/components/forms/time-picker/time-picker.component.scss @@ -0,0 +1,540 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-time-picker { + display: flex; + flex-direction: column; + width: 100%; + position: relative; + + // ========================================================================== + // SIZE VARIANTS + // ========================================================================== + + &--sm { + .ui-time-picker__container { + min-height: $semantic-sizing-input-height-sm; + } + + .ui-time-picker__field { + font-size: 0.875rem; + padding: $semantic-spacing-component-xs $semantic-spacing-component-sm; + } + + .ui-time-picker__label { + font-size: $semantic-typography-font-size-sm; + margin-bottom: $semantic-spacing-component-xs; + } + } + + &--md { + .ui-time-picker__container { + min-height: $semantic-sizing-input-height-md; + } + + .ui-time-picker__field { + font-size: 1rem; + padding: $semantic-spacing-component-sm $semantic-spacing-component-md; + } + + .ui-time-picker__label { + font-size: $semantic-typography-font-size-md; + margin-bottom: $semantic-spacing-component-sm; + } + } + + &--lg { + .ui-time-picker__container { + min-height: $semantic-sizing-input-height-lg; + } + + .ui-time-picker__field { + font-size: 1.125rem; + padding: $semantic-spacing-component-md; + } + + .ui-time-picker__label { + font-size: $semantic-typography-font-size-lg; + margin-bottom: $semantic-spacing-component-sm; + } + } + + // ========================================================================== + // VARIANT STYLES + // ========================================================================== + + &--outlined { + .ui-time-picker__container { + border: $semantic-border-width-1 solid $semantic-color-border-primary; + border-radius: $semantic-border-radius-md; + background: $semantic-color-surface-primary; + } + + .ui-time-picker__field { + border: none; + background: transparent; + } + } + + &--filled { + .ui-time-picker__container { + background: $semantic-color-surface-secondary; + border: $semantic-border-width-1 solid transparent; + border-radius: $semantic-border-radius-md; + border-bottom: $semantic-border-width-3 solid $semantic-color-border-primary; + } + + .ui-time-picker__field { + border: none; + background: transparent; + } + } + + &--underlined { + .ui-time-picker__container { + background: transparent; + border: none; + border-bottom: $semantic-border-width-1 solid $semantic-color-border-primary; + border-radius: 0; + } + + .ui-time-picker__field { + border: none; + background: transparent; + } + } + + // ========================================================================== + // STATE VARIANTS + // ========================================================================== + + &--error { + .ui-time-picker__container { + border-color: $semantic-color-error; + } + + .ui-time-picker__label { + color: $semantic-color-error; + } + } + + &--success { + .ui-time-picker__container { + border-color: $semantic-color-success; + } + } + + &--warning { + .ui-time-picker__container { + border-color: $semantic-color-warning; + } + } + + &--disabled { + opacity: 0.6; + pointer-events: none; + + .ui-time-picker__container { + background: $semantic-color-surface-secondary; + cursor: not-allowed; + } + } + + &--open { + .ui-time-picker__container { + border-color: $semantic-color-primary; + box-shadow: $semantic-shadow-input-focus; + } + } + + // ========================================================================== + // COMPONENT ELEMENTS + // ========================================================================== + + &__label { + font-weight: $semantic-typography-font-weight-medium; + color: $semantic-color-text-primary; + line-height: $semantic-typography-line-height-tight; + + &--required { + .ui-time-picker__required-indicator { + color: $semantic-color-error; + margin-left: $semantic-spacing-component-xs; + } + } + } + + &__container { + display: flex; + align-items: center; + position: relative; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease-in-out; + cursor: pointer; + + &:hover:not(.ui-time-picker--disabled &) { + border-color: $semantic-color-border-secondary; + } + + &--has-clear { + .ui-time-picker__field { + padding-right: $semantic-spacing-component-xl; + } + } + } + + &__prefix-icon { + display: flex; + align-items: center; + justify-content: center; + margin-left: $semantic-spacing-component-sm; + color: $semantic-color-text-tertiary; + pointer-events: none; + } + + &__field { + flex: 1; + border: none; + outline: none; + background: transparent; + color: $semantic-color-text-primary; + cursor: pointer; + font-family: inherit; + + &::placeholder { + color: $semantic-color-text-tertiary; + } + + &:focus { + outline: none; + } + } + + &__clear-btn { + position: absolute; + right: $semantic-spacing-component-sm; + display: flex; + align-items: center; + justify-content: center; + width: $semantic-sizing-icon-button; + height: $semantic-sizing-icon-button; + border: none; + background: transparent; + color: $semantic-color-text-tertiary; + cursor: pointer; + border-radius: $semantic-border-radius-sm; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease-in-out; + + &:hover { + background: $semantic-color-surface-secondary; + color: $semantic-color-text-secondary; + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + } + + &__helper-text { + font-size: $semantic-typography-font-size-sm; + margin-top: $semantic-spacing-component-xs; + line-height: $semantic-typography-line-height-normal; + + &--error { + color: $semantic-color-error; + } + + &--success { + color: $semantic-color-success; + } + + &--warning { + color: $semantic-color-warning; + } + + &--default { + color: $semantic-color-text-secondary; + } + } + + // ========================================================================== + // TIME PICKER DROPDOWN + // ========================================================================== + + &__dropdown { + position: absolute; + top: 100%; + left: 0; + right: 0; + z-index: 1000; + margin-top: $semantic-spacing-component-xs; + background: $semantic-color-surface-primary; + border: $semantic-border-width-1 solid $semantic-color-border-primary; + border-radius: $semantic-border-radius-lg; + box-shadow: $semantic-shadow-dropdown; + padding: $semantic-spacing-component-md; + min-width: 320px; + } + + &__time-display { + margin-bottom: $semantic-spacing-component-md; + } + + &__time-sections { + display: flex; + align-items: center; + justify-content: center; + gap: $semantic-spacing-component-sm; + } + + &__time-section { + display: flex; + flex-direction: column; + align-items: center; + gap: $semantic-spacing-component-xs; + } + + &__time-btn { + display: flex; + align-items: center; + justify-content: center; + width: $semantic-sizing-icon-button; + height: $semantic-sizing-icon-button; + border: none; + background: transparent; + color: $semantic-color-text-secondary; + cursor: pointer; + border-radius: $semantic-border-radius-sm; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease-in-out; + + &:hover { + background: $semantic-color-surface-secondary; + color: $semantic-color-text-primary; + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + } + + &__time-input { + width: 60px; + padding: $semantic-spacing-component-xs $semantic-spacing-component-sm; + border: $semantic-border-width-1 solid $semantic-color-border-primary; + border-radius: $semantic-border-radius-md; + background: $semantic-color-surface-primary; + color: $semantic-color-text-primary; + font-size: 1.125rem; + font-weight: $semantic-typography-font-weight-medium; + text-align: center; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease-in-out; + + &:focus { + outline: none; + border-color: $semantic-color-primary; + box-shadow: $semantic-shadow-input-focus; + } + + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } + + &[type=number] { + -moz-appearance: textfield; + } + } + + &__time-label { + font-size: $semantic-typography-font-size-xs; + color: $semantic-color-text-secondary; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + &__separator { + font-size: $semantic-typography-font-size-xl; + font-weight: $semantic-typography-font-weight-bold; + color: $semantic-color-text-primary; + margin: 0 $semantic-spacing-component-xs; + align-self: center; + margin-top: -20px; // Adjust to center with inputs + } + + &__ampm-section { + display: flex; + flex-direction: column; + gap: $semantic-spacing-component-xs; + margin-left: $semantic-spacing-component-md; + } + + &__ampm-btn { + padding: $semantic-spacing-component-xs $semantic-spacing-component-sm; + border: $semantic-border-width-1 solid $semantic-color-border-primary; + border-radius: $semantic-border-radius-sm; + background: $semantic-color-surface-primary; + color: $semantic-color-text-secondary; + cursor: pointer; + font-size: 0.875rem; + font-weight: $semantic-typography-font-weight-medium; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease-in-out; + + &:hover { + background: $semantic-color-surface-secondary; + color: $semantic-color-text-primary; + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + + &--active { + background: $semantic-color-primary; + color: $semantic-color-on-primary; + border-color: $semantic-color-primary; + + &:hover { + background: $semantic-color-primary-focus; + border-color: $semantic-color-primary-focus; + } + } + } + + // ========================================================================== + // PRESET TIMES + // ========================================================================== + + &__presets { + border-top: $semantic-border-width-1 solid $semantic-color-border-subtle; + padding-top: $semantic-spacing-component-md; + margin-bottom: $semantic-spacing-component-md; + } + + &__presets-title { + font-size: $semantic-typography-font-size-sm; + font-weight: $semantic-typography-font-weight-medium; + color: $semantic-color-text-secondary; + margin-bottom: $semantic-spacing-component-sm; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + &__presets-list { + display: flex; + flex-wrap: wrap; + gap: $semantic-spacing-component-xs; + } + + &__preset-btn { + padding: $semantic-spacing-component-xs $semantic-spacing-component-sm; + border: $semantic-border-width-1 solid $semantic-color-border-primary; + border-radius: $semantic-border-radius-sm; + background: $semantic-color-surface-primary; + color: $semantic-color-text-secondary; + cursor: pointer; + font-size: 0.875rem; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease-in-out; + + &:hover { + background: $semantic-color-surface-secondary; + color: $semantic-color-text-primary; + border-color: $semantic-color-border-secondary; + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + } + + // ========================================================================== + // ACTIONS + // ========================================================================== + + &__actions { + display: flex; + justify-content: flex-end; + gap: $semantic-spacing-component-sm; + border-top: $semantic-border-width-1 solid $semantic-color-border-subtle; + padding-top: $semantic-spacing-component-md; + } + + &__action-btn { + padding: $semantic-spacing-component-sm $semantic-spacing-component-md; + border-radius: $semantic-border-radius-md; + font-size: 0.875rem; + font-weight: $semantic-typography-font-weight-medium; + cursor: pointer; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease-in-out; + min-width: 80px; + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + + &--primary { + background: $semantic-color-primary; + color: $semantic-color-on-primary; + border: $semantic-border-width-1 solid $semantic-color-primary; + + &:hover { + background: $semantic-color-primary-focus; + border-color: $semantic-color-primary-focus; + } + + &:active { + background: $semantic-color-primary-pressed; + border-color: $semantic-color-primary-pressed; + } + } + + &--secondary { + background: transparent; + color: $semantic-color-text-secondary; + border: $semantic-border-width-1 solid $semantic-color-border-primary; + + &:hover { + background: $semantic-color-surface-secondary; + color: $semantic-color-text-primary; + border-color: $semantic-color-border-secondary; + } + + &:active { + background: $semantic-color-surface-secondary; + } + } + } + + // ========================================================================== + // RESPONSIVE DESIGN + // ========================================================================== + + @media (max-width: 768px) { + &__dropdown { + position: fixed; + top: 50%; + left: 50%; + right: auto; + transform: translate(-50%, -50%); + width: 90vw; + max-width: 350px; + } + + &__time-sections { + flex-direction: column; + gap: $semantic-spacing-component-md; + } + + &__separator { + display: none; + } + + &__ampm-section { + flex-direction: row; + margin-left: 0; + } + } +} \ No newline at end of file diff --git a/src/lib/components/forms/time-picker/time-picker.component.ts b/src/lib/components/forms/time-picker/time-picker.component.ts new file mode 100644 index 0000000..f9e5051 --- /dev/null +++ b/src/lib/components/forms/time-picker/time-picker.component.ts @@ -0,0 +1,609 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, forwardRef, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { IconDefinition } from '@fortawesome/fontawesome-svg-core'; +import { faClock, faTimes, faChevronUp, faChevronDown } from '@fortawesome/free-solid-svg-icons'; + +export type TimePickerSize = 'sm' | 'md' | 'lg'; +export type TimePickerVariant = 'outlined' | 'filled' | 'underlined'; +export type TimePickerState = 'default' | 'error' | 'success' | 'warning'; +export type TimePickerFormat = '12' | '24'; + +export interface TimeValue { + hours: number; + minutes: number; + seconds?: number; +} + +@Component({ + selector: 'ui-time-picker', + standalone: true, + imports: [CommonModule, FontAwesomeModule], + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TimePickerComponent), + multi: true + } + ], + template: ` +
+ + @if (label) { + + } + + +
+ +
+ +
+ + + + + + @if (clearable && value && !disabled) { + + } +
+ + + @if (isOpen()) { + + } + + + @if (helperText || errorMessage) { +
+ {{ state === 'error' ? errorMessage : helperText }} +
+ } +
+ `, + styleUrl: './time-picker.component.scss' +}) +export class TimePickerComponent implements ControlValueAccessor { + @Input() label: string = ''; + @Input() placeholder: string = 'Select time'; + @Input() size: TimePickerSize = 'md'; + @Input() variant: TimePickerVariant = 'outlined'; + @Input() state: TimePickerState = 'default'; + @Input() disabled = false; + @Input() required = false; + @Input() clearable = true; + @Input() helperText: string = ''; + @Input() errorMessage: string = ''; + @Input() timeFormat: TimePickerFormat = '12'; + @Input() showSeconds = false; + @Input() minuteStep = 1; + @Input() secondStep = 1; + @Input() presetTimes: Array<{label: string, value: TimeValue}> = []; + @Input() inputId: string = `time-picker-${Math.random().toString(36).substr(2, 9)}`; + + @Output() timeChange = new EventEmitter(); + @Output() pickerOpen = new EventEmitter(); + @Output() pickerClose = new EventEmitter(); + + value: TimeValue | null = null; + isOpen = signal(false); + ampm = signal<'AM' | 'PM'>('AM'); + + // Internal state for the picker + private tempValue: TimeValue = { hours: 12, minutes: 0, seconds: 0 }; + + readonly faClock = faClock; + readonly faTimes = faTimes; + readonly faChevronUp = faChevronUp; + readonly faChevronDown = faChevronDown; + + private onChange = (value: TimeValue | null) => {}; + private onTouched = () => {}; + + writeValue(value: TimeValue | null): void { + this.value = value; + if (value) { + this.tempValue = { ...value }; + if (this.timeFormat === '12') { + this.updateAMPM(); + } + } + } + + registerOnChange(fn: (value: TimeValue | null) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + get formattedValue(): string { + if (!this.value) return ''; + return this.formatTime(this.value); + } + + get displayHours(): string { + if (this.timeFormat === '12') { + const hours = this.tempValue.hours; + const displayHour = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours; + return displayHour.toString().padStart(2, '0'); + } + return this.tempValue.hours.toString().padStart(2, '0'); + } + + get displayMinutes(): string { + return this.tempValue.minutes.toString().padStart(2, '0'); + } + + get displaySeconds(): string { + return (this.tempValue.seconds || 0).toString().padStart(2, '0'); + } + + getWrapperClasses(): string { + const classes = [ + `ui-time-picker--${this.size}`, + `ui-time-picker--${this.variant}`, + `ui-time-picker--${this.state}` + ]; + + if (this.disabled) classes.push('ui-time-picker--disabled'); + if (this.isOpen()) classes.push('ui-time-picker--open'); + + return classes.join(' '); + } + + getContainerClasses(): string { + const classes: string[] = []; + if (this.clearable && this.value) classes.push('ui-time-picker__container--has-clear'); + return classes.join(' '); + } + + getHelperTextClasses(): string { + return `ui-time-picker__helper-text--${this.state}`; + } + + toggleTimePicker(): void { + if (this.disabled) return; + + if (this.isOpen()) { + this.closeTimePicker(); + } else { + this.openTimePicker(); + } + } + + openTimePicker(): void { + this.isOpen.set(true); + // Initialize temp value with current value or default + if (this.value) { + this.tempValue = { ...this.value }; + } else { + const now = new Date(); + this.tempValue = { + hours: now.getHours(), + minutes: now.getMinutes(), + seconds: this.showSeconds ? now.getSeconds() : 0 + }; + } + + if (this.timeFormat === '12') { + this.updateAMPM(); + } + + this.pickerOpen.emit(); + } + + closeTimePicker(): void { + this.isOpen.set(false); + this.onTouched(); + this.pickerClose.emit(); + } + + confirmTime(): void { + this.value = { ...this.tempValue }; + this.onChange(this.value); + this.timeChange.emit(this.value); + this.closeTimePicker(); + } + + clearValue(): void { + this.value = null; + this.onChange(null); + this.timeChange.emit(null); + } + + incrementHours(): void { + const maxHours = this.timeFormat === '24' ? 23 : 12; + const minHours = this.timeFormat === '24' ? 0 : 1; + + if (this.tempValue.hours >= maxHours) { + this.tempValue.hours = minHours; + } else { + this.tempValue.hours++; + } + + if (this.timeFormat === '12') { + this.updateAMPM(); + } + } + + decrementHours(): void { + const maxHours = this.timeFormat === '24' ? 23 : 12; + const minHours = this.timeFormat === '24' ? 0 : 1; + + if (this.tempValue.hours <= minHours) { + this.tempValue.hours = maxHours; + } else { + this.tempValue.hours--; + } + + if (this.timeFormat === '12') { + this.updateAMPM(); + } + } + + incrementMinutes(): void { + if (this.tempValue.minutes >= 59) { + this.tempValue.minutes = 0; + } else { + this.tempValue.minutes = Math.min(59, this.tempValue.minutes + this.minuteStep); + } + } + + decrementMinutes(): void { + if (this.tempValue.minutes <= 0) { + this.tempValue.minutes = 59; + } else { + this.tempValue.minutes = Math.max(0, this.tempValue.minutes - this.minuteStep); + } + } + + incrementSeconds(): void { + if (!this.showSeconds) return; + + if ((this.tempValue.seconds || 0) >= 59) { + this.tempValue.seconds = 0; + } else { + this.tempValue.seconds = Math.min(59, (this.tempValue.seconds || 0) + this.secondStep); + } + } + + decrementSeconds(): void { + if (!this.showSeconds) return; + + if ((this.tempValue.seconds || 0) <= 0) { + this.tempValue.seconds = 59; + } else { + this.tempValue.seconds = Math.max(0, (this.tempValue.seconds || 0) - this.secondStep); + } + } + + onHoursInput(event: Event): void { + const target = event.target as HTMLInputElement; + const hours = parseInt(target.value, 10); + + if (!isNaN(hours)) { + this.tempValue.hours = hours; + } + } + + onMinutesInput(event: Event): void { + const target = event.target as HTMLInputElement; + const minutes = parseInt(target.value, 10); + + if (!isNaN(minutes)) { + this.tempValue.minutes = minutes; + } + } + + onSecondsInput(event: Event): void { + if (!this.showSeconds) return; + + const target = event.target as HTMLInputElement; + const seconds = parseInt(target.value, 10); + + if (!isNaN(seconds)) { + this.tempValue.seconds = seconds; + } + } + + validateHours(): void { + const maxHours = this.timeFormat === '24' ? 23 : 12; + const minHours = this.timeFormat === '24' ? 0 : 1; + + this.tempValue.hours = Math.max(minHours, Math.min(maxHours, this.tempValue.hours)); + } + + validateMinutes(): void { + this.tempValue.minutes = Math.max(0, Math.min(59, this.tempValue.minutes)); + } + + validateSeconds(): void { + if (!this.showSeconds) return; + this.tempValue.seconds = Math.max(0, Math.min(59, this.tempValue.seconds || 0)); + } + + setAMPM(period: 'AM' | 'PM'): void { + if (this.timeFormat !== '12') return; + + this.ampm.set(period); + + // Convert temp hours to 24-hour format for internal storage + const currentHours = this.tempValue.hours; + + if (period === 'AM') { + if (currentHours === 12) { + this.tempValue.hours = 0; + } else if (currentHours > 12) { + this.tempValue.hours = currentHours - 12; + } + } else { + if (currentHours < 12) { + this.tempValue.hours = currentHours + 12; + } + } + } + + selectPreset(timeValue: TimeValue): void { + this.tempValue = { ...timeValue }; + if (this.timeFormat === '12') { + this.updateAMPM(); + } + this.confirmTime(); + } + + onInputKeyDown(event: KeyboardEvent): void { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + this.toggleTimePicker(); + } else if (event.key === 'Escape' && this.isOpen()) { + event.preventDefault(); + this.closeTimePicker(); + } + } + + private formatTime(time: TimeValue): string { + if (this.timeFormat === '12') { + const hours = time.hours; + const displayHour = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours; + const period = hours >= 12 ? 'PM' : 'AM'; + const minutes = time.minutes.toString().padStart(2, '0'); + + if (this.showSeconds && time.seconds !== undefined) { + const seconds = time.seconds.toString().padStart(2, '0'); + return `${displayHour}:${minutes}:${seconds} ${period}`; + } + + return `${displayHour}:${minutes} ${period}`; + } else { + const hours = time.hours.toString().padStart(2, '0'); + const minutes = time.minutes.toString().padStart(2, '0'); + + if (this.showSeconds && time.seconds !== undefined) { + const seconds = time.seconds.toString().padStart(2, '0'); + return `${hours}:${minutes}:${seconds}`; + } + + return `${hours}:${minutes}`; + } + } + + private updateAMPM(): void { + if (this.timeFormat !== '12') return; + + const period = this.tempValue.hours >= 12 ? 'PM' : 'AM'; + this.ampm.set(period); + } +} \ No newline at end of file diff --git a/src/lib/components/layout/aspect-ratio/aspect-ratio.component.scss b/src/lib/components/layout/aspect-ratio/aspect-ratio.component.scss new file mode 100644 index 0000000..3718c27 --- /dev/null +++ b/src/lib/components/layout/aspect-ratio/aspect-ratio.component.scss @@ -0,0 +1,188 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-aspect-ratio { + // Core Structure + display: block; + position: relative; + width: 100%; + overflow: hidden; + + // Visual Design + background: $semantic-color-surface-primary; + border-radius: $semantic-border-radius-md; + + // Default aspect ratio container + &::before { + content: ''; + display: block; + padding-bottom: 56.25%; // 16:9 default + } + + // Content wrapper + &__content { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + + // Ensure content fills the container properly + & > * { + width: 100%; + height: 100%; + object-fit: cover; + } + + // For images and videos + & > img, + & > video { + object-fit: cover; + border-radius: inherit; + } + + // For custom content that needs centering + &--center { + & > * { + width: auto; + height: auto; + object-fit: initial; + } + } + } + + // Common Aspect Ratio Variants + &--square { + &::before { + padding-bottom: 100%; // 1:1 + } + } + + &--video { + &::before { + padding-bottom: 56.25%; // 16:9 + } + } + + &--cinema { + &::before { + padding-bottom: 42.86%; // 21:9 + } + } + + &--photo { + &::before { + padding-bottom: 66.67%; // 3:2 + } + } + + &--portrait { + &::before { + padding-bottom: 133.33%; // 3:4 + } + } + + &--golden { + &::before { + padding-bottom: 61.8%; // Golden ratio ~1.618:1 + } + } + + // Size variants for different contexts + &--sm { + border-radius: $semantic-border-radius-sm; + + .ui-aspect-ratio__content { + & > img, + & > video { + border-radius: $semantic-border-radius-sm; + } + } + } + + &--lg { + border-radius: $semantic-border-radius-lg; + + .ui-aspect-ratio__content { + & > img, + & > video { + border-radius: $semantic-border-radius-lg; + } + } + } + + // Surface variants + &--elevated { + background: $semantic-color-surface-elevated; + box-shadow: $semantic-shadow-elevation-2; + } + + &--bordered { + border: $semantic-border-width-1 solid $semantic-color-border-primary; + } + + // Interactive state for clickable aspect ratios + &--interactive { + cursor: pointer; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + + &:hover { + box-shadow: $semantic-shadow-elevation-3; + transform: translateY(-1px); + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + + &:active { + transform: translateY(0); + box-shadow: $semantic-shadow-elevation-1; + } + } + + // Loading state + &--loading { + background: $semantic-color-surface-secondary; + + .ui-aspect-ratio__content { + display: flex; + align-items: center; + justify-content: center; + + &::after { + content: ''; + width: 32px; + height: 32px; + border: 3px solid $semantic-color-border-subtle; + border-top: 3px solid $semantic-color-primary; + border-radius: 50%; + animation: spin $semantic-motion-duration-slow linear infinite; + } + } + } + + // Responsive adjustments + @media (max-width: calc($semantic-breakpoint-md - 1px)) { + border-radius: $semantic-border-radius-sm; + + &--sm { + border-radius: $semantic-border-radius-sm; + } + + &--lg { + border-radius: $semantic-border-radius-md; + } + } +} + +// Loading animation +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} \ No newline at end of file diff --git a/src/lib/components/layout/aspect-ratio/aspect-ratio.component.ts b/src/lib/components/layout/aspect-ratio/aspect-ratio.component.ts new file mode 100644 index 0000000..ac5ed7d --- /dev/null +++ b/src/lib/components/layout/aspect-ratio/aspect-ratio.component.ts @@ -0,0 +1,77 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +type AspectRatioPreset = 'square' | 'video' | 'cinema' | 'photo' | 'portrait' | 'golden'; +type AspectRatioSize = 'sm' | 'md' | 'lg'; +type AspectRatioVariant = 'default' | 'elevated' | 'bordered' | 'interactive'; + +@Component({ + selector: 'ui-aspect-ratio', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ +
+ @if (loading) { + + } @else { + + } +
+
+ `, + styleUrl: './aspect-ratio.component.scss' +}) +export class AspectRatioComponent { + @Input() ratio: AspectRatioPreset = 'video'; + @Input() customRatio?: string; // e.g., "4/3", "1.5", "75%" + @Input() size: AspectRatioSize = 'md'; + @Input() variant: AspectRatioVariant = 'default'; + @Input() loading = false; + @Input() centerContent = false; + @Input() role?: string; + @Input() tabIndex = 0; + + @Output() clicked = new EventEmitter(); + + get interactive(): boolean { + return this.variant === 'interactive'; + } + + getClasses(): Record { + const classes: Record = { + 'ui-aspect-ratio': true, + [`ui-aspect-ratio--${this.ratio}`]: !this.customRatio, + [`ui-aspect-ratio--${this.size}`]: this.size !== 'md', + 'ui-aspect-ratio--loading': this.loading + }; + + if (this.variant !== 'default') { + classes[`ui-aspect-ratio--${this.variant}`] = true; + } + + return classes; + } + + handleClick(event: MouseEvent): void { + if (this.interactive && !this.loading) { + this.clicked.emit(event); + } + } + + handleKeydown(event: KeyboardEvent): void { + if (this.interactive && !this.loading && (event.key === 'Enter' || event.key === ' ')) { + event.preventDefault(); + this.handleClick(event as any); + } + } +} \ No newline at end of file diff --git a/src/lib/components/layout/aspect-ratio/index.ts b/src/lib/components/layout/aspect-ratio/index.ts new file mode 100644 index 0000000..b4cbf1e --- /dev/null +++ b/src/lib/components/layout/aspect-ratio/index.ts @@ -0,0 +1 @@ +export * from './aspect-ratio.component'; \ No newline at end of file diff --git a/src/lib/components/layout/bento-grid/bento-grid-item.component.ts b/src/lib/components/layout/bento-grid/bento-grid-item.component.ts new file mode 100644 index 0000000..2bbf2b5 --- /dev/null +++ b/src/lib/components/layout/bento-grid/bento-grid-item.component.ts @@ -0,0 +1,85 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +type BentoGridItemSpan = 1 | 2 | 3 | 4 | 'full' | "1" | "2" | "3" | "4"; +type BentoGridItemRowSpan = 1 | 2 | 3 | 4 | "1" | "2" | "3" | "4"; +type BentoGridItemFeatured = 'sm' | 'md' | 'lg' | 'xl'; +type BentoGridItemVariant = 'default' | 'primary' | 'secondary' | 'elevated'; + +@Component({ + selector: 'ui-bento-grid-item', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ + @if (header) { +
+ {{ header }} +
+ } + +
+ +
+
+ `, + styleUrl: './bento-grid.component.scss' +}) +export class BentoGridItemComponent { + @Input() colSpan: BentoGridItemSpan = 1; + @Input() rowSpan: BentoGridItemRowSpan = 1; + @Input() featured?: BentoGridItemFeatured; + @Input() variant: BentoGridItemVariant = 'default'; + @Input() interactive = false; + @Input() header?: string; + @Input() role = 'gridcell'; + @Input() ariaLabel?: string; + @Input() tabIndex = 0; + + @Output() clicked = new EventEmitter(); + + getComponentClasses(): string { + const classes = [ + 'ui-bento-grid-item', + `ui-bento-grid-item--span-${this.colSpan}`, + `ui-bento-grid-item--row-span-${this.rowSpan}` + ]; + + if (this.featured) { + classes.push(`ui-bento-grid-item--featured-${this.featured}`); + } + + if (this.variant !== 'default') { + classes.push(`ui-bento-grid-item--${this.variant}`); + } + + if (this.interactive) { + classes.push('ui-bento-grid-item--interactive'); + } + + return classes.join(' '); + } + + handleClick(event: MouseEvent): void { + if (this.interactive) { + this.clicked.emit(event); + } + } + + handleKeydown(event: KeyboardEvent): void { + if (this.interactive && (event.key === 'Enter' || event.key === ' ')) { + event.preventDefault(); + this.handleClick(event as any); + } + } +} \ No newline at end of file diff --git a/src/lib/components/layout/bento-grid/bento-grid.component.scss b/src/lib/components/layout/bento-grid/bento-grid.component.scss new file mode 100644 index 0000000..c756066 --- /dev/null +++ b/src/lib/components/layout/bento-grid/bento-grid.component.scss @@ -0,0 +1,322 @@ +@use 'ui-design-system/src/styles/semantic' as *; + +.ui-bento-grid { + display: grid; + position: relative; + width: 100%; + + // Base grid layout with masonry-like behavior + gap: $semantic-spacing-grid-gap-md; + grid-auto-rows: minmax(120px, auto); + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + + // Size variants for gap + &--gap-xs { + gap: $semantic-spacing-grid-gap-sm; + } + + &--gap-sm { + gap: $semantic-spacing-grid-gap-sm; + } + + &--gap-md { + gap: $semantic-spacing-grid-gap-md; + } + + &--gap-lg { + gap: $semantic-spacing-grid-gap-lg; + } + + // Column variants for different breakpoints + &--cols-2 { + grid-template-columns: repeat(2, 1fr); + } + + &--cols-3 { + grid-template-columns: repeat(3, 1fr); + } + + &--cols-4 { + grid-template-columns: repeat(4, 1fr); + } + + &--cols-5 { + grid-template-columns: repeat(5, 1fr); + } + + &--cols-6 { + grid-template-columns: repeat(6, 1fr); + } + + // Auto-fit responsive columns with different min widths + &--auto-fit-sm { + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + } + + &--auto-fit-md { + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + } + + &--auto-fit-lg { + grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); + } + + // Row height variants + &--rows-sm { + grid-auto-rows: minmax(80px, auto); + } + + &--rows-md { + grid-auto-rows: minmax(120px, auto); + } + + &--rows-lg { + grid-auto-rows: minmax(160px, auto); + } + + // Dense packing for masonry effect + &--dense { + grid-auto-flow: row dense; + } + + // Responsive behavior + @media (max-width: 1024px) { + &--cols-6, + &--cols-5 { + grid-template-columns: repeat(3, 1fr); + } + + &--cols-4 { + grid-template-columns: repeat(2, 1fr); + } + + &--auto-fit-lg { + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + } + } + + @media (max-width: 768px) { + gap: $semantic-spacing-grid-gap-sm; + + &--cols-6, + &--cols-5, + &--cols-4, + &--cols-3 { + grid-template-columns: repeat(2, 1fr); + } + + &--auto-fit-md, + &--auto-fit-lg { + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + } + } + + @media (max-width: 480px) { + gap: $semantic-spacing-grid-gap-sm; + + &--cols-6, + &--cols-5, + &--cols-4, + &--cols-3, + &--cols-2 { + grid-template-columns: 1fr; + } + + &--auto-fit-sm, + &--auto-fit-md, + &--auto-fit-lg { + grid-template-columns: 1fr; + } + } +} + +// Bento Grid Item - for individual grid cells with specific spans +.ui-bento-grid-item { + position: relative; + overflow: hidden; + background: $semantic-color-surface-primary; + border: $semantic-border-width-1 solid $semantic-color-border-subtle; + border-radius: $semantic-border-card-radius; + box-shadow: $semantic-shadow-elevation-1; + padding: $semantic-spacing-component-md; + + // Transitions + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + + // Size variants - column spans + &--span-1 { + grid-column: span 1; + } + + &--span-2 { + grid-column: span 2; + } + + &--span-3 { + grid-column: span 3; + } + + &--span-4 { + grid-column: span 4; + } + + &--span-full { + grid-column: 1 / -1; + } + + // Row spans for featured items + &--row-span-1 { + grid-row: span 1; + } + + &--row-span-2 { + grid-row: span 2; + } + + &--row-span-3 { + grid-row: span 3; + } + + &--row-span-4 { + grid-row: span 4; + } + + // Combined featured sizes + &--featured-sm { + grid-column: span 2; + grid-row: span 1; + } + + &--featured-md { + grid-column: span 2; + grid-row: span 2; + } + + &--featured-lg { + grid-column: span 3; + grid-row: span 2; + } + + &--featured-xl { + grid-column: span 4; + grid-row: span 3; + } + + // Color variants + &--primary { + background: $semantic-color-primary; + color: $semantic-color-on-primary; + border-color: $semantic-color-primary; + } + + &--secondary { + background: $semantic-color-secondary; + color: $semantic-color-on-secondary; + border-color: $semantic-color-secondary; + } + + &--elevated { + background: $semantic-color-surface-elevated; + box-shadow: $semantic-shadow-elevation-2; + } + + // Interactive states + &--interactive { + cursor: pointer; + + &:hover { + box-shadow: $semantic-shadow-elevation-3; + transform: translateY(-2px); + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + + &:active { + transform: translateY(-1px); + box-shadow: $semantic-shadow-elevation-2; + } + } + + // Content styling + &__header { + margin-bottom: $semantic-spacing-content-paragraph; + font-family: map-get($semantic-typography-heading-h5, font-family); + font-size: map-get($semantic-typography-heading-h5, font-size); + font-weight: map-get($semantic-typography-heading-h5, font-weight); + line-height: map-get($semantic-typography-heading-h5, line-height); + color: $semantic-color-text-primary; + } + + &__content { + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + color: $semantic-color-text-secondary; + } + + // Responsive item behavior + @media (max-width: 1024px) { + &--featured-xl, + &--featured-lg { + grid-column: span 2; + grid-row: span 2; + } + + &--span-4 { + grid-column: span 3; + } + + &--span-3 { + grid-column: span 2; + } + } + + @media (max-width: 768px) { + padding: $semantic-spacing-component-sm; + + &--featured-xl, + &--featured-lg, + &--featured-md, + &--featured-sm { + grid-column: span 2; + grid-row: span 1; + } + + &--span-4, + &--span-3, + &--span-2 { + grid-column: span 2; + } + + &--row-span-4, + &--row-span-3, + &--row-span-2 { + grid-row: span 2; + } + } + + @media (max-width: 480px) { + padding: $semantic-spacing-component-xs; + + &--featured-xl, + &--featured-lg, + &--featured-md, + &--featured-sm, + &--span-4, + &--span-3, + &--span-2, + &--span-full { + grid-column: span 1; + } + + &--row-span-4, + &--row-span-3, + &--row-span-2 { + grid-row: span 1; + } + } +} \ No newline at end of file diff --git a/src/lib/components/layout/bento-grid/bento-grid.component.ts b/src/lib/components/layout/bento-grid/bento-grid.component.ts new file mode 100644 index 0000000..738370d --- /dev/null +++ b/src/lib/components/layout/bento-grid/bento-grid.component.ts @@ -0,0 +1,57 @@ +import { Component, Input, ChangeDetectionStrategy, ViewEncapsulation } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +type BentoGridColumns = 2 | 3 | 4 | 5 | 6 | 'auto-fit-sm' | 'auto-fit-md' | 'auto-fit-lg' | "2" | "3" | "4" | "5" | "6"; +type BentoGridGap = 'xs' | 'sm' | 'md' | 'lg'; +type BentoGridRows = 'sm' | 'md' | 'lg'; + +@Component({ + selector: 'ui-bento-grid', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ + +
+ `, + styleUrl: './bento-grid.component.scss' +}) +export class BentoGridComponent { + @Input() columns: BentoGridColumns = 'auto-fit-md'; + @Input() gap: BentoGridGap = 'md'; + @Input() rowHeight: BentoGridRows = 'md'; + @Input() dense = true; + @Input() role = 'grid'; + @Input() ariaLabel = 'Bento grid layout'; + + getComponentClasses(): string { + const classes = ['ui-bento-grid']; + + if (typeof this.columns === 'number') { + classes.push(`ui-bento-grid--cols-${this.columns}`); + } else if (typeof this.columns === 'string') { + classes.push(`ui-bento-grid--${this.columns}`); + } + + if (this.gap) { + classes.push(`ui-bento-grid--gap-${this.gap}`); + } + + if (this.rowHeight) { + classes.push(`ui-bento-grid--rows-${this.rowHeight}`); + } + + if (this.dense) { + classes.push('ui-bento-grid--dense'); + } + + return classes.join(' '); + } +} \ No newline at end of file diff --git a/src/lib/components/layout/bento-grid/index.ts b/src/lib/components/layout/bento-grid/index.ts new file mode 100644 index 0000000..24ba511 --- /dev/null +++ b/src/lib/components/layout/bento-grid/index.ts @@ -0,0 +1,2 @@ +export * from './bento-grid.component'; +export * from './bento-grid-item.component'; \ No newline at end of file diff --git a/src/lib/components/layout/box/box.component.scss b/src/lib/components/layout/box/box.component.scss new file mode 100644 index 0000000..583fcdf --- /dev/null +++ b/src/lib/components/layout/box/box.component.scss @@ -0,0 +1,454 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-box { + // Base box behavior + box-sizing: border-box; + + // Display variants + &--display-block { + display: block; + } + + &--display-inline { + display: inline; + } + + &--display-inline-block { + display: inline-block; + } + + &--display-flex { + display: flex; + } + + &--display-inline-flex { + display: inline-flex; + } + + &--display-grid { + display: grid; + } + + &--display-inline-grid { + display: inline-grid; + } + + // Position variants + &--position-relative { + position: relative; + } + + &--position-absolute { + position: absolute; + } + + &--position-fixed { + position: fixed; + } + + &--position-sticky { + position: sticky; + } + + // Overflow variants + &--overflow-hidden { + overflow: hidden; + } + + &--overflow-scroll { + overflow: scroll; + } + + &--overflow-auto { + overflow: auto; + } + + // Padding utilities - All sides + &--p-none { + padding: 0; + } + + &--p-xs { + padding: $semantic-spacing-component-xs; + } + + &--p-sm { + padding: $semantic-spacing-component-sm; + } + + &--p-md { + padding: $semantic-spacing-component-md; + } + + &--p-lg { + padding: $semantic-spacing-component-lg; + } + + &--p-xl { + padding: $semantic-spacing-component-xl; + } + + &--p-2xl { + padding: $semantic-spacing-2xl; + } + + &--p-3xl { + padding: $semantic-spacing-3xl; + } + + &--p-4xl { + padding: $semantic-spacing-4xl; + } + + &--p-5xl { + padding: $semantic-spacing-5xl; + } + + // Padding utilities - Horizontal (left + right) + &--px-none { + padding-left: 0; + padding-right: 0; + } + + &--px-xs { + padding-left: $semantic-spacing-component-xs; + padding-right: $semantic-spacing-component-xs; + } + + &--px-sm { + padding-left: $semantic-spacing-component-sm; + padding-right: $semantic-spacing-component-sm; + } + + &--px-md { + padding-left: $semantic-spacing-component-md; + padding-right: $semantic-spacing-component-md; + } + + &--px-lg { + padding-left: $semantic-spacing-component-lg; + padding-right: $semantic-spacing-component-lg; + } + + &--px-xl { + padding-left: $semantic-spacing-component-xl; + padding-right: $semantic-spacing-component-xl; + } + + &--px-2xl { + padding-left: $semantic-spacing-2xl; + padding-right: $semantic-spacing-2xl; + } + + &--px-3xl { + padding-left: $semantic-spacing-3xl; + padding-right: $semantic-spacing-3xl; + } + + &--px-4xl { + padding-left: $semantic-spacing-4xl; + padding-right: $semantic-spacing-4xl; + } + + &--px-5xl { + padding-left: $semantic-spacing-5xl; + padding-right: $semantic-spacing-5xl; + } + + // Padding utilities - Vertical (top + bottom) + &--py-none { + padding-top: 0; + padding-bottom: 0; + } + + &--py-xs { + padding-top: $semantic-spacing-component-xs; + padding-bottom: $semantic-spacing-component-xs; + } + + &--py-sm { + padding-top: $semantic-spacing-component-sm; + padding-bottom: $semantic-spacing-component-sm; + } + + &--py-md { + padding-top: $semantic-spacing-component-md; + padding-bottom: $semantic-spacing-component-md; + } + + &--py-lg { + padding-top: $semantic-spacing-component-lg; + padding-bottom: $semantic-spacing-component-lg; + } + + &--py-xl { + padding-top: $semantic-spacing-component-xl; + padding-bottom: $semantic-spacing-component-xl; + } + + &--py-2xl { + padding-top: $semantic-spacing-2xl; + padding-bottom: $semantic-spacing-2xl; + } + + &--py-3xl { + padding-top: $semantic-spacing-3xl; + padding-bottom: $semantic-spacing-3xl; + } + + &--py-4xl { + padding-top: $semantic-spacing-4xl; + padding-bottom: $semantic-spacing-4xl; + } + + &--py-5xl { + padding-top: $semantic-spacing-5xl; + padding-bottom: $semantic-spacing-5xl; + } + + // Individual padding sides (pt, pr, pb, pl) + &--pt-none { padding-top: 0; } + &--pt-xs { padding-top: $semantic-spacing-component-xs; } + &--pt-sm { padding-top: $semantic-spacing-component-sm; } + &--pt-md { padding-top: $semantic-spacing-component-md; } + &--pt-lg { padding-top: $semantic-spacing-component-lg; } + &--pt-xl { padding-top: $semantic-spacing-component-xl; } + &--pt-2xl { padding-top: $semantic-spacing-2xl; } + &--pt-3xl { padding-top: $semantic-spacing-3xl; } + &--pt-4xl { padding-top: $semantic-spacing-4xl; } + &--pt-5xl { padding-top: $semantic-spacing-5xl; } + + &--pr-none { padding-right: 0; } + &--pr-xs { padding-right: $semantic-spacing-component-xs; } + &--pr-sm { padding-right: $semantic-spacing-component-sm; } + &--pr-md { padding-right: $semantic-spacing-component-md; } + &--pr-lg { padding-right: $semantic-spacing-component-lg; } + &--pr-xl { padding-right: $semantic-spacing-component-xl; } + &--pr-2xl { padding-right: $semantic-spacing-2xl; } + &--pr-3xl { padding-right: $semantic-spacing-3xl; } + &--pr-4xl { padding-right: $semantic-spacing-4xl; } + &--pr-5xl { padding-right: $semantic-spacing-5xl; } + + &--pb-none { padding-bottom: 0; } + &--pb-xs { padding-bottom: $semantic-spacing-component-xs; } + &--pb-sm { padding-bottom: $semantic-spacing-component-sm; } + &--pb-md { padding-bottom: $semantic-spacing-component-md; } + &--pb-lg { padding-bottom: $semantic-spacing-component-lg; } + &--pb-xl { padding-bottom: $semantic-spacing-component-xl; } + &--pb-2xl { padding-bottom: $semantic-spacing-2xl; } + &--pb-3xl { padding-bottom: $semantic-spacing-3xl; } + &--pb-4xl { padding-bottom: $semantic-spacing-4xl; } + &--pb-5xl { padding-bottom: $semantic-spacing-5xl; } + + &--pl-none { padding-left: 0; } + &--pl-xs { padding-left: $semantic-spacing-component-xs; } + &--pl-sm { padding-left: $semantic-spacing-component-sm; } + &--pl-md { padding-left: $semantic-spacing-component-md; } + &--pl-lg { padding-left: $semantic-spacing-component-lg; } + &--pl-xl { padding-left: $semantic-spacing-component-xl; } + &--pl-2xl { padding-left: $semantic-spacing-2xl; } + &--pl-3xl { padding-left: $semantic-spacing-3xl; } + &--pl-4xl { padding-left: $semantic-spacing-4xl; } + &--pl-5xl { padding-left: $semantic-spacing-5xl; } + + // Margin utilities - All sides + &--m-none { + margin: 0; + } + + &--m-xs { + margin: $semantic-spacing-layout-xs; + } + + &--m-sm { + margin: $semantic-spacing-layout-sm; + } + + &--m-md { + margin: $semantic-spacing-layout-md; + } + + &--m-lg { + margin: $semantic-spacing-layout-lg; + } + + &--m-xl { + margin: $semantic-spacing-layout-xl; + } + + &--m-2xl { + margin: $semantic-spacing-2xl; + } + + &--m-3xl { + margin: $semantic-spacing-3xl; + } + + &--m-4xl { + margin: $semantic-spacing-4xl; + } + + &--m-5xl { + margin: $semantic-spacing-5xl; + } + + // Margin utilities - Horizontal (left + right) + &--mx-none { + margin-left: 0; + margin-right: 0; + } + + &--mx-xs { + margin-left: $semantic-spacing-layout-xs; + margin-right: $semantic-spacing-layout-xs; + } + + &--mx-sm { + margin-left: $semantic-spacing-layout-sm; + margin-right: $semantic-spacing-layout-sm; + } + + &--mx-md { + margin-left: $semantic-spacing-layout-md; + margin-right: $semantic-spacing-layout-md; + } + + &--mx-lg { + margin-left: $semantic-spacing-layout-lg; + margin-right: $semantic-spacing-layout-lg; + } + + &--mx-xl { + margin-left: $semantic-spacing-layout-xl; + margin-right: $semantic-spacing-layout-xl; + } + + &--mx-2xl { + margin-left: $semantic-spacing-2xl; + margin-right: $semantic-spacing-2xl; + } + + &--mx-3xl { + margin-left: $semantic-spacing-3xl; + margin-right: $semantic-spacing-3xl; + } + + &--mx-4xl { + margin-left: $semantic-spacing-4xl; + margin-right: $semantic-spacing-4xl; + } + + &--mx-5xl { + margin-left: $semantic-spacing-5xl; + margin-right: $semantic-spacing-5xl; + } + + // Margin utilities - Vertical (top + bottom) + &--my-none { + margin-top: 0; + margin-bottom: 0; + } + + &--my-xs { + margin-top: $semantic-spacing-layout-xs; + margin-bottom: $semantic-spacing-layout-xs; + } + + &--my-sm { + margin-top: $semantic-spacing-layout-sm; + margin-bottom: $semantic-spacing-layout-sm; + } + + &--my-md { + margin-top: $semantic-spacing-layout-md; + margin-bottom: $semantic-spacing-layout-md; + } + + &--my-lg { + margin-top: $semantic-spacing-layout-lg; + margin-bottom: $semantic-spacing-layout-lg; + } + + &--my-xl { + margin-top: $semantic-spacing-layout-xl; + margin-bottom: $semantic-spacing-layout-xl; + } + + &--my-2xl { + margin-top: $semantic-spacing-2xl; + margin-bottom: $semantic-spacing-2xl; + } + + &--my-3xl { + margin-top: $semantic-spacing-3xl; + margin-bottom: $semantic-spacing-3xl; + } + + &--my-4xl { + margin-top: $semantic-spacing-4xl; + margin-bottom: $semantic-spacing-4xl; + } + + &--my-5xl { + margin-top: $semantic-spacing-5xl; + margin-bottom: $semantic-spacing-5xl; + } + + // Individual margin sides (mt, mr, mb, ml) + &--mt-none { margin-top: 0; } + &--mt-xs { margin-top: $semantic-spacing-layout-xs; } + &--mt-sm { margin-top: $semantic-spacing-layout-sm; } + &--mt-md { margin-top: $semantic-spacing-layout-md; } + &--mt-lg { margin-top: $semantic-spacing-layout-lg; } + &--mt-xl { margin-top: $semantic-spacing-layout-xl; } + &--mt-2xl { margin-top: $semantic-spacing-2xl; } + &--mt-3xl { margin-top: $semantic-spacing-3xl; } + &--mt-4xl { margin-top: $semantic-spacing-4xl; } + &--mt-5xl { margin-top: $semantic-spacing-5xl; } + + &--mr-none { margin-right: 0; } + &--mr-xs { margin-right: $semantic-spacing-layout-xs; } + &--mr-sm { margin-right: $semantic-spacing-layout-sm; } + &--mr-md { margin-right: $semantic-spacing-layout-md; } + &--mr-lg { margin-right: $semantic-spacing-layout-lg; } + &--mr-xl { margin-right: $semantic-spacing-layout-xl; } + &--mr-2xl { margin-right: $semantic-spacing-2xl; } + &--mr-3xl { margin-right: $semantic-spacing-3xl; } + &--mr-4xl { margin-right: $semantic-spacing-4xl; } + &--mr-5xl { margin-right: $semantic-spacing-5xl; } + + &--mb-none { margin-bottom: 0; } + &--mb-xs { margin-bottom: $semantic-spacing-layout-xs; } + &--mb-sm { margin-bottom: $semantic-spacing-layout-sm; } + &--mb-md { margin-bottom: $semantic-spacing-layout-md; } + &--mb-lg { margin-bottom: $semantic-spacing-layout-lg; } + &--mb-xl { margin-bottom: $semantic-spacing-layout-xl; } + &--mb-2xl { margin-bottom: $semantic-spacing-2xl; } + &--mb-3xl { margin-bottom: $semantic-spacing-3xl; } + &--mb-4xl { margin-bottom: $semantic-spacing-4xl; } + &--mb-5xl { margin-bottom: $semantic-spacing-5xl; } + + &--ml-none { margin-left: 0; } + &--ml-xs { margin-left: $semantic-spacing-layout-xs; } + &--ml-sm { margin-left: $semantic-spacing-layout-sm; } + &--ml-md { margin-left: $semantic-spacing-layout-md; } + &--ml-lg { margin-left: $semantic-spacing-layout-lg; } + &--ml-xl { margin-left: $semantic-spacing-layout-xl; } + &--ml-2xl { margin-left: $semantic-spacing-2xl; } + &--ml-3xl { margin-left: $semantic-spacing-3xl; } + &--ml-4xl { margin-left: $semantic-spacing-4xl; } + &--ml-5xl { margin-left: $semantic-spacing-5xl; } + + // Visual utilities + &--rounded { + border-radius: $semantic-border-radius-md; + } + + &--shadow { + box-shadow: $semantic-shadow-elevation-2; + } + + &--border { + border: $semantic-border-width-1 solid $semantic-color-border-primary; + } +} \ No newline at end of file diff --git a/src/lib/components/layout/box/box.component.ts b/src/lib/components/layout/box/box.component.ts new file mode 100644 index 0000000..8d6d9ba --- /dev/null +++ b/src/lib/components/layout/box/box.component.ts @@ -0,0 +1,116 @@ +import { Component, Input, ChangeDetectionStrategy, ViewEncapsulation } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +type BoxSpacing = 'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl'; +type BoxDisplay = 'block' | 'inline' | 'inline-block' | 'flex' | 'inline-flex' | 'grid' | 'inline-grid'; +type BoxPosition = 'static' | 'relative' | 'absolute' | 'fixed' | 'sticky'; +type BoxOverflow = 'visible' | 'hidden' | 'scroll' | 'auto'; + +@Component({ + selector: 'ui-box', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ + +
+ `, + styleUrl: './box.component.scss' +}) +export class BoxComponent { + // Padding variants + @Input() p?: BoxSpacing; // all sides + @Input() px?: BoxSpacing; // horizontal (left + right) + @Input() py?: BoxSpacing; // vertical (top + bottom) + @Input() pt?: BoxSpacing; // top + @Input() pr?: BoxSpacing; // right + @Input() pb?: BoxSpacing; // bottom + @Input() pl?: BoxSpacing; // left + + // Margin variants + @Input() m?: BoxSpacing; // all sides + @Input() mx?: BoxSpacing; // horizontal (left + right) + @Input() my?: BoxSpacing; // vertical (top + bottom) + @Input() mt?: BoxSpacing; // top + @Input() mr?: BoxSpacing; // right + @Input() mb?: BoxSpacing; // bottom + @Input() ml?: BoxSpacing; // left + + // Display and layout + @Input() display?: BoxDisplay; + @Input() position?: BoxPosition; + @Input() overflow?: BoxOverflow; + + // Dimensions + @Input() width?: string; + @Input() height?: string; + @Input() minWidth?: string; + @Input() minHeight?: string; + @Input() maxWidth?: string; + @Input() maxHeight?: string; + + // Visual + @Input() rounded = false; + @Input() shadow = false; + @Input() border = false; + + // Accessibility + @Input() role?: string; + + getClasses(): Record { + const classes: Record = { + 'ui-box': true + }; + + // Display + if (this.display) { + classes[`ui-box--display-${this.display}`] = true; + } + + // Position + if (this.position && this.position !== 'static') { + classes[`ui-box--position-${this.position}`] = true; + } + + // Overflow + if (this.overflow && this.overflow !== 'visible') { + classes[`ui-box--overflow-${this.overflow}`] = true; + } + + // Padding classes + if (this.p) classes[`ui-box--p-${this.p}`] = true; + if (this.px) classes[`ui-box--px-${this.px}`] = true; + if (this.py) classes[`ui-box--py-${this.py}`] = true; + if (this.pt) classes[`ui-box--pt-${this.pt}`] = true; + if (this.pr) classes[`ui-box--pr-${this.pr}`] = true; + if (this.pb) classes[`ui-box--pb-${this.pb}`] = true; + if (this.pl) classes[`ui-box--pl-${this.pl}`] = true; + + // Margin classes + if (this.m) classes[`ui-box--m-${this.m}`] = true; + if (this.mx) classes[`ui-box--mx-${this.mx}`] = true; + if (this.my) classes[`ui-box--my-${this.my}`] = true; + if (this.mt) classes[`ui-box--mt-${this.mt}`] = true; + if (this.mr) classes[`ui-box--mr-${this.mr}`] = true; + if (this.mb) classes[`ui-box--mb-${this.mb}`] = true; + if (this.ml) classes[`ui-box--ml-${this.ml}`] = true; + + // Visual styles + if (this.rounded) classes['ui-box--rounded'] = true; + if (this.shadow) classes['ui-box--shadow'] = true; + if (this.border) classes['ui-box--border'] = true; + + return classes; + } +} \ No newline at end of file diff --git a/src/lib/components/layout/box/index.ts b/src/lib/components/layout/box/index.ts new file mode 100644 index 0000000..bff941c --- /dev/null +++ b/src/lib/components/layout/box/index.ts @@ -0,0 +1 @@ +export * from './box.component'; \ No newline at end of file diff --git a/src/lib/components/layout/breakpoint-container/breakpoint-container.component.scss b/src/lib/components/layout/breakpoint-container/breakpoint-container.component.scss new file mode 100644 index 0000000..1201d24 --- /dev/null +++ b/src/lib/components/layout/breakpoint-container/breakpoint-container.component.scss @@ -0,0 +1,227 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-breakpoint-container { + position: relative; + display: block; + + // Base visibility - show by default + &:not(.ui-breakpoint-container--hidden) { + display: block; + } + + &--hidden { + display: none !important; + } + + // Show only on mobile (below tablet) + &--mobile-only { + display: block; + + @media (min-width: $semantic-breakpoint-md) { + display: none !important; + } + } + + // Hide on mobile + &--mobile-hidden { + display: none; + + @media (min-width: $semantic-breakpoint-md) { + display: block; + } + } + + // Show only on tablet (between mobile and desktop) + &--tablet-only { + display: none; + + @media (min-width: $semantic-breakpoint-md) and (max-width: $semantic-breakpoint-lg - 1px) { + display: block; + } + } + + // Hide on tablet + &--tablet-hidden { + display: block; + + @media (min-width: $semantic-breakpoint-md) and (max-width: $semantic-breakpoint-lg - 1px) { + display: none !important; + } + } + + // Show only on desktop and up + &--desktop-only { + display: none; + + @media (min-width: $semantic-breakpoint-lg) { + display: block; + } + } + + // Hide on desktop and up + &--desktop-hidden { + display: block; + + @media (min-width: $semantic-breakpoint-lg) { + display: none !important; + } + } + + // Custom breakpoint utilities + &--show-sm { + display: none; + + @media (min-width: $semantic-breakpoint-sm) { + display: block; + } + } + + &--hide-sm { + display: block; + + @media (min-width: $semantic-breakpoint-sm) { + display: none !important; + } + } + + &--show-md { + display: none; + + @media (min-width: $semantic-breakpoint-md) { + display: block; + } + } + + &--hide-md { + display: block; + + @media (min-width: $semantic-breakpoint-md) { + display: none !important; + } + } + + &--show-lg { + display: none; + + @media (min-width: $semantic-breakpoint-lg) { + display: block; + } + } + + &--hide-lg { + display: block; + + @media (min-width: $semantic-breakpoint-lg) { + display: none !important; + } + } + + // Screen size based visibility using semantic sizing breakpoints + &--mobile-screen { + display: block; + + @media (min-width: $semantic-sizing-breakpoint-tablet) { + display: none !important; + } + } + + &--tablet-screen { + display: none; + + @media (min-width: $semantic-sizing-breakpoint-tablet) and (max-width: $semantic-sizing-breakpoint-desktop - 1px) { + display: block; + } + } + + &--desktop-screen { + display: none; + + @media (min-width: $semantic-sizing-breakpoint-desktop) { + display: block; + } + } + + // Combination utilities - show/hide between specific ranges + &--sm-to-md { + display: none; + + @media (min-width: $semantic-breakpoint-sm) and (max-width: $semantic-breakpoint-md - 1px) { + display: block; + } + } + + &--md-to-lg { + display: none; + + @media (min-width: $semantic-breakpoint-md) and (max-width: $semantic-breakpoint-lg - 1px) { + display: block; + } + } + + // Inline display variant + &--inline { + display: inline; + + &.ui-breakpoint-container--hidden { + display: none !important; + } + + &.ui-breakpoint-container--mobile-only { + display: inline; + + @media (min-width: $semantic-breakpoint-md) { + display: none !important; + } + } + + &.ui-breakpoint-container--desktop-only { + display: none; + + @media (min-width: $semantic-breakpoint-lg) { + display: inline; + } + } + } + + // Flex display variant + &--flex { + display: flex; + + &.ui-breakpoint-container--hidden { + display: none !important; + } + + &.ui-breakpoint-container--mobile-only { + display: flex; + + @media (min-width: $semantic-breakpoint-md) { + display: none !important; + } + } + + &.ui-breakpoint-container--desktop-only { + display: none; + + @media (min-width: $semantic-breakpoint-lg) { + display: flex; + } + } + } + + // Print media query + @media print { + &--print-hidden { + display: none !important; + } + + &--print-only { + display: block; + } + + &:not(.ui-breakpoint-container--print-only) { + &.ui-breakpoint-container--print-hidden { + display: none !important; + } + } + } +} \ No newline at end of file diff --git a/src/lib/components/layout/breakpoint-container/breakpoint-container.component.ts b/src/lib/components/layout/breakpoint-container/breakpoint-container.component.ts new file mode 100644 index 0000000..6dd207a --- /dev/null +++ b/src/lib/components/layout/breakpoint-container/breakpoint-container.component.ts @@ -0,0 +1,108 @@ +import { Component, Input, ChangeDetectionStrategy, ViewEncapsulation } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +type BreakpointVisibility = + | 'always' + | 'mobile-only' + | 'tablet-only' + | 'desktop-only' + | 'mobile-hidden' + | 'tablet-hidden' + | 'desktop-hidden' + | 'mobile-screen' + | 'tablet-screen' + | 'desktop-screen' + | 'sm-to-md' + | 'md-to-lg'; + +type DisplayType = 'block' | 'inline' | 'flex'; + +type BreakpointRule = 'show-sm' | 'hide-sm' | 'show-md' | 'hide-md' | 'show-lg' | 'hide-lg'; + +@Component({ + selector: 'ui-breakpoint-container', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ + +
+ `, + styleUrl: './breakpoint-container.component.scss' +}) +export class BreakpointContainerComponent { + @Input() visibility: BreakpointVisibility = 'always'; + @Input() displayType: DisplayType = 'block'; + @Input() showSm = false; + @Input() hideSm = false; + @Input() showMd = false; + @Input() hideMd = false; + @Input() showLg = false; + @Input() hideLg = false; + @Input() printHidden = false; + @Input() printOnly = false; + @Input() role?: string; + + getComponentClasses(): string { + const classes = ['ui-breakpoint-container']; + + if (this.visibility !== 'always') { + classes.push(`ui-breakpoint-container--${this.visibility}`); + } + + if (this.displayType !== 'block') { + classes.push(`ui-breakpoint-container--${this.displayType}`); + } + + if (this.printHidden) { + classes.push('ui-breakpoint-container--print-hidden'); + } + + if (this.printOnly) { + classes.push('ui-breakpoint-container--print-only'); + } + + // Add breakpoint rule classes + const breakpointRules = this.getBreakpointRuleClasses(); + Object.keys(breakpointRules).forEach(className => { + if (breakpointRules[className]) { + classes.push(className); + } + }); + + return classes.join(' '); + } + + getBreakpointRuleClasses(): Record { + return { + 'ui-breakpoint-container--show-sm': this.showSm, + 'ui-breakpoint-container--hide-sm': this.hideSm, + 'ui-breakpoint-container--show-md': this.showMd, + 'ui-breakpoint-container--hide-md': this.hideMd, + 'ui-breakpoint-container--show-lg': this.showLg, + 'ui-breakpoint-container--hide-lg': this.hideLg + }; + } + + getAriaHidden(): string | null { + // Don't set aria-hidden for content that might be visible + if (this.visibility === 'always') { + return null; + } + + // For screen readers, we generally want to keep content accessible + // unless it's explicitly meant to be hidden for print or specific contexts + if (this.printOnly) { + return 'true'; + } + + return null; + } +} \ No newline at end of file diff --git a/src/lib/components/layout/breakpoint-container/index.ts b/src/lib/components/layout/breakpoint-container/index.ts new file mode 100644 index 0000000..c4721c7 --- /dev/null +++ b/src/lib/components/layout/breakpoint-container/index.ts @@ -0,0 +1 @@ +export * from './breakpoint-container.component'; \ No newline at end of file diff --git a/src/lib/components/layout/center/center.component.scss b/src/lib/components/layout/center/center.component.scss new file mode 100644 index 0000000..e944b66 --- /dev/null +++ b/src/lib/components/layout/center/center.component.scss @@ -0,0 +1,139 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-center { + // Base structure - centering container + display: flex; + box-sizing: border-box; + + // Both axis centering (default) + &--both { + align-items: center; + justify-content: center; + flex-direction: column; + } + + // Horizontal centering only + &--horizontal { + justify-content: center; + flex-direction: row; + } + + // Vertical centering only + &--vertical { + align-items: center; + flex-direction: column; + } + + // Inline variant + &--inline { + display: inline-flex; + } + + // Max width constraints + &--max-width-xs { + max-width: 475px; + } + + &--max-width-sm { + max-width: 640px; + } + + &--max-width-md { + max-width: 768px; + } + + &--max-width-lg { + max-width: 1024px; + } + + &--max-width-xl { + max-width: 1280px; + } + + &--max-width-2xl { + max-width: 1536px; + } + + &--max-width-3xl { + max-width: 1792px; + } + + &--max-width-full { + width: 100%; + } + + // Padding variants + &--padding-xs { + padding: $semantic-spacing-component-xs; + } + + &--padding-sm { + padding: $semantic-spacing-component-sm; + } + + &--padding-md { + padding: $semantic-spacing-component-md; + } + + &--padding-lg { + padding: $semantic-spacing-component-lg; + } + + &--padding-xl { + padding: $semantic-spacing-component-xl; + } + + &--padding-none { + padding: 0; + } + + // Margin variants + &--margin-xs { + margin: $semantic-spacing-component-xs; + } + + &--margin-sm { + margin: $semantic-spacing-component-sm; + } + + &--margin-md { + margin: $semantic-spacing-component-md; + } + + &--margin-lg { + margin: $semantic-spacing-component-lg; + } + + &--margin-xl { + margin: $semantic-spacing-component-xl; + } + + &--margin-none { + margin: 0; + } + + // Responsive adjustments - ensure centering works on all screen sizes + @media (max-width: 768px) { + &--max-width-xl, + &--max-width-lg { + max-width: 100%; + padding-left: $semantic-spacing-component-sm; + padding-right: $semantic-spacing-component-sm; + } + + // Stack content vertically on smaller screens for horizontal centering + &--horizontal { + flex-direction: column; + align-items: center; + } + } + + @media (max-width: 480px) { + &--max-width-md, + &--max-width-sm { + max-width: 100%; + padding-left: $semantic-spacing-component-xs; + padding-right: $semantic-spacing-component-xs; + } + } +} \ No newline at end of file diff --git a/src/lib/components/layout/center/center.component.ts b/src/lib/components/layout/center/center.component.ts new file mode 100644 index 0000000..fafdc00 --- /dev/null +++ b/src/lib/components/layout/center/center.component.ts @@ -0,0 +1,60 @@ +import { Component, Input, ChangeDetectionStrategy, ViewEncapsulation } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +type CenterAxis = 'both' | 'horizontal' | 'vertical'; +type CenterMaxWidth = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | 'full' | 'none'; +type CenterSpacing = 'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl'; + +@Component({ + selector: 'ui-center', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ + +
+ `, + styleUrl: './center.component.scss' +}) +export class CenterComponent { + @Input() axis: CenterAxis = 'both'; + @Input() maxWidth: CenterMaxWidth = 'none'; + @Input() padding?: CenterSpacing; + @Input() margin?: CenterSpacing; + @Input() inline = false; + @Input() customMaxWidth?: string; + @Input() customMinHeight?: string; + @Input() role?: string; + + getClasses(): Record { + const classes: Record = { + 'ui-center': true, + [`ui-center--${this.axis}`]: true + }; + + if (this.maxWidth !== 'none') { + classes[`ui-center--max-width-${this.maxWidth}`] = true; + } + + if (this.padding) { + classes[`ui-center--padding-${this.padding}`] = true; + } + + if (this.margin) { + classes[`ui-center--margin-${this.margin}`] = true; + } + + if (this.inline) { + classes['ui-center--inline'] = true; + } + + return classes; + } +} \ No newline at end of file diff --git a/src/lib/components/layout/center/index.ts b/src/lib/components/layout/center/index.ts new file mode 100644 index 0000000..4bbea30 --- /dev/null +++ b/src/lib/components/layout/center/index.ts @@ -0,0 +1 @@ +export * from './center.component'; \ No newline at end of file diff --git a/src/lib/components/layout/column/column.component.scss b/src/lib/components/layout/column/column.component.scss new file mode 100644 index 0000000..c4b671b --- /dev/null +++ b/src/lib/components/layout/column/column.component.scss @@ -0,0 +1,149 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-column { + // Core column container + box-sizing: border-box; + + // Column count variants + &--count-1 { + column-count: 1; + } + + &--count-2 { + column-count: 2; + } + + &--count-3 { + column-count: 3; + } + + &--count-4 { + column-count: 4; + } + + &--count-5 { + column-count: 5; + } + + &--count-6 { + column-count: 6; + } + + &--count-auto { + column-count: auto; + } + + // Gap variants using semantic spacing tokens + &--gap-xs { + column-gap: $semantic-spacing-component-xs; + } + + &--gap-sm { + column-gap: $semantic-spacing-component-sm; + } + + &--gap-md { + column-gap: $semantic-spacing-component-md; + } + + &--gap-lg { + column-gap: $semantic-spacing-component-lg; + } + + &--gap-xl { + column-gap: $semantic-spacing-component-xl; + } + + // Column fill variants + &--fill-auto { + column-fill: auto; + } + + &--fill-balance { + column-fill: balance; + } + + &--fill-balance-all { + column-fill: balance-all; + } + + // Column rule variants + &--rule-solid { + column-rule-style: solid; + column-rule-color: $semantic-color-border-secondary; + } + + &--rule-dashed { + column-rule-style: dashed; + column-rule-color: $semantic-color-border-secondary; + } + + &--rule-dotted { + column-rule-style: dotted; + column-rule-color: $semantic-color-border-secondary; + } + + // Rule width variants + &--rule-width-1 { + column-rule-width: $semantic-border-width-1; + } + + &--rule-width-2 { + column-rule-width: $semantic-border-width-2; + } + + &--rule-width-3 { + column-rule-width: 3px; // No semantic token for 3px, using fallback + } + + // Column span control + &--span-all { + * { + column-span: all; + } + } + + // Size variants + &--full-width { + width: 100%; + } + + &--full-height { + height: 100%; + } + + // Responsive behavior + &--responsive { + // Single column on small screens + @media (max-width: calc(768px - 1px)) { + column-count: 1 !important; + } + + // Reduce column count on medium screens + @media (max-width: calc(1024px - 1px)) and (min-width: 768px) { + &.ui-column--count-6 { + column-count: 3; + } + + &.ui-column--count-5 { + column-count: 3; + } + + &.ui-column--count-4 { + column-count: 2; + } + } + } + + // Prevent orphans and widows for better text flow + p, li, div { + break-inside: avoid-column; + page-break-inside: avoid; + } + + // Headings should not be orphaned + h1, h2, h3, h4, h5, h6 { + break-after: avoid-column; + page-break-after: avoid; + } +} \ No newline at end of file diff --git a/src/lib/components/layout/column/column.component.ts b/src/lib/components/layout/column/column.component.ts new file mode 100644 index 0000000..40bacf7 --- /dev/null +++ b/src/lib/components/layout/column/column.component.ts @@ -0,0 +1,98 @@ +import { Component, Input, ChangeDetectionStrategy, ViewEncapsulation } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +type ColumnCount = '1' | '2' | '3' | '4' | '5' | '6' | 'auto'; +type ColumnGap = 'xs' | 'sm' | 'md' | 'lg' | 'xl'; +type ColumnFill = 'auto' | 'balance' | 'balance-all'; +type ColumnRule = 'none' | 'solid' | 'dashed' | 'dotted'; +type ColumnRuleWidth = '1' | '2' | '3'; + +@Component({ + selector: 'ui-column', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ +
+ `, + styleUrl: './column.component.scss' +}) +export class ColumnComponent { + // Column layout properties + @Input() count: ColumnCount = '2'; + @Input() gap: ColumnGap = 'md'; + @Input() fill: ColumnFill = 'balance'; + + // Column rule (divider) properties + @Input() rule: ColumnRule = 'none'; + @Input() ruleWidth: ColumnRuleWidth = '1'; + + // Column span control + @Input() spanAll = false; + + // Size variants + @Input() fullWidth = false; + @Input() fullHeight = false; + + // Responsive breakpoint control + @Input() responsive = true; + + // Accessibility + @Input() role?: string; + + getClasses(): Record { + const classes: Record = { + 'ui-column': true + }; + + // Column count + if (this.count) { + classes[`ui-column--count-${this.count}`] = true; + } + + // Gap + if (this.gap) { + classes[`ui-column--gap-${this.gap}`] = true; + } + + // Fill + if (this.fill) { + classes[`ui-column--fill-${this.fill}`] = true; + } + + // Rule + if (this.rule && this.rule !== 'none') { + classes[`ui-column--rule-${this.rule}`] = true; + + if (this.ruleWidth) { + classes[`ui-column--rule-width-${this.ruleWidth}`] = true; + } + } + + // Span all + if (this.spanAll) { + classes['ui-column--span-all'] = true; + } + + // Size variants + if (this.fullWidth) { + classes['ui-column--full-width'] = true; + } + + if (this.fullHeight) { + classes['ui-column--full-height'] = true; + } + + // Responsive + if (this.responsive) { + classes['ui-column--responsive'] = true; + } + + return classes; + } +} \ No newline at end of file diff --git a/src/lib/components/layout/column/index.ts b/src/lib/components/layout/column/index.ts new file mode 100644 index 0000000..21e2b69 --- /dev/null +++ b/src/lib/components/layout/column/index.ts @@ -0,0 +1 @@ +export * from './column.component'; \ No newline at end of file diff --git a/src/lib/components/layout/container/container.component.scss b/src/lib/components/layout/container/container.component.scss new file mode 100644 index 0000000..2891531 --- /dev/null +++ b/src/lib/components/layout/container/container.component.scss @@ -0,0 +1,287 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-container { + display: block; + position: relative; + width: 100%; + margin: 0 auto; + box-sizing: border-box; + + // Base padding + padding: 0 $semantic-spacing-component-md; + + // Size variants + &--xs { + max-width: 475px; + } + + &--sm { + max-width: 640px; + } + + &--md { + max-width: 768px; + } + + &--lg { + max-width: 1024px; + } + + &--xl { + max-width: 1280px; + } + + &--2xl { + max-width: 1536px; + } + + &--3xl { + max-width: 1792px; + } + + &--4xl { + max-width: 1920px; + } + + &--full { + max-width: none; + } + + // Padding variants + &--padding-xs { + padding-left: $semantic-spacing-component-xs; + padding-right: $semantic-spacing-component-xs; + } + + &--padding-sm { + padding-left: $semantic-spacing-component-sm; + padding-right: $semantic-spacing-component-sm; + } + + &--padding-md { + padding-left: $semantic-spacing-component-md; + padding-right: $semantic-spacing-component-md; + } + + &--padding-lg { + padding-left: $semantic-spacing-component-lg; + padding-right: $semantic-spacing-component-lg; + } + + &--padding-xl { + padding-left: $semantic-spacing-component-xl; + padding-right: $semantic-spacing-component-xl; + } + + &--padding-none { + padding-left: 0; + padding-right: 0; + } + + // Vertical padding variants + &--padding-y-xs { + padding-top: $semantic-spacing-component-xs; + padding-bottom: $semantic-spacing-component-xs; + } + + &--padding-y-sm { + padding-top: $semantic-spacing-component-sm; + padding-bottom: $semantic-spacing-component-sm; + } + + &--padding-y-md { + padding-top: $semantic-spacing-component-md; + padding-bottom: $semantic-spacing-component-md; + } + + &--padding-y-lg { + padding-top: $semantic-spacing-component-lg; + padding-bottom: $semantic-spacing-component-lg; + } + + &--padding-y-xl { + padding-top: $semantic-spacing-component-xl; + padding-bottom: $semantic-spacing-component-xl; + } + + &--padding-y-none { + padding-top: 0; + padding-bottom: 0; + } + + // Background variants + &--surface { + background: $semantic-color-surface-primary; + } + + &--surface-secondary { + background: $semantic-color-surface-secondary; + } + + &--surface-elevated { + background: $semantic-color-surface-elevated; + } + + &--transparent { + background: transparent; + } + + // Visual variants + &--card { + background: $semantic-color-surface-primary; + border-radius: $semantic-border-radius-md; + box-shadow: $semantic-shadow-elevation-1; + border: 1px solid $semantic-color-border-subtle; + + padding: $semantic-spacing-container-card-padding; + + &.ui-container--padding-lg { + padding: $semantic-spacing-container-card-padding-lg; + } + } + + &--section { + padding-top: $semantic-spacing-layout-section-sm; + padding-bottom: $semantic-spacing-layout-section-sm; + } + + &--hero { + padding-top: $semantic-spacing-layout-section-lg; + padding-bottom: $semantic-spacing-layout-section-lg; + text-align: center; + } + + // Flex container variants + &--flex { + display: flex; + flex-direction: column; + gap: $semantic-spacing-stack-md; + + &.ui-container--flex-row { + flex-direction: row; + } + + &.ui-container--flex-center { + align-items: center; + justify-content: center; + } + + &.ui-container--flex-between { + justify-content: space-between; + } + + &.ui-container--flex-around { + justify-content: space-around; + } + + &.ui-container--flex-evenly { + justify-content: space-evenly; + } + + &.ui-container--flex-start { + align-items: flex-start; + justify-content: flex-start; + } + + &.ui-container--flex-end { + align-items: flex-end; + justify-content: flex-end; + } + } + + // Grid container variants + &--grid { + display: grid; + gap: $semantic-spacing-grid-gap-md; + + &.ui-container--grid-2 { + grid-template-columns: repeat(2, 1fr); + } + + &.ui-container--grid-3 { + grid-template-columns: repeat(3, 1fr); + } + + &.ui-container--grid-4 { + grid-template-columns: repeat(4, 1fr); + } + + &.ui-container--grid-auto { + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + } + } + + // Centered content + &--centered { + text-align: center; + + &.ui-container--flex { + align-items: center; + justify-content: center; + } + } + + // Responsive behavior + @media (max-width: 768px) { + &--responsive { + padding-left: $semantic-spacing-component-sm; + padding-right: $semantic-spacing-component-sm; + + &.ui-container--card { + border-radius: $semantic-border-radius-sm; + padding: $semantic-spacing-component-sm; + } + + &.ui-container--section { + padding-top: $semantic-spacing-layout-section-xs; + padding-bottom: $semantic-spacing-layout-section-xs; + } + + &.ui-container--hero { + padding-top: $semantic-spacing-layout-section-sm; + padding-bottom: $semantic-spacing-layout-section-sm; + } + + &.ui-container--grid-4, + &.ui-container--grid-3 { + grid-template-columns: repeat(2, 1fr); + } + + &.ui-container--flex-row { + flex-direction: column; + } + } + } + + @media (max-width: 480px) { + &--responsive { + padding-left: $semantic-spacing-component-xs; + padding-right: $semantic-spacing-component-xs; + + &.ui-container--grid-4, + &.ui-container--grid-3, + &.ui-container--grid-2 { + grid-template-columns: 1fr; + } + } + } + + // Constrain content width while maintaining container responsiveness + &--prose { + max-width: 65ch; + } + + // Scrollable container + &--scrollable { + overflow-y: auto; + + &.ui-container--scrollable-x { + overflow-x: auto; + overflow-y: visible; + } + + &.ui-container--scrollable-both { + overflow: auto; + } + } +} \ No newline at end of file diff --git a/src/lib/components/layout/container/container.component.ts b/src/lib/components/layout/container/container.component.ts new file mode 100644 index 0000000..ec04106 --- /dev/null +++ b/src/lib/components/layout/container/container.component.ts @@ -0,0 +1,93 @@ +import { Component, Input, ChangeDetectionStrategy, ViewEncapsulation } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +type ContainerSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | 'full'; +type ContainerPadding = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'none'; +type ContainerVariant = 'default' | 'card' | 'section' | 'hero' | 'flex' | 'grid' | 'centered' | 'prose'; +type ContainerBackground = 'transparent' | 'surface' | 'surface-secondary' | 'surface-elevated'; +type FlexDirection = 'row' | 'column'; +type FlexJustify = 'start' | 'end' | 'center' | 'between' | 'around' | 'evenly'; +type GridColumns = 2 | 3 | 4 | 'auto'; +type ScrollDirection = 'y' | 'x' | 'both'; + +@Component({ + selector: 'ui-container', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ + +
+ `, + styleUrl: './container.component.scss' +}) +export class ContainerComponent { + @Input() size: ContainerSize = 'lg'; + @Input() variant: ContainerVariant = 'default'; + @Input() background: ContainerBackground = 'transparent'; + @Input() padding?: ContainerPadding; + @Input() paddingY?: ContainerPadding; + @Input() flexDirection?: FlexDirection; + @Input() flexJustify?: FlexJustify; + @Input() gridColumns?: GridColumns; + @Input() scrollable?: ScrollDirection; + @Input() responsive = true; + @Input() customMaxWidth?: string; + @Input() customMinHeight?: string; + @Input() customMaxHeight?: string; + @Input() role?: string; + + getClasses(): Record { + const classes: Record = { + 'ui-container': true, + [`ui-container--${this.size}`]: true, + 'ui-container--responsive': this.responsive + }; + + if (this.variant !== 'default') { + classes[`ui-container--${this.variant}`] = true; + } + + if (this.background !== 'transparent') { + classes[`ui-container--${this.background}`] = true; + } + + if (this.padding) { + classes[`ui-container--padding-${this.padding}`] = true; + } + + if (this.paddingY) { + classes[`ui-container--padding-y-${this.paddingY}`] = true; + } + + if (this.flexDirection && this.variant === 'flex') { + classes[`ui-container--flex-${this.flexDirection}`] = true; + } + + if (this.flexJustify && this.variant === 'flex') { + classes[`ui-container--flex-${this.flexJustify}`] = true; + } + + if (this.gridColumns && this.variant === 'grid') { + classes[`ui-container--grid-${this.gridColumns}`] = true; + } + + if (this.scrollable === 'y') { + classes['ui-container--scrollable'] = true; + } else if (this.scrollable === 'x') { + classes['ui-container--scrollable-x'] = true; + } else if (this.scrollable === 'both') { + classes['ui-container--scrollable-both'] = true; + } + + return classes; + } +} \ No newline at end of file diff --git a/src/lib/components/layout/container/index.ts b/src/lib/components/layout/container/index.ts new file mode 100644 index 0000000..6a9b98f --- /dev/null +++ b/src/lib/components/layout/container/index.ts @@ -0,0 +1 @@ +export * from './container.component'; \ No newline at end of file diff --git a/src/lib/components/layout/dashboard-shell/dashboard-shell.component.scss b/src/lib/components/layout/dashboard-shell/dashboard-shell.component.scss new file mode 100644 index 0000000..ed406b6 --- /dev/null +++ b/src/lib/components/layout/dashboard-shell/dashboard-shell.component.scss @@ -0,0 +1,428 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-dashboard-shell { + // Core Structure + display: grid; + grid-template-areas: + "header header" + "sidebar main" + "footer footer"; + grid-template-rows: auto 1fr auto; + grid-template-columns: auto 1fr; + position: relative; + min-height: 100vh; + width: 100%; + + // Visual Design + background: $semantic-color-surface; + color: $semantic-color-text-primary; + + // Transitions + transition: all $semantic-motion-duration-normal $semantic-motion-easing-ease; + + // Variant: Bordered + &--bordered { + .ui-dashboard-shell__header { + border-bottom: $semantic-border-width-1 solid $semantic-color-border-primary; + } + + .ui-dashboard-shell__sidebar { + border-right: $semantic-border-width-1 solid $semantic-color-border-primary; + } + + .ui-dashboard-shell__footer { + border-top: $semantic-border-width-1 solid $semantic-color-border-primary; + } + } + + // Variant: Elevated + &--elevated { + .ui-dashboard-shell__header { + box-shadow: $semantic-shadow-elevation-2; + z-index: $semantic-z-index-dropdown; + } + + .ui-dashboard-shell__sidebar { + box-shadow: $semantic-shadow-elevation-3; + background: $semantic-color-surface-elevated; + } + + .ui-dashboard-shell__footer { + box-shadow: $semantic-shadow-elevation-1; + } + } + + // Sidebar collapsed state + &--sidebar-collapsed { + .ui-dashboard-shell__sidebar { + &--sm, &--md, &--lg { + width: 60px; + min-width: 60px; + } + } + } + + // No footer variant + &--no-footer { + grid-template-areas: + "header header" + "sidebar main"; + grid-template-rows: auto 1fr; + } + + // Mobile menu open state + &--mobile-menu-open { + .ui-dashboard-shell__sidebar { + transform: translateX(0); + } + } + + // Header Element + &__header { + grid-area: header; + display: flex; + align-items: center; + position: relative; + z-index: $semantic-z-index-dropdown; + + // Layout & Spacing + padding: 0 $semantic-spacing-component-md; + gap: $semantic-spacing-component-md; + + // Visual Design + background: $semantic-color-surface-secondary; + border-bottom: $semantic-border-width-1 solid $semantic-color-border-secondary; + + // Typography + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + + // Height variants + &--sm { + min-height: $semantic-sizing-input-height-sm; + padding: 0 $semantic-spacing-component-sm; + } + + &--md { + min-height: $semantic-sizing-input-height-md; + padding: 0 $semantic-spacing-component-md; + } + + &--lg { + min-height: $semantic-sizing-input-height-lg; + padding: 0 $semantic-spacing-component-lg; + } + } + + // Mobile Toggle Button + &__mobile-toggle { + display: none; + align-items: center; + justify-content: center; + + // Layout & Spacing + width: $semantic-sizing-touch-target; + height: $semantic-sizing-touch-target; + padding: $semantic-spacing-component-xs; + margin-right: $semantic-spacing-component-sm; + + // Visual Design + background: transparent; + border: none; + border-radius: $semantic-border-radius-sm; + color: $semantic-color-text-primary; + cursor: pointer; + + // Typography + font-family: map-get($semantic-typography-button-medium, font-family); + font-size: map-get($semantic-typography-button-medium, font-size); + font-weight: map-get($semantic-typography-button-medium, font-weight); + line-height: map-get($semantic-typography-button-medium, line-height); + + // Transitions + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + + // Interactive States + &:hover { + background: $semantic-color-surface-elevated; + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + + &:active { + background: $semantic-color-surface-container; + } + + &-icon { + display: block; + font-size: $semantic-typography-font-size-lg; + } + } + + // Header Content + &__header-content { + flex: 1; + display: flex; + align-items: center; + gap: $semantic-spacing-component-sm; + } + + // Header Actions + &__header-actions { + display: flex; + align-items: center; + gap: $semantic-spacing-component-sm; + } + + // Container for Sidebar and Main + &__container { + grid-area: sidebar / sidebar / main / main; + display: contents; + } + + // Sidebar Element + &__sidebar { + grid-area: sidebar; + display: flex; + flex-direction: column; + position: relative; + z-index: $semantic-z-index-dropdown; + + // Layout & Spacing + padding: $semantic-spacing-component-md; + + // Visual Design + background: $semantic-color-surface-secondary; + border-right: $semantic-border-width-1 solid $semantic-color-border-secondary; + + // Typography + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + + // Transitions + transition: all $semantic-motion-duration-normal $semantic-motion-easing-ease; + + // Width variants + &--sm { + width: 200px; + min-width: 200px; + } + + &--md { + width: 280px; + min-width: 280px; + } + + &--lg { + width: 360px; + min-width: 360px; + } + + // Collapsed state + &--collapsed { + padding: $semantic-spacing-component-sm; + overflow: hidden; + } + } + + // Mobile Backdrop + &__mobile-backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: $semantic-z-index-overlay; + + // Visual Design + background: $semantic-color-backdrop; + opacity: $semantic-opacity-backdrop; + + // Transitions + transition: opacity $semantic-motion-duration-fast $semantic-motion-easing-ease; + + // Interactive + cursor: pointer; + } + + // Main Content Element + &__main { + grid-area: main; + display: flex; + flex-direction: column; + position: relative; + min-width: 0; + + // Layout & Spacing + padding: $semantic-spacing-component-md; + + // Visual Design + background: $semantic-color-surface; + color: $semantic-color-text-primary; + + // Typography + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + } + + // Breadcrumbs Element + &__breadcrumbs { + padding-bottom: $semantic-spacing-component-sm; + margin-bottom: $semantic-spacing-component-md; + border-bottom: $semantic-border-width-1 solid $semantic-color-border-subtle; + + // Typography + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + color: $semantic-color-text-secondary; + } + + // Notifications Element + &__notifications { + margin-bottom: $semantic-spacing-component-md; + padding: $semantic-spacing-component-sm; + background: $semantic-color-surface-container; + border-radius: $semantic-border-radius-md; + border: $semantic-border-width-1 solid $semantic-color-border-subtle; + } + + // Content Element + &__content { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + } + + // Footer Element + &__footer { + grid-area: footer; + display: flex; + align-items: center; + justify-content: space-between; + + // Layout & Spacing + padding: $semantic-spacing-component-md; + + // Visual Design + background: $semantic-color-surface-secondary; + border-top: $semantic-border-width-1 solid $semantic-color-border-secondary; + + // Typography + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + color: $semantic-color-text-secondary; + + // Footer variants + &--minimal { + padding: $semantic-spacing-component-sm; + font-family: map-get($semantic-typography-caption, font-family); + font-size: map-get($semantic-typography-caption, font-size); + font-weight: map-get($semantic-typography-caption, font-weight); + line-height: map-get($semantic-typography-caption, line-height); + } + + &--standard { + // Default styling already applied above + } + + &--detailed { + flex-direction: column; + align-items: flex-start; + gap: $semantic-spacing-component-sm; + padding: $semantic-spacing-component-lg; + } + } + + // Responsive Design - Mobile First + @media (max-width: 768px) { + grid-template-areas: + "header" + "main" + "footer"; + grid-template-columns: 1fr; + + .ui-dashboard-shell__mobile-toggle { + display: flex; + } + + .ui-dashboard-shell__sidebar { + position: fixed; + top: 0; + left: 0; + height: 100vh; + z-index: $semantic-z-index-modal; + transform: translateX(-100%); + + &--sm, &--md, &--lg { + width: 280px; + min-width: 280px; + } + } + + .ui-dashboard-shell__main { + padding: $semantic-spacing-component-sm; + } + + .ui-dashboard-shell__header { + padding: 0 $semantic-spacing-component-sm; + gap: $semantic-spacing-component-sm; + + &--sm, &--md, &--lg { + min-height: $semantic-sizing-input-height-md; + padding: 0 $semantic-spacing-component-sm; + } + } + + .ui-dashboard-shell__footer { + padding: $semantic-spacing-component-sm; + + &--detailed { + padding: $semantic-spacing-component-md; + } + } + } + + @media (max-width: 480px) { + .ui-dashboard-shell__main { + padding: $semantic-spacing-component-xs; + } + + .ui-dashboard-shell__header { + padding: 0 $semantic-spacing-component-xs; + gap: $semantic-spacing-component-xs; + + &--sm, &--md, &--lg { + padding: 0 $semantic-spacing-component-xs; + } + } + + .ui-dashboard-shell__footer { + padding: $semantic-spacing-component-xs; + + &--detailed { + padding: $semantic-spacing-component-sm; + } + } + + .ui-dashboard-shell__sidebar { + &--sm, &--md, &--lg { + width: 100vw; + min-width: 100vw; + } + } + } +} \ No newline at end of file diff --git a/src/lib/components/layout/dashboard-shell/dashboard-shell.component.ts b/src/lib/components/layout/dashboard-shell/dashboard-shell.component.ts new file mode 100644 index 0000000..88702dc --- /dev/null +++ b/src/lib/components/layout/dashboard-shell/dashboard-shell.component.ts @@ -0,0 +1,181 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +type DashboardVariant = 'default' | 'bordered' | 'elevated'; +type SidebarWidth = 'sm' | 'md' | 'lg'; +type HeaderHeight = 'sm' | 'md' | 'lg'; +type FooterVariant = 'minimal' | 'standard' | 'detailed'; + +@Component({ + selector: 'ui-dashboard-shell', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ + + @if (mobileMenuOpen) { + + } + + + + + +
+ + + + + +
+ + + @if (showBreadcrumbs) { + + } + + + @if (showNotifications) { +
+ +
+ } + + +
+ +
+
+
+ + + @if (showFooter) { +
+ + +
+ } +
+ `, + styleUrl: './dashboard-shell.component.scss' +}) +export class DashboardShellComponent { + @Input() variant: DashboardVariant = 'default'; + @Input() sidebarWidth: SidebarWidth = 'md'; + @Input() headerHeight: HeaderHeight = 'md'; + @Input() footerVariant: FooterVariant = 'standard'; + @Input() sidebarCollapsed = false; + @Input() mobileMenuOpen = false; + @Input() showFooter = true; + @Input() showBreadcrumbs = true; + @Input() showNotifications = true; + @Input() notificationsLive = true; + @Input() enableMobileBackdropClose = true; + @Input() role = 'application'; + + // ARIA Labels + @Input() sidebarLabel = 'Main navigation'; + @Input() mainContentLabel = 'Main content'; + @Input() breadcrumbsLabel = 'Breadcrumb navigation'; + @Input() contentLabel = 'Page content'; + @Input() footerLabel = 'Site footer'; + + // Events + @Output() sidebarToggled = new EventEmitter(); + @Output() mobileMenuToggled = new EventEmitter(); + @Output() mobileBackdropClicked = new EventEmitter(); + + handleMobileToggle(): void { + const newState = !this.mobileMenuOpen; + this.mobileMenuToggled.emit(newState); + } + + handleMobileBackdropClick(): void { + if (this.enableMobileBackdropClose) { + this.mobileBackdropClicked.emit(); + } + } + + handleSidebarToggle(): void { + const newState = !this.sidebarCollapsed; + this.sidebarToggled.emit(newState); + } +} \ No newline at end of file diff --git a/src/lib/components/layout/dashboard-shell/index.ts b/src/lib/components/layout/dashboard-shell/index.ts new file mode 100644 index 0000000..8f52f9f --- /dev/null +++ b/src/lib/components/layout/dashboard-shell/index.ts @@ -0,0 +1 @@ +export { DashboardShellComponent } from './dashboard-shell.component'; \ No newline at end of file diff --git a/src/lib/components/layout/divider/divider.component.scss b/src/lib/components/layout/divider/divider.component.scss new file mode 100644 index 0000000..87bbefe --- /dev/null +++ b/src/lib/components/layout/divider/divider.component.scss @@ -0,0 +1,177 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-divider { + display: flex; + align-items: center; + position: relative; + + // Horizontal divider (default) + &--horizontal { + width: 100%; + flex-direction: row; + + &::before, + &::after { + content: ''; + flex: 1; + height: $semantic-border-width-1; + background-color: $semantic-color-border-primary; + } + + // Thickness variants for horizontal + &.ui-divider--thin { + &::before, + &::after { + height: $semantic-border-width-1; + } + } + + &.ui-divider--thick { + &::before, + &::after { + height: $semantic-border-width-2; + } + } + + // Style variants for horizontal + &.ui-divider--dashed { + &::before, + &::after { + background: none; + border-top: $semantic-border-width-1 dashed $semantic-color-border-primary; + height: 0; + } + + &.ui-divider--thin { + &::before, + &::after { + border-top-width: $semantic-border-width-1; + } + } + + &.ui-divider--thick { + &::before, + &::after { + border-top-width: $semantic-border-width-2; + } + } + } + + &.ui-divider--dotted { + &::before, + &::after { + background: none; + border-top: $semantic-border-width-1 dotted $semantic-color-border-primary; + height: 0; + } + + &.ui-divider--thin { + &::before, + &::after { + border-top-width: $semantic-border-width-1; + } + } + + &.ui-divider--thick { + &::before, + &::after { + border-top-width: $semantic-border-width-2; + } + } + } + } + + // Vertical divider + &--vertical { + height: 100%; + min-height: $semantic-spacing-layout-lg; + flex-direction: column; + width: $semantic-border-width-1; + background-color: $semantic-color-border-primary; + + // Thickness variants for vertical + &.ui-divider--thin { + width: $semantic-border-width-1; + } + + &.ui-divider--thick { + width: $semantic-border-width-2; + } + + // Style variants for vertical + &.ui-divider--dashed { + background: none; + border-left: $semantic-border-width-1 dashed $semantic-color-border-primary; + width: 0; + + &.ui-divider--thin { + border-left-width: $semantic-border-width-1; + } + + &.ui-divider--thick { + border-left-width: $semantic-border-width-2; + } + } + + &.ui-divider--dotted { + background: none; + border-left: $semantic-border-width-1 dotted $semantic-color-border-primary; + width: 0; + + &.ui-divider--thin { + border-left-width: $semantic-border-width-1; + } + + &.ui-divider--thick { + border-left-width: $semantic-border-width-2; + } + } + } + + // Content styling (only for horizontal dividers) + &__content { + padding: 0 $semantic-spacing-component-sm; + background: $semantic-color-surface-primary; + color: $semantic-color-text-secondary; + font-size: $semantic-typography-font-size-sm; + white-space: nowrap; + flex-shrink: 0; + } + + // Dark mode support + :host-context(.dark-theme) & { + &::before, + &::after { + background-color: $semantic-color-border-subtle; + } + + &--vertical { + background-color: $semantic-color-border-subtle; + } + + &--dashed, + &--dotted { + &::before, + &::after { + border-color: $semantic-color-border-subtle; + } + + &.ui-divider--vertical { + border-left-color: $semantic-color-border-subtle; + } + } + + .ui-divider__content { + background: $semantic-color-surface-primary; + color: $semantic-color-text-tertiary; + } + } + + // Responsive adjustments + @media (max-width: $semantic-breakpoint-sm - 1) { + &__content { + padding: 0 $semantic-spacing-component-xs; + font-size: $semantic-typography-font-size-xs; + } + } +} \ No newline at end of file diff --git a/src/lib/components/layout/divider/divider.component.ts b/src/lib/components/layout/divider/divider.component.ts new file mode 100644 index 0000000..666ee8b --- /dev/null +++ b/src/lib/components/layout/divider/divider.component.ts @@ -0,0 +1,45 @@ +import { Component, Input, ChangeDetectionStrategy, ViewEncapsulation } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +type DividerOrientation = 'horizontal' | 'vertical'; +type DividerVariant = 'solid' | 'dashed' | 'dotted'; +type DividerThickness = 'thin' | 'default' | 'thick'; + +@Component({ + selector: 'ui-divider', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ + @if (hasContent) { + + + + } +
+ `, + styleUrl: './divider.component.scss' +}) +export class DividerComponent { + @Input() orientation: DividerOrientation = 'horizontal'; + @Input() variant: DividerVariant = 'solid'; + @Input() thickness: DividerThickness = 'default'; + + get hasContent(): boolean { + return this.orientation === 'horizontal'; + } +} \ No newline at end of file diff --git a/src/lib/components/layout/divider/index.ts b/src/lib/components/layout/divider/index.ts new file mode 100644 index 0000000..52730b1 --- /dev/null +++ b/src/lib/components/layout/divider/index.ts @@ -0,0 +1 @@ +export * from './divider.component'; \ No newline at end of file diff --git a/src/lib/components/layout/feed-layout/feed-layout.component.scss b/src/lib/components/layout/feed-layout/feed-layout.component.scss new file mode 100644 index 0000000..e0a2891 --- /dev/null +++ b/src/lib/components/layout/feed-layout/feed-layout.component.scss @@ -0,0 +1,260 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-feed-layout { + // Core Structure + display: flex; + flex-direction: column; + position: relative; + width: 100%; + height: 100%; + + // Layout & Spacing + background: $semantic-color-surface; + + // Size Variants + &--sm { + max-width: 640px; + } + + &--md { + max-width: 768px; + } + + &--lg { + max-width: 1024px; + } + + // Loading State + &--loading { + .ui-feed-layout__container { + opacity: $semantic-opacity-subtle; + } + } + + // Refresh Enabled + &--refresh-enabled { + .ui-feed-layout__container { + touch-action: pan-y; + } + } + + // Refresh Indicator + &__refresh-indicator { + display: flex; + align-items: center; + justify-content: center; + gap: $semantic-spacing-component-sm; + padding: $semantic-spacing-component-md; + background: $semantic-color-surface-elevated; + border-bottom: $semantic-border-width-1 solid $semantic-color-border-subtle; + + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + color: $semantic-color-text-secondary; + } + + &__refresh-spinner { + width: $semantic-sizing-icon-inline; + height: $semantic-sizing-icon-inline; + border: 2px solid $semantic-color-border-subtle; + border-top-color: $semantic-color-primary; + border-radius: $semantic-border-radius-full; + animation: ui-feed-layout-spin $semantic-motion-duration-slow linear infinite; + } + + // Container + &__container { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + scroll-behavior: smooth; + -webkit-overflow-scrolling: touch; + + // Custom scrollbar + &::-webkit-scrollbar { + width: 4px; + } + + &::-webkit-scrollbar-track { + background: $semantic-color-surface-secondary; + } + + &::-webkit-scrollbar-thumb { + background: $semantic-color-border-primary; + border-radius: $semantic-border-radius-sm; + + &:hover { + background: $semantic-color-primary; + } + } + } + + // Loader + &__loader { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: $semantic-spacing-component-sm; + padding: $semantic-spacing-component-lg; + + border-top: $semantic-border-width-1 solid $semantic-color-border-subtle; + background: $semantic-color-surface-elevated; + } + + &__loader-spinner { + width: $semantic-sizing-icon-button; + height: $semantic-sizing-icon-button; + border: 2px solid $semantic-color-border-subtle; + border-top-color: $semantic-color-primary; + border-radius: $semantic-border-radius-full; + animation: ui-feed-layout-spin $semantic-motion-duration-slow linear infinite; + } + + &__loader-text { + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + color: $semantic-color-text-secondary; + } + + // Error State + &__error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: $semantic-spacing-component-md; + padding: $semantic-spacing-component-lg; + + background: $semantic-color-surface-elevated; + border: $semantic-border-width-1 solid $semantic-color-border-error; + border-radius: $semantic-border-radius-md; + margin: $semantic-spacing-component-md; + + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + color: $semantic-color-danger; + text-align: center; + } + + &__retry-button { + padding: $semantic-spacing-interactive-button-padding-y $semantic-spacing-interactive-button-padding-x; + background: $semantic-color-danger; + color: $semantic-color-on-danger; + border: none; + border-radius: $semantic-border-button-radius; + cursor: pointer; + + font-family: map-get($semantic-typography-button-small, font-family); + font-size: map-get($semantic-typography-button-small, font-size); + font-weight: map-get($semantic-typography-button-small, font-weight); + line-height: map-get($semantic-typography-button-small, line-height); + + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + + &:hover:not(:disabled) { + opacity: $semantic-opacity-hover; + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + + &:disabled { + opacity: $semantic-opacity-disabled; + cursor: not-allowed; + } + } + + // Empty State + &__empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: $semantic-spacing-layout-section-lg; + min-height: 200px; + + font-family: map-get($semantic-typography-body-large, font-family); + font-size: map-get($semantic-typography-body-large, font-size); + font-weight: map-get($semantic-typography-body-large, font-weight); + line-height: map-get($semantic-typography-body-large, line-height); + color: $semantic-color-text-tertiary; + text-align: center; + } + + // Refresh Zone (for pull-to-refresh) + &__refresh-zone { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 100px; + z-index: -1; + + &--active { + background: $semantic-color-surface-elevated; + border-bottom: 2px solid $semantic-color-primary; + } + } + + // Animation Keyframes + @keyframes ui-feed-layout-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } + } + + // Responsive Design + @media (max-width: 768px) { + &__loader, + &__error, + &__empty { + padding: $semantic-spacing-component-md; + } + + &__refresh-indicator { + padding: $semantic-spacing-component-sm; + + font-family: map-get($semantic-typography-caption, font-family); + font-size: map-get($semantic-typography-caption, font-size); + font-weight: map-get($semantic-typography-caption, font-weight); + line-height: map-get($semantic-typography-caption, line-height); + } + } + + @media (max-width: 480px) { + &--sm, + &--md, + &--lg { + max-width: 100%; + } + + &__empty { + min-height: 150px; + padding: $semantic-spacing-component-lg; + + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + } + } +} + +// Focus management for accessibility +.ui-feed-layout:focus-within { + .ui-feed-layout__container::-webkit-scrollbar-thumb { + background: $semantic-color-focus; + } +} \ No newline at end of file diff --git a/src/lib/components/layout/feed-layout/feed-layout.component.ts b/src/lib/components/layout/feed-layout/feed-layout.component.ts new file mode 100644 index 0000000..2c8647b --- /dev/null +++ b/src/lib/components/layout/feed-layout/feed-layout.component.ts @@ -0,0 +1,240 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation, ElementRef, ViewChild, OnInit, OnDestroy, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +type FeedLayoutSize = 'sm' | 'md' | 'lg'; + +export interface FeedItem { + id: string; + [key: string]: any; +} + +@Component({ + selector: 'ui-feed-layout', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ + @if (enableRefresh && isRefreshing()) { + + } + +
+ + + + @if (loading && !isRefreshing()) { +
+ + Loading more content... +
+ } + + @if (hasError) { + + } + + @if (isEmpty && !loading) { +
+ + No content available + +
+ } +
+ + @if (enableRefresh) { +
+
+ } +
+ `, + styleUrl: './feed-layout.component.scss' +}) +export class FeedLayoutComponent implements OnInit, OnDestroy { + @Input() size: FeedLayoutSize = 'md'; + @Input() loading = false; + @Input() hasError = false; + @Input() errorMessage = ''; + @Input() isEmpty = false; + @Input() enableInfiniteScroll = true; + @Input() enableRefresh = true; + @Input() scrollThreshold = 200; + @Input() refreshThreshold = 80; + @Input() ariaLabel = 'Content feed'; + + @Output() loadMore = new EventEmitter(); + @Output() refresh = new EventEmitter(); + @Output() retry = new EventEmitter(); + @Output() scrolled = new EventEmitter(); + + @ViewChild('scrollContainer', { static: true }) scrollContainer!: ElementRef; + + protected readonly isRefreshing = signal(false); + protected readonly isPullingToRefresh = signal(false); + + private touchStartY = 0; + private touchCurrentY = 0; + private scrollThrottleTimer: number | null = null; + private resizeObserver?: ResizeObserver; + + ngOnInit(): void { + this.setupResizeObserver(); + } + + ngOnDestroy(): void { + if (this.scrollThrottleTimer) { + clearTimeout(this.scrollThrottleTimer); + } + this.resizeObserver?.disconnect(); + } + + getFeedLayoutClasses(): string { + const classes = [ + 'ui-feed-layout', + `ui-feed-layout--${this.size}`, + ]; + + if (this.loading) { + classes.push('ui-feed-layout--loading'); + } + + if (this.enableRefresh) { + classes.push('ui-feed-layout--refresh-enabled'); + } + + return classes.join(' '); + } + + private setupResizeObserver(): void { + if (typeof ResizeObserver !== 'undefined') { + this.resizeObserver = new ResizeObserver(() => { + this.checkScrollPosition(); + }); + this.resizeObserver.observe(this.scrollContainer.nativeElement); + } + } + + handleScroll(event: Event): void { + this.scrolled.emit(event); + + if (!this.enableInfiniteScroll || this.loading || this.hasError) { + return; + } + + if (this.scrollThrottleTimer) { + clearTimeout(this.scrollThrottleTimer); + } + + this.scrollThrottleTimer = window.setTimeout(() => { + this.checkScrollPosition(); + }, 100); + } + + private checkScrollPosition(): void { + const container = this.scrollContainer.nativeElement; + const scrollTop = container.scrollTop; + const scrollHeight = container.scrollHeight; + const clientHeight = container.clientHeight; + + if (scrollTop + clientHeight >= scrollHeight - this.scrollThreshold) { + this.loadMore.emit(); + } + } + + handleTouchStart(event: TouchEvent): void { + if (!this.enableRefresh) return; + + this.touchStartY = event.touches[0].clientY; + } + + handleTouchMove(event: TouchEvent): void { + if (!this.enableRefresh || this.scrollContainer.nativeElement.scrollTop > 0) { + return; + } + + this.touchCurrentY = event.touches[0].clientY; + const pullDistance = this.touchCurrentY - this.touchStartY; + + if (pullDistance > 0) { + this.isPullingToRefresh.set(pullDistance > this.refreshThreshold); + + if (pullDistance > this.refreshThreshold) { + event.preventDefault(); + } + } + } + + handleTouchEnd(event: TouchEvent): void { + if (!this.enableRefresh) return; + + const pullDistance = this.touchCurrentY - this.touchStartY; + + if (pullDistance > this.refreshThreshold && !this.loading) { + this.triggerRefresh(); + } + + this.isPullingToRefresh.set(false); + this.touchStartY = 0; + this.touchCurrentY = 0; + } + + private triggerRefresh(): void { + this.isRefreshing.set(true); + this.refresh.emit(); + + // Auto-reset refreshing state after 2 seconds if not manually reset + setTimeout(() => { + if (this.isRefreshing()) { + this.isRefreshing.set(false); + } + }, 2000); + } + + handleRetry(): void { + this.retry.emit(); + } + + // Public method to reset refresh state + resetRefresh(): void { + this.isRefreshing.set(false); + } + + // Public method to scroll to top + scrollToTop(): void { + this.scrollContainer.nativeElement.scrollTo({ + top: 0, + behavior: 'smooth' + }); + } +} \ No newline at end of file diff --git a/src/lib/components/layout/feed-layout/index.ts b/src/lib/components/layout/feed-layout/index.ts new file mode 100644 index 0000000..d5c4306 --- /dev/null +++ b/src/lib/components/layout/feed-layout/index.ts @@ -0,0 +1 @@ +export * from './feed-layout.component'; \ No newline at end of file diff --git a/src/lib/components/layout/flex/flex.component.scss b/src/lib/components/layout/flex/flex.component.scss new file mode 100644 index 0000000..3f27037 --- /dev/null +++ b/src/lib/components/layout/flex/flex.component.scss @@ -0,0 +1,190 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-flex { + // Core flexbox container + display: flex; + box-sizing: border-box; + + // Direction variants + &--row { + flex-direction: row; + } + + &--row-reverse { + flex-direction: row-reverse; + } + + &--column { + flex-direction: column; + } + + &--column-reverse { + flex-direction: column-reverse; + } + + // Wrap variants + &--nowrap { + flex-wrap: nowrap; + } + + &--wrap { + flex-wrap: wrap; + } + + &--wrap-reverse { + flex-wrap: wrap-reverse; + } + + // Justify content variants + &--justify-start { + justify-content: flex-start; + } + + &--justify-end { + justify-content: flex-end; + } + + &--justify-center { + justify-content: center; + } + + &--justify-between { + justify-content: space-between; + } + + &--justify-around { + justify-content: space-around; + } + + &--justify-evenly { + justify-content: space-evenly; + } + + // Align items variants + &--align-start { + align-items: flex-start; + } + + &--align-end { + align-items: flex-end; + } + + &--align-center { + align-items: center; + } + + &--align-baseline { + align-items: baseline; + } + + &--align-stretch { + align-items: stretch; + } + + // Align content variants (for wrapped flex containers) + &--align-content-start { + align-content: flex-start; + } + + &--align-content-end { + align-content: flex-end; + } + + &--align-content-center { + align-content: center; + } + + &--align-content-between { + align-content: space-between; + } + + &--align-content-around { + align-content: space-around; + } + + &--align-content-stretch { + align-content: stretch; + } + + // Gap variants using semantic spacing tokens + &--gap-xs { + gap: $semantic-spacing-component-xs; + } + + &--gap-sm { + gap: $semantic-spacing-component-sm; + } + + &--gap-md { + gap: $semantic-spacing-component-md; + } + + &--gap-lg { + gap: $semantic-spacing-component-lg; + } + + &--gap-xl { + gap: $semantic-spacing-component-xl; + } + + // Row gap variants + &--row-gap-xs { + row-gap: $semantic-spacing-component-xs; + } + + &--row-gap-sm { + row-gap: $semantic-spacing-component-sm; + } + + &--row-gap-md { + row-gap: $semantic-spacing-component-md; + } + + &--row-gap-lg { + row-gap: $semantic-spacing-component-lg; + } + + &--row-gap-xl { + row-gap: $semantic-spacing-component-xl; + } + + // Column gap variants + &--column-gap-xs { + column-gap: $semantic-spacing-component-xs; + } + + &--column-gap-sm { + column-gap: $semantic-spacing-component-sm; + } + + &--column-gap-md { + column-gap: $semantic-spacing-component-md; + } + + &--column-gap-lg { + column-gap: $semantic-spacing-component-lg; + } + + &--column-gap-xl { + column-gap: $semantic-spacing-component-xl; + } + + // Inline flex variant + &--inline { + display: inline-flex; + } + + // Full width/height variants + &--full-width { + width: 100%; + } + + &--full-height { + height: 100%; + } + + &--full { + width: 100%; + height: 100%; + } +} \ No newline at end of file diff --git a/src/lib/components/layout/flex/flex.component.ts b/src/lib/components/layout/flex/flex.component.ts new file mode 100644 index 0000000..b88a187 --- /dev/null +++ b/src/lib/components/layout/flex/flex.component.ts @@ -0,0 +1,112 @@ +import { Component, Input, ChangeDetectionStrategy, ViewEncapsulation } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +type FlexDirection = 'row' | 'row-reverse' | 'column' | 'column-reverse'; +type FlexWrap = 'nowrap' | 'wrap' | 'wrap-reverse'; +type FlexJustify = 'start' | 'end' | 'center' | 'between' | 'around' | 'evenly'; +type FlexAlign = 'start' | 'end' | 'center' | 'baseline' | 'stretch'; +type FlexAlignContent = 'start' | 'end' | 'center' | 'between' | 'around' | 'stretch'; +type FlexGap = 'xs' | 'sm' | 'md' | 'lg' | 'xl'; + +@Component({ + selector: 'ui-flex', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ +
+ `, + styleUrl: './flex.component.scss' +}) +export class FlexComponent { + // Flexbox properties + @Input() direction: FlexDirection = 'row'; + @Input() wrap: FlexWrap = 'nowrap'; + @Input() justify: FlexJustify = 'start'; + @Input() align: FlexAlign = 'stretch'; + @Input() alignContent?: FlexAlignContent; + + // Gap properties + @Input() gap?: FlexGap; + @Input() rowGap?: FlexGap; + @Input() columnGap?: FlexGap; + + // Display variants + @Input() inline = false; + + // Size variants + @Input() fullWidth = false; + @Input() fullHeight = false; + @Input() full = false; + + // Accessibility + @Input() role?: string; + + getClasses(): Record { + const classes: Record = { + 'ui-flex': true + }; + + // Direction + if (this.direction) { + classes[`ui-flex--${this.direction}`] = true; + } + + // Wrap + if (this.wrap) { + classes[`ui-flex--${this.wrap}`] = true; + } + + // Justify content + if (this.justify) { + classes[`ui-flex--justify-${this.justify}`] = true; + } + + // Align items + if (this.align) { + classes[`ui-flex--align-${this.align}`] = true; + } + + // Align content (for wrapped containers) + if (this.alignContent) { + classes[`ui-flex--align-content-${this.alignContent}`] = true; + } + + // Gap properties + if (this.gap) { + classes[`ui-flex--gap-${this.gap}`] = true; + } + + if (this.rowGap) { + classes[`ui-flex--row-gap-${this.rowGap}`] = true; + } + + if (this.columnGap) { + classes[`ui-flex--column-gap-${this.columnGap}`] = true; + } + + // Display variant + if (this.inline) { + classes['ui-flex--inline'] = true; + } + + // Size variants + if (this.full) { + classes['ui-flex--full'] = true; + } else { + if (this.fullWidth) { + classes['ui-flex--full-width'] = true; + } + if (this.fullHeight) { + classes['ui-flex--full-height'] = true; + } + } + + return classes; + } +} \ No newline at end of file diff --git a/src/lib/components/layout/flex/index.ts b/src/lib/components/layout/flex/index.ts new file mode 100644 index 0000000..25ae054 --- /dev/null +++ b/src/lib/components/layout/flex/index.ts @@ -0,0 +1 @@ +export * from './flex.component'; \ No newline at end of file diff --git a/src/lib/components/layout/gallery-grid/gallery-grid.component.scss b/src/lib/components/layout/gallery-grid/gallery-grid.component.scss new file mode 100644 index 0000000..ca1cd3a --- /dev/null +++ b/src/lib/components/layout/gallery-grid/gallery-grid.component.scss @@ -0,0 +1,308 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-gallery-grid { + // Core Structure + display: grid; + position: relative; + width: 100%; + + // Layout & Spacing + gap: $semantic-spacing-grid-gap-md; + padding: $semantic-spacing-component-md; + + // Visual Design + background: $semantic-color-surface-primary; + border-radius: $semantic-border-radius-md; + + // Grid Sizing Variants + &--columns-1 { + grid-template-columns: 1fr; + } + + &--columns-2 { + grid-template-columns: repeat(2, 1fr); + } + + &--columns-3 { + grid-template-columns: repeat(3, 1fr); + } + + &--columns-4 { + grid-template-columns: repeat(4, 1fr); + } + + &--columns-5 { + grid-template-columns: repeat(5, 1fr); + } + + &--columns-6 { + grid-template-columns: repeat(6, 1fr); + } + + // Gap Size Variants + &--gap-sm { + gap: $semantic-spacing-grid-gap-sm; + } + + &--gap-md { + gap: $semantic-spacing-grid-gap-md; + } + + &--gap-lg { + gap: $semantic-spacing-grid-gap-lg; + } + + // Responsive Grid Auto-fit + &--auto-fit { + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + } + + // Masonry-style layout option + &--masonry { + grid-auto-rows: min-content; + align-items: start; + } + + // Gallery Item + &__item { + position: relative; + overflow: hidden; + border-radius: $semantic-border-radius-md; + background: $semantic-color-surface-secondary; + border: $semantic-border-width-1 solid $semantic-color-border-subtle; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + cursor: pointer; + + // Interactive States + &:hover { + transform: translateY(-2px); + box-shadow: $semantic-shadow-elevation-3; + border-color: $semantic-color-border-primary; + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + + &:active { + transform: translateY(0); + box-shadow: $semantic-shadow-elevation-1; + } + + // Loading State + &--loading { + opacity: $semantic-opacity-subtle; + pointer-events: none; + + &::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 20px; + height: 20px; + margin: -10px 0 0 -10px; + border: 2px solid $semantic-color-border-subtle; + border-top-color: $semantic-color-primary; + border-radius: $semantic-border-radius-full; + animation: ui-gallery-grid-spin 1s linear infinite; + } + } + + // Selected State + &--selected { + border-color: $semantic-color-primary; + box-shadow: $semantic-shadow-elevation-2; + + &::before { + content: ''; + position: absolute; + top: $semantic-spacing-component-xs; + right: $semantic-spacing-component-xs; + width: 20px; + height: 20px; + background: $semantic-color-primary; + border-radius: $semantic-border-radius-full; + z-index: 2; + } + + &::after { + content: '✓'; + position: absolute; + top: $semantic-spacing-component-xs; + right: $semantic-spacing-component-xs; + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + color: $semantic-color-on-primary; + font-size: $semantic-typography-font-size-xs; + font-weight: $semantic-typography-font-weight-bold; + z-index: 3; + } + } + } + + // Image within gallery item + &__image { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + transition: transform $semantic-motion-duration-fast $semantic-motion-easing-ease; + + &--contain { + object-fit: contain; + } + + &--fill { + object-fit: fill; + } + + &--scale-down { + object-fit: scale-down; + } + } + + // Overlay for additional information + &__overlay { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: linear-gradient(to top, rgba(0, 0, 0, 0.8), transparent); + color: $semantic-color-text-inverse; + padding: $semantic-spacing-component-md; + opacity: 0; + transform: translateY(100%); + transition: all $semantic-motion-duration-normal $semantic-motion-easing-ease; + + .ui-gallery-grid__item:hover & { + opacity: 1; + transform: translateY(0); + } + } + + // Overlay content + &__title { + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: $semantic-typography-font-weight-semibold; + line-height: map-get($semantic-typography-body-medium, line-height); + color: $semantic-color-text-inverse; + margin-bottom: $semantic-spacing-content-line-tight; + } + + &__caption { + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + color: $semantic-color-text-inverse; + opacity: $semantic-opacity-subtle; + } + + // Lightbox trigger indicator + &__zoom-indicator { + position: absolute; + top: $semantic-spacing-component-xs; + left: $semantic-spacing-component-xs; + width: 24px; + height: 24px; + background: rgba(0, 0, 0, 0.6); + color: $semantic-color-text-inverse; + border-radius: $semantic-border-radius-sm; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity $semantic-motion-duration-fast $semantic-motion-easing-ease; + z-index: 2; + + .ui-gallery-grid__item:hover & { + opacity: 1; + } + + svg { + width: 16px; + height: 16px; + fill: currentColor; + } + } + + // Empty state + &__empty { + grid-column: 1 / -1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: $semantic-spacing-layout-section-lg; + color: $semantic-color-text-secondary; + text-align: center; + + &-icon { + width: 48px; + height: 48px; + margin-bottom: $semantic-spacing-component-md; + opacity: $semantic-opacity-subtle; + } + + &-text { + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + } + } + + // Responsive Design + @media (max-width: $semantic-breakpoint-lg - 1) { + &--columns-6 { + grid-template-columns: repeat(4, 1fr); + } + + &--columns-5 { + grid-template-columns: repeat(3, 1fr); + } + } + + @media (max-width: $semantic-breakpoint-md - 1) { + padding: $semantic-spacing-component-sm; + gap: $semantic-spacing-grid-gap-sm; + + &--columns-6, + &--columns-5, + &--columns-4 { + grid-template-columns: repeat(2, 1fr); + } + + &--columns-3 { + grid-template-columns: repeat(2, 1fr); + } + } + + @media (max-width: $semantic-breakpoint-sm - 1) { + padding: $semantic-spacing-component-xs; + + &--columns-6, + &--columns-5, + &--columns-4, + &--columns-3, + &--columns-2 { + grid-template-columns: 1fr; + } + + &--auto-fit { + grid-template-columns: 1fr; + } + } +} + +// Loading animation keyframes +@keyframes ui-gallery-grid-spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} \ No newline at end of file diff --git a/src/lib/components/layout/gallery-grid/gallery-grid.component.ts b/src/lib/components/layout/gallery-grid/gallery-grid.component.ts new file mode 100644 index 0000000..1804eca --- /dev/null +++ b/src/lib/components/layout/gallery-grid/gallery-grid.component.ts @@ -0,0 +1,274 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +export type GalleryGridColumns = 1 | 2 | 3 | 4 | 5 | 6 | 'auto-fit'; +export type GalleryGridGap = 'sm' | 'md' | 'lg'; +export type GalleryGridObjectFit = 'cover' | 'contain' | 'fill' | 'scale-down'; + +export interface GalleryGridItem { + id: string | number; + src: string; + alt?: string; + title?: string; + caption?: string; + thumbnail?: string; + loading?: boolean; + selected?: boolean; +} + +@Component({ + selector: 'ui-gallery-grid', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` + + `, + styleUrl: './gallery-grid.component.scss' +}) +export class GalleryGridComponent { + @Input() items: GalleryGridItem[] = []; + @Input() columns: GalleryGridColumns = 3; + @Input() gap: GalleryGridGap = 'md'; + @Input() objectFit: GalleryGridObjectFit = 'cover'; + @Input() masonry = false; + @Input() showOverlay = true; + @Input() showZoomIndicator = true; + @Input() lazyLoading = true; + @Input() disabled = false; + @Input() emptyText = 'No images to display'; + @Input() ariaLabel?: string; + + @Output() itemClick = new EventEmitter<{ item: GalleryGridItem; event: MouseEvent }>(); + @Output() itemSelect = new EventEmitter(); + @Output() imageLoad = new EventEmitter<{ item: GalleryGridItem; event: Event }>(); + @Output() imageError = new EventEmitter<{ item: GalleryGridItem; event: Event }>(); + + handleItemClick(item: GalleryGridItem, event: MouseEvent): void { + if (this.disabled || item.loading) { + return; + } + + event.preventDefault(); + this.itemClick.emit({ item, event }); + + // Toggle selection if item supports it + if (item.selected !== undefined) { + this.itemSelect.emit(item); + } + } + + handleItemKeydown(item: GalleryGridItem, event: KeyboardEvent): void { + if (this.disabled || item.loading) { + return; + } + + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + this.handleItemClick(item, event as any); + } + + // Arrow key navigation + if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) { + event.preventDefault(); + this.handleArrowNavigation(item, event.key); + } + } + + handleImageLoad(item: GalleryGridItem, event: Event): void { + this.imageLoad.emit({ item, event }); + } + + handleImageError(item: GalleryGridItem, event: Event): void { + this.imageError.emit({ item, event }); + } + + private handleArrowNavigation(currentItem: GalleryGridItem, key: string): void { + const currentIndex = this.items.findIndex(item => item.id === currentItem.id); + if (currentIndex === -1) return; + + let nextIndex: number; + const columnsNum = typeof this.columns === 'number' ? this.columns : 3; + + switch (key) { + case 'ArrowLeft': + nextIndex = currentIndex > 0 ? currentIndex - 1 : this.items.length - 1; + break; + case 'ArrowRight': + nextIndex = currentIndex < this.items.length - 1 ? currentIndex + 1 : 0; + break; + case 'ArrowUp': + nextIndex = currentIndex - columnsNum; + if (nextIndex < 0) { + nextIndex = Math.floor((this.items.length - 1) / columnsNum) * columnsNum + (currentIndex % columnsNum); + if (nextIndex >= this.items.length) { + nextIndex -= columnsNum; + } + } + break; + case 'ArrowDown': + nextIndex = currentIndex + columnsNum; + if (nextIndex >= this.items.length) { + nextIndex = currentIndex % columnsNum; + } + break; + default: + return; + } + + // Focus the next item + this.focusItem(nextIndex); + } + + private focusItem(index: number): void { + // This would ideally use ViewChild to focus the specific item + // For now, we'll rely on the browser's tab navigation + setTimeout(() => { + const items = document.querySelectorAll('.ui-gallery-grid__item'); + const targetItem = items[index] as HTMLElement; + if (targetItem) { + targetItem.focus(); + } + }, 0); + } + + getGridClasses(): string { + const columnsClass = typeof this.columns === 'number' + ? `ui-gallery-grid--columns-${this.columns}` + : `ui-gallery-grid--${this.columns}`; + const gapClass = `ui-gallery-grid--gap-${this.gap}`; + + return `${columnsClass} ${gapClass}`; + } + + getImageClasses(): string { + return `ui-gallery-grid__image--${this.objectFit}`; + } + + getItemAriaLabel(item: GalleryGridItem): string { + const parts: string[] = []; + + if (item.title) { + parts.push(item.title); + } + + if (item.caption) { + parts.push(item.caption); + } + + if (item.alt) { + parts.push(item.alt); + } + + if (parts.length === 0) { + parts.push('Gallery image'); + } + + if (item.selected) { + parts.push('selected'); + } + + if (item.loading) { + parts.push('loading'); + } + + return parts.join(', '); + } + + // Utility methods for external use + selectItem(itemId: string | number): void { + const item = this.items.find(i => i.id === itemId); + if (item) { + item.selected = true; + this.itemSelect.emit(item); + } + } + + deselectItem(itemId: string | number): void { + const item = this.items.find(i => i.id === itemId); + if (item) { + item.selected = false; + } + } + + selectAll(): void { + this.items.forEach(item => { + item.selected = true; + }); + } + + deselectAll(): void { + this.items.forEach(item => { + item.selected = false; + }); + } + + getSelectedItems(): GalleryGridItem[] { + return this.items.filter(item => item.selected); + } +} \ No newline at end of file diff --git a/src/lib/components/layout/gallery-grid/index.ts b/src/lib/components/layout/gallery-grid/index.ts new file mode 100644 index 0000000..7971f56 --- /dev/null +++ b/src/lib/components/layout/gallery-grid/index.ts @@ -0,0 +1 @@ +export * from './gallery-grid.component'; \ No newline at end of file diff --git a/src/lib/components/layout/grid-container/grid-container.component.scss b/src/lib/components/layout/grid-container/grid-container.component.scss new file mode 100644 index 0000000..dbf2777 --- /dev/null +++ b/src/lib/components/layout/grid-container/grid-container.component.scss @@ -0,0 +1,315 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-grid-container { + // Core Structure + display: grid; + position: relative; + width: 100%; + + // Layout & Spacing + gap: $semantic-spacing-grid-gap-md; + padding: $semantic-spacing-component-md; + + // Visual Design + background: $semantic-color-surface-primary; + border-radius: $semantic-border-card-radius; + + // Sizing Variants + &--gap-sm { + gap: $semantic-spacing-grid-gap-sm; + } + + &--gap-md { + gap: $semantic-spacing-grid-gap-md; + } + + &--gap-lg { + gap: $semantic-spacing-grid-gap-lg; + } + + // Padding Variants + &--padding-none { + padding: 0; + } + + &--padding-sm { + padding: $semantic-spacing-component-sm; + } + + &--padding-md { + padding: $semantic-spacing-component-md; + } + + &--padding-lg { + padding: $semantic-spacing-component-lg; + } + + // Column Variants - Auto Grid + &--auto-fit { + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + } + + &--auto-fill { + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + } + + // Responsive Column Templates + &--cols-1 { + grid-template-columns: 1fr; + } + + &--cols-2 { + grid-template-columns: repeat(2, 1fr); + } + + &--cols-3 { + grid-template-columns: repeat(3, 1fr); + } + + &--cols-4 { + grid-template-columns: repeat(4, 1fr); + } + + &--cols-5 { + grid-template-columns: repeat(5, 1fr); + } + + &--cols-6 { + grid-template-columns: repeat(6, 1fr); + } + + // Row Variants + &--rows-auto { + grid-auto-rows: auto; + } + + &--rows-equal { + grid-auto-rows: 1fr; + } + + &--rows-min-content { + grid-auto-rows: min-content; + } + + &--rows-max-content { + grid-auto-rows: max-content; + } + + // Dense Grid for Auto Placement + &--dense { + grid-auto-flow: dense; + } + + // Alignment Options + &--justify-start { + justify-content: start; + } + + &--justify-center { + justify-content: center; + } + + &--justify-end { + justify-content: end; + } + + &--justify-space-between { + justify-content: space-between; + } + + &--justify-space-around { + justify-content: space-around; + } + + &--justify-space-evenly { + justify-content: space-evenly; + } + + &--align-start { + align-content: start; + } + + &--align-center { + align-content: center; + } + + &--align-end { + align-content: end; + } + + &--align-space-between { + align-content: space-between; + } + + &--align-space-around { + align-content: space-around; + } + + &--align-space-evenly { + align-content: space-evenly; + } + + // Item Alignment + &--items-start { + align-items: start; + } + + &--items-center { + align-items: center; + } + + &--items-end { + align-items: end; + } + + &--items-stretch { + align-items: stretch; + } + + // Responsive Behavior + @media (max-width: 1024px) { + &--cols-6 { + grid-template-columns: repeat(4, 1fr); + } + + &--cols-5 { + grid-template-columns: repeat(3, 1fr); + } + + &--cols-4 { + grid-template-columns: repeat(2, 1fr); + } + } + + @media (max-width: 768px) { + gap: $semantic-spacing-grid-gap-sm; + padding: $semantic-spacing-component-sm; + + &--cols-6, + &--cols-5, + &--cols-4, + &--cols-3 { + grid-template-columns: repeat(2, 1fr); + } + } + + @media (max-width: 480px) { + gap: $semantic-spacing-component-xs; + padding: $semantic-spacing-component-xs; + + &--cols-6, + &--cols-5, + &--cols-4, + &--cols-3, + &--cols-2 { + grid-template-columns: 1fr; + } + + &--auto-fit, + &--auto-fill { + grid-template-columns: 1fr; + } + } +} + +// Grid Item Utilities +.ui-grid-item { + // Span Utilities + &--span-1 { + grid-column: span 1; + } + + &--span-2 { + grid-column: span 2; + } + + &--span-3 { + grid-column: span 3; + } + + &--span-4 { + grid-column: span 4; + } + + &--span-5 { + grid-column: span 5; + } + + &--span-6 { + grid-column: span 6; + } + + &--span-full { + grid-column: 1 / -1; + } + + // Row Span Utilities + &--row-span-1 { + grid-row: span 1; + } + + &--row-span-2 { + grid-row: span 2; + } + + &--row-span-3 { + grid-row: span 3; + } + + &--row-span-4 { + grid-row: span 4; + } + + // Item Alignment + &--justify-self-start { + justify-self: start; + } + + &--justify-self-center { + justify-self: center; + } + + &--justify-self-end { + justify-self: end; + } + + &--justify-self-stretch { + justify-self: stretch; + } + + &--align-self-start { + align-self: start; + } + + &--align-self-center { + align-self: center; + } + + &--align-self-end { + align-self: end; + } + + &--align-self-stretch { + align-self: stretch; + } + + // Responsive Item Spans + @media (max-width: 768px) { + &--span-6, + &--span-5, + &--span-4, + &--span-3 { + grid-column: span 2; + } + } + + @media (max-width: 480px) { + &--span-6, + &--span-5, + &--span-4, + &--span-3, + &--span-2 { + grid-column: span 1; + } + } +} \ No newline at end of file diff --git a/src/lib/components/layout/grid-container/grid-container.component.ts b/src/lib/components/layout/grid-container/grid-container.component.ts new file mode 100644 index 0000000..1daed06 --- /dev/null +++ b/src/lib/components/layout/grid-container/grid-container.component.ts @@ -0,0 +1,86 @@ +import { Component, Input, ChangeDetectionStrategy, ViewEncapsulation } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +type GridColumns = 1 | 2 | 3 | 4 | 5 | 6 | 'auto-fit' | 'auto-fill'; +type GridGap = 'sm' | 'md' | 'lg'; +type GridPadding = 'none' | 'sm' | 'md' | 'lg'; +type GridRowMode = 'auto' | 'equal' | 'min-content' | 'max-content'; +type GridJustifyContent = 'start' | 'center' | 'end' | 'space-between' | 'space-around' | 'space-evenly'; +type GridAlignContent = 'start' | 'center' | 'end' | 'space-between' | 'space-around' | 'space-evenly'; +type GridAlignItems = 'start' | 'center' | 'end' | 'stretch'; + +@Component({ + selector: 'ui-grid-container', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ + +
+ `, + styleUrl: './grid-container.component.scss' +}) +export class GridContainerComponent { + @Input() columns: GridColumns = 'auto-fit'; + @Input() gap: GridGap = 'md'; + @Input() padding: GridPadding = 'md'; + @Input() rowMode: GridRowMode = 'auto'; + @Input() dense = false; + @Input() justifyContent: GridJustifyContent | null = null; + @Input() alignContent: GridAlignContent | null = null; + @Input() alignItems: GridAlignItems = 'stretch'; + + // Advanced CSS Grid Properties + @Input() customColumns: string | null = null; + @Input() customRows: string | null = null; + @Input() templateAreas: string | null = null; + + // Accessibility + @Input() role: string = 'grid'; + @Input() ariaLabel: string | null = null; +} \ No newline at end of file diff --git a/src/lib/components/layout/grid-container/index.ts b/src/lib/components/layout/grid-container/index.ts new file mode 100644 index 0000000..774633f --- /dev/null +++ b/src/lib/components/layout/grid-container/index.ts @@ -0,0 +1 @@ +export * from './grid-container.component'; \ No newline at end of file diff --git a/src/lib/components/layout/grid-system/grid-system.component.scss b/src/lib/components/layout/grid-system/grid-system.component.scss new file mode 100644 index 0000000..f3cb55d --- /dev/null +++ b/src/lib/components/layout/grid-system/grid-system.component.scss @@ -0,0 +1,242 @@ +@use 'ui-design-system/src/styles/semantic' as tokens; + +.ui-grid-system { + display: grid; + position: relative; + width: 100%; + + // Base grid layout + gap: tokens.$semantic-spacing-grid-gap-md; + + // Size variants + &--gap-xs { + gap: tokens.$semantic-spacing-grid-gap-xs; + } + + &--gap-sm { + gap: tokens.$semantic-spacing-grid-gap-sm; + } + + &--gap-md { + gap: tokens.$semantic-spacing-grid-gap-md; + } + + &--gap-lg { + gap: tokens.$semantic-spacing-grid-gap-lg; + } + + &--gap-xl { + gap: tokens.$semantic-spacing-grid-gap-xl; + } + + // Column variants + &--cols-1 { + grid-template-columns: repeat(1, 1fr); + } + + &--cols-2 { + grid-template-columns: repeat(2, 1fr); + } + + &--cols-3 { + grid-template-columns: repeat(3, 1fr); + } + + &--cols-4 { + grid-template-columns: repeat(4, 1fr); + } + + &--cols-6 { + grid-template-columns: repeat(6, 1fr); + } + + &--cols-12 { + grid-template-columns: repeat(12, 1fr); + } + + // Auto-fill responsive columns + &--auto-fill { + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + } + + &--auto-fit { + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + } + + // Row gap variants + &--row-gap-xs { + row-gap: tokens.$semantic-spacing-grid-gap-xs; + } + + &--row-gap-sm { + row-gap: tokens.$semantic-spacing-grid-gap-sm; + } + + &--row-gap-md { + row-gap: tokens.$semantic-spacing-grid-gap-md; + } + + &--row-gap-lg { + row-gap: tokens.$semantic-spacing-grid-gap-lg; + } + + &--row-gap-xl { + row-gap: tokens.$semantic-spacing-grid-gap-xl; + } + + // Column gap variants + &--column-gap-xs { + column-gap: tokens.$semantic-spacing-grid-gap-xs; + } + + &--column-gap-sm { + column-gap: tokens.$semantic-spacing-grid-gap-sm; + } + + &--column-gap-md { + column-gap: tokens.$semantic-spacing-grid-gap-md; + } + + &--column-gap-lg { + column-gap: tokens.$semantic-spacing-grid-gap-lg; + } + + &--column-gap-xl { + column-gap: tokens.$semantic-spacing-grid-gap-xl; + } + + // Alignment variants + &--justify-start { + justify-items: start; + } + + &--justify-end { + justify-items: end; + } + + &--justify-center { + justify-items: center; + } + + &--justify-stretch { + justify-items: stretch; + } + + &--align-start { + align-items: start; + } + + &--align-end { + align-items: end; + } + + &--align-center { + align-items: center; + } + + &--align-stretch { + align-items: stretch; + } + + // Content alignment + &--justify-content-start { + justify-content: start; + } + + &--justify-content-end { + justify-content: end; + } + + &--justify-content-center { + justify-content: center; + } + + &--justify-content-stretch { + justify-content: stretch; + } + + &--justify-content-space-around { + justify-content: space-around; + } + + &--justify-content-space-between { + justify-content: space-between; + } + + &--justify-content-space-evenly { + justify-content: space-evenly; + } + + &--align-content-start { + align-content: start; + } + + &--align-content-end { + align-content: end; + } + + &--align-content-center { + align-content: center; + } + + &--align-content-stretch { + align-content: stretch; + } + + &--align-content-space-around { + align-content: space-around; + } + + &--align-content-space-between { + align-content: space-between; + } + + &--align-content-space-evenly { + align-content: space-evenly; + } + + // Dense packing + &--auto-rows-min { + grid-auto-rows: min-content; + } + + &--auto-rows-max { + grid-auto-rows: max-content; + } + + &--auto-rows-fr { + grid-auto-rows: 1fr; + } + + &--dense { + grid-auto-flow: dense; + } + + // Responsive behavior + @media (max-width: 768px) { + &--responsive { + grid-template-columns: 1fr; + gap: tokens.$semantic-spacing-grid-gap-sm; + } + + &--cols-12, + &--cols-6, + &--cols-4 { + grid-template-columns: repeat(2, 1fr); + } + } + + @media (max-width: 480px) { + &--responsive { + gap: tokens.$semantic-spacing-grid-gap-xs; + } + + &--cols-12, + &--cols-6, + &--cols-4, + &--cols-3, + &--cols-2 { + grid-template-columns: 1fr; + } + } +} \ No newline at end of file diff --git a/src/lib/components/layout/grid-system/grid-system.component.ts b/src/lib/components/layout/grid-system/grid-system.component.ts new file mode 100644 index 0000000..ce741c1 --- /dev/null +++ b/src/lib/components/layout/grid-system/grid-system.component.ts @@ -0,0 +1,54 @@ +import { Component, Input, ChangeDetectionStrategy, ViewEncapsulation } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +type GridColumns = 1 | 2 | 3 | 4 | 6 | 12 | 'auto-fill' | 'auto-fit'; +type GridGap = 'xs' | 'sm' | 'md' | 'lg' | 'xl'; +type GridAlignment = 'start' | 'end' | 'center' | 'stretch'; +type GridContentAlignment = 'start' | 'end' | 'center' | 'stretch' | 'space-around' | 'space-between' | 'space-evenly'; +type GridAutoRows = 'min' | 'max' | 'fr'; + +@Component({ + selector: 'ui-grid-system', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ + +
+ `, + styleUrl: './grid-system.component.scss' +}) +export class GridSystemComponent { + @Input() columns: GridColumns = 'auto-fit'; + @Input() gap: GridGap = 'md'; + @Input() rowGap?: GridGap; + @Input() columnGap?: GridGap; + @Input() justifyItems?: GridAlignment; + @Input() alignItems?: GridAlignment; + @Input() justifyContent?: GridContentAlignment; + @Input() alignContent?: GridContentAlignment; + @Input() autoRows?: GridAutoRows; + @Input() dense = false; + @Input() responsive = true; + @Input() customColumns?: string; + @Input() customRows?: string; + @Input() role = 'grid'; +} \ No newline at end of file diff --git a/src/lib/components/layout/grid-system/index.ts b/src/lib/components/layout/grid-system/index.ts new file mode 100644 index 0000000..3f0f303 --- /dev/null +++ b/src/lib/components/layout/grid-system/index.ts @@ -0,0 +1 @@ +export * from './grid-system.component'; \ No newline at end of file diff --git a/src/lib/components/layout/hstack/hstack.component.scss b/src/lib/components/layout/hstack/hstack.component.scss new file mode 100644 index 0000000..48e2295 --- /dev/null +++ b/src/lib/components/layout/hstack/hstack.component.scss @@ -0,0 +1,165 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-hstack { + display: flex; + flex-direction: row; + position: relative; + + // Inline variant + &--inline { + display: inline-flex; + } + + // Full width variant + &--full-width { + width: 100%; + } + + // Spacing variants - using semantic stack spacing tokens + &--spacing-xs { + gap: $semantic-spacing-stack-xs; + } + + &--spacing-sm { + gap: $semantic-spacing-stack-sm; + } + + &--spacing-md { + gap: $semantic-spacing-stack-md; + } + + &--spacing-lg { + gap: $semantic-spacing-stack-lg; + } + + &--spacing-xl { + gap: $semantic-spacing-stack-xl; + } + + &--spacing-2xl { + gap: $semantic-spacing-2xl; + } + + &--spacing-3xl { + gap: $semantic-spacing-3xl; + } + + &--spacing-4xl { + gap: $semantic-spacing-4xl; + } + + &--spacing-5xl { + gap: $semantic-spacing-5xl; + } + + // Vertical alignment (cross-axis for row) + &--align-start { + align-items: flex-start; + } + + &--align-center { + align-items: center; + } + + &--align-end { + align-items: flex-end; + } + + &--align-stretch { + align-items: stretch; + } + + &--align-baseline { + align-items: baseline; + } + + // Horizontal justify (main-axis for row) + &--justify-start { + justify-content: flex-start; + } + + &--justify-center { + justify-content: center; + } + + &--justify-end { + justify-content: flex-end; + } + + &--justify-between { + justify-content: space-between; + } + + &--justify-around { + justify-content: space-around; + } + + &--justify-evenly { + justify-content: space-evenly; + } + + // Wrap variants + &--wrap-wrap { + flex-wrap: wrap; + } + + &--wrap-nowrap { + flex-wrap: nowrap; + } + + &--wrap-wrap-reverse { + flex-wrap: wrap-reverse; + } + + // Divider variant - adds vertical borders between children + &--divider { + > :not(:last-child) { + border-right: $semantic-border-width-1 solid $semantic-color-border-secondary; + padding-right: $semantic-spacing-component-sm; + margin-right: $semantic-spacing-component-sm; + } + + // Remove gap when using dividers to avoid double spacing + gap: 0; + } + + // Responsive behavior + &--responsive { + @media (max-width: 640px) { + // Convert to vertical stack on small screens for better usability + flex-direction: column; + + // Adjust dividers for responsive layout + &.ui-hstack--divider { + > :not(:last-child) { + border-right: none; + border-bottom: $semantic-border-width-1 solid $semantic-color-border-secondary; + padding-right: 0; + margin-right: 0; + padding-bottom: $semantic-spacing-component-sm; + margin-bottom: $semantic-spacing-component-sm; + } + } + + // Reduce spacing on small screens + &.ui-hstack--spacing-xl, + &.ui-hstack--spacing-2xl, + &.ui-hstack--spacing-3xl, + &.ui-hstack--spacing-4xl, + &.ui-hstack--spacing-5xl { + gap: $semantic-spacing-stack-lg; + } + } + + @media (max-width: 480px) { + &.ui-hstack--spacing-lg, + &.ui-hstack--spacing-xl, + &.ui-hstack--spacing-2xl, + &.ui-hstack--spacing-3xl, + &.ui-hstack--spacing-4xl, + &.ui-hstack--spacing-5xl { + gap: $semantic-spacing-stack-md; + } + } + } +} \ No newline at end of file diff --git a/src/lib/components/layout/hstack/hstack.component.ts b/src/lib/components/layout/hstack/hstack.component.ts new file mode 100644 index 0000000..e7a47a1 --- /dev/null +++ b/src/lib/components/layout/hstack/hstack.component.ts @@ -0,0 +1,62 @@ +import { Component, Input, ChangeDetectionStrategy, ViewEncapsulation } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +type StackSpacing = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl'; +type VerticalAlignment = 'start' | 'center' | 'end' | 'stretch' | 'baseline'; +type HorizontalJustify = 'start' | 'center' | 'end' | 'between' | 'around' | 'evenly'; +type StackWrap = 'nowrap' | 'wrap' | 'wrap-reverse'; + +@Component({ + selector: 'ui-hstack', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ + +
+ `, + styleUrl: './hstack.component.scss' +}) +export class HStackComponent { + @Input() spacing: StackSpacing = 'md'; + @Input() align?: VerticalAlignment; + @Input() justify?: HorizontalJustify; + @Input() wrap?: StackWrap; + @Input() inline = false; + @Input() responsive = true; + @Input() divider = false; + @Input() fullWidth = false; + @Input() customGap?: string; + @Input() role?: string; + + getClasses(): Record { + const classes: Record = { + 'ui-hstack': true, + [`ui-hstack--spacing-${this.spacing}`]: true, + 'ui-hstack--inline': this.inline, + 'ui-hstack--responsive': this.responsive, + 'ui-hstack--divider': this.divider, + 'ui-hstack--full-width': this.fullWidth + }; + + if (this.align) { + classes[`ui-hstack--align-${this.align}`] = true; + } + + if (this.justify) { + classes[`ui-hstack--justify-${this.justify}`] = true; + } + + if (this.wrap) { + classes[`ui-hstack--wrap-${this.wrap}`] = true; + } + + return classes; + } +} \ No newline at end of file diff --git a/src/lib/components/layout/hstack/index.ts b/src/lib/components/layout/hstack/index.ts new file mode 100644 index 0000000..348bab5 --- /dev/null +++ b/src/lib/components/layout/hstack/index.ts @@ -0,0 +1 @@ +export * from './hstack.component'; \ No newline at end of file diff --git a/src/lib/components/layout/index.ts b/src/lib/components/layout/index.ts new file mode 100644 index 0000000..6346e52 --- /dev/null +++ b/src/lib/components/layout/index.ts @@ -0,0 +1,29 @@ +export * from './aspect-ratio'; +export * from './bento-grid'; +export * from './box'; +export * from './breakpoint-container'; +export * from './center'; +export * from './column'; +export * from './container'; +export * from './dashboard-shell'; +export * from './divider'; +export * from './feed-layout'; +export * from './flex'; +export * from './gallery-grid'; +export * from './grid-container'; +export * from './grid-system'; +export * from './hstack'; +export * from './infinite-scroll-container'; +export * from './kanban-board'; +export * from './list-detail-layout'; +export * from './masonry'; +export * from './scroll-container'; +export * from './section'; +export * from './sidebar-layout'; +export * from './spacer'; +export * from './split-view'; +export * from './stack'; +export * from './sticky-layout'; +export * from './supporting-pane-layout'; +export * from './tabs-container'; +export * from './vstack'; \ No newline at end of file diff --git a/src/lib/components/layout/infinite-scroll-container/index.ts b/src/lib/components/layout/infinite-scroll-container/index.ts new file mode 100644 index 0000000..95d48b3 --- /dev/null +++ b/src/lib/components/layout/infinite-scroll-container/index.ts @@ -0,0 +1 @@ +export * from './infinite-scroll-container.component'; \ No newline at end of file diff --git a/src/lib/components/layout/infinite-scroll-container/infinite-scroll-container.component.scss b/src/lib/components/layout/infinite-scroll-container/infinite-scroll-container.component.scss new file mode 100644 index 0000000..b34e983 --- /dev/null +++ b/src/lib/components/layout/infinite-scroll-container/infinite-scroll-container.component.scss @@ -0,0 +1,286 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-infinite-scroll-container { + // Core Structure + display: flex; + flex-direction: column; + position: relative; + overflow: auto; + height: 100%; + + // Base styling + background: $semantic-color-surface-primary; + color: $semantic-color-text-primary; + + // Scrollbar styling + scrollbar-width: thin; + scrollbar-color: $semantic-color-border-secondary $semantic-color-surface-secondary; + + &::-webkit-scrollbar { + width: $semantic-spacing-2; + } + + &::-webkit-scrollbar-track { + background: $semantic-color-surface-secondary; + border-radius: $semantic-border-radius-sm; + } + + &::-webkit-scrollbar-thumb { + background: $semantic-color-border-secondary; + border-radius: $semantic-border-radius-sm; + + &:hover { + background: $semantic-color-border-primary; + } + } + + // Direction variants + &--direction-down { + // Default styling - no specific changes needed + } + + &--direction-up { + // Reverse flex direction for up-loading scenarios + flex-direction: column-reverse; + } + + &--direction-both { + // Both directions supported - default styling + } + + // State variants + &--loading { + cursor: wait; + } + + &--disabled { + pointer-events: none; + opacity: $semantic-opacity-disabled; + } + + &--has-fixed-height { + // Optimizations for fixed-height items + .ui-infinite-scroll-container__item { + flex-shrink: 0; + } + } + + // Content area + &__content { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; // Allow shrinking + } + + // Individual items + &__item { + display: flex; + flex-direction: column; + position: relative; + + // Ensure proper spacing between items + & + & { + margin-top: $semantic-spacing-grid-gap-sm; + } + } + + // Loading indicators + &__loader { + display: flex; + align-items: center; + justify-content: center; + padding: $semantic-spacing-component-lg; + background: $semantic-color-surface-secondary; + border-radius: $semantic-border-radius-sm; + + &--top { + order: -1; + margin-bottom: $semantic-spacing-component-md; + } + + &--bottom { + order: 999; + margin-top: $semantic-spacing-component-md; + } + } + + // Default loading content + &__default-loader { + display: flex; + align-items: center; + gap: $semantic-spacing-component-sm; + + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + color: $semantic-color-text-secondary; + } + + // Loading spinner + &__spinner { + display: inline-block; + width: $semantic-sizing-icon-inline; + height: $semantic-sizing-icon-inline; + border: 2px solid $semantic-color-border-subtle; + border-radius: 50%; + border-top-color: $semantic-color-primary; + animation: ui-infinite-scroll-spin $semantic-motion-duration-slow linear infinite; + } + + // End of content indicator + &__end { + display: flex; + align-items: center; + justify-content: center; + padding: $semantic-spacing-component-lg; + margin-top: $semantic-spacing-component-md; + } + + &__default-end { + padding: $semantic-spacing-component-md; + background: $semantic-color-surface-secondary; + border-radius: $semantic-border-radius-sm; + + font-family: map-get($semantic-typography-caption, font-family); + font-size: map-get($semantic-typography-caption, font-size); + font-weight: map-get($semantic-typography-caption, font-weight); + line-height: map-get($semantic-typography-caption, line-height); + color: $semantic-color-text-tertiary; + text-align: center; + } + + // Error state + &__error { + display: flex; + align-items: center; + justify-content: center; + padding: $semantic-spacing-component-lg; + margin-top: $semantic-spacing-component-md; + } + + &__default-error { + display: flex; + flex-direction: column; + align-items: center; + gap: $semantic-spacing-component-sm; + padding: $semantic-spacing-component-lg; + background: $semantic-color-surface-secondary; + border: $semantic-border-width-1 solid $semantic-color-border-error; + border-radius: $semantic-border-radius-md; + + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + color: $semantic-color-text-primary; + text-align: center; + } + + &__retry-button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: $semantic-spacing-interactive-button-padding-y $semantic-spacing-interactive-button-padding-x; + + background: $semantic-color-primary; + color: $semantic-color-on-primary; + border: none; + border-radius: $semantic-border-radius-sm; + + font-family: map-get($semantic-typography-button-small, font-family); + font-size: map-get($semantic-typography-button-small, font-size); + font-weight: map-get($semantic-typography-button-small, font-weight); + line-height: map-get($semantic-typography-button-small, line-height); + + cursor: pointer; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + + &:hover:not(:disabled) { + background: $semantic-color-primary; + box-shadow: $semantic-shadow-elevation-2; + transform: translateY(-1px); + } + + &:active:not(:disabled) { + transform: translateY(0); + box-shadow: $semantic-shadow-elevation-1; + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + + &:disabled { + opacity: $semantic-opacity-disabled; + cursor: not-allowed; + } + } + + // Focus styles + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + + // Smooth scrolling behavior + &:not(&--disabled) { + scroll-behavior: smooth; + } + + // High contrast mode support + @media (prefers-contrast: high) { + &__loader { + border: $semantic-border-width-1 solid $semantic-color-border-primary; + } + + &__default-end { + border: $semantic-border-width-1 solid $semantic-color-border-primary; + } + } + + // Reduced motion support + @media (prefers-reduced-motion: reduce) { + scroll-behavior: auto; + + &__spinner { + animation: none; + } + + &__retry-button { + transition: none; + + &:hover:not(:disabled) { + transform: none; + } + + &:active:not(:disabled) { + transform: none; + } + } + } + + // Print styles + @media print { + height: auto; + overflow: visible; + + &__loader, + &__error, + &__retry-button { + display: none; + } + } +} + +// Keyframes for spinner animation +@keyframes ui-infinite-scroll-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} \ No newline at end of file diff --git a/src/lib/components/layout/infinite-scroll-container/infinite-scroll-container.component.ts b/src/lib/components/layout/infinite-scroll-container/infinite-scroll-container.component.ts new file mode 100644 index 0000000..21d280c --- /dev/null +++ b/src/lib/components/layout/infinite-scroll-container/infinite-scroll-container.component.ts @@ -0,0 +1,352 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation, ElementRef, ViewChild, AfterViewInit, OnDestroy, TemplateRef } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +type InfiniteScrollDirection = 'down' | 'up' | 'both'; +type InfiniteScrollThreshold = number; + +export interface InfiniteScrollEvent { + direction: 'up' | 'down'; + currentIndex: number; + scrollPosition: number; +} + +export interface InfiniteScrollConfig { + threshold?: number; // Distance from edge to trigger load (in pixels) + debounceTime?: number; // Debounce time for scroll events (in ms) + disabled?: boolean; // Disable infinite scrolling + initialItemCount?: number; // Number of items to load initially + itemHeight?: number; // Fixed height for virtual scrolling optimization +} + +@Component({ + selector: 'ui-infinite-scroll-container', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ + @if (direction === 'up' || direction === 'both') { + +
+ @if (loadingUp && loadingTemplate) { + + } @else if (loadingUp) { +
+ + Loading more items... +
+ } +
+ } + + +
+ @if (items && items.length > 0 && itemTemplate) { + + @for (item of items; track trackByFn ? trackByFn($index, item) : item; let i = $index) { +
+ +
+ } + } @else { + + + } +
+ + @if (direction === 'down' || direction === 'both') { + +
+ @if (loadingDown && loadingTemplate) { + + } @else if (loadingDown) { +
+ + Loading more items... +
+ } +
+ } + + @if (hasMore === false && endTemplate) { + +
+ +
+ } @else if (hasMore === false) { +
+
+ No more items to load +
+
+ } + + @if (error && errorTemplate) { + +
+ +
+ } @else if (error) { +
+
+ Failed to load more items + +
+
+ } +
+ `, + styleUrl: './infinite-scroll-container.component.scss' +}) +export class InfiniteScrollContainerComponent implements AfterViewInit, OnDestroy { + @ViewChild('scrollContainer', { static: true }) scrollContainer!: ElementRef; + + // Basic Configuration + @Input() direction: InfiniteScrollDirection = 'down'; + @Input() config?: InfiniteScrollConfig; + @Input() role = 'region'; + @Input() ariaLabel = 'Infinite scroll content'; + @Input() tabIndex = 0; + + // Data Management + @Input() items?: any[]; + @Input() itemTemplate?: TemplateRef; + @Input() trackByFn?: (index: number, item: any) => any; + @Input() hasMore = true; + + // State Management + @Input() loading = false; + @Input() loadingUp = false; + @Input() loadingDown = false; + @Input() error?: string | Error; + + // Templates + @Input() loadingTemplate?: TemplateRef; + @Input() errorTemplate?: TemplateRef; + @Input() endTemplate?: TemplateRef; + + // Events + @Output() scrolledToEnd = new EventEmitter(); + @Output() scrolledToTop = new EventEmitter(); + @Output() loadMore = new EventEmitter(); + @Output() retryRequested = new EventEmitter(); + @Output() scrolled = new EventEmitter<{ scrollTop: number; scrollHeight: number; clientHeight: number }>(); + + // Internal State + private scrollTimeout?: number; + private isScrolling = false; + private lastScrollTop = 0; + private currentIndex = 0; + + private get threshold(): number { + return this.config?.threshold ?? 200; + } + + private get debounceTime(): number { + return this.config?.debounceTime ?? 100; + } + + private get isDisabled(): boolean { + return this.config?.disabled ?? false; + } + + ngAfterViewInit(): void { + // Initial setup + this.updateCurrentIndex(); + } + + ngOnDestroy(): void { + if (this.scrollTimeout) { + clearTimeout(this.scrollTimeout); + } + } + + onScroll(event: Event): void { + if (this.isDisabled) return; + + const element = event.target as HTMLElement; + const scrollTop = element.scrollTop; + const scrollHeight = element.scrollHeight; + const clientHeight = element.clientHeight; + + // Emit scroll position + this.scrolled.emit({ scrollTop, scrollHeight, clientHeight }); + + // Debounced scroll handling + if (this.scrollTimeout) { + clearTimeout(this.scrollTimeout); + } + + this.scrollTimeout = window.setTimeout(() => { + this.handleScroll(element, scrollTop, scrollHeight, clientHeight); + }, this.debounceTime); + + this.lastScrollTop = scrollTop; + } + + onKeyDown(event: KeyboardEvent): void { + if (this.isDisabled) return; + + const element = this.scrollContainer.nativeElement; + let handled = false; + + switch (event.key) { + case 'ArrowDown': + this.scrollBy(40); + handled = true; + break; + case 'ArrowUp': + this.scrollBy(-40); + handled = true; + break; + case 'PageDown': + this.scrollBy(element.clientHeight * 0.8); + handled = true; + break; + case 'PageUp': + this.scrollBy(-element.clientHeight * 0.8); + handled = true; + break; + case 'Home': + this.scrollToTop(); + handled = true; + break; + case 'End': + this.scrollToBottom(); + handled = true; + break; + } + + if (handled) { + event.preventDefault(); + } + } + + // Public API Methods + scrollBy(deltaY: number): void { + const element = this.scrollContainer.nativeElement; + element.scrollBy({ + top: deltaY, + behavior: 'smooth' + }); + } + + scrollToTop(): void { + const element = this.scrollContainer.nativeElement; + element.scrollTo({ top: 0, behavior: 'smooth' }); + } + + scrollToBottom(): void { + const element = this.scrollContainer.nativeElement; + element.scrollTo({ top: element.scrollHeight, behavior: 'smooth' }); + } + + scrollToIndex(index: number): void { + if (!this.config?.itemHeight || !this.items) return; + + const targetY = index * this.config.itemHeight; + const element = this.scrollContainer.nativeElement; + element.scrollTo({ top: targetY, behavior: 'smooth' }); + } + + retry(): void { + this.retryRequested.emit(); + } + + resetScroll(): void { + const element = this.scrollContainer.nativeElement; + element.scrollTo({ top: 0 }); + this.currentIndex = 0; + } + + // Private Methods + private handleScroll( + element: HTMLElement, + scrollTop: number, + scrollHeight: number, + clientHeight: number + ): void { + this.updateCurrentIndex(); + + const scrollDirection = scrollTop > this.lastScrollTop ? 'down' : 'up'; + + // Check for infinite scroll triggers + if (this.shouldLoadMore('down', element)) { + const event: InfiniteScrollEvent = { + direction: 'down', + currentIndex: this.currentIndex, + scrollPosition: scrollTop + }; + + this.scrolledToEnd.emit(event); + this.loadMore.emit(event); + } + + if (this.shouldLoadMore('up', element)) { + const event: InfiniteScrollEvent = { + direction: 'up', + currentIndex: this.currentIndex, + scrollPosition: scrollTop + }; + + this.scrolledToTop.emit(event); + this.loadMore.emit(event); + } + } + + private shouldLoadMore(direction: 'up' | 'down', element: HTMLElement): boolean { + if (this.isDisabled || this.loading) return false; + + if (direction === 'down') { + if (this.direction !== 'down' && this.direction !== 'both') return false; + if (!this.hasMore || this.loadingDown) return false; + + const scrollBottom = element.scrollHeight - element.scrollTop - element.clientHeight; + return scrollBottom <= this.threshold; + } else { + if (this.direction !== 'up' && this.direction !== 'both') return false; + if (this.loadingUp) return false; + + return element.scrollTop <= this.threshold; + } + } + + private updateCurrentIndex(): void { + if (!this.config?.itemHeight || !this.items) return; + + const element = this.scrollContainer.nativeElement; + const scrollTop = element.scrollTop; + this.currentIndex = Math.floor(scrollTop / this.config.itemHeight); + } +} \ No newline at end of file diff --git a/src/lib/components/layout/kanban-board/index.ts b/src/lib/components/layout/kanban-board/index.ts new file mode 100644 index 0000000..2f62cbc --- /dev/null +++ b/src/lib/components/layout/kanban-board/index.ts @@ -0,0 +1 @@ +export * from './kanban-board.component'; \ No newline at end of file diff --git a/src/lib/components/layout/kanban-board/kanban-board.component.scss b/src/lib/components/layout/kanban-board/kanban-board.component.scss new file mode 100644 index 0000000..922b047 --- /dev/null +++ b/src/lib/components/layout/kanban-board/kanban-board.component.scss @@ -0,0 +1,438 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-kanban-board { + // Core Structure + display: flex; + flex-direction: column; + position: relative; + width: 100%; + height: 100%; + + // Layout & Spacing + padding: $semantic-spacing-layout-section-md; + gap: $semantic-spacing-layout-section-sm; + + // Visual Design + background: $semantic-color-surface; + border: $semantic-border-width-1 solid $semantic-color-border-subtle; + border-radius: $semantic-border-radius-lg; + + // Size Variants + &--sm { + padding: $semantic-spacing-layout-section-xs; + gap: $semantic-spacing-layout-section-xs; + } + + &--md { + padding: $semantic-spacing-layout-section-sm; + gap: $semantic-spacing-layout-section-sm; + } + + &--lg { + padding: $semantic-spacing-layout-section-md; + gap: $semantic-spacing-layout-section-md; + } + + // Disabled State + &--disabled { + opacity: $semantic-opacity-disabled; + pointer-events: none; + } + + // Header Section + &__header { + display: flex; + flex-direction: column; + gap: $semantic-spacing-content-line-tight; + margin-bottom: $semantic-spacing-layout-section-sm; + } + + &__title { + margin: 0; + font-family: map-get($semantic-typography-heading-h2, font-family); + font-size: map-get($semantic-typography-heading-h2, font-size); + font-weight: map-get($semantic-typography-heading-h2, font-weight); + line-height: map-get($semantic-typography-heading-h2, line-height); + color: $semantic-color-text-primary; + } + + &__subtitle { + margin: 0; + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + color: $semantic-color-text-secondary; + } + + // Columns Container + &__columns { + display: flex; + gap: $semantic-spacing-grid-gap-md; + overflow-x: auto; + flex: 1; + padding-bottom: $semantic-spacing-component-sm; + + // Scroll styling + scrollbar-width: thin; + scrollbar-color: $semantic-color-border-primary $semantic-color-surface-secondary; + + &::-webkit-scrollbar { + height: 8px; + } + + &::-webkit-scrollbar-track { + background: $semantic-color-surface-secondary; + border-radius: $semantic-border-radius-sm; + } + + &::-webkit-scrollbar-thumb { + background: $semantic-color-border-primary; + border-radius: $semantic-border-radius-sm; + } + + &::-webkit-scrollbar-thumb:hover { + background: $semantic-color-border-secondary; + } + } + + // Individual Column + &__column { + display: flex; + flex-direction: column; + min-width: 280px; + max-width: 350px; + flex: 1; + + // Visual Design + background: $semantic-color-surface-secondary; + border: $semantic-border-width-1 solid $semantic-color-border-secondary; + border-radius: $semantic-border-radius-md; + box-shadow: $semantic-shadow-elevation-1; + + // Transitions + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + + // States + &--disabled { + opacity: $semantic-opacity-disabled; + background: $semantic-color-surface-container; + } + + &--full { + background: $semantic-color-surface-elevated; + border-color: $semantic-color-border-primary; + } + + // Drag over effect + &[data-drag-over="true"] { + border-color: $semantic-color-primary; + box-shadow: $semantic-shadow-elevation-2; + background: $semantic-color-surface-elevated; + } + } + + // Column Header + &__column-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: $semantic-spacing-component-md; + border-bottom: $semantic-border-width-1 solid $semantic-color-border-subtle; + background: $semantic-color-surface-primary; + border-radius: $semantic-border-radius-md $semantic-border-radius-md 0 0; + } + + &__column-title { + margin: 0; + font-family: map-get($semantic-typography-heading-h4, font-family); + font-size: map-get($semantic-typography-heading-h4, font-size); + font-weight: map-get($semantic-typography-heading-h4, font-weight); + line-height: map-get($semantic-typography-heading-h4, line-height); + color: $semantic-color-text-primary; + } + + &__column-count { + display: flex; + align-items: center; + gap: $semantic-spacing-component-xs; + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + color: $semantic-color-text-secondary; + + background: $semantic-color-surface-container; + padding: $semantic-spacing-component-xs $semantic-spacing-component-sm; + border-radius: $semantic-border-radius-full; + border: $semantic-border-width-1 solid $semantic-color-border-subtle; + } + + &__column-limit { + color: $semantic-color-text-tertiary; + } + + // Column Content + &__column-content { + display: flex; + flex-direction: column; + gap: $semantic-spacing-component-sm; + padding: $semantic-spacing-component-md; + flex: 1; + overflow-y: auto; + + // Scroll styling + scrollbar-width: thin; + scrollbar-color: $semantic-color-border-primary $semantic-color-surface-secondary; + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background: $semantic-color-surface-secondary; + border-radius: $semantic-border-radius-sm; + } + + &::-webkit-scrollbar-thumb { + background: $semantic-color-border-primary; + border-radius: $semantic-border-radius-sm; + } + } + + // Empty State + &__empty-state { + display: flex; + align-items: center; + justify-content: center; + min-height: 120px; + padding: $semantic-spacing-component-lg; + background: $semantic-color-surface-container; + border: 2px dashed $semantic-color-border-subtle; + border-radius: $semantic-border-radius-md; + } + + &__empty-text { + margin: 0; + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + color: $semantic-color-text-tertiary; + font-style: italic; + } + + // Kanban Item + &__item { + display: flex; + flex-direction: column; + gap: $semantic-spacing-component-xs; + padding: $semantic-spacing-component-sm; + + // Visual Design + background: $semantic-color-surface-primary; + border: $semantic-border-width-1 solid $semantic-color-border-subtle; + border-radius: $semantic-border-radius-md; + box-shadow: $semantic-shadow-elevation-1; + + // Interactive + cursor: grab; + user-select: none; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + + // States + &:hover:not(.ui-kanban-board__item--dragging) { + box-shadow: $semantic-shadow-elevation-2; + border-color: $semantic-color-border-primary; + transform: translateY(-1px); + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + + &--dragging { + cursor: grabbing; + opacity: $semantic-opacity-subtle; + transform: rotate(2deg) scale(1.02); + box-shadow: $semantic-shadow-elevation-4; + z-index: $semantic-z-index-overlay; + } + + // Priority variants + &--priority-high { + border-left: 4px solid $semantic-color-danger; + } + + &--priority-medium { + border-left: 4px solid $semantic-color-warning; + } + + &--priority-low { + border-left: 4px solid $semantic-color-success; + } + } + + // Item Header + &__item-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: $semantic-spacing-component-xs; + } + + &__item-title { + margin: 0; + flex: 1; + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: $semantic-typography-font-weight-semibold; + line-height: map-get($semantic-typography-body-medium, line-height); + color: $semantic-color-text-primary; + } + + &__item-priority { + font-size: $semantic-typography-font-size-sm; + line-height: 1; + } + + // Item Description + &__item-description { + margin: 0; + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + color: $semantic-color-text-secondary; + } + + // Item Tags + &__item-tags { + display: flex; + flex-wrap: wrap; + gap: $semantic-spacing-component-xs; + } + + &__item-tag { + display: inline-block; + padding: 2px $semantic-spacing-component-xs; + font-family: map-get($semantic-typography-caption, font-family); + font-size: map-get($semantic-typography-caption, font-size); + font-weight: map-get($semantic-typography-caption, font-weight); + line-height: map-get($semantic-typography-caption, line-height); + color: $semantic-color-text-primary; + background: $semantic-color-surface-container; + border: $semantic-border-width-1 solid $semantic-color-border-subtle; + border-radius: $semantic-border-radius-sm; + } + + // Item Meta + &__item-meta { + display: flex; + justify-content: space-between; + align-items: center; + gap: $semantic-spacing-component-xs; + margin-top: $semantic-spacing-component-xs; + } + + &__item-assignee, + &__item-due-date { + display: flex; + align-items: center; + gap: 2px; + font-family: map-get($semantic-typography-caption, font-family); + font-size: map-get($semantic-typography-caption, font-size); + font-weight: map-get($semantic-typography-caption, font-weight); + line-height: map-get($semantic-typography-caption, line-height); + color: $semantic-color-text-secondary; + } + + // Responsive Design + .ui-kanban-board--sm { + .ui-kanban-board__column { + min-width: 220px; + max-width: 280px; + } + + .ui-kanban-board__columns { + gap: $semantic-spacing-grid-gap-sm; + } + + .ui-kanban-board__column-header { + padding: $semantic-spacing-component-sm; + } + + .ui-kanban-board__column-content { + padding: $semantic-spacing-component-sm; + gap: $semantic-spacing-component-xs; + } + + .ui-kanban-board__item { + padding: $semantic-spacing-component-xs; + } + } + + .ui-kanban-board--lg { + .ui-kanban-board__column { + min-width: 320px; + max-width: 420px; + } + + .ui-kanban-board__columns { + gap: $semantic-spacing-grid-gap-lg; + } + + .ui-kanban-board__column-header { + padding: $semantic-spacing-component-lg; + } + + .ui-kanban-board__column-content { + padding: $semantic-spacing-component-lg; + gap: $semantic-spacing-component-md; + } + + .ui-kanban-board__item { + padding: $semantic-spacing-component-md; + } + } +} + +// High contrast mode support +@media (prefers-contrast: high) { + .ui-kanban-board { + &__column { + border-width: 2px; + } + + &__item { + border-width: 2px; + + &--priority-high, + &--priority-medium, + &--priority-low { + border-left-width: 6px; + } + } + } +} + +// Reduced motion support +@media (prefers-reduced-motion: reduce) { + .ui-kanban-board { + &__column, + &__item { + transition: none; + } + + &__item { + &:hover:not(.ui-kanban-board__item--dragging) { + transform: none; + } + + &--dragging { + transform: none; + } + } + } +} \ No newline at end of file diff --git a/src/lib/components/layout/kanban-board/kanban-board.component.ts b/src/lib/components/layout/kanban-board/kanban-board.component.ts new file mode 100644 index 0000000..2e2640a --- /dev/null +++ b/src/lib/components/layout/kanban-board/kanban-board.component.ts @@ -0,0 +1,262 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +export interface KanbanColumn { + id: string; + title: string; + items: KanbanItem[]; + maxItems?: number; + disabled?: boolean; +} + +export interface KanbanItem { + id: string; + title: string; + description?: string; + priority?: 'low' | 'medium' | 'high'; + tags?: string[]; + assignee?: string; + dueDate?: Date; +} + +export interface KanbanDragEvent { + item: KanbanItem; + fromColumn: string; + toColumn: string; + fromIndex: number; + toIndex: number; +} + +type KanbanSize = 'sm' | 'md' | 'lg'; + +@Component({ + selector: 'ui-kanban-board', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ +
+

{{ title }}

+ @if (subtitle) { +

{{ subtitle }}

+ } +
+ +
+ @for (column of columns; track column.id; let columnIndex = $index) { +
+ +
+

{{ column.title }}

+ + {{ column.items.length }} + @if (column.maxItems) { + / {{ column.maxItems }} + } + +
+ +
+ @for (item of column.items; track item.id; let itemIndex = $index) { +
+ +
+

{{ item.title }}

+ @if (item.priority) { + + @switch (item.priority) { + @case ('high') { 🔴 } + @case ('medium') { 🟡 } + @case ('low') { 🟢 } + } + + } +
+ + @if (item.description) { +

{{ item.description }}

+ } + + @if (item.tags && item.tags.length > 0) { +
+ @for (tag of item.tags; track tag) { + {{ tag }} + } +
+ } + + @if (item.assignee || item.dueDate) { +
+ @if (item.assignee) { + + 👤 {{ item.assignee }} + + } + @if (item.dueDate) { + + 📅 {{ item.dueDate | date:'short' }} + + } +
+ } +
+ } + + @if (column.items.length === 0) { +
+

No items

+
+ } +
+
+ } +
+
+ `, + styleUrl: './kanban-board.component.scss' +}) +export class KanbanBoardComponent { + @Input() columns: KanbanColumn[] = []; + @Input() size: KanbanSize = 'md'; + @Input() disabled = false; + @Input() title?: string; + @Input() subtitle?: string; + @Input() ariaLabel?: string; + + @Output() itemMoved = new EventEmitter(); + @Output() itemClicked = new EventEmitter<{ item: KanbanItem; columnId: string }>(); + @Output() columnChanged = new EventEmitter<{ column: KanbanColumn; action: 'add' | 'remove' | 'update' }>(); + + draggedItem: KanbanItem | null = null; + draggedFromColumn: string | null = null; + draggedFromIndex: number | null = null; + + onDragStart(event: DragEvent, item: KanbanItem, columnId: string, itemIndex: number): void { + if (this.disabled) { + event.preventDefault(); + return; + } + + this.draggedItem = item; + this.draggedFromColumn = columnId; + this.draggedFromIndex = itemIndex; + + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('text/plain', JSON.stringify({ + itemId: item.id, + columnId, + itemIndex + })); + } + } + + onDragOver(event: DragEvent, column: KanbanColumn): void { + if (this.disabled || column.disabled) { + return; + } + + event.preventDefault(); + + if (event.dataTransfer) { + event.dataTransfer.dropEffect = 'move'; + } + } + + onDrop(event: DragEvent, column: KanbanColumn, columnIndex: number): void { + event.preventDefault(); + + if (this.disabled || column.disabled || !this.draggedItem || !this.draggedFromColumn) { + return; + } + + // Check if column is at capacity + if (column.maxItems && column.items.length >= column.maxItems && column.id !== this.draggedFromColumn) { + return; + } + + const dragEvent: KanbanDragEvent = { + item: this.draggedItem, + fromColumn: this.draggedFromColumn, + toColumn: column.id, + fromIndex: this.draggedFromIndex!, + toIndex: column.items.length + }; + + this.itemMoved.emit(dragEvent); + } + + onDragEnd(event: DragEvent): void { + this.draggedItem = null; + this.draggedFromColumn = null; + this.draggedFromIndex = null; + } + + onItemClick(item: KanbanItem, columnId: string): void { + if (!this.disabled) { + this.itemClicked.emit({ item, columnId }); + } + } + + onItemKeydown(event: KeyboardEvent, item: KanbanItem, columnId: string): void { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + this.onItemClick(item, columnId); + } + } + + getItemAriaLabel(item: KanbanItem): string { + let label = `Item: ${item.title}`; + if (item.description) { + label += `, ${item.description}`; + } + if (item.priority) { + label += `, ${item.priority} priority`; + } + if (item.assignee) { + label += `, assigned to ${item.assignee}`; + } + return label; + } +} \ No newline at end of file diff --git a/src/lib/components/layout/list-detail-layout/index.ts b/src/lib/components/layout/list-detail-layout/index.ts new file mode 100644 index 0000000..6f37786 --- /dev/null +++ b/src/lib/components/layout/list-detail-layout/index.ts @@ -0,0 +1 @@ +export * from './list-detail-layout.component'; \ No newline at end of file diff --git a/src/lib/components/layout/list-detail-layout/list-detail-layout.component.scss b/src/lib/components/layout/list-detail-layout/list-detail-layout.component.scss new file mode 100644 index 0000000..21fa026 --- /dev/null +++ b/src/lib/components/layout/list-detail-layout/list-detail-layout.component.scss @@ -0,0 +1,247 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-list-detail-layout { + // Core structure + display: flex; + position: relative; + width: 100%; + height: 100vh; + + // Visual design + background: $semantic-color-surface-primary; + border: $semantic-border-width-1 solid $semantic-color-border-subtle; + border-radius: $semantic-border-radius-lg; + + // Typography + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + color: $semantic-color-text-primary; + + // Mobile-first orientation + flex-direction: column; + + // Desktop orientation + @media (min-width: $semantic-breakpoint-md) { + flex-direction: row; + } + + // List panel + &__list { + display: flex; + flex-direction: column; + position: relative; + background: $semantic-color-surface-secondary; + border-right: $semantic-border-width-1 solid $semantic-color-border-primary; + overflow: hidden; + + // Mobile: full height, collapsed when detail shown + flex: none; + width: 100%; + height: 100%; + + // Desktop: fixed width, full height + @media (min-width: $semantic-breakpoint-md) { + flex: none; + width: 320px; + height: auto; + } + + // Resizable on desktop + @media (min-width: $semantic-breakpoint-md) { + resize: horizontal; + min-width: 280px; + max-width: 600px; + } + + // Mobile: hide when detail is shown + &--hidden-mobile { + @media (max-width: calc($semantic-breakpoint-md - 1px)) { + display: none; + } + } + } + + // Detail panel + &__detail { + display: flex; + flex-direction: column; + position: relative; + background: $semantic-color-surface-primary; + overflow: hidden; + + // Mobile: full height, shown only when item selected + flex: 1; + width: 100%; + height: 100%; + + // Desktop: flexible width + @media (min-width: $semantic-breakpoint-md) { + flex: 1; + height: auto; + } + + // Mobile: hide when no selection + &--hidden-mobile { + @media (max-width: calc($semantic-breakpoint-md - 1px)) { + display: none; + } + } + + // Empty state + &--empty { + display: flex; + align-items: center; + justify-content: center; + padding: $semantic-spacing-layout-section-lg; + color: $semantic-color-text-secondary; + + font-family: map-get($semantic-typography-body-large, font-family); + font-size: map-get($semantic-typography-body-large, font-size); + font-weight: map-get($semantic-typography-body-large, font-weight); + line-height: map-get($semantic-typography-body-large, line-height); + } + } + + // Resize handle for desktop + &__resize-handle { + display: none; + position: absolute; + top: 0; + right: -2px; + width: 4px; + height: 100%; + cursor: col-resize; + background: transparent; + z-index: 1; + + @media (min-width: $semantic-breakpoint-md) { + display: block; + } + + &:hover, + &:active { + background: $semantic-color-primary; + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + } + + // Mobile navigation controls + &__mobile-nav { + display: flex; + align-items: center; + justify-content: space-between; + padding: $semantic-spacing-component-md; + background: $semantic-color-surface-elevated; + border-bottom: $semantic-border-width-1 solid $semantic-color-border-primary; + + @media (min-width: $semantic-breakpoint-md) { + display: none; + } + } + + &__mobile-nav-button { + display: flex; + align-items: center; + gap: $semantic-spacing-component-xs; + padding: $semantic-spacing-interactive-button-padding-y $semantic-spacing-interactive-button-padding-x; + background: $semantic-color-surface-primary; + border: $semantic-border-width-1 solid $semantic-color-border-primary; + border-radius: $semantic-border-button-radius; + color: $semantic-color-text-primary; + cursor: pointer; + + font-family: map-get($semantic-typography-button-medium, font-family); + font-size: map-get($semantic-typography-button-medium, font-size); + font-weight: map-get($semantic-typography-button-medium, font-weight); + line-height: map-get($semantic-typography-button-medium, line-height); + + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + + &:hover { + background: $semantic-color-surface-elevated; + box-shadow: $semantic-shadow-button-hover; + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + + &:active { + box-shadow: $semantic-shadow-button-rest; + } + + &[disabled] { + opacity: $semantic-opacity-disabled; + cursor: not-allowed; + pointer-events: none; + } + } + + // Size variants + &--sm { + .ui-list-detail-layout__list { + @media (min-width: $semantic-breakpoint-md) { + width: 280px; + min-width: 240px; + max-width: 400px; + } + } + } + + &--lg { + .ui-list-detail-layout__list { + @media (min-width: $semantic-breakpoint-md) { + width: 400px; + min-width: 320px; + max-width: 800px; + } + } + } + + // Variant styles + &--bordered { + border: $semantic-border-width-2 solid $semantic-color-border-primary; + box-shadow: $semantic-shadow-elevation-2; + } + + &--elevated { + border: none; + box-shadow: $semantic-shadow-elevation-3; + } + + // Selection states for items (to be applied via content projection) + ::ng-deep .ui-list-item { + &--selected { + background: $semantic-color-surface-elevated; + border-left: 3px solid $semantic-color-primary; + color: $semantic-color-text-primary; + } + + &--hover { + background: $semantic-color-surface-container; + } + } + + // Loading state + &--loading { + pointer-events: none; + opacity: $semantic-opacity-subtle; + } + + // Responsive adjustments for small screens + @media (max-width: calc($semantic-breakpoint-sm - 1px)) { + border-radius: 0; + height: 100vh; + + .ui-list-detail-layout__mobile-nav { + padding: $semantic-spacing-component-sm; + } + } +} \ No newline at end of file diff --git a/src/lib/components/layout/list-detail-layout/list-detail-layout.component.ts b/src/lib/components/layout/list-detail-layout/list-detail-layout.component.ts new file mode 100644 index 0000000..1ceea04 --- /dev/null +++ b/src/lib/components/layout/list-detail-layout/list-detail-layout.component.ts @@ -0,0 +1,242 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +type ListDetailSize = 'sm' | 'md' | 'lg'; +type ListDetailVariant = 'default' | 'bordered' | 'elevated'; +type MobileView = 'list' | 'detail'; + +export interface ListDetailNavigationEvent { + view: MobileView; + hasSelection: boolean; +} + +@Component({ + selector: 'ui-list-detail-layout', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ + +
+ + + + {{ currentView() === 'list' ? listTitle : detailTitle }} + + + +
+ + +
+ + + + +
+ + +
+ + @if (!hasSelection() && showEmptyState) { +
+ + {{ emptyStateText }} + +
+ } @else { + + } +
+
+ `, + styleUrl: './list-detail-layout.component.scss' +}) +export class ListDetailLayoutComponent { + // Appearance + @Input() size: ListDetailSize = 'md'; + @Input() variant: ListDetailVariant = 'default'; + @Input() loading = false; + + // Functionality + @Input() resizable = true; + @Input() showEmptyState = true; + @Input() showMobileNavigation = true; + + // Selection state + @Input() + set selectedItem(value: any) { + this._selectedItem.set(value); + } + get selectedItem() { + return this._selectedItem(); + } + private _selectedItem = signal(null); + + // Mobile view state + @Input() + set mobileView(value: MobileView) { + this._currentView.set(value); + } + get mobileView() { + return this._currentView(); + } + private _currentView = signal('list'); + + // Text customization + @Input() listTitle = 'List'; + @Input() detailTitle = 'Details'; + @Input() backToListText = 'Back to List'; + @Input() viewDetailText = 'View Details'; + @Input() emptyStateText = 'Select an item to view details'; + + // Accessibility + @Input() role = 'application'; + @Input() ariaLabel = 'List and detail view'; + @Input() listAriaLabel = 'Item list'; + @Input() detailAriaLabel = 'Item details'; + @Input() backToListLabel = 'Return to item list'; + @Input() viewDetailLabel = 'View item details'; + @Input() resizeHandleLabel = 'Resize panels'; + + // Events + @Output() selectionChanged = new EventEmitter(); + @Output() mobileNavigated = new EventEmitter(); + @Output() panelResized = new EventEmitter<{ listWidth: number }>(); + + // Computed properties + currentView = this._currentView.asReadonly(); + + hasSelection(): boolean { + return this._selectedItem() != null; + } + + // Event handlers + handleMobileNavigation(view: MobileView): void { + if (view === 'detail' && !this.hasSelection()) { + return; // Can't navigate to detail without selection + } + + this._currentView.set(view); + this.mobileNavigated.emit({ + view, + hasSelection: this.hasSelection() + }); + } + + handleResizeKeydown(event: KeyboardEvent): void { + if (!this.resizable) return; + + // Keyboard control for resize handle + const step = 20; // pixels + let deltaX = 0; + + switch (event.key) { + case 'ArrowLeft': + deltaX = -step; + break; + case 'ArrowRight': + deltaX = step; + break; + case 'Home': + // Reset to default size + this.panelResized.emit({ listWidth: 320 }); + event.preventDefault(); + return; + case 'End': + // Maximize list panel + this.panelResized.emit({ listWidth: 600 }); + event.preventDefault(); + return; + default: + return; + } + + if (deltaX !== 0) { + event.preventDefault(); + // In a real implementation, you'd calculate the new width + // For demo purposes, we'll just emit the event + this.panelResized.emit({ listWidth: 320 + deltaX }); + } + } + + // Public methods for external control + selectItem(item: any): void { + this._selectedItem.set(item); + this.selectionChanged.emit(item); + + // Auto-navigate to detail on mobile when item is selected + if (window.innerWidth < 768) { // md breakpoint + this._currentView.set('detail'); + this.mobileNavigated.emit({ + view: 'detail', + hasSelection: true + }); + } + } + + clearSelection(): void { + this._selectedItem.set(null); + this.selectionChanged.emit(null); + + // Auto-navigate to list on mobile when selection is cleared + if (window.innerWidth < 768) { // md breakpoint + this._currentView.set('list'); + this.mobileNavigated.emit({ + view: 'list', + hasSelection: false + }); + } + } + + showList(): void { + this._currentView.set('list'); + } + + showDetail(): void { + if (this.hasSelection()) { + this._currentView.set('detail'); + } + } +} \ No newline at end of file diff --git a/src/lib/components/layout/masonry/index.ts b/src/lib/components/layout/masonry/index.ts new file mode 100644 index 0000000..067cc08 --- /dev/null +++ b/src/lib/components/layout/masonry/index.ts @@ -0,0 +1 @@ +export * from './masonry.component'; \ No newline at end of file diff --git a/src/lib/components/layout/masonry/masonry.component.scss b/src/lib/components/layout/masonry/masonry.component.scss new file mode 100644 index 0000000..d261c04 --- /dev/null +++ b/src/lib/components/layout/masonry/masonry.component.scss @@ -0,0 +1,219 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-masonry { + // Core Structure - CSS Columns for masonry effect + display: block; + position: relative; + width: 100%; + + // Layout & Spacing + column-gap: $semantic-spacing-grid-gap-md; + padding: $semantic-spacing-component-md; + + // Visual Design + background: $semantic-color-surface-primary; + border-radius: $semantic-border-card-radius; + + // Prevent breaking items across columns + & > * { + break-inside: avoid; + page-break-inside: avoid; + margin-bottom: $semantic-spacing-grid-gap-md; + display: inline-block; + width: 100%; + + // Remove margin from last item in each column + &:last-child { + margin-bottom: 0; + } + } + + // Gap Variants + &--gap-sm { + column-gap: $semantic-spacing-grid-gap-sm; + + & > * { + margin-bottom: $semantic-spacing-grid-gap-sm; + } + } + + &--gap-md { + column-gap: $semantic-spacing-grid-gap-md; + + & > * { + margin-bottom: $semantic-spacing-grid-gap-md; + } + } + + &--gap-lg { + column-gap: $semantic-spacing-grid-gap-lg; + + & > * { + margin-bottom: $semantic-spacing-grid-gap-lg; + } + } + + // Padding Variants + &--padding-none { + padding: 0; + } + + &--padding-sm { + padding: $semantic-spacing-component-sm; + } + + &--padding-md { + padding: $semantic-spacing-component-md; + } + + &--padding-lg { + padding: $semantic-spacing-component-lg; + } + + // Column Variants - Fixed Column Count + &--cols-2 { + column-count: 2; + } + + &--cols-3 { + column-count: 3; + } + + &--cols-4 { + column-count: 4; + } + + &--cols-5 { + column-count: 5; + } + + &--cols-6 { + column-count: 6; + } + + // Auto Column Variants - Responsive masonry + &--auto-fit { + column-count: auto; + column-width: 250px; + column-fill: balance; + } + + &--auto-fill { + column-count: auto; + column-width: 200px; + column-fill: auto; + } + + // Alignment Options + &--align-start { + text-align: left; + } + + &--align-center { + text-align: center; + } + + &--align-end { + text-align: right; + } + + // Responsive Behavior + @media (max-width: 1024px) { + &--cols-6 { + column-count: 4; + } + + &--cols-5 { + column-count: 3; + } + + &--cols-4 { + column-count: 3; + } + } + + @media (max-width: 768px) { + column-gap: $semantic-spacing-grid-gap-sm; + padding: $semantic-spacing-component-sm; + + & > * { + margin-bottom: $semantic-spacing-grid-gap-sm; + } + + &--cols-6, + &--cols-5, + &--cols-4, + &--cols-3 { + column-count: 2; + } + + &--auto-fit, + &--auto-fill { + column-width: 200px; + } + } + + @media (max-width: 480px) { + column-gap: $semantic-spacing-component-xs; + padding: $semantic-spacing-component-xs; + + & > * { + margin-bottom: $semantic-spacing-component-xs; + } + + &--cols-6, + &--cols-5, + &--cols-4, + &--cols-3, + &--cols-2 { + column-count: 1; + } + + &--auto-fit, + &--auto-fill { + column-count: 1; + } + } +} + +// Masonry Item Utilities +.ui-masonry-item { + // Prevent breaking + break-inside: avoid; + page-break-inside: avoid; + + // Spacing + margin-bottom: $semantic-spacing-grid-gap-md; + display: inline-block; + width: 100%; + + // Visual Design + background: $semantic-color-surface-secondary; + border-radius: $semantic-border-card-radius; + overflow: hidden; + + // Interactive States + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + + &:hover { + box-shadow: $semantic-shadow-card-hover; + } + + // Size Variants + &--compact { + padding: $semantic-spacing-component-sm; + } + + &--comfortable { + padding: $semantic-spacing-component-md; + } + + &--spacious { + padding: $semantic-spacing-component-lg; + } + + // No spacing variant for full-bleed content + &--flush { + padding: 0; + } +} \ No newline at end of file diff --git a/src/lib/components/layout/masonry/masonry.component.ts b/src/lib/components/layout/masonry/masonry.component.ts new file mode 100644 index 0000000..06593c6 --- /dev/null +++ b/src/lib/components/layout/masonry/masonry.component.ts @@ -0,0 +1,54 @@ +import { Component, Input, ChangeDetectionStrategy, ViewEncapsulation } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +type MasonryColumns = 2 | 3 | 4 | 5 | 6 | 'auto-fit' | 'auto-fill'; +type MasonryGap = 'sm' | 'md' | 'lg'; +type MasonryPadding = 'none' | 'sm' | 'md' | 'lg'; +type MasonryAlignment = 'start' | 'center' | 'end'; + +@Component({ + selector: 'ui-masonry', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ + +
+ `, + styleUrl: './masonry.component.scss' +}) +export class MasonryComponent { + @Input() columns: MasonryColumns = 'auto-fit'; + @Input() gap: MasonryGap = 'md'; + @Input() padding: MasonryPadding = 'md'; + @Input() alignment: MasonryAlignment = 'start'; + @Input() minColumnWidth: string | null = null; + + // Accessibility + @Input() role: string = 'grid'; + @Input() ariaLabel: string | null = null; +} \ No newline at end of file diff --git a/src/lib/components/layout/scroll-container/index.ts b/src/lib/components/layout/scroll-container/index.ts new file mode 100644 index 0000000..8418f2d --- /dev/null +++ b/src/lib/components/layout/scroll-container/index.ts @@ -0,0 +1 @@ +export * from './scroll-container.component'; \ No newline at end of file diff --git a/src/lib/components/layout/scroll-container/scroll-container.component.scss b/src/lib/components/layout/scroll-container/scroll-container.component.scss new file mode 100644 index 0000000..8d3e5f6 --- /dev/null +++ b/src/lib/components/layout/scroll-container/scroll-container.component.scss @@ -0,0 +1,245 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-scroll-container { + // Core Structure + display: flex; + position: relative; + flex-direction: column; + + // Layout & Spacing + width: 100%; + height: 100%; + + // Visual Design + background: $semantic-color-surface-primary; + border: $semantic-border-width-1 solid $semantic-color-border-subtle; + border-radius: $semantic-border-radius-md; + + // Base overflow behavior + overflow: auto; + + // Smooth scrolling when enabled + &--smooth { + scroll-behavior: smooth; + } + + // Direction Variants + &--vertical { + overflow-x: hidden; + overflow-y: auto; + } + + &--horizontal { + overflow-y: hidden; + overflow-x: auto; + flex-direction: row; + } + + &--both { + overflow: auto; + } + + // Scrollbar Visibility Variants + &--scrollbar-auto { + // Default behavior - scrollbars appear when needed + } + + &--scrollbar-always { + overflow: scroll; + } + + &--scrollbar-never { + scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* Internet Explorer 10+ */ + + &::-webkit-scrollbar { + display: none; /* WebKit */ + } + } + + // Custom Scrollbar Styling + &:not(&--scrollbar-never) { + scrollbar-width: thin; + scrollbar-color: $semantic-color-border-secondary $semantic-color-surface-secondary; + + &::-webkit-scrollbar { + width: 8px; + height: 8px; + } + + &::-webkit-scrollbar-track { + background: $semantic-color-surface-secondary; + border-radius: $semantic-border-radius-sm; + } + + &::-webkit-scrollbar-thumb { + background: $semantic-color-border-secondary; + border-radius: $semantic-border-radius-sm; + transition: background-color $semantic-motion-duration-fast $semantic-motion-easing-ease; + + &:hover { + background: $semantic-color-border-primary; + } + } + + &::-webkit-scrollbar-corner { + background: $semantic-color-surface-secondary; + } + } + + // Content Container + &__content { + flex: 1; + position: relative; + padding: $semantic-spacing-component-md; + + // Typography + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + color: $semantic-color-text-primary; + } + + // Virtual Scrolling Components + &__virtual-spacer { + position: relative; + width: 100%; + } + + &__virtual-content { + position: absolute; + top: 0; + left: 0; + right: 0; + will-change: transform; + } + + &__virtual-item { + display: flex; + align-items: center; + padding: $semantic-spacing-component-sm; + border-bottom: $semantic-border-width-1 solid $semantic-color-border-subtle; + + // Typography + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + color: $semantic-color-text-primary; + + &:last-child { + border-bottom: none; + } + } + + // Scroll Indicators + &__indicators { + position: absolute; + top: $semantic-spacing-component-sm; + right: $semantic-spacing-component-sm; + z-index: $semantic-z-index-tooltip; + pointer-events: none; + } + + &__indicator { + width: 24px; + height: 24px; + border-radius: $semantic-border-radius-full; + background: $semantic-color-surface-elevated; + box-shadow: $semantic-shadow-elevation-2; + margin-bottom: $semantic-spacing-component-xs; + display: flex; + align-items: center; + justify-content: center; + opacity: $semantic-opacity-subtle; + transition: opacity $semantic-motion-duration-fast $semantic-motion-easing-ease; + + &--up::before { + content: '↑'; + color: $semantic-color-text-primary; + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: $semantic-typography-font-weight-bold; + } + + &--down::before { + content: '↓'; + color: $semantic-color-text-primary; + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: $semantic-typography-font-weight-bold; + } + + .ui-scroll-container:hover & { + opacity: 1; + } + } + + // Focus States + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + + // Responsive Design + @media (max-width: $semantic-breakpoint-md - 1) { + &__content { + padding: $semantic-spacing-component-sm; + } + + &__virtual-item { + padding: $semantic-spacing-component-xs; + + // Smaller typography on mobile + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + } + + &__indicators { + top: $semantic-spacing-component-xs; + right: $semantic-spacing-component-xs; + } + + &__indicator { + width: 20px; + height: 20px; + + &--up::before, + &--down::before { + font-size: map-get($semantic-typography-body-small, font-size); + } + } + } + + @media (max-width: $semantic-breakpoint-sm - 1) { + &__content { + padding: $semantic-spacing-component-xs; + } + } + + // Reduced Motion Support + @media (prefers-reduced-motion: reduce) { + &--smooth { + scroll-behavior: auto; + } + + &::-webkit-scrollbar-thumb, + &__indicator { + transition: none; + } + } + + // High Contrast Mode Support + @media (prefers-contrast: high) { + border-width: $semantic-border-width-2; + + &::-webkit-scrollbar-thumb { + background: $semantic-color-border-primary; + } + + &__indicator { + border: $semantic-border-width-1 solid $semantic-color-border-primary; + } + } +} \ No newline at end of file diff --git a/src/lib/components/layout/scroll-container/scroll-container.component.ts b/src/lib/components/layout/scroll-container/scroll-container.component.ts new file mode 100644 index 0000000..58d7f95 --- /dev/null +++ b/src/lib/components/layout/scroll-container/scroll-container.component.ts @@ -0,0 +1,342 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation, ElementRef, ViewChild, AfterViewInit, OnDestroy } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +type ScrollbarVisibility = 'auto' | 'always' | 'never'; +type ScrollDirection = 'vertical' | 'horizontal' | 'both'; +type ScrollBehavior = 'smooth' | 'auto'; + +export interface ScrollVirtualConfig { + itemHeight: number; + bufferSize?: number; + enabled?: boolean; +} + +export interface ScrollPosition { + top: number; + left: number; +} + +@Component({ + selector: 'ui-scroll-container', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ + @if (virtualScrollConfig?.enabled && items && items.length > 0) { + +
+
+ @for (item of visibleItems; track trackByFn ? trackByFn(item) : item) { +
+ +
+ } +
+
+ } @else { + +
+ +
+ } + + @if (showScrollIndicators) { +
+ @if (canScrollUp) { + + } + @if (canScrollDown) { + + } +
+ } +
+ `, + styleUrl: './scroll-container.component.scss' +}) +export class ScrollContainerComponent implements AfterViewInit, OnDestroy { + @ViewChild('scrollContainer', { static: true }) scrollContainer!: ElementRef; + + // Basic Configuration + @Input() direction: ScrollDirection = 'vertical'; + @Input() scrollbarVisibility: ScrollbarVisibility = 'auto'; + @Input() scrollBehavior: ScrollBehavior = 'auto'; + @Input() showScrollIndicators = false; + @Input() role = 'region'; + @Input() ariaLabel = 'Scrollable content'; + @Input() tabIndex = 0; + + // Virtual Scrolling + @Input() virtualScrollConfig?: ScrollVirtualConfig; + @Input() items?: any[]; + @Input() itemTemplate?: any; + @Input() trackByFn?: (item: any) => any; + + // Scroll Position Restoration + @Input() restoreScrollPosition = false; + @Input() scrollPositionKey?: string; + + // Events + @Output() scrolled = new EventEmitter(); + @Output() scrollStart = new EventEmitter(); + @Output() scrollEnd = new EventEmitter(); + @Output() reachedTop = new EventEmitter(); + @Output() reachedBottom = new EventEmitter(); + + // Virtual Scrolling State + visibleItems: any[] = []; + totalHeight = 0; + offsetY = 0; + startIndex = 0; + endIndex = 0; + + // Scroll State + canScrollUp = false; + canScrollDown = false; + private scrollTimeout?: number; + private isScrolling = false; + + ngAfterViewInit(): void { + if (this.virtualScrollConfig?.enabled && this.items) { + this.setupVirtualScrolling(); + } + + if (this.restoreScrollPosition) { + this.restorePosition(); + } + + this.updateScrollIndicators(); + } + + ngOnDestroy(): void { + if (this.scrollTimeout) { + clearTimeout(this.scrollTimeout); + } + + if (this.restoreScrollPosition) { + this.savePosition(); + } + } + + onScroll(event: Event): void { + const element = event.target as HTMLElement; + const scrollPosition: ScrollPosition = { + top: element.scrollTop, + left: element.scrollLeft + }; + + if (!this.isScrolling) { + this.isScrolling = true; + this.scrollStart.emit(); + } + + this.scrolled.emit(scrollPosition); + + // Check if reached boundaries + if (element.scrollTop === 0) { + this.reachedTop.emit(); + } + + if (element.scrollTop + element.clientHeight >= element.scrollHeight - 1) { + this.reachedBottom.emit(); + } + + // Update virtual scrolling + if (this.virtualScrollConfig?.enabled) { + this.updateVirtualScrolling(element.scrollTop); + } + + this.updateScrollIndicators(); + + // Debounce scroll end event + if (this.scrollTimeout) { + clearTimeout(this.scrollTimeout); + } + + this.scrollTimeout = window.setTimeout(() => { + this.isScrolling = false; + this.scrollEnd.emit(); + }, 150); + } + + onKeyDown(event: KeyboardEvent): void { + const element = this.scrollContainer.nativeElement; + let handled = false; + + switch (event.key) { + case 'ArrowUp': + this.scrollBy(0, -40); + handled = true; + break; + case 'ArrowDown': + this.scrollBy(0, 40); + handled = true; + break; + case 'ArrowLeft': + if (this.direction === 'horizontal' || this.direction === 'both') { + this.scrollBy(-40, 0); + handled = true; + } + break; + case 'ArrowRight': + if (this.direction === 'horizontal' || this.direction === 'both') { + this.scrollBy(40, 0); + handled = true; + } + break; + case 'PageUp': + this.scrollBy(0, -element.clientHeight * 0.8); + handled = true; + break; + case 'PageDown': + this.scrollBy(0, element.clientHeight * 0.8); + handled = true; + break; + case 'Home': + this.scrollTo({ top: 0, left: 0 }); + handled = true; + break; + case 'End': + this.scrollTo({ top: element.scrollHeight, left: 0 }); + handled = true; + break; + } + + if (handled) { + event.preventDefault(); + } + } + + // Public API Methods + scrollTo(position: Partial): void { + const element = this.scrollContainer.nativeElement; + const options: ScrollToOptions = { + behavior: this.scrollBehavior + }; + + if (position.top !== undefined) { + options.top = position.top; + } + if (position.left !== undefined) { + options.left = position.left; + } + + element.scrollTo(options); + } + + scrollBy(deltaX: number, deltaY: number): void { + const element = this.scrollContainer.nativeElement; + element.scrollBy({ + left: deltaX, + top: deltaY, + behavior: this.scrollBehavior + }); + } + + scrollToTop(): void { + this.scrollTo({ top: 0 }); + } + + scrollToBottom(): void { + const element = this.scrollContainer.nativeElement; + this.scrollTo({ top: element.scrollHeight }); + } + + getScrollPosition(): ScrollPosition { + const element = this.scrollContainer.nativeElement; + return { + top: element.scrollTop, + left: element.scrollLeft + }; + } + + // Virtual Scrolling Implementation + private setupVirtualScrolling(): void { + if (!this.virtualScrollConfig || !this.items) return; + + const bufferSize = this.virtualScrollConfig.bufferSize || 10; + const itemHeight = this.virtualScrollConfig.itemHeight; + + this.totalHeight = this.items.length * itemHeight; + + const containerHeight = this.scrollContainer.nativeElement.clientHeight; + const visibleCount = Math.ceil(containerHeight / itemHeight) + bufferSize * 2; + + this.endIndex = Math.min(visibleCount, this.items.length); + this.visibleItems = this.items.slice(this.startIndex, this.endIndex); + } + + private updateVirtualScrolling(scrollTop: number): void { + if (!this.virtualScrollConfig || !this.items) return; + + const itemHeight = this.virtualScrollConfig.itemHeight; + const bufferSize = this.virtualScrollConfig.bufferSize || 10; + const containerHeight = this.scrollContainer.nativeElement.clientHeight; + + this.startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - bufferSize); + const visibleCount = Math.ceil(containerHeight / itemHeight) + bufferSize * 2; + this.endIndex = Math.min(this.startIndex + visibleCount, this.items.length); + + this.visibleItems = this.items.slice(this.startIndex, this.endIndex); + this.offsetY = this.startIndex * itemHeight; + } + + getItemIndex(item: any): number { + if (!this.items) return -1; + return this.items.indexOf(item); + } + + // Scroll Position Restoration + private savePosition(): void { + if (!this.scrollPositionKey) return; + + const position = this.getScrollPosition(); + const key = `scroll-position-${this.scrollPositionKey}`; + sessionStorage.setItem(key, JSON.stringify(position)); + } + + private restorePosition(): void { + if (!this.scrollPositionKey) return; + + const key = `scroll-position-${this.scrollPositionKey}`; + const saved = sessionStorage.getItem(key); + + if (saved) { + try { + const position = JSON.parse(saved) as ScrollPosition; + setTimeout(() => this.scrollTo(position), 0); + } catch (e) { + console.warn('Failed to restore scroll position:', e); + } + } + } + + // Scroll Indicators + private updateScrollIndicators(): void { + if (!this.showScrollIndicators) return; + + const element = this.scrollContainer.nativeElement; + this.canScrollUp = element.scrollTop > 0; + this.canScrollDown = element.scrollTop + element.clientHeight < element.scrollHeight - 1; + } +} \ No newline at end of file diff --git a/src/lib/components/layout/section/index.ts b/src/lib/components/layout/section/index.ts new file mode 100644 index 0000000..cf19dc4 --- /dev/null +++ b/src/lib/components/layout/section/index.ts @@ -0,0 +1 @@ +export * from './section.component'; \ No newline at end of file diff --git a/src/lib/components/layout/section/section.component.scss b/src/lib/components/layout/section/section.component.scss new file mode 100644 index 0000000..11f131a --- /dev/null +++ b/src/lib/components/layout/section/section.component.scss @@ -0,0 +1,114 @@ +@use 'ui-design-system/src/styles/semantic' as *; + +.ui-section { + display: block; + position: relative; + width: 100%; + + // Base spacing - using semantic layout section tokens + &--spacing-xs { + padding-top: $semantic-spacing-layout-section-xs; + padding-bottom: $semantic-spacing-layout-section-xs; + } + + &--spacing-sm { + padding-top: $semantic-spacing-layout-section-sm; + padding-bottom: $semantic-spacing-layout-section-sm; + } + + &--spacing-md { + padding-top: $semantic-spacing-layout-section-md; + padding-bottom: $semantic-spacing-layout-section-md; + } + + &--spacing-lg { + padding-top: $semantic-spacing-layout-section-lg; + padding-bottom: $semantic-spacing-layout-section-lg; + } + + &--spacing-xl { + padding-top: $semantic-spacing-layout-section-xl; + padding-bottom: $semantic-spacing-layout-section-xl; + } + + // Background variants + &--surface { + background: $semantic-color-surface-primary; + } + + &--surface-secondary { + background: $semantic-color-surface-secondary; + } + + &--surface-elevated { + background: $semantic-color-surface-elevated; + } + + // Content alignment + &--align-center { + text-align: center; + } + + &--align-end { + text-align: end; + } + + // Width variants + &--full-width { + width: 100%; + max-width: none; + } + + &--contained { + max-width: 1200px; + margin-left: auto; + margin-right: auto; + padding-left: $semantic-spacing-component-md; + padding-right: $semantic-spacing-component-md; + } + + // Typography styling for section content + color: $semantic-color-text-primary; + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + + // Responsive behavior + @media (max-width: 768px) { + &--contained { + padding-left: $semantic-spacing-component-sm; + padding-right: $semantic-spacing-component-sm; + } + + // Reduce section spacing on mobile + &--spacing-xl { + padding-top: $semantic-spacing-layout-section-lg; + padding-bottom: $semantic-spacing-layout-section-lg; + } + + &--spacing-lg { + padding-top: $semantic-spacing-layout-section-md; + padding-bottom: $semantic-spacing-layout-section-md; + } + } + + @media (max-width: 480px) { + &--contained { + padding-left: $semantic-spacing-component-xs; + padding-right: $semantic-spacing-component-xs; + } + + // Further reduce section spacing on very small screens + &--spacing-xl, + &--spacing-lg { + padding-top: $semantic-spacing-layout-section-sm; + padding-bottom: $semantic-spacing-layout-section-sm; + } + + &--spacing-md { + padding-top: $semantic-spacing-layout-section-xs; + padding-bottom: $semantic-spacing-layout-section-xs; + } + } +} \ No newline at end of file diff --git a/src/lib/components/layout/section/section.component.ts b/src/lib/components/layout/section/section.component.ts new file mode 100644 index 0000000..1745dd3 --- /dev/null +++ b/src/lib/components/layout/section/section.component.ts @@ -0,0 +1,46 @@ +import { Component, Input, ChangeDetectionStrategy, ViewEncapsulation } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +type SectionSpacing = 'xs' | 'sm' | 'md' | 'lg' | 'xl'; +type SectionBackground = 'transparent' | 'surface' | 'surface-secondary' | 'surface-elevated'; +type SectionAlign = 'start' | 'center' | 'end'; + +@Component({ + selector: 'ui-section', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ + +
+ `, + styleUrl: './section.component.scss' +}) +export class SectionComponent { + @Input() spacing: SectionSpacing = 'md'; + @Input() background: SectionBackground = 'transparent'; + @Input() align: SectionAlign = 'start'; + @Input() fullWidth = false; + @Input() contained = true; + @Input() ariaLabel?: string; + @Input() role?: string; + + getClasses(): Record { + const classes: Record = { + 'ui-section': true, + [`ui-section--spacing-${this.spacing}`]: true, + [`ui-section--${this.background}`]: this.background !== 'transparent', + [`ui-section--align-${this.align}`]: this.align !== 'start', + 'ui-section--full-width': this.fullWidth, + 'ui-section--contained': this.contained + }; + + return classes; + } +} \ No newline at end of file diff --git a/src/lib/components/layout/sidebar-layout/index.ts b/src/lib/components/layout/sidebar-layout/index.ts new file mode 100644 index 0000000..622d712 --- /dev/null +++ b/src/lib/components/layout/sidebar-layout/index.ts @@ -0,0 +1 @@ +export * from './sidebar-layout.component'; \ No newline at end of file diff --git a/src/lib/components/layout/sidebar-layout/sidebar-layout.component.scss b/src/lib/components/layout/sidebar-layout/sidebar-layout.component.scss new file mode 100644 index 0000000..450331c --- /dev/null +++ b/src/lib/components/layout/sidebar-layout/sidebar-layout.component.scss @@ -0,0 +1,217 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-sidebar-layout { + // Core Structure + display: flex; + position: relative; + min-height: 100vh; + width: 100%; + + // Layout & Spacing + gap: 0; + + // Visual Design + background: $semantic-color-surface; + color: $semantic-color-text-primary; + + // Transitions + transition: all $semantic-motion-duration-normal $semantic-motion-easing-ease; + + // Right position variant + &--position-right { + flex-direction: row-reverse; + + .ui-sidebar-layout__sidebar { + border-left: $semantic-border-width-1 solid $semantic-color-border-secondary; + border-right: none; + } + } + + // Collapsed state + &--collapsed { + .ui-sidebar-layout__content { + margin-left: 0; + } + + &.ui-sidebar-layout--position-right .ui-sidebar-layout__content { + margin-right: 0; + } + } + + // Hidden variant + &--hidden { + .ui-sidebar-layout__sidebar { + display: none; + } + } + + // Overlay mode + &--overlay { + .ui-sidebar-layout__sidebar { + position: fixed; + top: 0; + left: 0; + height: 100vh; + z-index: $semantic-z-index-modal; + + &--collapsed { + transform: translateX(-100%); + } + } + + &.ui-sidebar-layout--position-right .ui-sidebar-layout__sidebar { + left: auto; + right: 0; + + &--collapsed { + transform: translateX(100%); + } + } + + .ui-sidebar-layout__content { + width: 100%; + margin-left: 0; + margin-right: 0; + } + } + + // Visual variants + &--bordered { + .ui-sidebar-layout__sidebar { + border-right: $semantic-border-width-1 solid $semantic-color-border-primary; + } + } + + &--elevated { + .ui-sidebar-layout__sidebar { + box-shadow: $semantic-shadow-elevation-2; + background: $semantic-color-surface-elevated; + } + } + + // Sidebar Element + &__sidebar { + display: flex; + flex-direction: column; + flex-shrink: 0; + position: relative; + + // Layout & Spacing + padding: $semantic-spacing-component-md; + + // Visual Design + background: $semantic-color-surface-secondary; + border-right: $semantic-border-width-1 solid $semantic-color-border-secondary; + + // Typography + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + + // Transitions + transition: all $semantic-motion-duration-normal $semantic-motion-easing-ease; + + // Width variants + &--sm { + width: 200px; + min-width: 200px; + } + + &--md { + width: 280px; + min-width: 280px; + } + + &--lg { + width: 360px; + min-width: 360px; + } + + &--xl { + width: 440px; + min-width: 440px; + } + + // Collapsed state + &--collapsed { + width: 60px; + min-width: 60px; + padding: $semantic-spacing-component-sm; + } + } + + // Backdrop Element + &__backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: $semantic-z-index-overlay; + + // Visual Design + background: $semantic-color-backdrop; + opacity: $semantic-opacity-backdrop; + + // Transitions + transition: opacity $semantic-motion-duration-fast $semantic-motion-easing-ease; + + // Interactive + cursor: pointer; + } + + // Content Element + &__content { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + position: relative; + + // Layout & Spacing + padding: $semantic-spacing-component-md; + + // Visual Design + background: $semantic-color-surface; + color: $semantic-color-text-primary; + + // Typography + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + + // Transitions + transition: margin $semantic-motion-duration-normal $semantic-motion-easing-ease; + } + + // Responsive Design + @media (max-width: 768px) { + &:not(.ui-sidebar-layout--overlay) { + .ui-sidebar-layout__sidebar { + &--sm { + width: 60px; + min-width: 60px; + padding: $semantic-spacing-component-xs; + } + + &--md, &--lg, &--xl { + width: 60px; + min-width: 60px; + padding: $semantic-spacing-component-xs; + } + } + } + + .ui-sidebar-layout__content { + padding: $semantic-spacing-component-sm; + } + } + + @media (max-width: 480px) { + .ui-sidebar-layout__content { + padding: $semantic-spacing-component-xs; + } + } +} \ No newline at end of file diff --git a/src/lib/components/layout/sidebar-layout/sidebar-layout.component.ts b/src/lib/components/layout/sidebar-layout/sidebar-layout.component.ts new file mode 100644 index 0000000..a997304 --- /dev/null +++ b/src/lib/components/layout/sidebar-layout/sidebar-layout.component.ts @@ -0,0 +1,77 @@ +import { Component, Input, ChangeDetectionStrategy, ViewEncapsulation } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +type SidebarPosition = 'left' | 'right'; +type SidebarWidth = 'sm' | 'md' | 'lg' | 'xl'; +type SidebarVariant = 'default' | 'bordered' | 'elevated'; +type CollapseMode = 'hidden' | 'collapsed' | 'overlay'; + +@Component({ + selector: 'ui-sidebar-layout', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ + + + @if (collapseMode === 'overlay' && !collapsed) { + + } + +
+ + +
+
+ `, + styleUrl: './sidebar-layout.component.scss' +}) +export class SidebarLayoutComponent { + @Input() position: SidebarPosition = 'left'; + @Input() sidebarWidth: SidebarWidth = 'md'; + @Input() variant: SidebarVariant = 'default'; + @Input() collapsed = false; + @Input() collapseMode: CollapseMode = 'collapsed'; + @Input() role = 'application'; + @Input() contentLabel = 'Main content'; + @Input() enableBackdropClose = true; + + handleBackdropClick(): void { + if (this.enableBackdropClose && this.collapseMode === 'overlay') { + // In a real implementation, this would emit an event + // For demo purposes, we'll just log + console.log('Backdrop clicked - sidebar should close'); + } + } +} \ No newline at end of file diff --git a/src/lib/components/layout/spacer/index.ts b/src/lib/components/layout/spacer/index.ts new file mode 100644 index 0000000..ce69c4d --- /dev/null +++ b/src/lib/components/layout/spacer/index.ts @@ -0,0 +1 @@ +export * from './spacer.component'; \ No newline at end of file diff --git a/src/lib/components/layout/spacer/spacer.component.scss b/src/lib/components/layout/spacer/spacer.component.scss new file mode 100644 index 0000000..70805c1 --- /dev/null +++ b/src/lib/components/layout/spacer/spacer.component.scss @@ -0,0 +1,322 @@ +@use 'ui-design-system/src/styles/semantic' as tokens; + +.ui-spacer { + display: block; + position: relative; + + // Default spacing (only when not flexible) + &:not(.ui-spacer--flexible) { + width: tokens.$semantic-spacing-md; + height: tokens.$semantic-spacing-md; + } + + // Size variants (only when not flexible) + &--xs:not(.ui-spacer--flexible) { + width: tokens.$semantic-spacing-xs; + height: tokens.$semantic-spacing-xs; + } + + &--sm:not(.ui-spacer--flexible) { + width: tokens.$semantic-spacing-sm; + height: tokens.$semantic-spacing-sm; + } + + &--md:not(.ui-spacer--flexible) { + width: tokens.$semantic-spacing-md; + height: tokens.$semantic-spacing-md; + } + + &--lg:not(.ui-spacer--flexible) { + width: tokens.$semantic-spacing-lg; + height: tokens.$semantic-spacing-lg; + } + + &--xl:not(.ui-spacer--flexible) { + width: tokens.$semantic-spacing-xl; + height: tokens.$semantic-spacing-xl; + } + + &--2xl:not(.ui-spacer--flexible) { + width: tokens.$semantic-spacing-2xl; + height: tokens.$semantic-spacing-2xl; + } + + &--3xl:not(.ui-spacer--flexible) { + width: tokens.$semantic-spacing-3xl; + height: tokens.$semantic-spacing-3xl; + } + + &--4xl:not(.ui-spacer--flexible) { + width: tokens.$semantic-spacing-4xl; + height: tokens.$semantic-spacing-4xl; + } + + &--5xl:not(.ui-spacer--flexible) { + width: tokens.$semantic-spacing-5xl; + height: tokens.$semantic-spacing-5xl; + } + + // Directional variants (only when not flexible) + &--horizontal:not(.ui-spacer--flexible) { + height: 0; + width: tokens.$semantic-spacing-md; + + &.ui-spacer--xs { width: tokens.$semantic-spacing-xs; } + &.ui-spacer--sm { width: tokens.$semantic-spacing-sm; } + &.ui-spacer--md { width: tokens.$semantic-spacing-md; } + &.ui-spacer--lg { width: tokens.$semantic-spacing-lg; } + &.ui-spacer--xl { width: tokens.$semantic-spacing-xl; } + &.ui-spacer--2xl { width: tokens.$semantic-spacing-2xl; } + &.ui-spacer--3xl { width: tokens.$semantic-spacing-3xl; } + &.ui-spacer--4xl { width: tokens.$semantic-spacing-4xl; } + &.ui-spacer--5xl { width: tokens.$semantic-spacing-5xl; } + } + + &--vertical:not(.ui-spacer--flexible) { + width: 0; + height: tokens.$semantic-spacing-md; + + &.ui-spacer--xs { height: tokens.$semantic-spacing-xs; } + &.ui-spacer--sm { height: tokens.$semantic-spacing-sm; } + &.ui-spacer--md { height: tokens.$semantic-spacing-md; } + &.ui-spacer--lg { height: tokens.$semantic-spacing-lg; } + &.ui-spacer--xl { height: tokens.$semantic-spacing-xl; } + &.ui-spacer--2xl { height: tokens.$semantic-spacing-2xl; } + &.ui-spacer--3xl { height: tokens.$semantic-spacing-3xl; } + &.ui-spacer--4xl { height: tokens.$semantic-spacing-4xl; } + &.ui-spacer--5xl { height: tokens.$semantic-spacing-5xl; } + } + + // Flexible spacer (grows to fill available space) + &--flexible { + flex: 1 1 0; + + &.ui-spacer--horizontal { + height: 0; + min-width: tokens.$semantic-spacing-xs; + } + + &.ui-spacer--vertical { + width: 0; + min-height: tokens.$semantic-spacing-xs; + } + + // When both directions, allow flex in any direction + &:not(.ui-spacer--horizontal):not(.ui-spacer--vertical) { + min-width: tokens.$semantic-spacing-xs; + min-height: tokens.$semantic-spacing-xs; + } + } + + // Component-specific spacing variants + &--component-xs { + width: tokens.$semantic-spacing-component-xs; + height: tokens.$semantic-spacing-component-xs; + + &.ui-spacer--horizontal { + width: tokens.$semantic-spacing-component-xs; + height: 0; + } + + &.ui-spacer--vertical { + width: 0; + height: tokens.$semantic-spacing-component-xs; + } + } + + &--component-sm { + width: tokens.$semantic-spacing-component-sm; + height: tokens.$semantic-spacing-component-sm; + + &.ui-spacer--horizontal { + width: tokens.$semantic-spacing-component-sm; + height: 0; + } + + &.ui-spacer--vertical { + width: 0; + height: tokens.$semantic-spacing-component-sm; + } + } + + &--component-md { + width: tokens.$semantic-spacing-component-md; + height: tokens.$semantic-spacing-component-md; + + &.ui-spacer--horizontal { + width: tokens.$semantic-spacing-component-md; + height: 0; + } + + &.ui-spacer--vertical { + width: 0; + height: tokens.$semantic-spacing-component-md; + } + } + + &--component-lg { + width: tokens.$semantic-spacing-component-lg; + height: tokens.$semantic-spacing-component-lg; + + &.ui-spacer--horizontal { + width: tokens.$semantic-spacing-component-lg; + height: 0; + } + + &.ui-spacer--vertical { + width: 0; + height: tokens.$semantic-spacing-component-lg; + } + } + + &--component-xl { + width: tokens.$semantic-spacing-component-xl; + height: tokens.$semantic-spacing-component-xl; + + &.ui-spacer--horizontal { + width: tokens.$semantic-spacing-component-xl; + height: 0; + } + + &.ui-spacer--vertical { + width: 0; + height: tokens.$semantic-spacing-component-xl; + } + } + + // Layout-specific spacing variants + &--layout-xs { + width: tokens.$semantic-spacing-layout-xs; + height: tokens.$semantic-spacing-layout-xs; + + &.ui-spacer--horizontal { + width: tokens.$semantic-spacing-layout-xs; + height: 0; + } + + &.ui-spacer--vertical { + width: 0; + height: tokens.$semantic-spacing-layout-xs; + } + } + + &--layout-sm { + width: tokens.$semantic-spacing-layout-sm; + height: tokens.$semantic-spacing-layout-sm; + + &.ui-spacer--horizontal { + width: tokens.$semantic-spacing-layout-sm; + height: 0; + } + + &.ui-spacer--vertical { + width: 0; + height: tokens.$semantic-spacing-layout-sm; + } + } + + &--layout-md { + width: tokens.$semantic-spacing-layout-md; + height: tokens.$semantic-spacing-layout-md; + + &.ui-spacer--horizontal { + width: tokens.$semantic-spacing-layout-md; + height: 0; + } + + &.ui-spacer--vertical { + width: 0; + height: tokens.$semantic-spacing-layout-md; + } + } + + &--layout-lg { + width: tokens.$semantic-spacing-layout-lg; + height: tokens.$semantic-spacing-layout-lg; + + &.ui-spacer--horizontal { + width: tokens.$semantic-spacing-layout-lg; + height: 0; + } + + &.ui-spacer--vertical { + width: 0; + height: tokens.$semantic-spacing-layout-lg; + } + } + + &--layout-xl { + width: tokens.$semantic-spacing-layout-xl; + height: tokens.$semantic-spacing-layout-xl; + + &.ui-spacer--horizontal { + width: tokens.$semantic-spacing-layout-xl; + height: 0; + } + + &.ui-spacer--vertical { + width: 0; + height: tokens.$semantic-spacing-layout-xl; + } + } + + // Visual debug mode (only in development) + &--debug { + background: repeating-linear-gradient( + 45deg, + transparent, + transparent 2px, + rgba(255, 0, 0, 0.1) 2px, + rgba(255, 0, 0, 0.1) 4px + ); + border: 1px dashed rgba(255, 0, 0, 0.3); + } + + // Responsive behavior + @media (max-width: 768px) { + &--responsive { + &.ui-spacer--xl, + &.ui-spacer--2xl, + &.ui-spacer--3xl, + &.ui-spacer--4xl, + &.ui-spacer--5xl { + width: tokens.$semantic-spacing-lg; + height: tokens.$semantic-spacing-lg; + + &.ui-spacer--horizontal { + width: tokens.$semantic-spacing-lg; + height: 0; + } + + &.ui-spacer--vertical { + width: 0; + height: tokens.$semantic-spacing-lg; + } + } + } + } + + @media (max-width: 480px) { + &--responsive { + &.ui-spacer--lg, + &.ui-spacer--xl, + &.ui-spacer--2xl, + &.ui-spacer--3xl, + &.ui-spacer--4xl, + &.ui-spacer--5xl { + width: tokens.$semantic-spacing-md; + height: tokens.$semantic-spacing-md; + + &.ui-spacer--horizontal { + width: tokens.$semantic-spacing-md; + height: 0; + } + + &.ui-spacer--vertical { + width: 0; + height: tokens.$semantic-spacing-md; + } + } + } + } +} \ No newline at end of file diff --git a/src/lib/components/layout/spacer/spacer.component.ts b/src/lib/components/layout/spacer/spacer.component.ts new file mode 100644 index 0000000..45145ed --- /dev/null +++ b/src/lib/components/layout/spacer/spacer.component.ts @@ -0,0 +1,57 @@ +import { Component, Input, ChangeDetectionStrategy, ViewEncapsulation } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +type SpacerSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl'; +type SpacerVariant = 'component-xs' | 'component-sm' | 'component-md' | 'component-lg' | 'component-xl' | + 'layout-xs' | 'layout-sm' | 'layout-md' | 'layout-lg' | 'layout-xl'; +type SpacerDirection = 'both' | 'horizontal' | 'vertical'; + +@Component({ + selector: 'ui-spacer', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+
+ `, + styleUrl: './spacer.component.scss' +}) +export class SpacerComponent { + @Input() size: SpacerSize = 'md'; + @Input() variant?: SpacerVariant; + @Input() direction: SpacerDirection = 'both'; + @Input() flexible = false; + @Input() responsive = true; + @Input() debug = false; + @Input() customWidth?: string; + @Input() customHeight?: string; + + getClasses(): Record { + const classes: Record = {}; + + if (this.size && !this.variant) { + classes[`ui-spacer--${this.size}`] = true; + } + + if (this.variant) { + classes[`ui-spacer--${this.variant}`] = true; + } + + if (this.direction !== 'both') { + classes[`ui-spacer--${this.direction}`] = true; + } + + classes['ui-spacer--flexible'] = this.flexible; + classes['ui-spacer--responsive'] = this.responsive; + classes['ui-spacer--debug'] = this.debug; + + return classes; + } +} \ No newline at end of file diff --git a/src/lib/components/layout/split-view/index.ts b/src/lib/components/layout/split-view/index.ts new file mode 100644 index 0000000..877cb3a --- /dev/null +++ b/src/lib/components/layout/split-view/index.ts @@ -0,0 +1 @@ +export * from './split-view.component'; \ No newline at end of file diff --git a/src/lib/components/layout/split-view/split-view.component.scss b/src/lib/components/layout/split-view/split-view.component.scss new file mode 100644 index 0000000..d329f3a --- /dev/null +++ b/src/lib/components/layout/split-view/split-view.component.scss @@ -0,0 +1,262 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-split-view { + // Core Structure + display: flex; + position: relative; + width: 100%; + height: 100%; + overflow: hidden; + + // Layout & Spacing + gap: $semantic-spacing-component-xs; + + // Visual Design + background: $semantic-color-surface; + border: $semantic-border-width-1 solid $semantic-color-border-subtle; + border-radius: $semantic-border-radius-md; + + // Direction Variants + &--horizontal { + flex-direction: row; + } + + &--vertical { + flex-direction: column; + } + + // Size Variants + &--sm { + min-height: $semantic-sizing-button-height-sm * 4; // Reasonable minimum for resizable panels + gap: $semantic-spacing-component-xs; + } + + &--md { + min-height: $semantic-sizing-button-height-md * 6; + gap: $semantic-spacing-component-sm; + } + + &--lg { + min-height: $semantic-sizing-button-height-lg * 8; + gap: $semantic-spacing-component-md; + } + + // Panel Element + &__panel { + position: relative; + overflow: hidden; + background: $semantic-color-surface-primary; + border: $semantic-border-width-1 solid $semantic-color-border-secondary; + border-radius: $semantic-border-radius-sm; + + // Typography + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + color: $semantic-color-text-primary; + + // Transitions + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + + // Panel Content + &-content { + padding: $semantic-spacing-component-md; + height: 100%; + overflow: auto; + } + + // Panel States + &--primary { + background: $semantic-color-surface-primary; + border-color: $semantic-color-border-primary; + } + + &--secondary { + background: $semantic-color-surface-secondary; + border-color: $semantic-color-border-secondary; + } + + &--elevated { + background: $semantic-color-surface-elevated; + box-shadow: $semantic-shadow-elevation-2; + } + + &--collapsed { + min-width: 0; + min-height: 0; + opacity: $semantic-opacity-subtle; + } + } + + // Resizer Handle + &__resizer { + position: relative; + background: $semantic-color-surface-secondary; + border: $semantic-border-width-1 solid $semantic-color-border-primary; + cursor: col-resize; + z-index: $semantic-z-index-dropdown; + + // Transitions + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + + // Horizontal Resizer (for vertical split) + &--horizontal { + min-height: 4px; + cursor: row-resize; + border-left: none; + border-right: none; + + &:hover, + &:focus-visible { + background: $semantic-color-interactive-primary; + min-height: 6px; + } + + &:active { + background: $semantic-color-primary; + min-height: 8px; + } + } + + // Vertical Resizer (for horizontal split) + &--vertical { + min-width: 4px; + cursor: col-resize; + border-top: none; + border-bottom: none; + + &:hover, + &:focus-visible { + background: $semantic-color-interactive-primary; + min-width: 6px; + } + + &:active { + background: $semantic-color-primary; + min-width: 8px; + } + } + + // Focus States + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + + // Disabled State + &--disabled { + cursor: not-allowed; + opacity: $semantic-opacity-disabled; + pointer-events: none; + } + } + + // Resizer Handle Visual Indicator + &__resizer-handle { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 20px; + height: 4px; + background: $semantic-color-text-tertiary; + border-radius: $semantic-border-radius-sm; + opacity: $semantic-opacity-hover; + + &::before, + &::after { + content: ''; + position: absolute; + left: 0; + width: 100%; + height: 2px; + background: $semantic-color-text-tertiary; + border-radius: $semantic-border-radius-sm; + } + + &::before { + top: -4px; + } + + &::after { + bottom: -4px; + } + + // Horizontal orientation + .ui-split-view__resizer--horizontal & { + width: 4px; + height: 20px; + + &::before, + &::after { + top: 0; + width: 2px; + height: 100%; + } + + &::before { + left: -4px; + } + + &::after { + right: -4px; + left: auto; + } + } + } + + // State Variants + &--disabled { + opacity: $semantic-opacity-disabled; + pointer-events: none; + } + + &--resizing { + user-select: none; + + .ui-split-view__panel { + pointer-events: none; + } + } + + // Responsive Design + @media (max-width: $semantic-breakpoint-md - 1) { + gap: $semantic-spacing-component-xs; + + .ui-split-view__panel-content { + padding: $semantic-spacing-component-sm; + } + + .ui-split-view__resizer { + &--vertical { + min-width: 6px; + } + + &--horizontal { + min-height: 6px; + } + } + } + + @media (max-width: $semantic-breakpoint-sm - 1) { + // Stack vertically on small screens + &--horizontal { + flex-direction: column; + + .ui-split-view__resizer--vertical { + cursor: row-resize; + min-width: auto; + min-height: 6px; + } + } + + .ui-split-view__panel-content { + padding: $semantic-spacing-component-xs; + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + } + } +} \ No newline at end of file diff --git a/src/lib/components/layout/split-view/split-view.component.ts b/src/lib/components/layout/split-view/split-view.component.ts new file mode 100644 index 0000000..ff426b2 --- /dev/null +++ b/src/lib/components/layout/split-view/split-view.component.ts @@ -0,0 +1,361 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation, ElementRef, OnDestroy, AfterViewInit, ViewChild } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +export type SplitDirection = 'horizontal' | 'vertical'; +export type SplitSize = 'sm' | 'md' | 'lg'; +export type PanelVariant = 'primary' | 'secondary' | 'elevated'; + +export interface PanelConfig { + id: string; + size?: number | string; + minSize?: number; + maxSize?: number; + resizable?: boolean; + variant?: PanelVariant; + collapsed?: boolean; +} + +export interface ResizeEvent { + panelId: string; + newSize: number; + direction: SplitDirection; +} + +@Component({ + selector: 'ui-split-view', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` + + `, + styleUrl: './split-view.component.scss' +}) +export class SplitViewComponent implements AfterViewInit, OnDestroy { + @Input() direction: SplitDirection = 'horizontal'; + @Input() size: SplitSize = 'md'; + @Input() disabled = false; + @Input() panels: PanelConfig[] = [ + { id: 'panel-1', size: '50%' }, + { id: 'panel-2', size: '50%' } + ]; + @Input() minPanelSize = 100; + @Input() resizerSize = 4; + + @Output() panelResized = new EventEmitter(); + @Output() resizeStart = new EventEmitter<{ panelId: string, direction: SplitDirection }>(); + @Output() resizeEnd = new EventEmitter<{ panelId: string, direction: SplitDirection }>(); + + @ViewChild('splitContainer', { static: true }) splitContainer!: ElementRef; + + isResizing = false; + private currentResizerIndex = -1; + private startPosition = 0; + private startSizes: number[] = []; + private boundMouseMove = this.handleMouseMove.bind(this); + private boundMouseUp = this.handleMouseUp.bind(this); + private boundTouchMove = this.handleTouchMove.bind(this); + private boundTouchEnd = this.handleTouchEnd.bind(this); + + ngAfterViewInit(): void { + // Initialize panel sizes if not set + this.initializePanelSizes(); + } + + ngOnDestroy(): void { + this.removeGlobalListeners(); + } + + private initializePanelSizes(): void { + // Ensure panels have proper sizes + const totalPanels = this.panels.length; + const defaultSize = `${100 / totalPanels}%`; + + this.panels.forEach((panel, index) => { + if (!panel.size) { + panel.size = defaultSize; + } + }); + } + + getPanelSize(panel: PanelConfig, index: number): string { + if (panel.collapsed) return '0'; + return typeof panel.size === 'number' ? `${panel.size}px` : panel.size || '1fr'; + } + + startResize(event: MouseEvent | TouchEvent, resizerIndex: number): void { + if (this.disabled) return; + + event.preventDefault(); + event.stopPropagation(); + + this.isResizing = true; + this.currentResizerIndex = resizerIndex; + + const clientPos = this.getClientPosition(event); + this.startPosition = this.direction === 'horizontal' ? clientPos.x : clientPos.y; + + // Store current panel sizes + this.startSizes = this.getCurrentPanelSizes(); + + // Add global listeners + this.addGlobalListeners(); + + // Emit resize start event + const panelId = this.panels[resizerIndex].id; + this.resizeStart.emit({ panelId, direction: this.direction }); + } + + private handleMouseMove(event: MouseEvent): void { + this.handleMove(this.getClientPosition(event)); + } + + private handleTouchMove(event: TouchEvent): void { + this.handleMove(this.getClientPosition(event)); + } + + private handleMove(clientPos: { x: number, y: number }): void { + if (!this.isResizing || this.currentResizerIndex === -1) return; + + const currentPosition = this.direction === 'horizontal' ? clientPos.x : clientPos.y; + const delta = currentPosition - this.startPosition; + + const containerRect = this.splitContainer.nativeElement.getBoundingClientRect(); + const containerSize = this.direction === 'horizontal' ? containerRect.width : containerRect.height; + + const leftPanelIndex = this.currentResizerIndex; + const rightPanelIndex = this.currentResizerIndex + 1; + + if (leftPanelIndex >= 0 && rightPanelIndex < this.panels.length) { + const leftPanel = this.panels[leftPanelIndex]; + const rightPanel = this.panels[rightPanelIndex]; + + // Calculate new sizes + const leftStartSize = this.startSizes[leftPanelIndex]; + const rightStartSize = this.startSizes[rightPanelIndex]; + + let newLeftSize = leftStartSize + delta; + let newRightSize = rightStartSize - delta; + + // Apply minimum size constraints + const minSize = leftPanel.minSize || this.minPanelSize; + const rightMinSize = rightPanel.minSize || this.minPanelSize; + + if (newLeftSize < minSize) { + newLeftSize = minSize; + newRightSize = leftStartSize + rightStartSize - newLeftSize; + } + + if (newRightSize < rightMinSize) { + newRightSize = rightMinSize; + newLeftSize = leftStartSize + rightStartSize - newRightSize; + } + + // Apply maximum size constraints + if (leftPanel.maxSize && newLeftSize > leftPanel.maxSize) { + newLeftSize = leftPanel.maxSize; + newRightSize = leftStartSize + rightStartSize - newLeftSize; + } + + if (rightPanel.maxSize && newRightSize > rightPanel.maxSize) { + newRightSize = rightPanel.maxSize; + newLeftSize = leftStartSize + rightStartSize - newRightSize; + } + + // Convert to percentages + const leftPercentage = (newLeftSize / containerSize) * 100; + const rightPercentage = (newRightSize / containerSize) * 100; + + // Update panel sizes + leftPanel.size = `${leftPercentage}%`; + rightPanel.size = `${rightPercentage}%`; + + // Emit resize event + this.panelResized.emit({ + panelId: leftPanel.id, + newSize: newLeftSize, + direction: this.direction + }); + } + } + + private handleMouseUp(): void { + this.endResize(); + } + + private handleTouchEnd(): void { + this.endResize(); + } + + private endResize(): void { + if (!this.isResizing) return; + + this.isResizing = false; + const panelId = this.panels[this.currentResizerIndex]?.id; + this.currentResizerIndex = -1; + + this.removeGlobalListeners(); + + // Emit resize end event + if (panelId) { + this.resizeEnd.emit({ panelId, direction: this.direction }); + } + } + + private getCurrentPanelSizes(): number[] { + const containerRect = this.splitContainer.nativeElement.getBoundingClientRect(); + const containerSize = this.direction === 'horizontal' ? containerRect.width : containerRect.height; + + return this.panels.map((panel) => { + if (typeof panel.size === 'string' && panel.size.endsWith('%')) { + const percentage = parseFloat(panel.size.replace('%', '')); + return (percentage / 100) * containerSize; + } else if (typeof panel.size === 'number') { + return panel.size; + } + return containerSize / this.panels.length; // Default equal distribution + }); + } + + private getClientPosition(event: MouseEvent | TouchEvent): { x: number, y: number } { + if (event instanceof MouseEvent) { + return { x: event.clientX, y: event.clientY }; + } else { + const touch = event.touches[0] || event.changedTouches[0]; + return { x: touch.clientX, y: touch.clientY }; + } + } + + private addGlobalListeners(): void { + document.addEventListener('mousemove', this.boundMouseMove); + document.addEventListener('mouseup', this.boundMouseUp); + document.addEventListener('touchmove', this.boundTouchMove, { passive: false }); + document.addEventListener('touchend', this.boundTouchEnd); + document.addEventListener('selectstart', this.preventSelection); + } + + private removeGlobalListeners(): void { + document.removeEventListener('mousemove', this.boundMouseMove); + document.removeEventListener('mouseup', this.boundMouseUp); + document.removeEventListener('touchmove', this.boundTouchMove); + document.removeEventListener('touchend', this.boundTouchEnd); + document.removeEventListener('selectstart', this.preventSelection); + } + + private preventSelection(event: Event): void { + event.preventDefault(); + } + + handleResizerKeydown(event: KeyboardEvent, resizerIndex: number): void { + const step = 10; // pixels + let delta = 0; + + switch (event.key) { + case 'ArrowLeft': + if (this.direction === 'horizontal') delta = -step; + break; + case 'ArrowRight': + if (this.direction === 'horizontal') delta = step; + break; + case 'ArrowUp': + if (this.direction === 'vertical') delta = -step; + break; + case 'ArrowDown': + if (this.direction === 'vertical') delta = step; + break; + case 'Home': + delta = -1000; // Move to minimum + break; + case 'End': + delta = 1000; // Move to maximum + break; + default: + return; + } + + event.preventDefault(); + + // Simulate resize with keyboard + this.currentResizerIndex = resizerIndex; + this.startSizes = this.getCurrentPanelSizes(); + this.handleMove({ x: this.startPosition + delta, y: this.startPosition + delta }); + this.currentResizerIndex = -1; + } + + // Public methods for programmatic control + collapsePanel(panelId: string): void { + const panel = this.panels.find(p => p.id === panelId); + if (panel) { + panel.collapsed = true; + } + } + + expandPanel(panelId: string): void { + const panel = this.panels.find(p => p.id === panelId); + if (panel) { + panel.collapsed = false; + } + } + + setPanelSize(panelId: string, size: number | string): void { + const panel = this.panels.find(p => p.id === panelId); + if (panel) { + panel.size = size; + } + } + + resetPanelSizes(): void { + const equalSize = `${100 / this.panels.length}%`; + this.panels.forEach(panel => { + panel.size = equalSize; + panel.collapsed = false; + }); + } +} \ No newline at end of file diff --git a/src/lib/components/layout/stack/index.ts b/src/lib/components/layout/stack/index.ts new file mode 100644 index 0000000..d8c4b26 --- /dev/null +++ b/src/lib/components/layout/stack/index.ts @@ -0,0 +1 @@ +export * from './stack.component'; \ No newline at end of file diff --git a/src/lib/components/layout/stack/stack.component.scss b/src/lib/components/layout/stack/stack.component.scss new file mode 100644 index 0000000..748fc7a --- /dev/null +++ b/src/lib/components/layout/stack/stack.component.scss @@ -0,0 +1,176 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-stack { + display: flex; + position: relative; + + // Base structure + &--column { + flex-direction: column; + } + + &--row { + flex-direction: row; + } + + // Inline variant + &--inline { + display: inline-flex; + } + + // Spacing variants - using semantic stack spacing tokens + &--spacing-xs { + gap: $semantic-spacing-stack-xs; + } + + &--spacing-sm { + gap: $semantic-spacing-stack-sm; + } + + &--spacing-md { + gap: $semantic-spacing-stack-md; + } + + &--spacing-lg { + gap: $semantic-spacing-stack-lg; + } + + &--spacing-xl { + gap: $semantic-spacing-stack-xl; + } + + &--spacing-2xl { + gap: $semantic-spacing-2xl; + } + + &--spacing-3xl { + gap: $semantic-spacing-3xl; + } + + &--spacing-4xl { + gap: $semantic-spacing-4xl; + } + + &--spacing-5xl { + gap: $semantic-spacing-5xl; + } + + // Alignment variants + &--align-start { + align-items: flex-start; + } + + &--align-center { + align-items: center; + } + + &--align-end { + align-items: flex-end; + } + + &--align-stretch { + align-items: stretch; + } + + &--align-baseline { + align-items: baseline; + } + + // Justify variants + &--justify-start { + justify-content: flex-start; + } + + &--justify-center { + justify-content: center; + } + + &--justify-end { + justify-content: flex-end; + } + + &--justify-between { + justify-content: space-between; + } + + &--justify-around { + justify-content: space-around; + } + + &--justify-evenly { + justify-content: space-evenly; + } + + // Wrap variants + &--wrap-wrap { + flex-wrap: wrap; + } + + &--wrap-nowrap { + flex-wrap: nowrap; + } + + &--wrap-wrap-reverse { + flex-wrap: wrap-reverse; + } + + // Divider variant - adds borders between children + &--divider { + &.ui-stack--column > :not(:last-child) { + border-bottom: $semantic-border-width-1 solid $semantic-color-border-secondary; + padding-bottom: $semantic-spacing-component-sm; + margin-bottom: $semantic-spacing-component-sm; + } + + &.ui-stack--row > :not(:last-child) { + border-right: $semantic-border-width-1 solid $semantic-color-border-secondary; + padding-right: $semantic-spacing-component-sm; + margin-right: $semantic-spacing-component-sm; + } + + // Remove gap when using dividers to avoid double spacing + gap: 0; + } + + // Responsive behavior + &--responsive { + // On small screens, convert horizontal stacks to vertical for better usability + @media (max-width: 640px) { + &.ui-stack--row { + flex-direction: column; + + // Adjust dividers for responsive layout + &.ui-stack--divider { + > :not(:last-child) { + border-right: none; + border-bottom: $semantic-border-width-1 solid $semantic-color-border-secondary; + padding-right: 0; + margin-right: 0; + padding-bottom: $semantic-spacing-component-sm; + margin-bottom: $semantic-spacing-component-sm; + } + } + } + + // Reduce spacing on small screens + &.ui-stack--spacing-xl, + &.ui-stack--spacing-2xl, + &.ui-stack--spacing-3xl, + &.ui-stack--spacing-4xl, + &.ui-stack--spacing-5xl { + gap: $semantic-spacing-stack-lg; + } + } + + @media (max-width: 480px) { + &.ui-stack--spacing-lg, + &.ui-stack--spacing-xl, + &.ui-stack--spacing-2xl, + &.ui-stack--spacing-3xl, + &.ui-stack--spacing-4xl, + &.ui-stack--spacing-5xl { + gap: $semantic-spacing-stack-md; + } + } + } +} \ No newline at end of file diff --git a/src/lib/components/layout/stack/stack.component.ts b/src/lib/components/layout/stack/stack.component.ts new file mode 100644 index 0000000..095d1ea --- /dev/null +++ b/src/lib/components/layout/stack/stack.component.ts @@ -0,0 +1,63 @@ +import { Component, Input, ChangeDetectionStrategy, ViewEncapsulation } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +type StackDirection = 'column' | 'row'; +type StackSpacing = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl'; +type StackAlignment = 'start' | 'center' | 'end' | 'stretch' | 'baseline'; +type StackJustify = 'start' | 'center' | 'end' | 'between' | 'around' | 'evenly'; +type StackWrap = 'nowrap' | 'wrap' | 'wrap-reverse'; + +@Component({ + selector: 'ui-stack', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ + +
+ `, + styleUrl: './stack.component.scss' +}) +export class StackComponent { + @Input() direction: StackDirection = 'column'; + @Input() spacing: StackSpacing = 'md'; + @Input() align?: StackAlignment; + @Input() justify?: StackJustify; + @Input() wrap?: StackWrap; + @Input() inline = false; + @Input() responsive = true; + @Input() divider = false; + @Input() customGap?: string; + @Input() role?: string; + + getClasses(): Record { + const classes: Record = { + 'ui-stack': true, + [`ui-stack--${this.direction}`]: true, + [`ui-stack--spacing-${this.spacing}`]: true, + 'ui-stack--inline': this.inline, + 'ui-stack--responsive': this.responsive, + 'ui-stack--divider': this.divider + }; + + if (this.align) { + classes[`ui-stack--align-${this.align}`] = true; + } + + if (this.justify) { + classes[`ui-stack--justify-${this.justify}`] = true; + } + + if (this.wrap) { + classes[`ui-stack--wrap-${this.wrap}`] = true; + } + + return classes; + } +} \ No newline at end of file diff --git a/src/lib/components/layout/sticky-layout/index.ts b/src/lib/components/layout/sticky-layout/index.ts new file mode 100644 index 0000000..087164d --- /dev/null +++ b/src/lib/components/layout/sticky-layout/index.ts @@ -0,0 +1 @@ +export * from './sticky-layout.component'; \ No newline at end of file diff --git a/src/lib/components/layout/sticky-layout/sticky-layout.component.scss b/src/lib/components/layout/sticky-layout/sticky-layout.component.scss new file mode 100644 index 0000000..17a4e37 --- /dev/null +++ b/src/lib/components/layout/sticky-layout/sticky-layout.component.scss @@ -0,0 +1,300 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-sticky-layout { + position: sticky; + display: block; + box-sizing: border-box; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + + // Position variants + &--top { + top: 0; + z-index: $semantic-z-index-sticky; + } + + &--bottom { + bottom: 0; + z-index: $semantic-z-index-sticky; + } + + &--left { + left: 0; + z-index: $semantic-z-index-sticky; + } + + &--right { + right: 0; + z-index: $semantic-z-index-sticky; + } + + // Size variants + &--full { + width: 100%; + + &.ui-sticky-layout--left, + &.ui-sticky-layout--right { + height: 100vh; + } + } + + &--content { + width: fit-content; + height: fit-content; + } + + // Offset variants + &--offset-xs { + &.ui-sticky-layout--top { + top: $semantic-spacing-component-xs; + } + + &.ui-sticky-layout--bottom { + bottom: $semantic-spacing-component-xs; + } + + &.ui-sticky-layout--left { + left: $semantic-spacing-component-xs; + } + + &.ui-sticky-layout--right { + right: $semantic-spacing-component-xs; + } + } + + &--offset-sm { + &.ui-sticky-layout--top { + top: $semantic-spacing-component-sm; + } + + &.ui-sticky-layout--bottom { + bottom: $semantic-spacing-component-sm; + } + + &.ui-sticky-layout--left { + left: $semantic-spacing-component-sm; + } + + &.ui-sticky-layout--right { + right: $semantic-spacing-component-sm; + } + } + + &--offset-md { + &.ui-sticky-layout--top { + top: $semantic-spacing-component-md; + } + + &.ui-sticky-layout--bottom { + bottom: $semantic-spacing-component-md; + } + + &.ui-sticky-layout--left { + left: $semantic-spacing-component-md; + } + + &.ui-sticky-layout--right { + right: $semantic-spacing-component-md; + } + } + + &--offset-lg { + &.ui-sticky-layout--top { + top: $semantic-spacing-component-lg; + } + + &.ui-sticky-layout--bottom { + bottom: $semantic-spacing-component-lg; + } + + &.ui-sticky-layout--left { + left: $semantic-spacing-component-lg; + } + + &.ui-sticky-layout--right { + right: $semantic-spacing-component-lg; + } + } + + &--offset-xl { + &.ui-sticky-layout--top { + top: $semantic-spacing-component-xl; + } + + &.ui-sticky-layout--bottom { + bottom: $semantic-spacing-component-xl; + } + + &.ui-sticky-layout--left { + left: $semantic-spacing-component-xl; + } + + &.ui-sticky-layout--right { + right: $semantic-spacing-component-xl; + } + } + + // Background variants + &--surface { + background: $semantic-color-surface-primary; + } + + &--surface-secondary { + background: $semantic-color-surface-secondary; + } + + &--surface-elevated { + background: $semantic-color-surface-elevated; + } + + &--backdrop { + background: $semantic-color-backdrop; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + } + + // Variant styles + &--header { + padding: $semantic-spacing-component-md $semantic-spacing-component-lg; + background: $semantic-color-surface-primary; + border-bottom: $semantic-border-width-1 solid $semantic-color-border-subtle; + z-index: $semantic-z-index-header; + + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + } + + &--sidebar { + padding: $semantic-spacing-component-lg; + background: $semantic-color-surface-secondary; + border-right: $semantic-border-width-1 solid $semantic-color-border-subtle; + z-index: $semantic-z-index-sidebar; + min-width: 250px; + height: 100vh; + overflow-y: auto; + } + + &--footer { + padding: $semantic-spacing-component-md $semantic-spacing-component-lg; + background: $semantic-color-surface-primary; + border-top: $semantic-border-width-1 solid $semantic-color-border-subtle; + z-index: $semantic-z-index-footer; + + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + } + + &--floating { + padding: $semantic-spacing-component-sm; + background: $semantic-color-surface-elevated; + border-radius: $semantic-border-radius-lg; + box-shadow: $semantic-shadow-elevation-3; + border: $semantic-border-width-1 solid $semantic-color-border-subtle; + z-index: $semantic-z-index-floating; + margin: $semantic-spacing-component-md; + } + + &--toolbar { + padding: $semantic-spacing-component-sm $semantic-spacing-component-md; + background: $semantic-color-surface-elevated; + border: $semantic-border-width-1 solid $semantic-color-border-primary; + border-radius: $semantic-border-radius-md; + z-index: $semantic-z-index-elevated; + + display: flex; + align-items: center; + gap: $semantic-spacing-component-sm; + } + + // Shadow enhancement + &--shadow { + box-shadow: $semantic-shadow-elevation-2; + + &.ui-sticky-layout--header { + box-shadow: $semantic-shadow-elevation-1; + } + + &.ui-sticky-layout--floating { + box-shadow: $semantic-shadow-elevation-4; + } + } + + // Border enhancement + &--border { + border: $semantic-border-width-1 solid $semantic-color-border-primary; + + &.ui-sticky-layout--top { + border-bottom: $semantic-border-width-2 solid $semantic-color-border-primary; + } + + &.ui-sticky-layout--bottom { + border-top: $semantic-border-width-2 solid $semantic-color-border-primary; + } + + &.ui-sticky-layout--left { + border-right: $semantic-border-width-2 solid $semantic-color-border-primary; + } + + &.ui-sticky-layout--right { + border-left: $semantic-border-width-2 solid $semantic-color-border-primary; + } + } + + // Blur effect + &--blur { + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + background: rgba(255, 255, 255, 0.8); + + &.ui-sticky-layout--surface { + background: color-mix(in srgb, $semantic-color-surface-primary 80%, transparent); + } + + &.ui-sticky-layout--surface-secondary { + background: color-mix(in srgb, $semantic-color-surface-secondary 80%, transparent); + } + + &.ui-sticky-layout--surface-elevated { + background: color-mix(in srgb, $semantic-color-surface-elevated 80%, transparent); + } + } + + // Responsive behavior + @media (max-width: 768px) { + &--sidebar { + min-width: 200px; + padding: $semantic-spacing-component-md; + } + + &--header, + &--footer { + padding: $semantic-spacing-component-sm $semantic-spacing-component-md; + } + + &--floating { + margin: $semantic-spacing-component-sm; + padding: $semantic-spacing-component-xs; + } + } + + @media (max-width: 480px) { + &--sidebar { + min-width: 180px; + padding: $semantic-spacing-component-sm; + } + + &--header, + &--footer, + &--toolbar { + padding: $semantic-spacing-component-xs $semantic-spacing-component-sm; + } + + &--floating { + margin: $semantic-spacing-component-xs; + border-radius: $semantic-border-radius-sm; + } + } +} \ No newline at end of file diff --git a/src/lib/components/layout/sticky-layout/sticky-layout.component.ts b/src/lib/components/layout/sticky-layout/sticky-layout.component.ts new file mode 100644 index 0000000..7a1414c --- /dev/null +++ b/src/lib/components/layout/sticky-layout/sticky-layout.component.ts @@ -0,0 +1,78 @@ +import { Component, Input, ChangeDetectionStrategy, ViewEncapsulation } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +type StickyPosition = 'top' | 'bottom' | 'left' | 'right'; +type StickyOffset = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'none'; +type StickyVariant = 'default' | 'header' | 'sidebar' | 'footer' | 'floating' | 'toolbar'; +type StickyBackground = 'transparent' | 'surface' | 'surface-secondary' | 'surface-elevated' | 'backdrop'; +type StickySize = 'auto' | 'full' | 'content'; + +@Component({ + selector: 'ui-sticky-layout', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ + +
+ `, + styleUrl: './sticky-layout.component.scss' +}) +export class StickyLayoutComponent { + @Input() position: StickyPosition = 'top'; + @Input() variant: StickyVariant = 'default'; + @Input() background: StickyBackground = 'transparent'; + @Input() offset?: StickyOffset; + @Input() size: StickySize = 'auto'; + @Input() shadow = false; + @Input() border = false; + @Input() blur = false; + @Input() customZIndex?: number; + @Input() customWidth?: string; + @Input() customHeight?: string; + @Input() role?: string; + @Input() ariaLabel?: string; + + getClasses(): Record { + const classes: Record = { + 'ui-sticky-layout': true, + [`ui-sticky-layout--${this.position}`]: true, + [`ui-sticky-layout--${this.size}`]: this.size !== 'auto' + }; + + if (this.variant !== 'default') { + classes[`ui-sticky-layout--${this.variant}`] = true; + } + + if (this.background !== 'transparent') { + classes[`ui-sticky-layout--${this.background}`] = true; + } + + if (this.offset) { + classes[`ui-sticky-layout--offset-${this.offset}`] = true; + } + + if (this.shadow) { + classes['ui-sticky-layout--shadow'] = true; + } + + if (this.border) { + classes['ui-sticky-layout--border'] = true; + } + + if (this.blur) { + classes['ui-sticky-layout--blur'] = true; + } + + return classes; + } +} \ No newline at end of file diff --git a/src/lib/components/layout/supporting-pane-layout/index.ts b/src/lib/components/layout/supporting-pane-layout/index.ts new file mode 100644 index 0000000..566e68f --- /dev/null +++ b/src/lib/components/layout/supporting-pane-layout/index.ts @@ -0,0 +1 @@ +export * from './supporting-pane-layout.component'; \ No newline at end of file diff --git a/src/lib/components/layout/supporting-pane-layout/supporting-pane-layout.component.scss b/src/lib/components/layout/supporting-pane-layout/supporting-pane-layout.component.scss new file mode 100644 index 0000000..053bbd8 --- /dev/null +++ b/src/lib/components/layout/supporting-pane-layout/supporting-pane-layout.component.scss @@ -0,0 +1,369 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-supporting-pane-layout { + // Core Structure + display: flex; + position: relative; + min-height: 100vh; + width: 100%; + + // Layout & Spacing + gap: 0; + + // Visual Design + background: $semantic-color-surface; + color: $semantic-color-text-primary; + + // Transitions + transition: all $semantic-motion-duration-normal $semantic-motion-easing-ease; + + // Right position variant + &--position-right { + flex-direction: row-reverse; + + .ui-supporting-pane-layout__pane { + border-left: $semantic-border-width-1 solid $semantic-color-border-secondary; + border-right: none; + } + } + + // Collapsed state + &--collapsed { + .ui-supporting-pane-layout__pane { + width: 60px; + min-width: 60px; + + .ui-supporting-pane-layout__pane-content { + opacity: 0; + visibility: hidden; + } + + .ui-supporting-pane-layout__pane-toggle { + padding: $semantic-spacing-component-xs; + } + } + } + + // Sticky positioning + &--sticky-pane { + .ui-supporting-pane-layout__pane { + position: sticky; + top: 0; + height: 100vh; + overflow-y: auto; + } + } + + // Visual variants + &--bordered { + .ui-supporting-pane-layout__pane { + border-right: $semantic-border-width-1 solid $semantic-color-border-primary; + } + } + + &--elevated { + .ui-supporting-pane-layout__pane { + box-shadow: $semantic-shadow-elevation-2; + background: $semantic-color-surface-elevated; + } + } + + &--subtle { + .ui-supporting-pane-layout__pane { + background: $semantic-color-surface-secondary; + border-right: $semantic-border-width-1 solid $semantic-color-border-subtle; + } + } + + // Size variants + &--sm { + .ui-supporting-pane-layout__pane:not(.ui-supporting-pane-layout__pane--collapsed) { + width: 240px; + min-width: 240px; + } + } + + &--md { + .ui-supporting-pane-layout__pane:not(.ui-supporting-pane-layout__pane--collapsed) { + width: 320px; + min-width: 320px; + } + } + + &--lg { + .ui-supporting-pane-layout__pane:not(.ui-supporting-pane-layout__pane--collapsed) { + width: 400px; + min-width: 400px; + } + } + + &--xl { + .ui-supporting-pane-layout__pane:not(.ui-supporting-pane-layout__pane--collapsed) { + width: 480px; + min-width: 480px; + } + } + + // Main Content Element + &__content { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + position: relative; + + // Layout & Spacing + padding: $semantic-spacing-component-md; + + // Visual Design + background: $semantic-color-surface; + color: $semantic-color-text-primary; + + // Typography + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + + // Transitions + transition: margin $semantic-motion-duration-normal $semantic-motion-easing-ease; + } + + // Supporting Pane Element + &__pane { + display: flex; + flex-direction: column; + flex-shrink: 0; + position: relative; + width: 320px; + min-width: 320px; + + // Layout & Spacing + padding: 0; + + // Visual Design + background: $semantic-color-surface-secondary; + border-right: $semantic-border-width-1 solid $semantic-color-border-secondary; + + // Typography + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + + // Transitions + transition: all $semantic-motion-duration-normal $semantic-motion-easing-ease; + + &--collapsed { + width: 60px; + min-width: 60px; + } + } + + // Pane Header Element + &__pane-header { + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; + + // Layout & Spacing + padding: $semantic-spacing-component-sm $semantic-spacing-component-md; + border-bottom: $semantic-border-width-1 solid $semantic-color-border-subtle; + + // Visual Design + background: $semantic-color-surface-elevated; + color: $semantic-color-text-primary; + + // Typography + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: $semantic-typography-font-weight-semibold; + line-height: map-get($semantic-typography-body-medium, line-height); + } + + // Pane Content Element + &__pane-content { + display: flex; + flex-direction: column; + flex: 1; + + // Layout & Spacing + padding: $semantic-spacing-component-md; + + // Visual Design + background: inherit; + color: $semantic-color-text-primary; + + // Transitions + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + + // When collapsed + .ui-supporting-pane-layout--collapsed & { + opacity: 0; + visibility: hidden; + padding: 0; + } + } + + // Pane Toggle Button + &__pane-toggle { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + // Layout & Spacing + width: $semantic-sizing-touch-target; + height: $semantic-sizing-touch-target; + padding: $semantic-spacing-component-xs; + + // Visual Design + background: transparent; + border: none; + border-radius: $semantic-border-radius-sm; + color: $semantic-color-text-secondary; + + // Typography + font-size: $semantic-sizing-icon-button; + line-height: 1; + + // Transitions + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + cursor: pointer; + + // Interactive States + &:hover { + background: $semantic-color-interactive-primary; + color: $semantic-color-text-primary; + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + + &:active { + background: $semantic-color-interactive-primary; + opacity: $semantic-opacity-hover; + } + + // Icon styling + .fa-icon { + font-size: inherit; + transition: transform $semantic-motion-duration-fast $semantic-motion-easing-ease; + } + + // Collapsed state rotation + .ui-supporting-pane-layout--collapsed & .fa-icon { + transform: rotate(180deg); + } + + .ui-supporting-pane-layout--position-right & .fa-icon { + transform: rotate(180deg); + } + + .ui-supporting-pane-layout--position-right.ui-supporting-pane-layout--collapsed & .fa-icon { + transform: rotate(0deg); + } + } + + // Pane Footer Element (optional) + &__pane-footer { + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; + + // Layout & Spacing + padding: $semantic-spacing-component-sm $semantic-spacing-component-md; + border-top: $semantic-border-width-1 solid $semantic-color-border-subtle; + + // Visual Design + background: $semantic-color-surface-elevated; + color: $semantic-color-text-secondary; + + // Typography + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + } + + // Responsive Design + @media (max-width: 1024px) { + &:not(.ui-supporting-pane-layout--always-visible) { + .ui-supporting-pane-layout__pane { + position: fixed; + top: 0; + right: 0; + height: 100vh; + z-index: $semantic-z-index-modal; + box-shadow: $semantic-shadow-elevation-4; + transform: translateX(100%); + + &:not(.ui-supporting-pane-layout__pane--collapsed) { + transform: translateX(0); + } + } + + &.ui-supporting-pane-layout--position-left .ui-supporting-pane-layout__pane { + left: 0; + right: auto; + transform: translateX(-100%); + + &:not(.ui-supporting-pane-layout__pane--collapsed) { + transform: translateX(0); + } + } + + .ui-supporting-pane-layout__content { + width: 100%; + padding: $semantic-spacing-component-sm; + } + } + } + + @media (max-width: 768px) { + .ui-supporting-pane-layout__content { + padding: $semantic-spacing-component-sm; + } + + .ui-supporting-pane-layout__pane { + width: 280px !important; + min-width: 280px !important; + + &--collapsed { + width: 60px !important; + min-width: 60px !important; + } + } + + .ui-supporting-pane-layout__pane-content { + padding: $semantic-spacing-component-sm; + } + } + + @media (max-width: 480px) { + .ui-supporting-pane-layout__content { + padding: $semantic-spacing-component-xs; + } + + .ui-supporting-pane-layout__pane { + width: 240px !important; + min-width: 240px !important; + } + + .ui-supporting-pane-layout__pane-content { + padding: $semantic-spacing-component-xs; + } + + .ui-supporting-pane-layout__pane-header { + padding: $semantic-spacing-component-xs $semantic-spacing-component-sm; + } + + .ui-supporting-pane-layout__pane-footer { + padding: $semantic-spacing-component-xs $semantic-spacing-component-sm; + } + } +} \ No newline at end of file diff --git a/src/lib/components/layout/supporting-pane-layout/supporting-pane-layout.component.ts b/src/lib/components/layout/supporting-pane-layout/supporting-pane-layout.component.ts new file mode 100644 index 0000000..9aa6830 --- /dev/null +++ b/src/lib/components/layout/supporting-pane-layout/supporting-pane-layout.component.ts @@ -0,0 +1,164 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { faChevronLeft, faChevronRight } from '@fortawesome/free-solid-svg-icons'; + +type PanePosition = 'left' | 'right'; +type PaneSize = 'sm' | 'md' | 'lg' | 'xl'; +type PaneVariant = 'default' | 'bordered' | 'elevated' | 'subtle'; + +@Component({ + selector: 'ui-supporting-pane-layout', + standalone: true, + imports: [CommonModule, FontAwesomeModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ + +
+ + +
+ + + +
+ `, + styleUrl: './supporting-pane-layout.component.scss' +}) +export class SupportingPaneLayoutComponent { + @Input() position: PanePosition = 'right'; + @Input() paneSize: PaneSize = 'md'; + @Input() variant: PaneVariant = 'default'; + @Input() collapsed = false; + @Input() collapsible = true; + @Input() stickyPane = false; + @Input() alwaysVisible = false; + @Input() showPaneHeader = true; + @Input() showPaneFooter = false; + @Input() role = 'application'; + @Input() contentLabel = 'Main content'; + @Input() paneLabel = 'Supporting information'; + @Input() collapseAriaLabel = 'Collapse supporting pane'; + @Input() expandAriaLabel = 'Expand supporting pane'; + + @Output() paneToggled = new EventEmitter(); + @Output() paneCollapsed = new EventEmitter(); + @Output() paneExpanded = new EventEmitter(); + + // FontAwesome icons + faChevronLeft = faChevronLeft; + faChevronRight = faChevronRight; + + handleTogglePane(): void { + if (!this.collapsible) return; + + const newCollapsedState = !this.collapsed; + this.collapsed = newCollapsedState; + + // Emit events + this.paneToggled.emit(newCollapsedState); + + if (newCollapsedState) { + this.paneCollapsed.emit(); + } else { + this.paneExpanded.emit(); + } + } + + handleToggleKeydown(event: KeyboardEvent): void { + // Support Enter and Space keys for accessibility + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + this.handleTogglePane(); + } + } + + /** + * Programmatically collapse the pane + */ + collapsePane(): void { + if (!this.collapsed && this.collapsible) { + this.handleTogglePane(); + } + } + + /** + * Programmatically expand the pane + */ + expandPane(): void { + if (this.collapsed && this.collapsible) { + this.handleTogglePane(); + } + } + + /** + * Get current pane state + */ + isPaneCollapsed(): boolean { + return this.collapsed; + } +} \ No newline at end of file diff --git a/src/lib/components/layout/tabs-container/index.ts b/src/lib/components/layout/tabs-container/index.ts new file mode 100644 index 0000000..eee69b4 --- /dev/null +++ b/src/lib/components/layout/tabs-container/index.ts @@ -0,0 +1 @@ +export * from './tabs-container.component'; \ No newline at end of file diff --git a/src/lib/components/layout/tabs-container/tabs-container.component.scss b/src/lib/components/layout/tabs-container/tabs-container.component.scss new file mode 100644 index 0000000..297127b --- /dev/null +++ b/src/lib/components/layout/tabs-container/tabs-container.component.scss @@ -0,0 +1,404 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-tabs-container { + display: flex; + position: relative; + width: 100%; + + // Layout variants based on position + &--position-top, + &--position-bottom { + flex-direction: column; + } + + &--position-left, + &--position-right { + flex-direction: row; + } + + &--position-bottom { + flex-direction: column-reverse; + } + + &--position-right { + flex-direction: row-reverse; + } + + // Tab Navigation + &__nav { + position: relative; + display: flex; + align-items: center; + background: $semantic-color-surface; + border-bottom: $semantic-border-width-1 solid $semantic-color-border-subtle; + + .ui-tabs-container--position-bottom & { + border-bottom: none; + border-top: $semantic-border-width-1 solid $semantic-color-border-subtle; + } + + .ui-tabs-container--position-left & { + border-bottom: none; + border-right: $semantic-border-width-1 solid $semantic-color-border-subtle; + flex-direction: column; + align-items: stretch; + } + + .ui-tabs-container--position-right & { + border-bottom: none; + border-left: $semantic-border-width-1 solid $semantic-color-border-subtle; + flex-direction: column; + align-items: stretch; + } + } + + &__nav-content { + display: flex; + flex: 1; + overflow-x: auto; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + + .ui-tabs-container--position-left &, + .ui-tabs-container--position-right & { + flex-direction: column; + overflow-x: visible; + overflow-y: auto; + } + + .ui-tabs-container--scrollable & { + scroll-behavior: smooth; + } + + &--dragging { + pointer-events: none; + + .ui-tabs-container__tab { + pointer-events: auto; + } + } + } + + // Individual Tab Button + &__tab { + display: flex; + align-items: center; + gap: $semantic-spacing-component-xs; + position: relative; + background: transparent; + border: none; + cursor: pointer; + font-family: map-get($semantic-typography-button-medium, font-family); + font-size: map-get($semantic-typography-button-medium, font-size); + font-weight: map-get($semantic-typography-button-medium, font-weight); + line-height: map-get($semantic-typography-button-medium, line-height); + color: $semantic-color-text-secondary; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + white-space: nowrap; + user-select: none; + + // Size variants + .ui-tabs-container--size-sm & { + padding: $semantic-spacing-component-xs $semantic-spacing-component-sm; + min-height: $semantic-sizing-button-height-sm; + font-family: map-get($semantic-typography-button-small, font-family); + font-size: map-get($semantic-typography-button-small, font-size); + font-weight: map-get($semantic-typography-button-small, font-weight); + line-height: map-get($semantic-typography-button-small, line-height); + } + + .ui-tabs-container--size-md & { + padding: $semantic-spacing-component-sm $semantic-spacing-component-md; + min-height: $semantic-sizing-button-height-md; + } + + .ui-tabs-container--size-lg & { + padding: $semantic-spacing-component-md $semantic-spacing-component-lg; + min-height: $semantic-sizing-button-height-lg; + font-family: map-get($semantic-typography-button-large, font-family); + font-size: map-get($semantic-typography-button-large, font-size); + font-weight: map-get($semantic-typography-button-large, font-weight); + line-height: map-get($semantic-typography-button-large, line-height); + } + + // Variant styles + .ui-tabs-container--variant-default & { + border-bottom: 2px solid transparent; + + &--active { + color: $semantic-color-primary; + border-bottom-color: $semantic-color-primary; + } + } + + .ui-tabs-container--variant-filled & { + border-radius: $semantic-border-radius-sm; + margin: 0 $semantic-spacing-component-xs; + + &--active { + background: $semantic-color-primary; + color: $semantic-color-on-primary; + } + } + + .ui-tabs-container--variant-pills & { + border-radius: $semantic-border-radius-full; + margin: 0 $semantic-spacing-component-xs; + + &--active { + background: $semantic-color-primary; + color: $semantic-color-on-primary; + } + } + + .ui-tabs-container--variant-underlined & { + border-radius: 0; + border-bottom: 2px solid transparent; + + &--active { + color: $semantic-color-primary; + border-bottom-color: $semantic-color-primary; + background: $semantic-color-surface-elevated; + } + } + + // Position-specific styles + .ui-tabs-container--position-left &, + .ui-tabs-container--position-right & { + justify-content: flex-start; + width: 100%; + text-align: left; + + .ui-tabs-container--variant-default & { + border-bottom: none; + border-right: 2px solid transparent; + + &--active { + border-right-color: $semantic-color-primary; + } + } + + .ui-tabs-container--position-right & { + .ui-tabs-container--variant-default & { + border-right: none; + border-left: 2px solid transparent; + + &--active { + border-left-color: $semantic-color-primary; + } + } + } + } + + // Interactive states + &:hover:not(&--disabled):not(&--active) { + color: $semantic-color-text-primary; + background: $semantic-color-surface-secondary; + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + + &--active { + color: $semantic-color-primary; + font-weight: $semantic-typography-font-weight-semibold; + + .ui-tabs-container--variant-filled &, + .ui-tabs-container--variant-pills & { + background: $semantic-color-primary; + color: $semantic-color-on-primary; + + &:hover { + background: $semantic-color-primary; + } + } + } + + &--disabled { + color: $semantic-color-text-disabled; + cursor: not-allowed; + opacity: $semantic-opacity-disabled; + + &:hover { + background: transparent; + color: $semantic-color-text-disabled; + } + } + + // Draggable state + &[draggable="true"]:not(&--disabled) { + cursor: grab; + + &:active { + cursor: grabbing; + } + } + } + + // Tab elements + &__tab-icon { + display: flex; + align-items: center; + font-size: $semantic-sizing-icon-inline; + } + + &__tab-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + } + + &__tab-close { + display: flex; + align-items: center; + justify-content: center; + width: $semantic-sizing-touch-minimum; + height: $semantic-sizing-touch-minimum; + border: none; + background: transparent; + border-radius: $semantic-border-radius-sm; + color: inherit; + cursor: pointer; + opacity: $semantic-opacity-subtle; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + font-size: $semantic-typography-font-size-lg; + line-height: 1; + + &:hover { + opacity: 1; + background: $semantic-color-surface-secondary; + color: $semantic-color-danger; + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 1px; + } + } + + // Scroll Controls + &__scroll-controls { + display: flex; + gap: $semantic-spacing-component-xs; + padding: 0 $semantic-spacing-component-sm; + border-left: $semantic-border-width-1 solid $semantic-color-border-subtle; + + .ui-tabs-container--position-left &, + .ui-tabs-container--position-right & { + flex-direction: column; + border-left: none; + border-top: $semantic-border-width-1 solid $semantic-color-border-subtle; + padding: $semantic-spacing-component-sm 0; + } + } + + &__scroll-button { + display: flex; + align-items: center; + justify-content: center; + width: $semantic-sizing-button-height-sm; + height: $semantic-sizing-button-height-sm; + border: $semantic-border-width-1 solid $semantic-color-border-primary; + background: $semantic-color-surface; + border-radius: $semantic-border-radius-sm; + color: $semantic-color-text-primary; + cursor: pointer; + font-size: $semantic-typography-font-size-md; + line-height: 1; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + + &:hover:not(:disabled) { + background: $semantic-color-surface-secondary; + border-color: $semantic-color-border-secondary; + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + + &:disabled { + color: $semantic-color-text-disabled; + border-color: $semantic-color-border-subtle; + cursor: not-allowed; + opacity: $semantic-opacity-disabled; + } + } + + // Content Area + &__content { + flex: 1; + position: relative; + min-height: 0; + } + + &__panel { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + padding: $semantic-spacing-component-lg; + background: $semantic-color-surface; + opacity: 0; + visibility: hidden; + transition: opacity $semantic-motion-duration-fast $semantic-motion-easing-ease; + overflow: auto; + + &--active { + opacity: 1; + visibility: visible; + position: relative; + top: auto; + left: auto; + right: auto; + bottom: auto; + } + } + + // Responsive adjustments + @media (max-width: $semantic-breakpoint-md - 1) { + &__tab { + .ui-tabs-container--size-lg & { + padding: $semantic-spacing-component-sm $semantic-spacing-component-md; + min-height: $semantic-sizing-button-height-md; + font-family: map-get($semantic-typography-button-medium, font-family); + font-size: map-get($semantic-typography-button-medium, font-size); + font-weight: map-get($semantic-typography-button-medium, font-weight); + line-height: map-get($semantic-typography-button-medium, line-height); + } + } + + &__panel { + padding: $semantic-spacing-component-md; + } + } + + @media (max-width: $semantic-breakpoint-sm - 1) { + &__tab { + .ui-tabs-container--size-md &, + .ui-tabs-container--size-lg & { + padding: $semantic-spacing-component-xs $semantic-spacing-component-sm; + min-height: $semantic-sizing-button-height-sm; + font-family: map-get($semantic-typography-button-small, font-family); + font-size: map-get($semantic-typography-button-small, font-size); + font-weight: map-get($semantic-typography-button-small, font-weight); + line-height: map-get($semantic-typography-button-small, line-height); + } + } + + &__panel { + padding: $semantic-spacing-component-sm; + } + + &__scroll-controls { + padding: 0 $semantic-spacing-component-xs; + } + } +} \ No newline at end of file diff --git a/src/lib/components/layout/tabs-container/tabs-container.component.ts b/src/lib/components/layout/tabs-container/tabs-container.component.ts new file mode 100644 index 0000000..85ac006 --- /dev/null +++ b/src/lib/components/layout/tabs-container/tabs-container.component.ts @@ -0,0 +1,337 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation, signal, computed } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +export interface Tab { + id: string; + label: string; + content?: any; + closeable?: boolean; + disabled?: boolean; + lazyLoad?: boolean; + icon?: string; +} + +type TabSize = 'sm' | 'md' | 'lg'; +type TabVariant = 'default' | 'filled' | 'pills' | 'underlined'; +type TabPosition = 'top' | 'bottom' | 'left' | 'right'; + +@Component({ + selector: 'ui-tabs-container', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ + +
+
+ @for (tab of tabs; track tab.id; let index = $index) { + + } + + } +
+ + @if (scrollable && showScrollControls()) { +
+ + +
+ } +
+ + +
+ +
+
+ `, + styleUrl: './tabs-container.component.scss' +}) +export class TabsContainerComponent { + @Input() tabs: Tab[] = []; + @Input() size: TabSize = 'md'; + @Input() variant: TabVariant = 'default'; + @Input() position: TabPosition = 'top'; + @Input() scrollable = false; + @Input() reorderable = false; + @Input() lazyLoad = true; + + // Active tab management + private _activeTabId = signal(null); + activeTabId = computed(() => this._activeTabId()); + + // Drag and drop state + isDragging = false; + draggedIndex: number | null = null; + + @Output() tabSelected = new EventEmitter(); + @Output() tabClosed = new EventEmitter(); + @Output() tabsReordered = new EventEmitter(); + + ngOnInit() { + // Set initial active tab if none is set + if (!this.activeTabId() && this.tabs.length > 0) { + const firstEnabledTab = this.tabs.find(tab => !tab.disabled); + if (firstEnabledTab) { + this._activeTabId.set(firstEnabledTab.id); + } + } + } + + getContainerClasses(): Record { + return { + 'ui-tabs-container': true, + [`ui-tabs-container--size-${this.size}`]: true, + [`ui-tabs-container--variant-${this.variant}`]: true, + [`ui-tabs-container--position-${this.position}`]: true, + 'ui-tabs-container--scrollable': this.scrollable + }; + } + + selectTab(tabId: string): void { + const tab = this.tabs.find(t => t.id === tabId); + if (!tab || tab.disabled) return; + + this._activeTabId.set(tabId); + this.tabSelected.emit(tabId); + } + + closeTab(event: Event, tabId: string): void { + event.stopPropagation(); + + const tabIndex = this.tabs.findIndex(t => t.id === tabId); + if (tabIndex === -1) return; + + // If closing active tab, select adjacent tab + if (this.activeTabId() === tabId) { + const remainingTabs = this.tabs.filter(t => t.id !== tabId && !t.disabled); + if (remainingTabs.length > 0) { + // Try to select next tab, or previous if no next + const nextTab = this.tabs[tabIndex + 1]; + const prevTab = this.tabs[tabIndex - 1]; + + if (nextTab && !nextTab.disabled) { + this._activeTabId.set(nextTab.id); + } else if (prevTab && !prevTab.disabled) { + this._activeTabId.set(prevTab.id); + } else { + this._activeTabId.set(remainingTabs[0].id); + } + } else { + this._activeTabId.set(null); + } + } + + this.tabClosed.emit(tabId); + } + + handleTabKeydown(event: KeyboardEvent, index: number): void { + const isHorizontal = this.position === 'top' || this.position === 'bottom'; + let newIndex = index; + + switch (event.key) { + case 'ArrowRight': + if (isHorizontal) { + event.preventDefault(); + newIndex = this.getNextEnabledTabIndex(index, 1); + } + break; + case 'ArrowLeft': + if (isHorizontal) { + event.preventDefault(); + newIndex = this.getNextEnabledTabIndex(index, -1); + } + break; + case 'ArrowDown': + if (!isHorizontal) { + event.preventDefault(); + newIndex = this.getNextEnabledTabIndex(index, 1); + } + break; + case 'ArrowUp': + if (!isHorizontal) { + event.preventDefault(); + newIndex = this.getNextEnabledTabIndex(index, -1); + } + break; + case 'Home': + event.preventDefault(); + newIndex = this.getNextEnabledTabIndex(-1, 1); + break; + case 'End': + event.preventDefault(); + newIndex = this.getNextEnabledTabIndex(this.tabs.length, -1); + break; + case 'Delete': + if (this.tabs[index].closeable) { + event.preventDefault(); + this.closeTab(event, this.tabs[index].id); + } + break; + } + + if (newIndex !== index && newIndex >= 0 && newIndex < this.tabs.length) { + this.selectTab(this.tabs[newIndex].id); + // Focus the new tab + setTimeout(() => { + const newTabElement = document.getElementById('tab-' + this.tabs[newIndex].id); + newTabElement?.focus(); + }); + } + } + + private getNextEnabledTabIndex(startIndex: number, direction: number): number { + let index = startIndex + direction; + + while (index >= 0 && index < this.tabs.length) { + if (!this.tabs[index].disabled) { + return index; + } + index += direction; + } + + // If we didn't find an enabled tab, wrap around + if (direction > 0) { + // Going forward, start from beginning + for (let i = 0; i < startIndex; i++) { + if (!this.tabs[i].disabled) { + return i; + } + } + } else { + // Going backward, start from end + for (let i = this.tabs.length - 1; i > startIndex; i--) { + if (!this.tabs[i].disabled) { + return i; + } + } + } + + return startIndex; // Return original index if no other enabled tab found + } + + // Drag and drop methods + handleDragStart(event: DragEvent, index: number): void { + if (!this.reorderable) return; + + this.isDragging = true; + this.draggedIndex = index; + + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('text/plain', index.toString()); + } + } + + handleDragOver(event: DragEvent): void { + if (!this.reorderable || !this.isDragging) return; + + event.preventDefault(); + event.dataTransfer!.dropEffect = 'move'; + } + + handleDrop(event: DragEvent, dropIndex: number): void { + if (!this.reorderable || this.draggedIndex === null) return; + + event.preventDefault(); + + if (this.draggedIndex !== dropIndex) { + const newTabs = [...this.tabs]; + const draggedTab = newTabs.splice(this.draggedIndex, 1)[0]; + newTabs.splice(dropIndex, 0, draggedTab); + + this.tabsReordered.emit(newTabs); + } + + this.handleDragEnd(); + } + + handleDragEnd(): void { + this.isDragging = false; + this.draggedIndex = null; + } + + // Scrolling methods + showScrollControls(): boolean { + // This would need actual element measurement in a real implementation + return this.scrollable; + } + + canScrollPrev(): boolean { + // Implementation would check actual scroll position + return true; + } + + canScrollNext(): boolean { + // Implementation would check actual scroll position + return true; + } + + scrollTabs(direction: 'prev' | 'next'): void { + // Implementation would handle actual scrolling + const navContent = document.querySelector('.ui-tabs-container__nav-content') as HTMLElement; + if (navContent) { + const scrollAmount = 200; + navContent.scrollBy({ + left: direction === 'next' ? scrollAmount : -scrollAmount, + behavior: 'smooth' + }); + } + } +} \ No newline at end of file diff --git a/src/lib/components/layout/vstack/index.ts b/src/lib/components/layout/vstack/index.ts new file mode 100644 index 0000000..ac918e6 --- /dev/null +++ b/src/lib/components/layout/vstack/index.ts @@ -0,0 +1 @@ +export * from './vstack.component'; \ No newline at end of file diff --git a/src/lib/components/layout/vstack/vstack.component.scss b/src/lib/components/layout/vstack/vstack.component.scss new file mode 100644 index 0000000..e4e8f27 --- /dev/null +++ b/src/lib/components/layout/vstack/vstack.component.scss @@ -0,0 +1,137 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-vstack { + display: flex; + flex-direction: column; + position: relative; + + // Inline variant + &--inline { + display: inline-flex; + } + + // Full width/height variants + &--full-width { + width: 100%; + } + + &--full-height { + height: 100%; + } + + // Spacing variants - using semantic stack spacing tokens + &--spacing-xs { + gap: $semantic-spacing-stack-xs; + } + + &--spacing-sm { + gap: $semantic-spacing-stack-sm; + } + + &--spacing-md { + gap: $semantic-spacing-stack-md; + } + + &--spacing-lg { + gap: $semantic-spacing-stack-lg; + } + + &--spacing-xl { + gap: $semantic-spacing-stack-xl; + } + + &--spacing-2xl { + gap: $semantic-spacing-2xl; + } + + &--spacing-3xl { + gap: $semantic-spacing-3xl; + } + + &--spacing-4xl { + gap: $semantic-spacing-4xl; + } + + &--spacing-5xl { + gap: $semantic-spacing-5xl; + } + + // Horizontal alignment (cross-axis for column) + &--align-start { + align-items: flex-start; + } + + &--align-center { + align-items: center; + } + + &--align-end { + align-items: flex-end; + } + + &--align-stretch { + align-items: stretch; + } + + // Vertical justify (main-axis for column) + &--justify-start { + justify-content: flex-start; + } + + &--justify-center { + justify-content: center; + } + + &--justify-end { + justify-content: flex-end; + } + + &--justify-between { + justify-content: space-between; + } + + &--justify-around { + justify-content: space-around; + } + + &--justify-evenly { + justify-content: space-evenly; + } + + // Divider variant - adds horizontal borders between children + &--divider { + > :not(:last-child) { + border-bottom: $semantic-border-width-1 solid $semantic-color-border-secondary; + padding-bottom: $semantic-spacing-component-sm; + margin-bottom: $semantic-spacing-component-sm; + } + + // Remove gap when using dividers to avoid double spacing + gap: 0; + } + + // Responsive behavior + &--responsive { + @media (max-width: 640px) { + // Reduce spacing on small screens + &.ui-vstack--spacing-xl, + &.ui-vstack--spacing-2xl, + &.ui-vstack--spacing-3xl, + &.ui-vstack--spacing-4xl, + &.ui-vstack--spacing-5xl { + gap: $semantic-spacing-stack-lg; + } + } + + @media (max-width: 480px) { + &.ui-vstack--spacing-lg, + &.ui-vstack--spacing-xl, + &.ui-vstack--spacing-2xl, + &.ui-vstack--spacing-3xl, + &.ui-vstack--spacing-4xl, + &.ui-vstack--spacing-5xl { + gap: $semantic-spacing-stack-md; + } + } + } +} \ No newline at end of file diff --git a/src/lib/components/layout/vstack/vstack.component.ts b/src/lib/components/layout/vstack/vstack.component.ts new file mode 100644 index 0000000..b9fae5c --- /dev/null +++ b/src/lib/components/layout/vstack/vstack.component.ts @@ -0,0 +1,58 @@ +import { Component, Input, ChangeDetectionStrategy, ViewEncapsulation } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +type StackSpacing = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl'; +type HorizontalAlignment = 'start' | 'center' | 'end' | 'stretch'; +type VerticalJustify = 'start' | 'center' | 'end' | 'between' | 'around' | 'evenly'; + +@Component({ + selector: 'ui-vstack', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ + +
+ `, + styleUrl: './vstack.component.scss' +}) +export class VStackComponent { + @Input() spacing: StackSpacing = 'md'; + @Input() align?: HorizontalAlignment; + @Input() justify?: VerticalJustify; + @Input() inline = false; + @Input() responsive = true; + @Input() divider = false; + @Input() fullWidth = false; + @Input() fullHeight = false; + @Input() customGap?: string; + @Input() role?: string; + + getClasses(): Record { + const classes: Record = { + 'ui-vstack': true, + [`ui-vstack--spacing-${this.spacing}`]: true, + 'ui-vstack--inline': this.inline, + 'ui-vstack--responsive': this.responsive, + 'ui-vstack--divider': this.divider, + 'ui-vstack--full-width': this.fullWidth, + 'ui-vstack--full-height': this.fullHeight + }; + + if (this.align) { + classes[`ui-vstack--align-${this.align}`] = true; + } + + if (this.justify) { + classes[`ui-vstack--justify-${this.justify}`] = true; + } + + return classes; + } +} \ No newline at end of file diff --git a/src/lib/components/media/index.ts b/src/lib/components/media/index.ts new file mode 100644 index 0000000..03fe505 --- /dev/null +++ b/src/lib/components/media/index.ts @@ -0,0 +1 @@ +export * from './video-player'; \ No newline at end of file diff --git a/src/lib/components/media/video-player/index.ts b/src/lib/components/media/video-player/index.ts new file mode 100644 index 0000000..52524e3 --- /dev/null +++ b/src/lib/components/media/video-player/index.ts @@ -0,0 +1 @@ +export * from './video-player.component'; \ No newline at end of file diff --git a/src/lib/components/media/video-player/video-player.component.scss b/src/lib/components/media/video-player/video-player.component.scss new file mode 100644 index 0000000..a872fc5 --- /dev/null +++ b/src/lib/components/media/video-player/video-player.component.scss @@ -0,0 +1,403 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +@mixin font-properties($font-map) { + @if type-of($font-map) == 'map' { + font-family: map-get($font-map, 'font-family'); + font-size: map-get($font-map, 'font-size'); + line-height: map-get($font-map, 'line-height'); + font-weight: map-get($font-map, 'font-weight'); + letter-spacing: map-get($font-map, 'letter-spacing'); + } @else { + font: $font-map; + } +} + +.ui-video-player { + position: relative; + width: 100%; + background: $semantic-color-surface; + border-radius: 12px; + overflow: hidden; + box-shadow: $semantic-shadow-elevation-2; + + // Default size (medium) + max-width: 800px; + + &--fullscreen { + max-width: none !important; + width: 100vw !important; + height: 100vh !important; + border-radius: 0; + position: fixed; + top: 0; + left: 0; + z-index: 9999; + } + + &--disabled { + opacity: 0.6; + pointer-events: none; + } + + &--loading { + .ui-video-player__video { + cursor: wait; + } + } + + // Size variants - placed after default to override + &--sm { + max-width: 400px; + } + + &--md { + max-width: 800px; + } + + &--lg { + max-width: 1200px; + } + + // Variant styles + &--minimal { + border-radius: 0; + box-shadow: none; + background: transparent; + } + + &--theater { + width: 100%; + background: #000; + + .ui-video-player__video-container { + padding-bottom: 42.85%; // 21:9 aspect ratio for theater mode + } + + // Only remove max-width if no size variant is applied + &:not(.ui-video-player--sm):not(.ui-video-player--md):not(.ui-video-player--lg) { + max-width: none; + } + } + + &__video-container { + position: relative; + width: 100%; + height: 0; + padding-bottom: 56.25%; // 16:9 aspect ratio + background: #000; + } + + &__video { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: contain; + cursor: pointer; + } + + &__loading-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.8); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: $semantic-spacing-component-md; + z-index: 2; + } + + &__loading-spinner { + width: 40px; + height: 40px; + border: 3px solid rgba(255, 255, 255, 0.3); + border-top: 3px solid $semantic-color-primary; + border-radius: 50%; + animation: spin 1s linear infinite; + } + + &__loading-text { + color: white; + @include font-properties($semantic-typography-body-medium); + } + + &__controls { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: linear-gradient(transparent, rgba(0, 0, 0, 0.8)); + padding: $semantic-spacing-component-lg $semantic-spacing-component-md $semantic-spacing-component-md; + opacity: 0; + transition: opacity $semantic-duration-medium $semantic-easing-standard; + z-index: 3; + display: flex; + align-items: center; + gap: $semantic-spacing-component-sm; + + &--visible { + opacity: 1; + } + } + + &__control { + background: none; + border: none; + color: white; + cursor: pointer; + padding: $semantic-spacing-component-xs; + border-radius: 4px; + transition: background-color $semantic-duration-fast $semantic-easing-standard; + display: flex; + align-items: center; + justify-content: center; + + &:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.2); + } + + &:focus { + outline: 2px solid $semantic-color-primary; + outline-offset: 2px; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } + + &__control-icon { + width: 20px; + height: 20px; + } + + &__progress-container { + flex: 1; + display: flex; + align-items: center; + position: relative; + margin: 0 $semantic-spacing-component-sm; + } + + &__progress-bar { + width: 100%; + height: 4px; + background: transparent; + border: none; + cursor: pointer; + outline: none; + -webkit-appearance: none; + appearance: none; + + &::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 12px; + height: 12px; + border-radius: 50%; + background: $semantic-color-primary; + cursor: pointer; + border: 2px solid white; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + } + + &::-moz-range-thumb { + width: 12px; + height: 12px; + border-radius: 50%; + background: $semantic-color-primary; + cursor: pointer; + border: 2px solid white; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + } + } + + &__progress-track { + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 100%; + height: 4px; + background: rgba(255, 255, 255, 0.3); + border-radius: 2px; + pointer-events: none; + } + + &__progress-filled { + height: 100%; + background: $semantic-color-primary; + border-radius: 2px; + transition: width 0.1s linear; + } + + &__time { + color: white; + @include font-properties($semantic-typography-body-small); + white-space: nowrap; + margin: 0 $semantic-spacing-component-xs; + } + + &__volume-bar { + width: 80px; + height: 4px; + background: transparent; + border: none; + cursor: pointer; + outline: none; + -webkit-appearance: none; + appearance: none; + margin-left: $semantic-spacing-component-xs; + + &::-webkit-slider-track { + width: 100%; + height: 4px; + background: rgba(255, 255, 255, 0.3); + border-radius: 2px; + } + + &::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 12px; + height: 12px; + border-radius: 50%; + background: $semantic-color-primary; + cursor: pointer; + border: 2px solid white; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + } + + &::-moz-range-track { + width: 100%; + height: 4px; + background: rgba(255, 255, 255, 0.3); + border-radius: 2px; + } + + &::-moz-range-thumb { + width: 12px; + height: 12px; + border-radius: 50%; + background: $semantic-color-primary; + cursor: pointer; + border: 2px solid white; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + } + } + + &__fallback { + color: white; + @include font-properties($semantic-typography-body-medium); + text-align: center; + padding: $semantic-spacing-component-lg; + + a { + color: $semantic-color-primary; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + } + + &__error { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.9); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: $semantic-spacing-component-md; + z-index: 4; + padding: $semantic-spacing-component-lg; + } + + &__error-icon { + width: 48px; + height: 48px; + color: $semantic-color-error; + + svg { + width: 100%; + height: 100%; + } + } + + &__error-message { + color: white; + @include font-properties($semantic-typography-body-large); + text-align: center; + margin: 0; + } + + &__error-link { + color: $semantic-color-primary; + @include font-properties($semantic-typography-body-medium); + text-decoration: none; + padding: $semantic-spacing-component-sm $semantic-spacing-component-md; + border: 1px solid $semantic-color-primary; + border-radius: 4px; + transition: all $semantic-duration-fast $semantic-easing-standard; + + &:hover { + background: $semantic-color-primary; + color: white; + } + } + + // Show controls on hover or when paused + &:hover &__controls, + &__controls--visible { + opacity: 1; + } + + // Responsive design + @media (max-width: 768px) { + // On mobile, make size variants responsive but maintain relative sizing + &--sm { + max-width: 320px; + } + + &--md { + max-width: 600px; + } + + &--lg { + max-width: 100%; + } + + &__controls { + padding: $semantic-spacing-component-md $semantic-spacing-component-sm; + gap: $semantic-spacing-component-xs; + } + + &__control-icon { + width: 18px; + height: 18px; + } + + &__volume-bar { + width: 60px; + } + + &__time { + @include font-properties($semantic-typography-body-small); + } + } +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} \ No newline at end of file diff --git a/src/lib/components/media/video-player/video-player.component.ts b/src/lib/components/media/video-player/video-player.component.ts new file mode 100644 index 0000000..851f040 --- /dev/null +++ b/src/lib/components/media/video-player/video-player.component.ts @@ -0,0 +1,448 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation, ViewChild, ElementRef, OnDestroy, signal, computed } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +export type VideoPlayerSize = 'sm' | 'md' | 'lg'; +export type VideoPlayerVariant = 'default' | 'minimal' | 'theater'; +export type VideoQuality = '240p' | '360p' | '480p' | '720p' | '1080p' | 'auto'; + +export interface VideoSource { + src: string; + type: string; + quality?: VideoQuality; + label?: string; +} + +export interface VideoTrack { + src: string; + kind: 'subtitles' | 'captions' | 'descriptions' | 'chapters' | 'metadata'; + srclang: string; + label: string; + default?: boolean; +} + +@Component({ + selector: 'ui-video-player', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ + +
+ + + + @if (loading()) { +
+
+ Loading video... +
+ } + + + @if (showControls && !disabled) { +
+ + + + + +
+ +
+
+
+
+ + + + {{ formatTime(currentTime()) }} / {{ formatTime(duration()) }} + + + + @if (showVolumeControl) { + + + + } + + + @if (allowFullscreen) { + + } +
+ } +
+ + + @if (hasError()) { +
+
+ + + + + +
+

{{ errorMessage() || 'Failed to load video' }}

+ @if (sources.length > 0) { + + Download video + + } +
+ } +
+ `, + styleUrl: './video-player.component.scss' +}) +export class VideoPlayerComponent implements OnDestroy { + @ViewChild('videoElement') videoElement!: ElementRef; + + @Input() sources: VideoSource[] = []; + @Input() tracks: VideoTrack[] = []; + @Input() poster?: string; + @Input() size: VideoPlayerSize = 'md'; + @Input() variant: VideoPlayerVariant = 'default'; + @Input() disabled = false; + @Input() autoplay = false; + @Input() loop = false; + @Input() muted = false; + @Input() playsinline = true; + @Input() preload: 'none' | 'metadata' | 'auto' = 'metadata'; + @Input() showControls = true; + @Input() showVolumeControl = true; + @Input() allowFullscreen = true; + @Input() ariaLabel = 'Video player'; + @Input() videoAriaLabel = 'Video content'; + + @Output() play = new EventEmitter(); + @Output() pause = new EventEmitter(); + @Output() ended = new EventEmitter(); + @Output() timeUpdate = new EventEmitter(); + @Output() volumeChange = new EventEmitter(); + @Output() fullscreenChange = new EventEmitter(); + @Output() error = new EventEmitter(); + + // Signals for reactive state + private _isPlaying = signal(false); + private _currentTime = signal(0); + private _duration = signal(0); + private _volume = signal(1); + private _isMuted = signal(false); + private _loading = signal(false); + private _hasError = signal(false); + private _errorMessage = signal(''); + private _isFullscreen = signal(false); + private _controlsVisible = signal(true); + + // Public readonly signals + isPlaying = this._isPlaying.asReadonly(); + currentTime = this._currentTime.asReadonly(); + duration = this._duration.asReadonly(); + volume = this._volume.asReadonly(); + isMuted = this._isMuted.asReadonly(); + loading = this._loading.asReadonly(); + hasError = this._hasError.asReadonly(); + errorMessage = this._errorMessage.asReadonly(); + isFullscreen = this._isFullscreen.asReadonly(); + controlsVisible = this._controlsVisible.asReadonly(); + + // Computed values + progressPercentage = computed(() => { + const duration = this.duration(); + const currentTime = this.currentTime(); + return duration > 0 ? (currentTime / duration) * 100 : 0; + }); + + private controlsHideTimer?: number; + + getComponentClasses(): string { + const classes = [ + 'ui-video-player', + `ui-video-player--${this.size}`, + `ui-video-player--${this.variant}` + ]; + + if (this.disabled) { + classes.push('ui-video-player--disabled'); + } + + if (this.loading()) { + classes.push('ui-video-player--loading'); + } + + if (this.isFullscreen()) { + classes.push('ui-video-player--fullscreen'); + } + + return classes.join(' '); + } + + ngOnDestroy(): void { + if (this.controlsHideTimer) { + clearTimeout(this.controlsHideTimer); + } + } + + // Video event handlers + handleLoadStart(): void { + this._loading.set(true); + this._hasError.set(false); + } + + handleLoadedData(): void { + if (this.videoElement?.nativeElement) { + this._duration.set(this.videoElement.nativeElement.duration); + } + } + + handleCanPlay(): void { + this._loading.set(false); + } + + handlePlay(): void { + this._isPlaying.set(true); + this.play.emit(); + this.hideControlsAfterDelay(); + } + + handlePause(): void { + this._isPlaying.set(false); + this.pause.emit(); + this.showControlsOverlay(); + } + + handleEnded(): void { + this._isPlaying.set(false); + this.ended.emit(); + this.showControlsOverlay(); + } + + handleTimeUpdate(): void { + if (this.videoElement?.nativeElement) { + const currentTime = this.videoElement.nativeElement.currentTime; + this._currentTime.set(currentTime); + this.timeUpdate.emit(currentTime); + } + } + + handleVideoVolumeChange(): void { + if (this.videoElement?.nativeElement) { + const video = this.videoElement.nativeElement; + this._volume.set(video.volume); + this._isMuted.set(video.muted); + this.volumeChange.emit(video.volume); + } + } + + handleError(event: Event): void { + this._loading.set(false); + this._hasError.set(true); + this._errorMessage.set('Failed to load video'); + this.error.emit(event); + } + + // Control methods + togglePlayPause(): void { + if (!this.videoElement?.nativeElement || this.disabled) return; + + if (this.isPlaying()) { + this.videoElement.nativeElement.pause(); + } else { + this.videoElement.nativeElement.play(); + } + } + + handleSeek(event: Event): void { + if (!this.videoElement?.nativeElement || this.disabled) return; + + const target = event.target as HTMLInputElement; + const seekTime = parseFloat(target.value); + this.videoElement.nativeElement.currentTime = seekTime; + } + + toggleMute(): void { + if (!this.videoElement?.nativeElement || this.disabled) return; + + this.videoElement.nativeElement.muted = !this.videoElement.nativeElement.muted; + } + + handleVolumeSliderChange(event: Event): void { + if (!this.videoElement?.nativeElement || this.disabled) return; + + const target = event.target as HTMLInputElement; + const newVolume = parseFloat(target.value); + this.videoElement.nativeElement.volume = newVolume; + } + + toggleFullscreen(): void { + if (!this.allowFullscreen || this.disabled) return; + + if (!document.fullscreenElement) { + this.videoElement?.nativeElement.requestFullscreen().then(() => { + this._isFullscreen.set(true); + this.fullscreenChange.emit(true); + }); + } else { + document.exitFullscreen().then(() => { + this._isFullscreen.set(false); + this.fullscreenChange.emit(false); + }); + } + } + + // Control visibility + private showControlsOverlay(): void { + this._controlsVisible.set(true); + if (this.controlsHideTimer) { + clearTimeout(this.controlsHideTimer); + } + } + + private hideControlsAfterDelay(): void { + if (this.controlsHideTimer) { + clearTimeout(this.controlsHideTimer); + } + + this.controlsHideTimer = window.setTimeout(() => { + if (this.isPlaying()) { + this._controlsVisible.set(false); + } + }, 3000); + } + + // Utility methods + formatTime(seconds: number): string { + if (isNaN(seconds)) return '0:00'; + + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = Math.floor(seconds % 60); + + if (hours > 0) { + return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; + } + + return `${minutes}:${secs.toString().padStart(2, '0')}`; + } +} \ No newline at end of file diff --git a/src/lib/components/navigation/README.md b/src/lib/components/navigation/README.md new file mode 100644 index 0000000..1ef3912 --- /dev/null +++ b/src/lib/components/navigation/README.md @@ -0,0 +1,223 @@ +# List Components + +Comprehensive list component system built with Angular 19+ and semantic design tokens. Features multiple variants, sizes, and interactive states for maximum flexibility. + +## Components + +### `ListItemComponent` +Individual list item with support for avatars, media, icons, and multiple text lines. + +### `ListContainerComponent` +Container for list items with elevation, spacing, and scrolling capabilities. + +### `ListExamplesComponent` +Demonstration component showcasing all variants and usage patterns. + +## Features + +- **Multiple Sizes**: `sm`, `md`, `lg` +- **Line Variants**: One, two, or three lines of text +- **Content Types**: Text-only, avatar, media, icon +- **Interactive States**: Hover, focus, selected, disabled +- **Container Options**: Elevation, spacing, scrolling, rounded corners +- **Text Overflow**: Automatic ellipsis handling +- **Accessibility**: Full ARIA support and keyboard navigation +- **Responsive**: Mobile-first design with breakpoint adjustments + +## Usage + +### Basic Text List + +```typescript +import { ListItemComponent, ListContainerComponent } from './shared/components'; + +// In your component: +items = [ + { primary: 'Inbox' }, + { primary: 'Starred' }, + { primary: 'Sent' } +]; +``` + +```html + + @for (item of items; track item.primary) { + + } + +``` + +### Avatar List with Two Lines + +```typescript +avatarItems = [ + { + primary: 'John Doe', + secondary: 'Software Engineer', + avatarSrc: 'https://example.com/avatar.jpg', + avatarAlt: 'John Doe' + } +]; +``` + +```html + + @for (item of avatarItems; track item.primary) { + + + + } + +``` + +### Media List with Three Lines + +```typescript +mediaItems = [ + { + primary: 'Angular Course', + secondary: 'Complete guide to modern development', + tertiary: 'Duration: 2h 30m • Updated: Today', + mediaSrc: 'https://example.com/thumb.jpg', + mediaAlt: 'Course thumbnail' + } +]; +``` + +```html + + @for (item of mediaItems; track item.primary) { + +
+ + 4.8 +
+
+ } +
+``` + +### Scrollable List + +```html + + @for (item of longList; track item.id) { + + } + +``` + +## API + +### ListItemComponent Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `data` | `ListItemData` | required | Item content and configuration | +| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Item height and spacing | +| `lines` | `'one' \| 'two' \| 'three'` | `'one'` | Number of text lines | +| `variant` | `'text' \| 'avatar' \| 'media'` | `'text'` | Content type | +| `interactive` | `boolean` | `true` | Enable hover/focus states | +| `divider` | `boolean` | `false` | Show bottom border | + +### ListContainerComponent Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `elevation` | `'none' \| 'sm' \| 'md' \| 'lg'` | `'none'` | Shadow elevation | +| `spacing` | `'none' \| 'xs' \| 'sm' \| 'md' \| 'lg'` | `'sm'` | Item spacing | +| `scrollable` | `boolean` | `false` | Enable vertical scrolling | +| `maxHeight` | `string?` | - | Maximum container height | +| `fadeIndicator` | `boolean` | `true` | Show fade at scroll bottom | +| `dense` | `boolean` | `false` | Reduce spacing between items | +| `rounded` | `boolean` | `false` | Round container corners | +| `ariaLabel` | `string?` | - | Accessibility label | + +### ListItemData Interface + +```typescript +interface ListItemData { + primary: string; // Main text (required) + secondary?: string; // Secondary text + tertiary?: string; // Tertiary text (three-line only) + avatarSrc?: string; // Avatar image URL + avatarAlt?: string; // Avatar alt text + mediaSrc?: string; // Media image URL + mediaAlt?: string; // Media alt text + icon?: string; // Icon class name + disabled?: boolean; // Disabled state + selected?: boolean; // Selected state +} +``` + +## Slots + +### Trailing Content +Use the `slot="trailing"` attribute to add content to the right side of list items: + +```html + + + +``` + +## Design Tokens + +The components use semantic design tokens for consistent styling: + +- **Colors**: `$semantic-color-*` +- **Spacing**: `$semantic-spacing-*` +- **Shadows**: `$semantic-shadow-*` +- **Borders**: `$semantic-border-radius-*` + +## Accessibility + +- Full ARIA support with proper roles and labels +- Keyboard navigation support +- Focus management and indicators +- Screen reader friendly +- High contrast mode support +- Reduced motion support + +## Responsive Design + +- Mobile-first approach +- Breakpoint-specific adjustments +- Touch-friendly tap targets +- Optimized spacing for different screen sizes + +## Examples + +Import and use the `ListExamplesComponent` to see all variants in action: + +```typescript +import { ListExamplesComponent } from './shared/components'; +``` + +```html + +``` \ No newline at end of file diff --git a/src/lib/components/navigation/appbar/appbar.component.scss b/src/lib/components/navigation/appbar/appbar.component.scss new file mode 100644 index 0000000..62e0021 --- /dev/null +++ b/src/lib/components/navigation/appbar/appbar.component.scss @@ -0,0 +1,224 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; +/** + * ========================================================================== + * APPBAR COMPONENT STYLES + * ========================================================================== + * Material Design 3 inspired appbar component with design token integration. + * Supports multiple height variants, positioning options, and content slots. + * ========================================================================== + */ + + +// Tokens available globally via main application styles + +.ui-appbar { + display: flex; + flex-wrap: nowrap; + flex-direction: row; + align-items: center; + overflow: hidden; + position: relative; + box-sizing: border-box; + width: 100%; + z-index: $semantic-layer-appbar; + transition: all $semantic-duration-short $semantic-easing-standard; + background-color: $semantic-color-surface-primary; + color: $semantic-color-text-primary; + border-bottom: $semantic-border-width-1 solid $semantic-color-border-subtle; + padding-left: $semantic-spacing-component-lg; + padding-right: $semantic-spacing-component-lg; + + // Variant heights + &[data-variant="compact"] { + height: $semantic-sizing-appbar-compact; + min-height: $semantic-sizing-appbar-compact; + } + + &[data-variant="standard"] { + height: $semantic-sizing-appbar-standard; + min-height: $semantic-sizing-appbar-standard; + } + + &[data-variant="large"] { + height: $semantic-sizing-appbar-large; + min-height: $semantic-sizing-appbar-large; + } + + &[data-variant="prominent"] { + height: $semantic-sizing-appbar-prominent; + min-height: $semantic-sizing-appbar-prominent; + } + + // Position variants + &[data-position="fixed"] { + position: fixed; + top: 0; + left: 0; + right: 0; + } + + &[data-position="sticky"] { + position: sticky; + top: 0; + } + + // Elevated state + &[data-elevated="true"] { + box-shadow: $semantic-shadow-elevation-2; + } + + // Responsive adjustments + @media (max-width: calc($semantic-breakpoint-md - 1px)) { + padding-left: $semantic-spacing-component-md; + padding-right: $semantic-spacing-component-md; + + &[data-variant="large"] { + height: $semantic-sizing-appbar-standard; + min-height: $semantic-sizing-appbar-standard; + } + + &[data-variant="prominent"] { + height: $semantic-sizing-appbar-large; + min-height: $semantic-sizing-appbar-large; + } + } + + @media (max-width: calc($semantic-breakpoint-sm - 1px)) { + &[data-variant="large"], + &[data-variant="prominent"] { + height: $semantic-sizing-appbar-compact; + min-height: $semantic-sizing-appbar-compact; + } + } +} + +// Left side components +.ui-appbar__left-icon { + flex: 0 0 $semantic-sizing-touch-target; + display: flex; + align-items: center; + justify-content: center; + user-select: none; + padding: $semantic-spacing-component-xs; +} + +.ui-appbar__left-logo { + flex: 0 0 auto; + display: flex; + align-items: center; + padding: $semantic-spacing-component-xs; + user-select: none; + overflow: hidden; + box-sizing: border-box; + + ::ng-deep img { + max-height: calc(100% - #{$semantic-spacing-component-sm}); + width: auto; + object-fit: contain; + } +} + +.ui-appbar__left-avatar { + flex: 0 0 $semantic-sizing-touch-target; + display: flex; + align-items: center; + justify-content: center; + padding: $semantic-spacing-component-xs; + box-sizing: border-box; + overflow: hidden; +} + +// Center title +.ui-appbar__title { + flex: 1 1 auto; + display: flex; + align-items: center; + text-align: center; + font-size: $semantic-typography-font-size-lg; + font-weight: $semantic-typography-font-weight-medium; + line-height: $semantic-typography-line-height-normal; + padding: 0 $semantic-spacing-component-sm; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + // Adjust for prominent variant + [data-variant="prominent"] & { + font-size: $semantic-typography-font-size-lg; + font-weight: $semantic-typography-font-weight-semibold; + line-height: $semantic-typography-line-height-normal; + } + + // Responsive adjustments + @media (max-width: calc($semantic-breakpoint-sm - 1px)) { + font-size: $semantic-typography-font-size-md; + font-weight: $semantic-typography-font-weight-medium; + line-height: $semantic-typography-line-height-normal; + } +} + +.ui-appbar__spacer { + flex: 1 1 auto; +} + +// Right side components +.ui-appbar__right-icon { + flex: 0 0 $semantic-sizing-touch-target; + display: flex; + align-items: center; + justify-content: center; + user-select: none; + padding: $semantic-spacing-component-xs; +} + +.ui-appbar__right-logo { + flex: 0 0 auto; + display: flex; + align-items: center; + padding: $semantic-spacing-component-xs; + user-select: none; + overflow: hidden; + box-sizing: border-box; + + ::ng-deep img { + max-height: calc(100% - #{$semantic-spacing-component-sm}); + width: auto; + object-fit: contain; + } +} + +.ui-appbar__right-avatar { + flex: 0 0 $semantic-sizing-touch-target; + display: flex; + align-items: center; + justify-content: center; + padding: $semantic-spacing-component-xs; + box-sizing: border-box; + overflow: hidden; +} + +.ui-appbar__right-menu { + flex: 0 0 auto; + display: flex; + align-items: center; + padding: $semantic-spacing-component-xs; + user-select: none; +} + +// Icon styling for all slots +.ui-appbar__left-icon, +.ui-appbar__right-icon, +.ui-appbar__right-menu { + ::ng-deep i, + ::ng-deep mat-icon, + ::ng-deep [class*="icon"] { + font-size: $semantic-typography-font-size-lg; + line-height: 1; + color: $semantic-color-text-secondary; + transition: color $semantic-duration-short $semantic-easing-standard; + + &:hover { + color: $semantic-color-text-primary; + } + } +} \ No newline at end of file diff --git a/src/lib/components/navigation/appbar/appbar.component.ts b/src/lib/components/navigation/appbar/appbar.component.ts new file mode 100644 index 0000000..0846e5e --- /dev/null +++ b/src/lib/components/navigation/appbar/appbar.component.ts @@ -0,0 +1,108 @@ +import { Component, Input, ChangeDetectionStrategy } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +export type AppbarVariant = 'compact' | 'standard' | 'large' | 'prominent'; +export type AppbarPosition = 'static' | 'fixed' | 'sticky'; + +export interface AppbarSlots { + leftIcon?: boolean; + leftLogo?: boolean; + leftAvatar?: boolean; + title?: boolean; + rightIcon?: boolean; + rightLogo?: boolean; + rightAvatar?: boolean; + rightMenu?: boolean; +} + +@Component({ + selector: 'ui-appbar', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + styleUrls: ['./appbar.component.scss'], + template: ` +
+ @if (slots.leftIcon) { +
+ +
+ } + + @if (slots.leftLogo) { + + } + + @if (slots.leftAvatar) { +
+ +
+ } + + @if (slots.title) { +
+ +
+ } + +
+ + @if (slots.rightIcon) { +
+ +
+ } + + @if (slots.rightLogo) { + + } + + @if (slots.rightAvatar) { +
+ +
+ } + + @if (slots.rightMenu) { +
+ +
+ } +
+ ` +}) +export class AppbarComponent { + @Input() variant: AppbarVariant = 'standard'; + @Input() position: AppbarPosition = 'static'; + @Input() elevated: boolean = false; + @Input() slots: AppbarSlots = { + leftIcon: false, + leftLogo: false, + leftAvatar: false, + title: true, + rightIcon: false, + rightLogo: false, + rightAvatar: false, + rightMenu: false + }; + + getAppbarClasses(): string { + const classes: string[] = []; + + if (this.elevated) { + classes.push('ui-appbar--elevated'); + } + + return classes.join(' '); + } +} \ No newline at end of file diff --git a/src/lib/components/navigation/appbar/index.ts b/src/lib/components/navigation/appbar/index.ts new file mode 100644 index 0000000..3fbc045 --- /dev/null +++ b/src/lib/components/navigation/appbar/index.ts @@ -0,0 +1 @@ +export * from './appbar.component'; \ No newline at end of file diff --git a/src/lib/components/navigation/bottom-navigation/bottom-navigation.component.scss b/src/lib/components/navigation/bottom-navigation/bottom-navigation.component.scss new file mode 100644 index 0000000..b4b1581 --- /dev/null +++ b/src/lib/components/navigation/bottom-navigation/bottom-navigation.component.scss @@ -0,0 +1,244 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-bottom-navigation { + // Core Structure + display: flex; + position: fixed; + bottom: 0; + left: 0; + right: 0; + z-index: $semantic-z-index-dropdown; + align-items: center; + justify-content: space-around; + + // Layout & Spacing + padding: $semantic-spacing-component-sm; + min-height: $semantic-sizing-touch-target; + + // Visual Design + background: $semantic-color-surface-primary; + border-top: $semantic-border-width-1 solid $semantic-color-border-primary; + box-shadow: $semantic-shadow-elevation-3; + + // Transitions + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + + // Size Variants + &--sm { + min-height: $semantic-sizing-button-height-sm; + padding: $semantic-spacing-component-xs; + } + + &--md { + min-height: $semantic-sizing-touch-target; + padding: $semantic-spacing-component-sm; + } + + &--lg { + min-height: $semantic-sizing-button-height-lg; + padding: $semantic-spacing-component-md; + } + + // Variant Styles + &--primary { + background: $semantic-color-primary; + border-top-color: $semantic-color-primary; + color: $semantic-color-on-primary; + + .ui-bottom-navigation__item { + color: $semantic-color-on-primary; + + &--active { + background: rgba(255, 255, 255, 0.1); + } + + &:hover:not(&--active) { + background: rgba(255, 255, 255, 0.05); + } + } + } + + &--secondary { + background: $semantic-color-secondary; + border-top-color: $semantic-color-secondary; + color: $semantic-color-on-secondary; + + .ui-bottom-navigation__item { + color: $semantic-color-on-secondary; + + &--active { + background: rgba(255, 255, 255, 0.1); + } + + &:hover:not(&--active) { + background: rgba(255, 255, 255, 0.05); + } + } + } + + // Elevated variant + &--elevated { + box-shadow: $semantic-shadow-elevation-4; + } + + // Hidden variant for dynamic showing/hiding + &--hidden { + transform: translateY(100%); + } + + // BEM Element - Navigation Item + &__item { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex: 1; + min-width: $semantic-sizing-touch-minimum; + min-height: $semantic-sizing-touch-minimum; + padding: $semantic-spacing-component-xs; + border-radius: $semantic-border-radius-sm; + cursor: pointer; + text-decoration: none; + color: $semantic-color-text-secondary; + + // Typography + font-family: map-get($semantic-typography-caption, font-family); + font-size: map-get($semantic-typography-caption, font-size); + font-weight: map-get($semantic-typography-caption, font-weight); + line-height: map-get($semantic-typography-caption, line-height); + + // Transitions + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + + // Interactive States + &:hover:not(&--disabled):not(&--active) { + background: $semantic-color-surface-elevated; + color: $semantic-color-text-primary; + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + + &:active:not(&--disabled) { + transform: scale(0.98); + } + + // Active State + &--active { + background: $semantic-color-interactive-primary; + color: $semantic-color-primary; + font-weight: $semantic-typography-font-weight-semibold; + } + + // Disabled State + &--disabled { + opacity: $semantic-opacity-disabled; + cursor: not-allowed; + pointer-events: none; + } + + // Badge modifier + &--has-badge { + position: relative; + } + } + + // BEM Element - Icon + &__icon { + display: flex; + align-items: center; + justify-content: center; + width: $semantic-sizing-icon-navigation; + height: $semantic-sizing-icon-navigation; + margin-bottom: $semantic-spacing-content-line-tight; + + // Icon sizing variants + .ui-bottom-navigation--sm & { + width: $semantic-sizing-icon-inline; + height: $semantic-sizing-icon-inline; + } + + .ui-bottom-navigation--lg & { + width: $semantic-sizing-icon-button; + height: $semantic-sizing-icon-button; + } + } + + // BEM Element - Label + &__label { + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; + + // Typography variants by size + .ui-bottom-navigation--sm & { + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + } + + .ui-bottom-navigation--lg & { + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + } + } + + // BEM Element - Badge + &__badge { + position: absolute; + top: -$semantic-spacing-component-xs; + right: -$semantic-spacing-component-xs; + min-width: $semantic-sizing-icon-inline; + height: $semantic-sizing-icon-inline; + display: flex; + align-items: center; + justify-content: center; + background: $semantic-color-danger; + color: $semantic-color-on-danger; + border-radius: $semantic-border-radius-full; + padding: 0 $semantic-spacing-component-xs; + + // Typography + font-family: map-get($semantic-typography-caption, font-family); + font-size: map-get($semantic-typography-caption, font-size); + font-weight: $semantic-typography-font-weight-semibold; + line-height: map-get($semantic-typography-caption, line-height); + + // Hide if no content + &:empty { + min-width: 8px; + height: 8px; + padding: 0; + } + } + + // Responsive Design + @media (max-width: $semantic-breakpoint-sm - 1) { + padding: $semantic-spacing-component-xs; + + .ui-bottom-navigation__item { + padding: $semantic-spacing-component-xs * 0.5; + } + + .ui-bottom-navigation__label { + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + } + } + + @media (max-width: $semantic-breakpoint-md - 1) { + // Ensure touch targets remain accessible on tablets + .ui-bottom-navigation__item { + min-height: $semantic-sizing-touch-target; + } + } +} \ No newline at end of file diff --git a/src/lib/components/navigation/bottom-navigation/bottom-navigation.component.ts b/src/lib/components/navigation/bottom-navigation/bottom-navigation.component.ts new file mode 100644 index 0000000..907af54 --- /dev/null +++ b/src/lib/components/navigation/bottom-navigation/bottom-navigation.component.ts @@ -0,0 +1,144 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +export type BottomNavigationSize = 'sm' | 'md' | 'lg'; +export type BottomNavigationVariant = 'default' | 'primary' | 'secondary'; + +export interface BottomNavigationItem { + id: string; + label: string; + icon?: string; + badge?: string | number; + disabled?: boolean; + href?: string; + route?: string; +} + +@Component({ + selector: 'ui-bottom-navigation', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` + + `, + styleUrl: './bottom-navigation.component.scss' +}) +export class BottomNavigationComponent { + @Input() size: BottomNavigationSize = 'md'; + @Input() variant: BottomNavigationVariant = 'default'; + @Input() elevated = false; + @Input() hidden = false; + @Input() items: BottomNavigationItem[] = []; + @Input() activeItemId?: string; + @Input() ariaLabel = 'Bottom navigation'; + + @Output() itemClicked = new EventEmitter(); + @Output() activeItemChanged = new EventEmitter(); + + handleItemClick(event: MouseEvent, item: BottomNavigationItem): void { + if (item.disabled) { + event.preventDefault(); + return; + } + + // If it's a route navigation, prevent default to let router handle it + if (item.route && !item.href) { + event.preventDefault(); + } + + // Update active item if not disabled + if (this.activeItemId !== item.id) { + this.activeItemChanged.emit(item.id); + } + + // Emit item clicked event + this.itemClicked.emit(item); + } + + handleItemKeydown(event: KeyboardEvent, item: BottomNavigationItem): void { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + this.handleItemClick(event as any, item); + } else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { + event.preventDefault(); + this.navigateToAdjacentItem(event.key === 'ArrowLeft' ? -1 : 1); + } + } + + private navigateToAdjacentItem(direction: number): void { + const enabledItems = this.items.filter(item => !item.disabled); + const currentIndex = enabledItems.findIndex(item => item.id === this.activeItemId); + + if (currentIndex === -1) return; + + const nextIndex = (currentIndex + direction + enabledItems.length) % enabledItems.length; + const nextItem = enabledItems[nextIndex]; + + if (nextItem) { + this.activeItemChanged.emit(nextItem.id); + + // Focus the next item + setTimeout(() => { + const itemElement = document.querySelector( + `.ui-bottom-navigation__item[aria-current="page"]` + ) as HTMLElement; + itemElement?.focus(); + }); + } + } + + getBadgeDisplay(badge: string | number): string { + if (typeof badge === 'number' && badge > 99) { + return '99+'; + } + return badge.toString(); + } + + getBadgeAriaLabel(badge: string | number): string { + if (typeof badge === 'number') { + return badge > 99 ? 'More than 99 notifications' : `${badge} notifications`; + } + return `Badge: ${badge}`; + } +} \ No newline at end of file diff --git a/src/lib/components/navigation/bottom-navigation/index.ts b/src/lib/components/navigation/bottom-navigation/index.ts new file mode 100644 index 0000000..ef6937c --- /dev/null +++ b/src/lib/components/navigation/bottom-navigation/index.ts @@ -0,0 +1 @@ +export * from './bottom-navigation.component'; \ No newline at end of file diff --git a/src/lib/components/navigation/breadcrumb/breadcrumb.component.scss b/src/lib/components/navigation/breadcrumb/breadcrumb.component.scss new file mode 100644 index 0000000..2aa15d6 --- /dev/null +++ b/src/lib/components/navigation/breadcrumb/breadcrumb.component.scss @@ -0,0 +1,116 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +// Tokens available globally via main application styles + +.ui-breadcrumb { + display: flex; + align-items: center; + padding: $semantic-spacing-2 0; // 0.5rem vertical +} + +.breadcrumb-list { + display: flex; + align-items: center; + flex-wrap: wrap; + margin: 0; + padding: 0; + list-style: none; + gap: $semantic-spacing-1; // 0.25rem +} + +.breadcrumb-item { + display: flex; + align-items: center; + gap: $semantic-spacing-2; // 0.5rem + + &:not(:last-child) { + margin-right: $semantic-spacing-1; // 0.25rem + } +} + +.breadcrumb-link { + font-family: $semantic-typography-font-family-sans; + font-size: $semantic-typography-font-size-sm; + font-weight: $semantic-typography-font-weight-normal; + color: $semantic-color-text-secondary; + text-decoration: none; + line-height: $semantic-typography-line-height-normal; + transition: color $semantic-motion-duration-fast ease; + border-radius: $semantic-border-radius-sm; + padding: $semantic-spacing-1 $semantic-spacing-1-5; // 0.25rem 0.375rem + + &:hover { + color: $semantic-color-interactive-primary; + background-color: $semantic-color-surface-hover; + } + + &:focus-visible { + outline: 2px solid $semantic-color-interactive-primary; + outline-offset: 2px; + } + + &:active { + transform: scale(0.98); + } +} + +.breadcrumb-label { + font-family: $semantic-typography-font-family-sans; + font-size: $semantic-typography-font-size-sm; + font-weight: $semantic-typography-font-weight-normal; + color: $semantic-color-text-secondary; + line-height: $semantic-typography-line-height-normal; + padding: $semantic-spacing-1 $semantic-spacing-1-5; // 0.25rem 0.375rem + + &--current { + color: $semantic-color-text-primary; + font-weight: $semantic-typography-font-weight-medium; + } + + &--disabled { + opacity: $semantic-opacity-disabled; + cursor: not-allowed; + } +} + +.breadcrumb-separator { + color: $semantic-color-text-tertiary; + font-size: $semantic-typography-font-size-xs; + opacity: $semantic-opacity-heavy; + margin: 0 $semantic-spacing-0-5; // 0 0.125rem + + svg { + width: 12px; + height: 12px; + } +} + +// Responsive design +@media (max-width: 768px) { + .breadcrumb-list { + gap: $semantic-spacing-0-5; // 0.125rem + } + + .breadcrumb-item { + gap: $semantic-spacing-1; // 0.25rem + + &:not(:last-child) { + margin-right: $semantic-spacing-0-5; // 0.125rem + } + } + + .breadcrumb-link, + .breadcrumb-label { + font-size: $semantic-typography-font-size-xs; + padding: $semantic-spacing-0-5 $semantic-spacing-1; // 0.125rem 0.25rem + } + + .breadcrumb-separator { + margin: 0 $semantic-spacing-0-5; // 0 0.125rem + + svg { + width: 10px; + height: 10px; + } + } +} \ No newline at end of file diff --git a/src/lib/components/navigation/breadcrumb/breadcrumb.component.ts b/src/lib/components/navigation/breadcrumb/breadcrumb.component.ts new file mode 100644 index 0000000..b32cbc8 --- /dev/null +++ b/src/lib/components/navigation/breadcrumb/breadcrumb.component.ts @@ -0,0 +1,84 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { faChevronRight } from '@fortawesome/free-solid-svg-icons'; + +export interface BreadcrumbItem { + label: string; + route?: string; + disabled?: boolean; +} + +@Component({ + selector: 'ui-breadcrumb', + standalone: true, + imports: [CommonModule, FontAwesomeModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + `, + styleUrl: './breadcrumb.component.scss' +}) +export class BreadcrumbComponent { + @Input() items: BreadcrumbItem[] = []; + @Input() separator: 'chevron' | 'slash' = 'chevron'; + + @Output() itemClick = new EventEmitter<{item: BreadcrumbItem, index: number}>(); + + separatorIcon = faChevronRight; + + get wrapperClasses(): string { + return [ + 'ui-breadcrumb' + ].filter(Boolean).join(' '); + } + + getItemClasses(index: number, isLast: boolean): string { + return [ + 'breadcrumb-item', + isLast ? 'breadcrumb-item--current' : '' + ].filter(Boolean).join(' '); + } + + getLabelClasses(isLast: boolean, disabled?: boolean): string { + return [ + 'breadcrumb-label', + isLast ? 'breadcrumb-label--current' : '', + disabled ? 'breadcrumb-label--disabled' : '' + ].filter(Boolean).join(' '); + } + + onItemClick(event: Event, item: BreadcrumbItem, index: number): void { + event.preventDefault(); + if (!item.disabled) { + this.itemClick.emit({ item, index }); + } + } +} \ No newline at end of file diff --git a/src/lib/components/navigation/breadcrumb/index.ts b/src/lib/components/navigation/breadcrumb/index.ts new file mode 100644 index 0000000..614e29a --- /dev/null +++ b/src/lib/components/navigation/breadcrumb/index.ts @@ -0,0 +1 @@ +export * from './breadcrumb.component'; \ No newline at end of file diff --git a/src/lib/components/navigation/index.ts b/src/lib/components/navigation/index.ts new file mode 100644 index 0000000..5e8338f --- /dev/null +++ b/src/lib/components/navigation/index.ts @@ -0,0 +1,7 @@ +export * from './appbar'; +export * from './bottom-navigation'; +export * from './breadcrumb'; +export * from './menu'; +export * from './pagination'; +export * from './stepper'; +export * from './tab-group'; \ No newline at end of file diff --git a/src/lib/components/navigation/menu/index.ts b/src/lib/components/navigation/menu/index.ts new file mode 100644 index 0000000..88a3549 --- /dev/null +++ b/src/lib/components/navigation/menu/index.ts @@ -0,0 +1,3 @@ +export * from './menu-container.component'; +export * from './menu-item.component'; +export * from './menu-submenu.component'; \ No newline at end of file diff --git a/src/lib/components/navigation/menu/menu-container.component.scss b/src/lib/components/navigation/menu/menu-container.component.scss new file mode 100644 index 0000000..c091c76 --- /dev/null +++ b/src/lib/components/navigation/menu/menu-container.component.scss @@ -0,0 +1,350 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +// Tokens available globally via main application styles + +// ========================================================================== +// MENU CONTAINER COMPONENT +// ========================================================================== +// Container component for menu items with elevation, spacing, and orientation +// Provides consistent layout and styling using semantic design tokens +// ========================================================================== + +.menu-container { + display: flex; + width: 100%; + box-sizing: border-box; + background-color: $semantic-color-surface-primary; + position: relative; + overflow: hidden; + + // ========================================================================== + // ORIENTATION + // ========================================================================== + + &--vertical { + flex-direction: column; + } + + &--horizontal { + flex-direction: row; + align-items: center; + flex-wrap: wrap; + } + + // ========================================================================== + // ELEVATION VARIANTS + // ========================================================================== + + &--elevation-none { + box-shadow: none; + } + + &--elevation-sm { + box-shadow: $semantic-shadow-elevation-1; + } + + &--elevation-md { + box-shadow: $semantic-shadow-elevation-3; + } + + &--elevation-lg { + box-shadow: $semantic-shadow-elevation-4; + } + + // ========================================================================== + // SPACING VARIANTS + // ========================================================================== + + &--spacing-none { + padding: 0; + gap: 0; + } + + &--spacing-xs { + padding: $semantic-spacing-component-xs; + gap: $semantic-spacing-component-xs; + } + + &--spacing-sm { + padding: $semantic-spacing-component-sm; + gap: $semantic-spacing-micro-tight; + } + + &--spacing-md { + padding: $semantic-spacing-component-md; + gap: $semantic-spacing-component-xs; + } + + &--spacing-lg { + padding: $semantic-spacing-component-lg; + gap: $semantic-spacing-component-sm; + } + + // ========================================================================== + // LAYOUT MODIFIERS + // ========================================================================== + + &--scrollable { + overflow-y: auto; + -webkit-overflow-scrolling: touch; + + // Custom scrollbar styling + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background: $semantic-color-surface-secondary; + border-radius: 3px; + } + + &::-webkit-scrollbar-thumb { + background: $semantic-color-border-primary; + border-radius: 3px; + + &:hover { + background: $semantic-color-border-secondary; + } + } + + // Firefox scrollbar + scrollbar-width: thin; + scrollbar-color: $semantic-color-border-primary $semantic-color-surface-secondary; + } + + &--max-height { + height: var(--menu-max-height); + max-height: var(--menu-max-height); + } + + &--dense { + &.menu-container--spacing-xs { + padding: $semantic-spacing-micro-tight; + gap: $semantic-spacing-micro-hairline; + } + + &.menu-container--spacing-sm { + padding: $semantic-spacing-component-xs; + gap: $semantic-spacing-micro-tight; + } + + &.menu-container--spacing-md { + padding: $semantic-spacing-component-sm; + gap: $semantic-spacing-micro-tight; + } + + &.menu-container--spacing-lg { + padding: $semantic-spacing-component-md; + gap: $semantic-spacing-component-xs; + } + } + + &--rounded { + border-radius: $semantic-border-radius-md; + overflow: hidden; + } + + // ========================================================================== + // HORIZONTAL LAYOUT ADJUSTMENTS + // ========================================================================== + + &--horizontal { + .menu-item { + flex-shrink: 0; + + &:not(:last-child) { + margin-right: $semantic-spacing-component-xs; + } + } + + &.menu-container--spacing-none .menu-item:not(:last-child) { + margin-right: 0; + } + + &.menu-container--spacing-sm .menu-item:not(:last-child) { + margin-right: $semantic-spacing-component-sm; + } + + &.menu-container--spacing-md .menu-item:not(:last-child) { + margin-right: $semantic-spacing-component-md; + } + + &.menu-container--spacing-lg .menu-item:not(:last-child) { + margin-right: $semantic-spacing-component-lg; + } + } +} + +// ========================================================================== +// DYNAMIC HEIGHT SETUP +// ========================================================================== + +.menu-container--max-height { + --menu-max-height: 400px; +} + +// ========================================================================== +// MENU DIVIDERS +// ========================================================================== + +.menu-divider { + height: 1px; + background-color: $semantic-color-border-secondary; + margin: $semantic-spacing-component-xs 0; + + .menu-container--spacing-none & { + margin: 0; + } + + .menu-container--spacing-sm & { + margin: $semantic-spacing-component-sm $semantic-spacing-component-sm; + } + + .menu-container--spacing-md & { + margin: $semantic-spacing-component-md $semantic-spacing-component-md; + } + + .menu-container--spacing-lg & { + margin: $semantic-spacing-component-lg $semantic-spacing-component-lg; + } + + .menu-container--horizontal & { + height: auto; + width: 1px; + margin: 0 $semantic-spacing-component-xs; + align-self: stretch; + } +} + +// ========================================================================== +// MENU SECTION HEADERS +// ========================================================================== + +.menu-section-header { + padding: $semantic-spacing-component-sm $semantic-spacing-component-md; + font-size: $semantic-typography-font-size-xs; + font-weight: $semantic-typography-font-weight-semibold; + text-transform: uppercase; + letter-spacing: $semantic-typography-letter-spacing-wider; + color: $semantic-color-text-tertiary; + background-color: $semantic-color-surface-secondary; + + .menu-container--spacing-sm & { + padding: $semantic-spacing-component-xs $semantic-spacing-component-sm; + } + + .menu-container--spacing-lg & { + padding: $semantic-spacing-component-md $semantic-spacing-component-lg; + } + + .menu-container--horizontal & { + display: none; // Hide section headers in horizontal layout + } +} + +// ========================================================================== +// RESPONSIVE ADJUSTMENTS +// ========================================================================== + +@media (max-width: 768px) { + .menu-container { + &--horizontal { + flex-direction: column; + align-items: stretch; + + .menu-item { + margin-right: 0; + + &:not(:last-child) { + margin-bottom: $semantic-spacing-component-xs; + } + } + } + + &--spacing-md { + padding: $semantic-spacing-component-sm; + gap: $semantic-spacing-component-xs; + } + + &--spacing-lg { + padding: $semantic-spacing-component-md; + gap: $semantic-spacing-component-sm; + } + } +} + +// ========================================================================== +// ACCESSIBILITY ENHANCEMENTS +// ========================================================================== + +.menu-container { + &:focus-within { + outline: 2px solid $semantic-color-border-focus; + outline-offset: 2px; + } +} + +// ========================================================================== +// SUBMENU STYLES +// ========================================================================== + +.menu-submenu { + background-color: $semantic-color-surface-elevated; + border: $semantic-border-width-1 solid $semantic-color-border-secondary; + border-radius: $semantic-border-radius-md; + box-shadow: $semantic-shadow-elevation-3; + overflow: hidden; + // Nested submenu indentation + .menu-item { + padding-left: calc($semantic-spacing-component-md + $semantic-spacing-component-lg); + } + + .menu-container--spacing-sm & .menu-item { + padding-left: calc($semantic-spacing-component-sm + $semantic-spacing-component-md); + } + + .menu-container--spacing-lg & .menu-item { + padding-left: calc($semantic-spacing-component-lg + $semantic-spacing-component-xl); + } +} + +// ========================================================================== +// ANIMATION UTILITIES +// ========================================================================== + +.menu-container { + // Smooth height transitions when content changes + transition: height $semantic-motion-duration-fast $semantic-motion-easing-ease-in-out; +} + +// Menu item enter/leave animations +@keyframes menu-item-enter { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes menu-item-leave { + from { + opacity: 1; + transform: translateY(0); + } + to { + opacity: 0; + transform: translateY(-4px); + } +} + +// Add these classes via JavaScript for dynamic content +.menu-item-enter { + animation: menu-item-enter $semantic-motion-duration-fast $semantic-motion-easing-ease-out; +} + +.menu-item-leave { + animation: menu-item-leave $semantic-motion-duration-fast $semantic-motion-easing-ease-out; +} \ No newline at end of file diff --git a/src/lib/components/navigation/menu/menu-container.component.ts b/src/lib/components/navigation/menu/menu-container.component.ts new file mode 100644 index 0000000..ce308a3 --- /dev/null +++ b/src/lib/components/navigation/menu/menu-container.component.ts @@ -0,0 +1,49 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +export type MenuContainerElevation = 'none' | 'sm' | 'md' | 'lg'; +export type MenuContainerSpacing = 'none' | 'xs' | 'sm' | 'md' | 'lg'; + +@Component({ + selector: 'ui-menu-container', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule], + template: ` + + `, + styleUrls: ['./menu-container.component.scss'] +}) +export class MenuContainerComponent { + @Input() elevation: MenuContainerElevation = 'sm'; + @Input() spacing: MenuContainerSpacing = 'xs'; + @Input() scrollable = false; + @Input() maxHeight?: string; + @Input() rounded = true; + @Input() dense = false; + @Input() orientation: 'vertical' | 'horizontal' = 'vertical'; + @Input() ariaLabel?: string; + + getContainerClasses(): string { + const classes = [ + `menu-container--elevation-${this.elevation}`, + `menu-container--spacing-${this.spacing}`, + `menu-container--${this.orientation}` + ]; + + if (this.scrollable) classes.push('menu-container--scrollable'); + if (this.dense) classes.push('menu-container--dense'); + if (this.rounded) classes.push('menu-container--rounded'); + if (this.maxHeight) classes.push('menu-container--max-height'); + + return classes.join(' '); + } +} \ No newline at end of file diff --git a/src/lib/components/navigation/menu/menu-item.component.scss b/src/lib/components/navigation/menu/menu-item.component.scss new file mode 100644 index 0000000..4acb91e --- /dev/null +++ b/src/lib/components/navigation/menu/menu-item.component.scss @@ -0,0 +1,500 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +// Tokens available globally via main application styles + +// ========================================================================== +// MENU ITEM COMPONENT +// ========================================================================== +// Comprehensive menu item component with multiple variants and sizes +// Uses semantic design tokens for consistent styling across the design system +// ========================================================================== + +.menu-item { + display: flex; + flex-direction: row; // Changed from column to row + align-items: center; // Add this to vertically center content + width: 100%; + box-sizing: border-box; + position: relative; + user-select: none; + background-color: $semantic-color-surface-primary; + color: $semantic-color-text-primary; + cursor: pointer; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease-in-out; + border-radius: $semantic-border-radius-sm; + + + + &:hover:not(.menu-item--disabled) { + background-color: $semantic-color-surface-interactive; + } + + &:focus-within:not(.menu-item--disabled) { +// outline: 2px solid $semantic-color-border-focus; + // outline-offset: 2px; + } + + // States + &--active { + background-color: $semantic-color-container-primary; + color: $semantic-color-on-container-primary; + + &:hover { + background-color: $semantic-color-container-primary; + opacity: 0.9; + } + } + + &--disabled { + opacity: 0.6; + cursor: not-allowed; + color: $semantic-color-text-disabled; + + &:hover { + background-color: $semantic-color-surface-primary; + } + } + + // ========================================================================== + // SIZE VARIANTS + // ========================================================================== + + &--sm { + min-height: 36px; + + &.menu-item--dense { + min-height: 32px; + } + } + + &--md { + min-height: 48px; + + &.menu-item--dense { + min-height: 40px; + } + } + + &--lg { + min-height: 56px; + + &.menu-item--dense { + min-height: 48px; + } + } + + // ========================================================================== + // VARIANT STYLES + // ========================================================================== + + &--primary { + color: $semantic-color-interactive-primary; + + &.menu-item--active { + background-color: $semantic-color-container-primary; + color: $semantic-color-on-container-primary; + } + + &:hover:not(.menu-item--disabled):not(.menu-item--active) { + background-color: $semantic-color-surface-hover; + } + } + + &--secondary { + color: $semantic-color-interactive-secondary; + + &.menu-item--active { + background-color: $semantic-color-container-secondary; + color: $semantic-color-on-container-secondary; + } + + &:hover:not(.menu-item--disabled):not(.menu-item--active) { + background-color: $semantic-color-surface-hover; + } + } + + &--danger { + color: $semantic-color-danger; + + &.menu-item--active { + background-color: $semantic-color-container-error; + color: $semantic-color-on-container-error; + } + + &:hover:not(.menu-item--disabled):not(.menu-item--active) { + background-color: $semantic-color-surface-hover; + } + } + + // ========================================================================== + // LAYOUT MODIFIERS + // ========================================================================== + + &--indent { + margin-left: $semantic-spacing-component-md; + } + + &--with-divider { + margin-bottom: $semantic-spacing-component-xs; + } +} + +// ========================================================================== +// MENU ITEM LINK +// ========================================================================== + +.menu-item__link { + display: flex; + align-items: center; + width: 100%; + height: 100%; + text-decoration: none; + color: inherit; + padding: $semantic-spacing-component-sm $semantic-spacing-component-md; + + .menu-item--sm & { + padding: $semantic-spacing-component-xs $semantic-spacing-component-sm; + } + + .menu-item--lg & { + padding: $semantic-spacing-component-md $semantic-spacing-component-lg; + } + + &:focus { + outline: none; + } +} + +// ========================================================================== +// MENU ITEM CONTENT (when no link) +// ========================================================================== + +.menu-item:not(:has(.menu-item__link)) { + padding: $semantic-spacing-component-sm $semantic-spacing-component-md; + + &.menu-item--sm { + padding: $semantic-spacing-component-xs $semantic-spacing-component-sm; + } + + &.menu-item--lg { + padding: $semantic-spacing-component-md $semantic-spacing-component-lg; + } +} + +// FontAwesome icon content wrapper +.menu-item:not(:has(.menu-item__link)) ng-container { + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + width: 100%; + height: 100%; + min-height: inherit; +} + +// ========================================================================== +// ICON STYLES +// ========================================================================== + +.menu-item__icon { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + color: $semantic-color-text-secondary; + + &--left { + margin-right: $semantic-spacing-component-md; + align-self: center; + } + + &--right { + margin-left: auto; // Push right icons to the end + align-self: center; + } + + &--circle { + border-radius: 50%; + background-color: $semantic-color-surface-elevated; + border: 1px solid $semantic-color-border-secondary; + + .menu-item--sm & { + width: 28px; + height: 28px; + } + + .menu-item--md & { + width: 32px; + height: 32px; + } + + .menu-item--lg & { + width: 36px; + height: 36px; + } + } + + i { + .menu-item--sm & { font-size: 14px; } + .menu-item--md & { font-size: 16px; } + .menu-item--lg & { font-size: 18px; } + + .menu-item__icon--circle & { + .menu-item--sm & { font-size: $semantic-typography-font-size-xs; } + .menu-item--md & { font-size: $semantic-typography-font-size-sm; } + .menu-item--lg & { font-size: $semantic-typography-font-size-md; } + } + } + + // Variant specific icon colors + .menu-item--primary & { + color: $semantic-color-interactive-primary; + } + + .menu-item--secondary & { + color: $semantic-color-interactive-secondary; + } + + .menu-item--danger & { + color: $semantic-color-danger; + } + + // Custom emoji-based icon mappings using ::before pseudo-elements + // These apply to the element when it has these specific classes + &.icon-dashboard::before { content: '📊'; } + &.icon-chart::before { content: '📈'; } + &.icon-projects::before { content: '📁'; } + &.icon-team::before { content: '👥'; } + &.icon-documents::before { content: '📄'; } + &.icon-demo::before { content: '🎨'; } + &.icon-button::before { content: '🔘'; } + &.icon-badge::before { content: '🏷️'; } + &.icon-avatar::before { content: '👤'; } + &.icon-table::before { content: '📊'; } + &.icon-appbar::before { content: '📱'; } + &.icon-home::before { content: '🏠'; } + &.icon-settings::before { content: '⚙️'; } + &.icon-help::before { content: '❓'; } + &.icon-overview::before { content: '👁️'; } + &.icon-reports::before { content: '📋'; } + &.icon-insights::before { content: '💡'; } + &.icon-members::before { content: '👤'; } + &.icon-roles::before { content: '🏷️'; } + &.icon-chevron-left::before { content: '‹'; } + &.icon-chevron-double-left::before { content: '«'; } + &.icon-chevron-right::before { content: '›'; } + + // FontAwesome component support + fa-icon { + display: inline-flex; + align-items: center; + justify-content: center; + color: currentColor; + + .menu-item--sm & { + font-size: 14px; + width: 14px; + height: 14px; + } + .menu-item--md & { + font-size: 16px; + width: 16px; + height: 16px; + } + .menu-item--lg & { + font-size: 18px; + width: 18px; + height: 18px; + } + + .menu-item__icon--circle & { + .menu-item--sm & { + font-size: 12px; + width: 12px; + height: 12px; + } + .menu-item--md & { + font-size: 14px; + width: 14px; + height: 14px; + } + .menu-item--lg & { + font-size: 16px; + width: 16px; + height: 16px; + } + } + } +} + +// ========================================================================== +// CONTENT AREA +// ========================================================================== + +.menu-item__content { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + justify-content: flex-start; + gap: $semantic-spacing-component-sm; + width: 100%; +} + +.menu-item__label { + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + .menu-item--sm & { + font-size: $semantic-typography-font-size-sm; + } + + .menu-item--md & { + font-size: $semantic-typography-font-size-md; + } + + .menu-item--lg & { + font-size: $semantic-typography-font-size-lg; + } +} + +.menu-item__badge { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 6px; + margin-left: auto; /* Push badge to the right */ + background-color: $semantic-color-interactive-primary; + color: $semantic-color-on-brand-primary; + border-radius: $semantic-border-radius-full; + font-size: $semantic-typography-font-size-xs; + font-weight: 600; + line-height: 1; + + .menu-item--sm & { + min-width: 16px; + height: 16px; + padding: 0 4px; + font-size: $semantic-typography-font-size-xs; + } + + .menu-item--lg & { + min-width: 24px; + height: 24px; + padding: 0 8px; + font-size: $semantic-typography-font-size-sm; + } +} + +// ========================================================================== +// SUBMENU CARET +// ========================================================================== + +.menu-item__caret { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + margin-left: $semantic-spacing-component-sm; + color: $semantic-color-text-tertiary; + transition: transform $semantic-motion-duration-normal $semantic-motion-easing-ease-in-out; + + i { + .menu-item--sm & { font-size: $semantic-typography-font-size-xs; } + .menu-item--md & { font-size: $semantic-typography-font-size-sm; } + .menu-item--lg & { font-size: $semantic-typography-font-size-md; } + } + + fa-icon { + .menu-item--sm & { font-size: $semantic-typography-font-size-xs; } + .menu-item--md & { font-size: $semantic-typography-font-size-sm; } + .menu-item--lg & { font-size: $semantic-typography-font-size-md; } + } + + .menu-item--has-submenu.menu-item--active & { + transform: rotate(90deg); + } +} + +// ========================================================================== +// DIVIDER +// ========================================================================== + +.menu-item__divider { + position: absolute; + bottom: 0; + left: $semantic-spacing-component-md; + right: $semantic-spacing-component-md; + height: 1px; + background-color: $semantic-color-border-secondary; + + .menu-item--sm & { + left: $semantic-spacing-component-sm; + right: $semantic-spacing-component-sm; + } + + .menu-item--lg & { + left: $semantic-spacing-component-lg; + right: $semantic-spacing-component-lg; + } +} + +// ========================================================================== +// RESPONSIVE ADJUSTMENTS +// ========================================================================== + +@media (max-width: 768px) { + .menu-item { + &--lg { + min-height: 48px; + + &.menu-item--dense { + min-height: 40px; + } + } + } + + .menu-item__content { + padding-left: $semantic-spacing-component-sm; + padding-right: $semantic-spacing-component-sm; + } +} + +// ========================================================================== +// ACCESSIBILITY ENHANCEMENTS +// ========================================================================== + +.menu-item { + &:focus-visible { + outline: 2px solid $semantic-color-border-focus; + outline-offset: 2px; + } +} + +// High contrast mode support +@media (prefers-contrast: high) { + .menu-item { + border: 1px solid transparent; + + &:hover:not(.menu-item--disabled) { + border-color: $semantic-color-border-primary; + } + + &--active { + border-color: $semantic-color-interactive-primary; + } + } +} + +// Reduced motion support +@media (prefers-reduced-motion: reduce) { + .menu-item, + .menu-item__caret { + transition: none; + } +} \ No newline at end of file diff --git a/src/lib/components/navigation/menu/menu-item.component.ts b/src/lib/components/navigation/menu/menu-item.component.ts new file mode 100644 index 0000000..8457d41 --- /dev/null +++ b/src/lib/components/navigation/menu/menu-item.component.ts @@ -0,0 +1,135 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { faChevronRight } from '@fortawesome/free-solid-svg-icons'; + +export type MenuItemSize = 'sm' | 'md' | 'lg'; +export type MenuItemIconPosition = 'left' | 'right'; +export type MenuItemVariant = 'default' | 'primary' | 'secondary' | 'danger'; + +export interface MenuItemData { + id?: string; + label: string; + icon?: any; // FontAwesome icon definition + iconCircle?: boolean; + disabled?: boolean; + active?: boolean; + hasSubmenu?: boolean; + badge?: string | number; + href?: string; + target?: string; +} + +@Component({ + selector: 'ui-menu-item', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, FaIconComponent], + template: ` + + `, + styleUrls: ['./menu-item.component.scss'] +}) +export class MenuItemComponent { + @Input() data!: MenuItemData; + @Input() size: MenuItemSize = 'md'; + @Input() variant: MenuItemVariant = 'default'; + @Input() iconPosition: MenuItemIconPosition = 'left'; + @Input() showDivider = false; + @Input() indent = false; + @Input() dense = false; + + @Output() itemClick = new EventEmitter(); + @Output() submenuToggle = new EventEmitter(); + + // FontAwesome icons + faChevronRight = faChevronRight; + + getItemClasses(): string { + const classes = [ + `menu-item--${this.size}`, + `menu-item--${this.variant}` + ]; + + if (this.dense) classes.push('menu-item--dense'); + if (this.indent) classes.push('menu-item--indent'); + if (this.data.disabled) classes.push('menu-item--disabled'); + if (this.data.active) classes.push('menu-item--active'); + if (this.data.hasSubmenu) classes.push('menu-item--has-submenu'); + if (this.showDivider) classes.push('menu-item--with-divider'); + + return classes.join(' '); + } + + handleClick(event: Event): void { + if (this.data.disabled) { + event.preventDefault(); + return; + } + + if (this.data.hasSubmenu) { + event.preventDefault(); + this.submenuToggle.emit(this.data); + } else if (!this.data.href) { + this.itemClick.emit(this.data); + } + } +} \ No newline at end of file diff --git a/src/lib/components/navigation/menu/menu-submenu.component.scss b/src/lib/components/navigation/menu/menu-submenu.component.scss new file mode 100644 index 0000000..cdcdaa8 --- /dev/null +++ b/src/lib/components/navigation/menu/menu-submenu.component.scss @@ -0,0 +1,132 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +// Tokens available globally via main application styles + +// ========================================================================== +// MENU SUBMENU COMPONENT +// ========================================================================== +// Collapsible submenu component with smooth animations +// ========================================================================== + +.submenu-wrapper { + display: flex; + flex-direction: column; + width: 100%; + position: relative; + + // Ensure parent menu item doesn't interfere with submenu hover states + > ui-menu-item { + position: relative; + z-index: 1; + } +} + +.submenu-content { + overflow: hidden; + transition: max-height $semantic-motion-duration-slow $semantic-motion-easing-ease-in-out, opacity $semantic-motion-duration-normal $semantic-motion-easing-ease-in-out; + // Apply indentation to the entire submenu content block + margin-left: calc($semantic-spacing-component-lg + $semantic-spacing-component-sm); + // Fix z-index and positioning to prevent hover state conflicts + position: relative; + z-index: 1; + + .menu-submenu { + //border-left: 2px solid $semantic-color-border-secondary; + border-radius: 0; + background-color: $semantic-color-surface-elevated; + box-shadow: none; + position: relative; + z-index: 2; + } +} + +// ========================================================================== +// ANIMATION STATES +// ========================================================================== + +.submenu-wrapper--open { + .submenu-content { + max-height: 1000px; // Large enough to accommodate content + opacity: 1; + } +} + +.submenu-wrapper:not(.submenu-wrapper--open) { + .submenu-content { + max-height: 0; + opacity: 0; + } +} + +// ========================================================================== +// NESTED SUBMENU STYLING +// ========================================================================== + +.submenu-content { + .menu-item { + position: relative; + // Ensure submenu items have proper stacking context + z-index: 3; + // Clear any potential interaction with parent items + pointer-events: auto; + + &::before { + content: ''; + position: absolute; + left: -2px; + top: 50%; + width: 8px; + height: 1px; + background-color: $semantic-color-border-secondary; + transform: translateY(-50%); + } + } +} + +// Force vertical layout for all submenus +.menu-submenu { + // Override any inherited horizontal layout + .menu-container { + flex-direction: column !important; + align-items: stretch !important; + flex-wrap: nowrap !important; + } + + // Ensure menu items stack vertically and take full width + .menu-item { + width: 100% !important; + margin-right: 0 !important; + margin-bottom: 0; + // Create isolated interaction area to prevent hover conflicts + isolation: isolate; + + &:not(:last-child) { + margin-bottom: 0 !important; + } + + // Ensure hover states don't bleed to adjacent items + &:hover { + z-index: 4; + } + } +} + +// ========================================================================== +// RESPONSIVE ADJUSTMENTS +// ========================================================================== + +@media (max-width: 768px) { + .submenu-content { + margin-left: calc($semantic-spacing-component-md + $semantic-spacing-component-sm); + } +} + +// ========================================================================== +// REDUCED MOTION SUPPORT +// ========================================================================== + +@media (prefers-reduced-motion: reduce) { + .submenu-content { + transition: none; + } +} \ No newline at end of file diff --git a/src/lib/components/navigation/menu/menu-submenu.component.ts b/src/lib/components/navigation/menu/menu-submenu.component.ts new file mode 100644 index 0000000..6992ce4 --- /dev/null +++ b/src/lib/components/navigation/menu/menu-submenu.component.ts @@ -0,0 +1,91 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, OnInit, OnChanges, SimpleChanges, ChangeDetectorRef } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MenuItemComponent, MenuItemData } from './menu-item.component'; +import { MenuContainerComponent } from './menu-container.component'; + +@Component({ + selector: 'ui-menu-submenu', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, MenuItemComponent, MenuContainerComponent], + template: ` + + `, + styleUrls: ['./menu-submenu.component.scss'], + animations: [] +}) +export class MenuSubmenuComponent implements OnInit, OnChanges { + @Input() parentItem!: MenuItemData; + @Input() size: 'sm' | 'md' | 'lg' = 'md'; + @Input() variant: 'default' | 'primary' | 'secondary' | 'danger' = 'default'; + @Input() iconPosition: 'left' | 'right' = 'left'; + @Input() showDivider = false; + @Input() indent = false; + @Input() dense = false; + @Input() isOpen = false; + @Input() submenuElevation: 'none' | 'sm' | 'md' | 'lg' = 'none'; + @Input() submenuSpacing: 'none' | 'xs' | 'sm' | 'md' | 'lg' = 'xs'; + + @Output() submenuToggled = new EventEmitter<{ item: MenuItemData; isOpen: boolean }>(); + @Output() parentItemClick = new EventEmitter(); + + constructor(private cdr: ChangeDetectorRef) {} + + ngOnInit(): void { + // Initialize parent item active state based on submenu open state + this.updateParentItemState(); + } + + ngOnChanges(changes: SimpleChanges): void { + // Update parent item state when isOpen input changes + if (changes['isOpen']) { + this.updateParentItemState(); + } + } + + toggleSubmenu(item: MenuItemData): void { + this.isOpen = !this.isOpen; + this.updateParentItemState(); + this.submenuToggled.emit({ item, isOpen: this.isOpen }); + } + + private updateParentItemState(): void { + // Create a new object reference to trigger change detection + this.parentItem = { + ...this.parentItem, + active: this.isOpen + }; + } + + onParentClick(item: MenuItemData): void { + this.parentItemClick.emit(item); + } +} \ No newline at end of file diff --git a/src/lib/components/navigation/pagination/index.ts b/src/lib/components/navigation/pagination/index.ts new file mode 100644 index 0000000..03e034e --- /dev/null +++ b/src/lib/components/navigation/pagination/index.ts @@ -0,0 +1 @@ +export * from './pagination.component'; \ No newline at end of file diff --git a/src/lib/components/navigation/pagination/pagination.component.scss b/src/lib/components/navigation/pagination/pagination.component.scss new file mode 100644 index 0000000..9389d8f --- /dev/null +++ b/src/lib/components/navigation/pagination/pagination.component.scss @@ -0,0 +1,233 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-pagination { + display: flex; + align-items: center; + justify-content: center; + padding: $semantic-spacing-2 0; + + &--left { + justify-content: flex-start; + } + + &--right { + justify-content: flex-end; + } + + &--sm { + padding: $semantic-spacing-1 0; + + .pagination-list { + gap: $semantic-spacing-1; + } + + .pagination-item { + min-width: $semantic-sizing-button-height-sm; + height: $semantic-sizing-button-height-sm; + font-size: $semantic-typography-font-size-xs; + } + } + + &--md { + .pagination-list { + gap: $semantic-spacing-1-5; + } + + .pagination-item { + min-width: $semantic-sizing-button-height-md; + height: $semantic-sizing-button-height-md; + font-size: $semantic-typography-font-size-sm; + } + } + + &--lg { + padding: $semantic-spacing-3 0; + + .pagination-list { + gap: $semantic-spacing-2; + } + + .pagination-item { + min-width: $semantic-sizing-button-height-lg; + height: $semantic-sizing-button-height-lg; + font-size: $semantic-typography-font-size-md; + } + } +} + +.pagination-list { + display: flex; + align-items: center; + margin: 0; + padding: 0; + list-style: none; + gap: $semantic-spacing-1-5; +} + +.pagination-item { + display: flex; + align-items: center; + justify-content: center; + min-width: $semantic-sizing-button-height-md; + height: $semantic-sizing-button-height-md; + border-radius: $semantic-border-radius-md; + font-family: $semantic-typography-font-family-sans; + font-size: $semantic-typography-font-size-sm; + font-weight: $semantic-typography-font-weight-medium; + line-height: $semantic-typography-line-height-none; + text-decoration: none; + cursor: pointer; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + user-select: none; + + // Default state + background: $semantic-color-surface-primary; + border: $semantic-border-width-1 solid $semantic-color-border-primary; + color: $semantic-color-text-primary; + + &:hover:not(.pagination-item--disabled):not(.pagination-item--current) { + background: $semantic-color-surface-hover; + border-color: $semantic-color-border-focus; + transform: translateY(-1px); + box-shadow: $semantic-shadow-elevation-2; + } + + &:focus-visible { + outline: 2px solid $semantic-color-interactive-primary; + outline-offset: 2px; + } + + &:active:not(.pagination-item--disabled):not(.pagination-item--current) { + transform: translateY(0); + box-shadow: $semantic-shadow-elevation-1; + } + + // Current page state + &--current { + background: $semantic-color-interactive-primary; + border-color: $semantic-color-interactive-primary; + color: $semantic-color-on-primary; + cursor: default; + + &:hover { + background: $semantic-color-interactive-primary; + border-color: $semantic-color-interactive-primary; + transform: none; + } + } + + // Disabled state + &--disabled { + opacity: $semantic-opacity-disabled; + cursor: not-allowed; + pointer-events: none; + } + + // Ellipsis state + &--ellipsis { + cursor: default; + pointer-events: none; + border: none; + background: transparent; + color: $semantic-color-text-tertiary; + font-weight: $semantic-typography-font-weight-normal; + } + + // Navigation buttons (prev/next) + &--nav { + padding: 0 $semantic-spacing-2; + min-width: auto; + gap: $semantic-spacing-1; + + &:hover:not(.pagination-item--disabled) { + color: $semantic-color-interactive-primary; + } + } + + // Icon sizing + fa-icon { + font-size: inherit; + } +} + +.pagination-info { + display: flex; + align-items: center; + margin-left: $semantic-spacing-4; + font-family: $semantic-typography-font-family-sans; + font-size: $semantic-typography-font-size-sm; + color: $semantic-color-text-secondary; + white-space: nowrap; + + &--sm { + margin-left: $semantic-spacing-2; + font-size: $semantic-typography-font-size-xs; + } + + &--lg { + margin-left: $semantic-spacing-6; + font-size: $semantic-typography-font-size-md; + } +} + +// Compact variant +.ui-pagination--compact { + .pagination-list { + gap: $semantic-spacing-1; + } + + .pagination-item { + min-width: $semantic-sizing-button-height-sm; + height: $semantic-sizing-button-height-sm; + font-size: $semantic-typography-font-size-xs; + border-radius: $semantic-border-radius-sm; + + &--nav { + padding: 0 $semantic-spacing-1-5; + } + } + + .pagination-info { + margin-left: $semantic-spacing-2; + font-size: $semantic-typography-font-size-xs; + } +} + +// Responsive design +@media (max-width: 768px) { + .ui-pagination { + padding: $semantic-spacing-1 0; + + .pagination-list { + gap: $semantic-spacing-1; + } + + .pagination-item { + min-width: $semantic-sizing-button-height-sm; + height: $semantic-sizing-button-height-sm; + font-size: $semantic-typography-font-size-xs; + + &--nav { + padding: 0 $semantic-spacing-1-5; + gap: $semantic-spacing-0-5; + } + } + + .pagination-info { + margin-left: $semantic-spacing-2; + font-size: $semantic-typography-font-size-xs; + + // Hide detailed info on very small screens + @media (max-width: 480px) { + display: none; + } + } + } + + // Show only essential pages on mobile + .pagination-item--hide-mobile { + display: none; + } +} + +// Dark mode support will be added in future versions \ No newline at end of file diff --git a/src/lib/components/navigation/pagination/pagination.component.ts b/src/lib/components/navigation/pagination/pagination.component.ts new file mode 100644 index 0000000..4d053e9 --- /dev/null +++ b/src/lib/components/navigation/pagination/pagination.component.ts @@ -0,0 +1,244 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, computed, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { faChevronLeft, faChevronRight, faEllipsisH } from '@fortawesome/free-solid-svg-icons'; + +export interface PageChangeEvent { + page: number; + previousPage: number; +} + +type PaginationSize = 'sm' | 'md' | 'lg'; +type PaginationAlignment = 'left' | 'center' | 'right'; + +@Component({ + selector: 'ui-pagination', + standalone: true, + imports: [CommonModule, FontAwesomeModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + `, + styleUrl: './pagination.component.scss' +}) +export class PaginationComponent { + @Input() currentPage = signal(1); + @Input() totalPages = signal(1); + @Input() totalItems?: number; + @Input() itemsPerPage?: number; + @Input() size: PaginationSize = 'md'; + @Input() alignment: PaginationAlignment = 'center'; + @Input() compact = false; + @Input() showInfo = true; + @Input() showLabels = false; + @Input() maxVisible = 7; + @Input() ariaLabel = 'Pagination navigation'; + @Input() previousLabel = 'Previous'; + @Input() nextLabel = 'Next'; + @Input() previousAriaLabel = 'Go to previous page'; + @Input() nextAriaLabel = 'Go to next page'; + @Input() disabled = false; + + @Output() pageChange = new EventEmitter(); + + // Icons + prevIcon = faChevronLeft; + nextIcon = faChevronRight; + ellipsisIcon = faEllipsisH; + + // Computed properties using signals + hasPrevious = computed(() => this.currentPage() > 1 && !this.disabled); + hasNext = computed(() => this.currentPage() < this.totalPages() && !this.disabled); + + visiblePages = computed(() => { + const current = this.currentPage(); + const total = this.totalPages(); + const max = this.maxVisible; + + if (total <= max) { + // Show all pages if total is within max visible + return Array.from({ length: total }, (_, i) => ({ + value: i + 1, + display: (i + 1).toString(), + type: 'page' as const + })); + } + + const pages: Array<{ value: number; display: string; type: 'page' | 'ellipsis' }> = []; + const sidePages = Math.floor((max - 3) / 2); // Reserve space for first, last, and ellipses + + // Always show first page + pages.push({ value: 1, display: '1', type: 'page' }); + + if (current <= sidePages + 2) { + // Current is near the beginning + for (let i = 2; i <= Math.min(max - 1, total - 1); i++) { + pages.push({ value: i, display: i.toString(), type: 'page' }); + } + if (total > max - 1) { + pages.push({ value: -1, display: '...', type: 'ellipsis' }); + } + } else if (current >= total - sidePages - 1) { + // Current is near the end + if (total > max - 1) { + pages.push({ value: -1, display: '...', type: 'ellipsis' }); + } + for (let i = Math.max(total - max + 2, 2); i <= total - 1; i++) { + pages.push({ value: i, display: i.toString(), type: 'page' }); + } + } else { + // Current is in the middle + pages.push({ value: -1, display: '...', type: 'ellipsis' }); + const start = Math.max(2, current - sidePages); + const end = Math.min(total - 1, current + sidePages); + for (let i = start; i <= end; i++) { + pages.push({ value: i, display: i.toString(), type: 'page' }); + } + pages.push({ value: -2, display: '...', type: 'ellipsis' }); + } + + // Always show last page if more than 1 page + if (total > 1) { + pages.push({ value: total, display: total.toString(), type: 'page' }); + } + + return pages; + }); + + wrapperClasses = computed(() => { + return [ + 'ui-pagination', + `ui-pagination--${this.size}`, + `ui-pagination--${this.alignment}`, + this.compact ? 'ui-pagination--compact' : '', + this.disabled ? 'ui-pagination--disabled' : '' + ].filter(Boolean).join(' '); + }); + + getItemClasses(isCurrent: boolean, isEllipsis: boolean, isDisabled: boolean): string { + return [ + 'pagination-item', + isCurrent ? 'pagination-item--current' : '', + isEllipsis ? 'pagination-item--ellipsis' : '', + isDisabled ? 'pagination-item--disabled' : '', + !isCurrent && !isEllipsis ? 'pagination-item--nav' : '' + ].filter(Boolean).join(' '); + } + + getInfoClasses(): string { + return [ + 'pagination-info', + `pagination-info--${this.size}` + ].filter(Boolean).join(' '); + } + + getPageAriaLabel(page: number): string { + return page === this.currentPage() + ? `Page ${page}, current page` + : `Go to page ${page}`; + } + + getInfoText(): string { + const current = this.currentPage(); + const total = this.totalPages(); + + if (this.totalItems && this.itemsPerPage) { + const startItem = (current - 1) * this.itemsPerPage + 1; + const endItem = Math.min(current * this.itemsPerPage, this.totalItems); + return `${startItem}–${endItem} of ${this.totalItems} items`; + } + + return `Page ${current} of ${total}`; + } + + goToPage(page: number): void { + if (page !== this.currentPage() && page >= 1 && page <= this.totalPages() && !this.disabled) { + const previousPage = this.currentPage(); + this.currentPage.set(page); + this.pageChange.emit({ page, previousPage }); + } + } + + goToPrevious(): void { + if (this.hasPrevious()) { + this.goToPage(this.currentPage() - 1); + } + } + + goToNext(): void { + if (this.hasNext()) { + this.goToPage(this.currentPage() + 1); + } + } + + // Public methods for programmatic navigation + first(): void { + this.goToPage(1); + } + + last(): void { + this.goToPage(this.totalPages()); + } +} \ No newline at end of file diff --git a/src/lib/components/navigation/stepper/index.ts b/src/lib/components/navigation/stepper/index.ts new file mode 100644 index 0000000..3222386 --- /dev/null +++ b/src/lib/components/navigation/stepper/index.ts @@ -0,0 +1 @@ +export * from './stepper.component'; \ No newline at end of file diff --git a/src/lib/components/navigation/stepper/stepper.component.scss b/src/lib/components/navigation/stepper/stepper.component.scss new file mode 100644 index 0000000..0b84cb6 --- /dev/null +++ b/src/lib/components/navigation/stepper/stepper.component.scss @@ -0,0 +1,318 @@ +@use 'ui-design-system/src/styles/semantic' as *; + +.ui-stepper { + display: flex; + width: 100%; + position: relative; + + // Layout variants + &--horizontal { + flex-direction: row; + align-items: center; + } + + &--vertical { + flex-direction: column; + align-items: flex-start; + } +} + +.ui-stepper__step { + display: flex; + position: relative; + + .ui-stepper--horizontal & { + flex-direction: column; + align-items: center; + flex: 1; + + &:not(:last-child) { + margin-right: $semantic-spacing-component-lg; + } + } + + .ui-stepper--vertical & { + flex-direction: row; + align-items: flex-start; + width: 100%; + + &:not(:last-child) { + margin-bottom: $semantic-spacing-component-xl; + } + } +} + +.ui-stepper__step-header { + display: flex; + align-items: center; + cursor: pointer; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + border-radius: $semantic-border-radius-md; + + .ui-stepper--horizontal & { + flex-direction: column; + text-align: center; + padding: $semantic-spacing-component-sm; + } + + .ui-stepper--vertical & { + flex-direction: row; + padding: $semantic-spacing-component-sm $semantic-spacing-component-md; + margin-bottom: $semantic-spacing-component-sm; + } + + &:hover:not(.ui-stepper__step-header--disabled) { + background: $semantic-color-surface-secondary; + } + + &:focus-visible { + outline: 2px solid $semantic-color-brand-primary; + outline-offset: 2px; + } + + &--disabled { + cursor: not-allowed; + opacity: $semantic-opacity-disabled; + } +} + +.ui-stepper__step-indicator { + display: flex; + align-items: center; + justify-content: center; + width: $semantic-sizing-touch-target; + height: $semantic-sizing-touch-target; + border-radius: $semantic-border-radius-full; + border: $semantic-border-width-2 solid $semantic-color-border-primary; + background: $semantic-color-surface-primary; + color: $semantic-color-text-secondary; + font-weight: $semantic-typography-font-weight-semibold; + font-size: $semantic-typography-font-size-sm; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + position: relative; + flex-shrink: 0; + + .ui-stepper--horizontal & { + margin-bottom: $semantic-spacing-component-sm; + } + + .ui-stepper--vertical & { + margin-right: $semantic-spacing-component-md; + } + + // Step states + &--pending { + border-color: $semantic-color-border-primary; + background: $semantic-color-surface-primary; + color: $semantic-color-text-secondary; + } + + &--current { + border-color: $semantic-color-brand-primary; + background: $semantic-color-brand-primary; + color: $semantic-color-on-brand-primary; + box-shadow: $semantic-shadow-elevation-2; + } + + &--completed { + border-color: $semantic-color-success; + background: $semantic-color-success; + color: $semantic-color-on-success; + } + + &--error { + border-color: $semantic-color-danger; + background: $semantic-color-danger; + color: $semantic-color-on-danger; + } + + &--disabled { + border-color: $semantic-color-border-primary; + background: $semantic-color-surface-disabled; + color: $semantic-color-text-disabled; + } +} + +.ui-stepper__step-content { + display: flex; + flex-direction: column; + + .ui-stepper--horizontal & { + align-items: center; + text-align: center; + } + + .ui-stepper--vertical & { + align-items: flex-start; + flex: 1; + } +} + +.ui-stepper__step-title { + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: $semantic-typography-font-weight-semibold; + line-height: map-get($semantic-typography-body-medium, line-height); + color: $semantic-color-text-primary; + margin: 0; + + .ui-stepper__step-header--disabled & { + color: $semantic-color-text-disabled; + } +} + +.ui-stepper__step-description { + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + color: $semantic-color-text-secondary; + margin: $semantic-spacing-content-line-tight 0 0 0; + + .ui-stepper__step-header--disabled & { + color: $semantic-color-text-disabled; + } +} + +.ui-stepper__connector { + position: absolute; + background: $semantic-color-border-primary; + transition: background-color $semantic-motion-duration-fast $semantic-motion-easing-ease; + + .ui-stepper--horizontal & { + top: calc(#{$semantic-sizing-touch-target / 2} + #{$semantic-spacing-component-sm} - 1px); + left: calc(#{$semantic-sizing-touch-target / 2} + #{$semantic-spacing-component-sm} + #{$semantic-sizing-touch-target / 2} + #{$semantic-sizing-touch-target * 2} - 13px); + right: calc(-#{$semantic-spacing-component-lg} - #{$semantic-spacing-component-sm} + #{$semantic-sizing-touch-target / 2}); + height: 2px; + transform: none; + } + + .ui-stepper--vertical & { + left: calc(#{$semantic-sizing-touch-target / 2} + #{$semantic-spacing-component-sm} + #{$semantic-spacing-component-sm} - 2px); + top: calc(#{$semantic-sizing-touch-target} + #{$semantic-spacing-component-sm} + 2px); + height: calc(100% - 2px); + width: 2px; + } + + &--completed { + background: $semantic-color-success; + } + + &--error { + background: $semantic-color-danger; + } + + &--incoming { + .ui-stepper--horizontal & { + left: -16px; + width: calc(#{$semantic-sizing-touch-target / 2} + #{$semantic-spacing-component-sm} + 4px + #{$semantic-sizing-touch-target} + 20px); + } + + .ui-stepper--vertical & { + top: calc(-#{$semantic-spacing-component-xl} + #{$semantic-spacing-component-sm}); + height: calc(#{$semantic-spacing-component-xl} - #{$semantic-spacing-component-sm * 2} - 4px); + } + } +} + +// Size variants +.ui-stepper--sm { + .ui-stepper__step-indicator { + width: $semantic-sizing-button-height-sm; + height: $semantic-sizing-button-height-sm; + font-size: $semantic-typography-font-size-xs; + } + + .ui-stepper__step-title { + font-size: map-get($semantic-typography-body-small, font-size); + } + + .ui-stepper__step-description { + font-size: $semantic-typography-font-size-xs; + } +} + +.ui-stepper--lg { + .ui-stepper__step-indicator { + width: $semantic-sizing-button-height-lg; + height: $semantic-sizing-button-height-lg; + font-size: $semantic-typography-font-size-md; + } + + .ui-stepper__step-title { + font-size: map-get($semantic-typography-body-large, font-size); + font-weight: map-get($semantic-typography-body-large, font-weight); + } +} + +// Alternative visual style +.ui-stepper--outlined { + .ui-stepper__step-indicator { + &--current { + background: $semantic-color-surface-primary; + color: $semantic-color-brand-primary; + border-color: $semantic-color-brand-primary; + border-width: 3px; + } + } +} + +// Dense variant for compact layouts +.ui-stepper--dense { + .ui-stepper__step { + .ui-stepper--horizontal & { + &:not(:last-child) { + margin-right: $semantic-spacing-component-md; + } + } + + .ui-stepper--vertical & { + &:not(:last-child) { + margin-bottom: $semantic-spacing-component-lg; + } + } + } + + .ui-stepper__step-header { + padding: $semantic-spacing-component-xs; + } + + .ui-stepper__step-indicator { + .ui-stepper--vertical & { + margin-right: $semantic-spacing-component-sm; + } + } +} + +// Responsive design +@media (max-width: 768px) { + .ui-stepper--horizontal { + .ui-stepper__step { + &:not(:last-child) { + margin-right: $semantic-spacing-component-sm; + } + } + + .ui-stepper__step-description { + display: none; + } + + .ui-stepper__step-title { + font-size: map-get($semantic-typography-body-small, font-size); + } + } + + .ui-stepper__step-indicator { + width: $semantic-sizing-button-height-sm; + height: $semantic-sizing-button-height-sm; + font-size: $semantic-typography-font-size-xs; + } +} + +@media (max-width: 480px) { + .ui-stepper--horizontal { + .ui-stepper__step-title { + font-size: $semantic-typography-font-size-xs; + } + } +} \ No newline at end of file diff --git a/src/lib/components/navigation/stepper/stepper.component.ts b/src/lib/components/navigation/stepper/stepper.component.ts new file mode 100644 index 0000000..5863bb0 --- /dev/null +++ b/src/lib/components/navigation/stepper/stepper.component.ts @@ -0,0 +1,271 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { faCheck, faTimes } from '@fortawesome/free-solid-svg-icons'; + +export interface StepperStep { + id: string; + title: string; + description?: string; + completed?: boolean; + current?: boolean; + error?: boolean; + disabled?: boolean; + optional?: boolean; +} + +export type StepperOrientation = 'horizontal' | 'vertical'; +export type StepperSize = 'sm' | 'md' | 'lg'; +export type StepperVariant = 'default' | 'outlined'; + +@Component({ + selector: 'ui-stepper', + standalone: true, + imports: [CommonModule, FontAwesomeModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ @for (step of steps; track step.id; let i = $index; let isLast = $last) { +
+
+ +
+ @if (step.completed && !step.error) { + + } @else if (step.error) { + + } @else { + {{ i + 1 }} + } +
+ +
+

{{ step.title }}

+ @if (step.description) { +

{{ step.description }}

+ } +
+
+ + @if (!isLast) { +
+ } + @if (i > 0) { +
+ } +
+ } +
+ `, + styleUrl: './stepper.component.scss' +}) +export class StepperComponent { + @Input() steps: StepperStep[] = []; + @Input() orientation: StepperOrientation = 'horizontal'; + @Input() size: StepperSize = 'md'; + @Input() variant: StepperVariant = 'default'; + @Input() dense = false; + @Input() clickable = false; + @Input() linear = true; + + @Output() stepClick = new EventEmitter<{step: StepperStep, index: number}>(); + @Output() stepChange = new EventEmitter<{step: StepperStep, index: number}>(); + + checkIcon = faCheck; + errorIcon = faTimes; + + getStepperClasses(): string { + return [ + 'ui-stepper', + `ui-stepper--${this.orientation}`, + `ui-stepper--${this.size}`, + this.variant === 'outlined' ? 'ui-stepper--outlined' : '', + this.dense ? 'ui-stepper--dense' : '' + ].filter(Boolean).join(' '); + } + + getStepHeaderClasses(step: StepperStep): string { + return [ + 'ui-stepper__step-header', + step.disabled ? 'ui-stepper__step-header--disabled' : '' + ].filter(Boolean).join(' '); + } + + getStepIndicatorClasses(step: StepperStep): string { + let state = 'pending'; + + if (step.disabled) { + state = 'disabled'; + } else if (step.error) { + state = 'error'; + } else if (step.completed) { + state = 'completed'; + } else if (step.current) { + state = 'current'; + } + + return [ + 'ui-stepper__step-indicator', + `ui-stepper__step-indicator--${state}` + ].filter(Boolean).join(' '); + } + + getConnectorClasses(currentStep: StepperStep, nextStep: StepperStep): string { + let state = 'default'; + + if (currentStep.error) { + state = 'error'; + } else if (currentStep.completed && !nextStep.error) { + state = 'completed'; + } + + return [ + 'ui-stepper__connector', + state !== 'default' ? `ui-stepper__connector--${state}` : '' + ].filter(Boolean).join(' '); + } + + getTabIndex(step: StepperStep): number { + if (!this.clickable || step.disabled) { + return -1; + } + + if (this.linear) { + // In linear mode, only allow interaction with current step and completed steps + return step.current || step.completed ? 0 : -1; + } + + return 0; + } + + onStepClick(step: StepperStep, index: number): void { + if (!this.clickable || step.disabled) { + return; + } + + if (this.linear && !step.current && !step.completed) { + // In linear mode, don't allow clicking on future steps + return; + } + + this.stepClick.emit({ step, index }); + + if (!step.current) { + this.updateCurrentStep(index); + this.stepChange.emit({ step, index }); + } + } + + onStepKeydown(event: KeyboardEvent, step: StepperStep, index: number): void { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + this.onStepClick(step, index); + } + + // Arrow navigation + if (this.clickable && ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) { + event.preventDefault(); + this.handleArrowNavigation(event.key, index); + } + } + + private handleArrowNavigation(key: string, currentIndex: number): void { + const isHorizontal = this.orientation === 'horizontal'; + const isForward = (isHorizontal && key === 'ArrowRight') || (!isHorizontal && key === 'ArrowDown'); + const isBackward = (isHorizontal && key === 'ArrowLeft') || (!isHorizontal && key === 'ArrowUp'); + + if (!isForward && !isBackward) return; + + const direction = isForward ? 1 : -1; + let targetIndex = currentIndex + direction; + + // Find next available step + while (targetIndex >= 0 && targetIndex < this.steps.length) { + const targetStep = this.steps[targetIndex]; + + if (!targetStep.disabled && this.getTabIndex(targetStep) >= 0) { + this.focusStep(targetIndex); + break; + } + + targetIndex += direction; + } + } + + private focusStep(index: number): void { + // This would require ViewChild to focus the element + // For now, emit an event that parent can handle + const step = this.steps[index]; + if (step) { + this.stepClick.emit({ step, index }); + } + } + + private updateCurrentStep(newCurrentIndex: number): void { + this.steps.forEach((step, index) => { + step.current = index === newCurrentIndex; + }); + } + + // Public methods for programmatic control + public goToStep(index: number): void { + if (index >= 0 && index < this.steps.length) { + const step = this.steps[index]; + if (!step.disabled && (!this.linear || step.completed || step.current || index === 0)) { + this.updateCurrentStep(index); + this.stepChange.emit({ step, index }); + } + } + } + + public markStepCompleted(index: number, completed = true): void { + if (index >= 0 && index < this.steps.length) { + this.steps[index].completed = completed; + this.steps[index].error = false; // Clear error when marking completed + } + } + + public markStepError(index: number, error = true): void { + if (index >= 0 && index < this.steps.length) { + this.steps[index].error = error; + if (error) { + this.steps[index].completed = false; // Clear completed when marking error + } + } + } + + public nextStep(): void { + const currentIndex = this.steps.findIndex(step => step.current); + if (currentIndex >= 0 && currentIndex < this.steps.length - 1) { + // Mark current step as completed if not already + if (!this.steps[currentIndex].completed && !this.steps[currentIndex].error) { + this.markStepCompleted(currentIndex); + } + + this.goToStep(currentIndex + 1); + } + } + + public previousStep(): void { + const currentIndex = this.steps.findIndex(step => step.current); + if (currentIndex > 0) { + this.goToStep(currentIndex - 1); + } + } + + public resetStepper(): void { + this.steps.forEach((step, index) => { + step.current = index === 0; + step.completed = false; + step.error = false; + }); + } +} \ No newline at end of file diff --git a/src/lib/components/navigation/tab-group/index.ts b/src/lib/components/navigation/tab-group/index.ts new file mode 100644 index 0000000..20851d2 --- /dev/null +++ b/src/lib/components/navigation/tab-group/index.ts @@ -0,0 +1 @@ +export * from './tab-group.component'; \ No newline at end of file diff --git a/src/lib/components/navigation/tab-group/tab-group.component.scss b/src/lib/components/navigation/tab-group/tab-group.component.scss new file mode 100644 index 0000000..8a09404 --- /dev/null +++ b/src/lib/components/navigation/tab-group/tab-group.component.scss @@ -0,0 +1,245 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +// Tokens available globally via main application styles + +.ui-tab-group { + display: flex; + flex-direction: column; + width: 100%; + + &--full-width .tab-list { + width: 100%; + + .tab-button { + flex: 1; + } + } +} + +.tab-list { + display: flex; + align-items: center; + position: relative; + + // Line variant (default) + .ui-tab-group--line & { + border-bottom: $semantic-border-width-1 solid $semantic-color-border-primary; + gap: 0; + } + + // Pills variant + .ui-tab-group--pills & { + background-color: $semantic-color-surface-elevated; + padding: $semantic-spacing-1; // 0.25rem + border-radius: $semantic-border-radius-lg; + gap: $semantic-spacing-1; // 0.25rem + } + + // Cards variant + .ui-tab-group--cards & { + gap: $semantic-spacing-1; // 0.25rem + border-bottom: $semantic-border-width-1 solid $semantic-color-border-primary; + } +} + +.tab-button { + display: flex; + align-items: center; + gap: $semantic-spacing-2; // 0.5rem + border: none; + background: transparent; + cursor: pointer; + font-family: $semantic-typography-font-family-sans; + font-weight: $semantic-typography-font-weight-medium; + color: $semantic-color-text-secondary; + transition: all $semantic-motion-duration-normal $semantic-motion-easing-ease-out; + position: relative; + outline: none; + + // Size variants + .ui-tab-group--small & { + padding: $semantic-spacing-2 $semantic-spacing-3; // 0.5rem 0.75rem + font-size: $semantic-typography-font-size-sm; + line-height: $semantic-typography-line-height-tight; + min-height: $semantic-spacing-8; // 2rem + } + + .ui-tab-group--medium & { + padding: $semantic-spacing-3 $semantic-spacing-4; // 0.75rem 1rem + font-size: $semantic-typography-font-size-md; + line-height: $semantic-typography-line-height-normal; + min-height: $semantic-spacing-10; // 2.5rem + } + + .ui-tab-group--large & { + padding: $semantic-spacing-4 $semantic-spacing-6; // 1rem 1.5rem + font-size: $semantic-typography-font-size-lg; + line-height: $semantic-typography-line-height-normal; + min-height: $semantic-spacing-12; // 3rem + } + + // Line variant styling + .ui-tab-group--line & { + border-radius: 0; + margin-bottom: -1px; + + &::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 2px; + background-color: transparent; + transition: background-color $semantic-motion-duration-normal $semantic-motion-easing-ease; + } + + &:hover:not(:disabled) { + color: $semantic-color-interactive-primary; + background-color: $semantic-color-surface-hover; + } + + &--active { + color: $semantic-color-interactive-primary; + + &::after { + background-color: $semantic-color-interactive-primary; + } + } + } + + // Pills variant styling + .ui-tab-group--pills & { + border-radius: $semantic-border-radius-md; + + &:hover:not(:disabled) { + background-color: $semantic-color-surface-hover; + color: $semantic-color-text-primary; + } + + &--active { + background-color: $semantic-color-surface-primary; + color: $semantic-color-interactive-primary; + box-shadow: $semantic-shadow-button-hover; + } + } + + // Cards variant styling + .ui-tab-group--cards & { + border: $semantic-border-width-1 solid transparent; + border-radius: $semantic-border-radius-md $semantic-border-radius-md 0 0; + margin-bottom: -1px; + + &:hover:not(:disabled) { + background-color: $semantic-color-surface-hover; + color: $semantic-color-text-primary; + } + + &--active { + background-color: $semantic-color-surface-primary; + color: $semantic-color-interactive-primary; + border-color: $semantic-color-border-primary; + border-bottom-color: $semantic-color-surface-primary; + } + } + + // Focus state + &:focus-visible { + outline: 2px solid $semantic-color-interactive-primary; + outline-offset: 2px; + } + + // Disabled state + &:disabled, + &--disabled { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; + } +} + +.tab-icon { + flex-shrink: 0; + + .ui-tab-group--small & { + font-size: $semantic-typography-font-size-xs; + } + + .ui-tab-group--medium & { + font-size: $semantic-typography-font-size-sm; + } + + .ui-tab-group--large & { + font-size: $semantic-typography-font-size-md; + } +} + +.tab-label { + white-space: nowrap; + user-select: none; +} + +.tab-badge { + display: flex; + align-items: center; + justify-content: center; + min-width: $semantic-spacing-5; // 1.25rem + height: $semantic-spacing-5; // 1.25rem + background-color: $semantic-color-interactive-primary; + color: $semantic-color-on-brand-primary; + border-radius: $semantic-border-radius-full; + font-size: $semantic-typography-font-size-xs; + font-weight: $semantic-typography-font-weight-semibold; + line-height: 1; + padding: 0 $semantic-spacing-1; // 0 0.25rem + + .ui-tab-group--small & { + min-width: $semantic-spacing-4; // 1rem + height: $semantic-spacing-4; // 1rem + font-size: $semantic-typography-font-size-xs; + } + + .ui-tab-group--large & { + min-width: $semantic-spacing-6; // 1.5rem + height: $semantic-spacing-6; // 1.5rem + font-size: $semantic-typography-font-size-sm; + } +} + +.tab-panels { + padding-top: $semantic-spacing-4; // 1rem + + .ui-tab-group--pills & { + padding-top: $semantic-spacing-6; // 1.5rem + } +} + +// Responsive design +@media (max-width: 768px) { + .tab-list { + overflow-x: auto; + scrollbar-width: none; + -ms-overflow-style: none; + + &::-webkit-scrollbar { + display: none; + } + } + + .tab-button { + white-space: nowrap; + flex-shrink: 0; + + .ui-tab-group--small & { + padding: $semantic-spacing-1-5 $semantic-spacing-2-5; // 0.375rem 0.625rem + } + + .ui-tab-group--medium & { + padding: $semantic-spacing-2 $semantic-spacing-3; // 0.5rem 0.75rem + } + + .ui-tab-group--large & { + padding: $semantic-spacing-3 $semantic-spacing-4; // 0.75rem 1rem + } + } +} \ No newline at end of file diff --git a/src/lib/components/navigation/tab-group/tab-group.component.ts b/src/lib/components/navigation/tab-group/tab-group.component.ts new file mode 100644 index 0000000..b5c8e20 --- /dev/null +++ b/src/lib/components/navigation/tab-group/tab-group.component.ts @@ -0,0 +1,107 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { IconDefinition } from '@fortawesome/fontawesome-svg-core'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; + +export interface TabItem { + id: string; + label: string; + icon?: string | IconDefinition; + disabled?: boolean; + badge?: string | number; + closeable?: boolean; +} + +export type TabVariant = 'line' | 'pills' | 'cards'; +export type TabSize = 'small' | 'medium' | 'large'; + +@Component({ + selector: 'ui-tab-group', + standalone: true, + imports: [CommonModule, FontAwesomeModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+
+ @for (tab of tabs; track tab.id) { + + } +
+ +
+ +
+
+ `, + styleUrl: './tab-group.component.scss' +}) +export class TabGroupComponent { + @Input() tabs: TabItem[] = []; + @Input() variant: TabVariant = 'line'; + @Input() size: TabSize = 'medium'; + @Input() fullWidth: boolean = false; + + @Output() tabChange = new EventEmitter(); + + activeTabId = signal(''); + + ngOnInit(): void { + // Set the first non-disabled tab as active by default + if (this.tabs.length > 0 && !this.activeTabId()) { + const firstEnabledTab = this.tabs.find(tab => !tab.disabled); + if (firstEnabledTab) { + this.activeTabId.set(firstEnabledTab.id); + } + } + } + + get wrapperClasses(): string { + return [ + 'ui-tab-group', + `ui-tab-group--${this.variant}`, + `ui-tab-group--${this.size}`, + this.fullWidth ? 'ui-tab-group--full-width' : '' + ].filter(Boolean).join(' '); + } + + getTabClasses(tab: TabItem): string { + return [ + 'tab-button', + this.activeTabId() === tab.id ? 'tab-button--active' : '', + tab.disabled ? 'tab-button--disabled' : '' + ].filter(Boolean).join(' '); + } + + selectTab(tabId: string): void { + const tab = this.tabs.find(t => t.id === tabId); + if (tab && !tab.disabled) { + this.activeTabId.set(tabId); + this.tabChange.emit(tabId); + } + } + + isIconDefinition(icon: string | IconDefinition): icon is IconDefinition { + return typeof icon === 'object' && icon !== null && 'iconName' in icon; + } +} \ No newline at end of file diff --git a/src/lib/components/overlays/backdrop/backdrop.component.scss b/src/lib/components/overlays/backdrop/backdrop.component.scss new file mode 100644 index 0000000..f6a0162 --- /dev/null +++ b/src/lib/components/overlays/backdrop/backdrop.component.scss @@ -0,0 +1,166 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-backdrop { + // Core Structure + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: var(--backdrop-z-index, 1000); + + // Visual Design + background: rgba(0, 0, 0, 0.5); + opacity: 0; + + // Transitions + transition: opacity $semantic-motion-duration-normal $semantic-motion-easing-ease; + + // Pointer Events + pointer-events: none; + + // Backdrop Variants + &--default { + background: rgba(0, 0, 0, 0.5); + } + + &--blur { + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + } + + &--dark { + background: rgba(0, 0, 0, 0.6); + } + + &--light { + background: rgba(255, 255, 255, 0.6); + } + + // State Variants + &--visible { + pointer-events: auto; + opacity: var(--backdrop-opacity, 0.5); + } + + &--clickable { + cursor: pointer; + + &:hover { + opacity: calc(var(--backdrop-opacity, 0.5) + 0.05); + } + + &:active { + opacity: calc(var(--backdrop-opacity, 0.5) + 0.1); + } + } + + // Animation States + &--entering { + opacity: 0; + animation: backdrop-fade-in $semantic-motion-duration-normal $semantic-motion-easing-ease forwards; + } + + &--leaving { + opacity: var(--backdrop-opacity, 0.5); + animation: backdrop-fade-out $semantic-motion-duration-normal $semantic-motion-easing-ease forwards; + } + + // Content Positioning + &__content { + position: relative; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + padding: $semantic-spacing-layout-md; + } + + // Dark Mode Support + :host-context(.dark-theme) & { + &--default { + background: rgba(0, 0, 0, 0.7); + } + + &--light { + background: rgba(255, 255, 255, 0.1); + } + } + + // High Contrast Mode + @media (prefers-contrast: high) { + &--default { + background: rgba(0, 0, 0, 0.8); + } + + &--light { + background: rgba(255, 255, 255, 0.8); + } + } + + // Reduced Motion Support + @media (prefers-reduced-motion: reduce) { + transition: none; + animation: none; + + &--entering, + &--leaving { + animation: none; + } + } + + // Responsive Design + @media (max-width: 768px - 1) { + &__content { + padding: $semantic-spacing-layout-sm; + } + } + + @media (max-width: 576px - 1) { + &__content { + padding: $semantic-spacing-layout-xs; + } + } +} + +// Keyframe Animations +@keyframes backdrop-fade-in { + from { + opacity: 0; + } + + to { + opacity: var(--backdrop-opacity, 0.5); + } +} + +@keyframes backdrop-fade-out { + from { + opacity: var(--backdrop-opacity, 0.5); + } + + to { + opacity: 0; + } +} + +// Z-index Management Utilities +.ui-backdrop { + // Common z-index levels + &--z-index-modal { + z-index: 1050; + } + + &--z-index-drawer { + z-index: 1040; + } + + &--z-index-popover { + z-index: 1030; + } + + &--z-index-tooltip { + z-index: 1020; + } +} \ No newline at end of file diff --git a/src/lib/components/overlays/backdrop/backdrop.component.ts b/src/lib/components/overlays/backdrop/backdrop.component.ts new file mode 100644 index 0000000..2ec1744 --- /dev/null +++ b/src/lib/components/overlays/backdrop/backdrop.component.ts @@ -0,0 +1,182 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { signal, computed } from '@angular/core'; + +type BackdropVariant = 'default' | 'blur' | 'dark' | 'light'; + +export interface BackdropConfig { + variant?: BackdropVariant; + visible?: boolean; + clickable?: boolean; + blur?: boolean; + opacity?: number; + zIndex?: number; + preventBodyScroll?: boolean; +} + +@Component({ + selector: 'ui-backdrop', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` + @if (isVisible()) { +
+ +
+ } + `, + styleUrl: './backdrop.component.scss' +}) +export class BackdropComponent { + // Inputs + @Input() variant: BackdropVariant = 'default'; + @Input() clickable = true; + @Input() blur = false; + @Input() opacity = 0.5; + @Input() zIndex = 1000; + @Input() preventBodyScroll = false; + + // State Inputs + @Input() set visible(value: boolean) { + if (value !== this._visible()) { + this._visible.set(value); + if (value) { + this.show(); + } else { + this.hide(); + } + } + } + + get visible(): boolean { + return this._visible(); + } + + // Outputs + @Output() visibleChange = new EventEmitter(); + @Output() clicked = new EventEmitter(); + @Output() shown = new EventEmitter(); + @Output() hidden = new EventEmitter(); + + // Internal state signals + private _visible = signal(false); + private _entering = signal(false); + private _leaving = signal(false); + + // Computed signals + readonly isVisible = computed(() => this._visible() || this._entering() || this._leaving()); + readonly isEntering = computed(() => this._entering()); + readonly isLeaving = computed(() => this._leaving()); + + // Internal properties + private originalBodyOverflow = ''; + + /** + * Shows the backdrop with animation + */ + show(): void { + if (this._visible()) return; + + if (this.preventBodyScroll) { + this.preventBodyScrollAction(); + } + + this._visible.set(true); + this._entering.set(true); + this.visibleChange.emit(true); + + // Animation timing + setTimeout(() => { + this._entering.set(false); + this.shown.emit(); + }, 200); + } + + /** + * Hides the backdrop with animation + */ + hide(): void { + if (!this._visible()) return; + + this._leaving.set(true); + + // Animation timing + setTimeout(() => { + this._visible.set(false); + this._leaving.set(false); + this.restoreBodyScroll(); + this.visibleChange.emit(false); + this.hidden.emit(); + }, 200); + } + + /** + * Toggles backdrop visibility + */ + toggle(): void { + if (this._visible()) { + this.hide(); + } else { + this.show(); + } + } + + /** + * Handles click events + */ + handleClick(event: MouseEvent): void { + if (this.clickable) { + this.clicked.emit(event); + } + } + + /** + * Handles animation end events + */ + handleAnimationEnd(event: AnimationEvent): void { + // Additional animation handling can be added here if needed + event.stopPropagation(); + } + + /** + * Prevents body scrolling + */ + private preventBodyScrollAction(): void { + const body = document.body; + this.originalBodyOverflow = body.style.overflow; + body.style.overflow = 'hidden'; + } + + /** + * Restores body scrolling + */ + private restoreBodyScroll(): void { + if (this.preventBodyScroll) { + const body = document.body; + body.style.overflow = this.originalBodyOverflow; + } + } + + /** + * Public API method to apply configuration + */ + configure(config: BackdropConfig): void { + Object.assign(this, config); + } +} \ No newline at end of file diff --git a/src/lib/components/overlays/backdrop/index.ts b/src/lib/components/overlays/backdrop/index.ts new file mode 100644 index 0000000..1efe750 --- /dev/null +++ b/src/lib/components/overlays/backdrop/index.ts @@ -0,0 +1 @@ +export * from './backdrop.component'; \ No newline at end of file diff --git a/src/lib/components/overlays/command-palette/command-palette-item.component.scss b/src/lib/components/overlays/command-palette/command-palette-item.component.scss new file mode 100644 index 0000000..d57e738 --- /dev/null +++ b/src/lib/components/overlays/command-palette/command-palette-item.component.scss @@ -0,0 +1,167 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-command-palette-item { + display: flex; + align-items: center; + width: 100%; + padding: $semantic-spacing-component-sm; + margin: 0; + border: none; + border-radius: $semantic-border-radius-sm; + background: transparent; + color: $semantic-color-text-primary; + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + text-align: left; + cursor: pointer; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + + &:hover, + &--selected { + background: $semantic-color-surface-elevated; + box-shadow: $semantic-shadow-elevation-1; + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + + &--disabled { + opacity: $semantic-opacity-disabled; + cursor: not-allowed; + pointer-events: none; + } + + &__content { + display: flex; + align-items: center; + width: 100%; + gap: $semantic-spacing-component-sm; + } + + &__icon { + display: flex; + align-items: center; + justify-content: center; + width: $semantic-sizing-icon-navigation; + height: $semantic-sizing-icon-navigation; + flex-shrink: 0; + color: $semantic-color-text-secondary; + transition: color $semantic-motion-duration-fast $semantic-motion-easing-ease; + } + + &:hover &__icon, + &--selected &__icon { + color: $semantic-color-primary; + } + + &__text { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: $semantic-spacing-content-line-tight; + } + + &__title { + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + color: $semantic-color-text-primary; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__description { + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + color: $semantic-color-text-secondary; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__highlight { + background: $semantic-color-primary; + color: $semantic-color-on-primary; + padding: 0 2px; + border-radius: $semantic-border-radius-sm; + font-weight: $semantic-typography-font-weight-semibold; + } + + &__shortcut { + display: flex; + align-items: center; + gap: $semantic-spacing-content-line-tight; + flex-shrink: 0; + margin-left: auto; + } + + &__kbd { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: $semantic-sizing-touch-minimum; + height: $semantic-sizing-button-height-sm; + padding: 0 $semantic-spacing-component-xs; + background: $semantic-color-surface-secondary; + border: $semantic-border-width-1 solid $semantic-color-border-subtle; + border-radius: $semantic-border-radius-sm; + font-family: map-get($semantic-typography-caption, font-family); + font-size: map-get($semantic-typography-caption, font-size); + font-weight: map-get($semantic-typography-caption, font-weight); + line-height: map-get($semantic-typography-caption, line-height); + color: $semantic-color-text-secondary; + text-transform: uppercase; + box-shadow: $semantic-shadow-elevation-1; + } + + &__plus { + color: $semantic-color-text-tertiary; + font-family: map-get($semantic-typography-caption, font-family); + font-size: map-get($semantic-typography-caption, font-size); + font-weight: map-get($semantic-typography-caption, font-weight); + line-height: map-get($semantic-typography-caption, line-height); + } + + // Hover state for shortcut keys + &:hover &__kbd, + &--selected &__kbd { + background: $semantic-color-surface-elevated; + border-color: $semantic-color-border-primary; + color: $semantic-color-text-primary; + } + + // Focus state improvements + &:focus-visible { + .ui-command-palette-item__icon { + color: $semantic-color-primary; + } + + .ui-command-palette-item__kbd { + background: $semantic-color-surface-elevated; + border-color: $semantic-color-focus; + color: $semantic-color-text-primary; + } + } + + // Responsive adjustments + @media (max-width: calc(#{$semantic-breakpoint-md} - 1px)) { + padding: $semantic-spacing-component-xs; + + &__content { + gap: $semantic-spacing-component-xs; + } + + &__shortcut { + display: none; // Hide shortcuts on smaller screens + } + } +} \ No newline at end of file diff --git a/src/lib/components/overlays/command-palette/command-palette-item.component.ts b/src/lib/components/overlays/command-palette/command-palette-item.component.ts new file mode 100644 index 0000000..7f90984 --- /dev/null +++ b/src/lib/components/overlays/command-palette/command-palette-item.component.ts @@ -0,0 +1,127 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { CommandSearchResult } from './command-palette.types'; + +@Component({ + selector: 'ui-command-palette-item', + standalone: true, + imports: [CommonModule, FontAwesomeModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` + + `, + styleUrl: './command-palette-item.component.scss' +}) +export class CommandPaletteItemComponent { + @Input({ required: true }) result!: CommandSearchResult; + @Input() selected = false; + + @Output() itemClick = new EventEmitter(); + @Output() itemHover = new EventEmitter(); + + handleClick(): void { + if (!this.result.command.disabled) { + this.itemClick.emit(this.result); + } + } + + handleMouseEnter(): void { + this.itemHover.emit(this.result); + } + + getHighlightedSegments(): Array<{ text: string; highlighted: boolean }> { + const title = this.result.command.title; + const ranges = this.result.highlightRanges; + + if (ranges.length === 0) { + return [{ text: title, highlighted: false }]; + } + + const segments: Array<{ text: string; highlighted: boolean }> = []; + let currentIndex = 0; + + // Sort ranges by start position + const sortedRanges = [...ranges].sort((a, b) => a.start - b.start); + + for (const range of sortedRanges) { + // Add non-highlighted text before this range + if (currentIndex < range.start) { + segments.push({ + text: title.substring(currentIndex, range.start), + highlighted: false + }); + } + + // Add highlighted text + segments.push({ + text: title.substring(range.start, range.end), + highlighted: true + }); + + currentIndex = range.end; + } + + // Add remaining non-highlighted text + if (currentIndex < title.length) { + segments.push({ + text: title.substring(currentIndex), + highlighted: false + }); + } + + return segments; + } +} \ No newline at end of file diff --git a/src/lib/components/overlays/command-palette/command-palette.component.scss b/src/lib/components/overlays/command-palette/command-palette.component.scss new file mode 100644 index 0000000..5ad28f8 --- /dev/null +++ b/src/lib/components/overlays/command-palette/command-palette.component.scss @@ -0,0 +1,383 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-command-palette { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: $semantic-z-index-modal; + display: none; + align-items: flex-start; + justify-content: center; + padding: $semantic-spacing-layout-section-lg $semantic-spacing-component-md; + + &--open, + &--entering, + &--leaving { + display: flex; + } + + &__backdrop { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: $semantic-color-backdrop; + opacity: 0; + animation: backdropEnter $semantic-motion-duration-normal $semantic-motion-easing-ease forwards; + } + + &--leaving &__backdrop { + animation: backdropExit $semantic-motion-duration-normal $semantic-motion-easing-ease forwards; + } + + &__container { + position: relative; + width: 100%; + max-width: 640px; + background: $semantic-color-surface-primary; + border: $semantic-border-width-1 solid $semantic-color-border-subtle; + border-radius: $semantic-border-radius-lg; + box-shadow: $semantic-shadow-modal; + overflow: hidden; + transform: translateY(-20px); + opacity: 0; + animation: containerEnter $semantic-motion-duration-normal $semantic-motion-easing-spring forwards; + } + + &--leaving &__container { + animation: containerExit $semantic-motion-duration-normal $semantic-motion-easing-ease forwards; + } + + // Size variants + &--md &__container { + max-width: 480px; + } + + &--lg &__container { + max-width: 640px; + } + + &--xl &__container { + max-width: 800px; + } + + // Search Section + &__search { + position: relative; + display: flex; + align-items: center; + padding: $semantic-spacing-component-md; + border-bottom: $semantic-border-width-1 solid $semantic-color-border-subtle; + background: $semantic-color-surface-primary; + } + + &__search-icon { + display: flex; + align-items: center; + justify-content: center; + width: $semantic-sizing-icon-navigation; + height: $semantic-sizing-icon-navigation; + margin-right: $semantic-spacing-component-sm; + color: $semantic-color-text-secondary; + flex-shrink: 0; + } + + &__input { + flex: 1; + padding: 0; + border: none; + background: transparent; + font-family: map-get($semantic-typography-input, font-family); + font-size: map-get($semantic-typography-input, font-size); + font-weight: map-get($semantic-typography-input, font-weight); + line-height: map-get($semantic-typography-input, line-height); + color: $semantic-color-text-primary; + + &::placeholder { + color: $semantic-color-text-tertiary; + } + + &:focus { + outline: none; + } + + // Remove search input styling + &::-webkit-search-cancel-button, + &::-webkit-search-decoration { + -webkit-appearance: none; + } + + &[type="search"] { + -webkit-appearance: textfield; + } + } + + &__clear { + display: flex; + align-items: center; + justify-content: center; + width: $semantic-sizing-touch-minimum; + height: $semantic-sizing-touch-minimum; + padding: 0; + margin-left: $semantic-spacing-component-xs; + border: none; + border-radius: $semantic-border-radius-sm; + background: transparent; + color: $semantic-color-text-secondary; + cursor: pointer; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + font-size: $semantic-typography-font-size-lg; + font-weight: $semantic-typography-font-weight-bold; + + &:hover { + background: $semantic-color-surface-elevated; + color: $semantic-color-text-primary; + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + } + + // Results Section + &__results { + max-height: 400px; + overflow-y: auto; + padding: $semantic-spacing-component-xs 0; + background: $semantic-color-surface-primary; + + // Custom scrollbar + &::-webkit-scrollbar { + width: 8px; + } + + &::-webkit-scrollbar-track { + background: $semantic-color-surface-secondary; + } + + &::-webkit-scrollbar-thumb { + background: $semantic-color-border-secondary; + border-radius: $semantic-border-radius-sm; + + &:hover { + background: $semantic-color-border-primary; + } + } + } + + // Group styling + &__group { + &:not(:first-child) { + margin-top: $semantic-spacing-component-md; + padding-top: $semantic-spacing-component-sm; + border-top: $semantic-border-width-1 solid $semantic-color-border-subtle; + } + } + + &__group-header { + display: flex; + align-items: center; + gap: $semantic-spacing-component-xs; + padding: $semantic-spacing-component-xs $semantic-spacing-component-md; + margin-bottom: $semantic-spacing-component-xs; + } + + &__group-icon { + display: flex; + align-items: center; + justify-content: center; + width: $semantic-sizing-icon-inline; + height: $semantic-sizing-icon-inline; + color: $semantic-color-text-tertiary; + } + + &__group-title { + flex: 1; + margin: 0; + font-family: map-get($semantic-typography-label, font-family); + font-size: map-get($semantic-typography-label, font-size); + font-weight: map-get($semantic-typography-label, font-weight); + line-height: map-get($semantic-typography-label, line-height); + color: $semantic-color-text-secondary; + text-transform: uppercase; + letter-spacing: 0.05em; + } + + &__group-count { + font-family: map-get($semantic-typography-caption, font-family); + font-size: map-get($semantic-typography-caption, font-size); + font-weight: map-get($semantic-typography-caption, font-weight); + line-height: map-get($semantic-typography-caption, line-height); + color: $semantic-color-text-tertiary; + background: $semantic-color-surface-secondary; + padding: 2px $semantic-spacing-component-xs; + border-radius: $semantic-border-radius-full; + min-width: 20px; + text-align: center; + } + + // Empty state + &__empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: $semantic-spacing-layout-section-md; + text-align: center; + } + + &__empty-icon { + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + margin-bottom: $semantic-spacing-component-md; + color: $semantic-color-text-tertiary; + opacity: $semantic-opacity-subtle; + } + + &__empty-text { + margin: 0; + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + color: $semantic-color-text-secondary; + } + + // Footer + &__footer { + display: flex; + align-items: center; + gap: $semantic-spacing-component-lg; + padding: $semantic-spacing-component-sm $semantic-spacing-component-md; + border-top: $semantic-border-width-1 solid $semantic-color-border-subtle; + background: $semantic-color-surface-secondary; + justify-content: center; + } + + &__footer-section { + display: flex; + align-items: center; + gap: $semantic-spacing-component-xs; + } + + &__footer-kbd { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 24px; + height: 20px; + padding: 0 $semantic-spacing-component-xs; + background: $semantic-color-surface-primary; + border: $semantic-border-width-1 solid $semantic-color-border-subtle; + border-radius: $semantic-border-radius-sm; + font-family: map-get($semantic-typography-caption, font-family); + font-size: map-get($semantic-typography-caption, font-size); + font-weight: map-get($semantic-typography-caption, font-weight); + line-height: map-get($semantic-typography-caption, line-height); + color: $semantic-color-text-secondary; + box-shadow: $semantic-shadow-elevation-1; + } + + &__footer-section span { + font-family: map-get($semantic-typography-caption, font-family); + font-size: map-get($semantic-typography-caption, font-size); + font-weight: map-get($semantic-typography-caption, font-weight); + line-height: map-get($semantic-typography-caption, line-height); + color: $semantic-color-text-tertiary; + } + + // Responsive design + @media (max-width: calc(#{$semantic-breakpoint-md} - 1px)) { + padding: $semantic-spacing-component-md $semantic-spacing-component-sm; + + &__container { + max-width: none; + } + + &__search { + padding: $semantic-spacing-component-sm; + } + + &__results { + max-height: 300px; + } + + &__footer { + padding: $semantic-spacing-component-xs $semantic-spacing-component-sm; + gap: $semantic-spacing-component-md; + } + } + + @media (max-width: calc(#{$semantic-breakpoint-sm} - 1px)) { + padding: $semantic-spacing-component-sm; + + &__footer-section:not(:first-child) { + display: none; // Hide extra keyboard hints on mobile + } + } + + // Reduce motion for users who prefer it + @media (prefers-reduced-motion: reduce) { + &__backdrop, + &__container { + animation: none !important; + } + + &--entering &__backdrop { + opacity: $semantic-opacity-backdrop; + } + + &--entering &__container { + transform: none; + opacity: 1; + } + } +} + +// Animations +@keyframes backdropEnter { + from { + opacity: 0; + } + to { + opacity: $semantic-opacity-backdrop; + } +} + +@keyframes backdropExit { + from { + opacity: $semantic-opacity-backdrop; + } + to { + opacity: 0; + } +} + +@keyframes containerEnter { + from { + transform: translateY(-20px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +@keyframes containerExit { + from { + transform: translateY(0); + opacity: 1; + } + to { + transform: translateY(-10px); + opacity: 0; + } +} \ No newline at end of file diff --git a/src/lib/components/overlays/command-palette/command-palette.component.ts b/src/lib/components/overlays/command-palette/command-palette.component.ts new file mode 100644 index 0000000..c0359d1 --- /dev/null +++ b/src/lib/components/overlays/command-palette/command-palette.component.ts @@ -0,0 +1,485 @@ +import { + Component, + Input, + Output, + EventEmitter, + ChangeDetectionStrategy, + ViewEncapsulation, + OnInit, + OnDestroy, + ViewChild, + ElementRef, + inject, + signal, + computed, + effect +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { faSearch, faClock, faFolder, faTerminal } from '@fortawesome/free-solid-svg-icons'; +import { CommandPaletteService } from './command-palette.service'; +import { CommandPaletteItemComponent } from './command-palette-item.component'; +import { + CommandSearchResult, + CommandGroup, + CommandCategory, + CommandExecutionContext +} from './command-palette.types'; + +export type CommandPaletteSize = 'md' | 'lg' | 'xl'; + +@Component({ + selector: 'ui-command-palette', + standalone: true, + imports: [CommonModule, FormsModule, FontAwesomeModule, CommandPaletteItemComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` + + `, + styleUrl: './command-palette.component.scss' +}) +export class CommandPaletteComponent implements OnInit, OnDestroy { + @ViewChild('searchInput') searchInput!: ElementRef; + + @Input() size: CommandPaletteSize = 'lg'; + @Input() showFooter = true; + @Input() autoFocus = true; + + @Output() opened = new EventEmitter(); + @Output() closed = new EventEmitter(); + @Output() commandExecuted = new EventEmitter<{ commandId: string; context: CommandExecutionContext }>(); + + // Inject services + readonly commandService = inject(CommandPaletteService); + + // Icons + readonly faSearch = faSearch; + readonly faClock = faClock; + readonly faFolder = faFolder; + readonly faTerminal = faTerminal; + + // State signals + private _isOpen = signal(false); + private _isEntering = signal(false); + private _isLeaving = signal(false); + private _searchQuery = signal(''); + private _selectedIndex = signal(0); + private _searchResults = signal([]); + + // Computed signals + readonly isOpen = this._isOpen.asReadonly(); + readonly isEntering = this._isEntering.asReadonly(); + readonly isLeaving = this._isLeaving.asReadonly(); + readonly searchQuery = this._searchQuery.asReadonly(); + readonly selectedIndex = this._selectedIndex.asReadonly(); + readonly searchResults = this._searchResults.asReadonly(); + readonly isVisible = computed(() => this._isOpen() || this._isEntering() || this._isLeaving()); + + readonly groupedResults = computed(() => { + const results = this._searchResults(); + if (!results.length || this._searchQuery()) { + return []; + } + + const groups = new Map(); + + results.forEach(result => { + const category = result.command.category; + const categoryResults = groups.get(category) || []; + categoryResults.push(result); + groups.set(category, categoryResults); + }); + + return Array.from(groups.entries()).map(([category, commands]) => ({ + category, + title: this.getCategoryTitle(category), + commands: [] + })).sort((a, b) => this.getCategoryOrder(a.category) - this.getCategoryOrder(b.category)); + }); + + // Internal properties + readonly paletteId = `palette-${Math.random().toString(36).substr(2, 9)}`; + private previousActiveElement: Element | null = null; + + constructor() { + // Auto-update search results when query changes + effect(() => { + const query = this._searchQuery(); + const results = this.commandService.searchCommands(query); + this._searchResults.set(results); + this._selectedIndex.set(0); + }); + } + + ngOnInit(): void { + // Initial search to show recent commands + this.updateSearchResults(); + } + + ngOnDestroy(): void { + this.restoreFocus(); + } + + open(): void { + if (this._isOpen()) return; + + this.storePreviousFocus(); + this._isOpen.set(true); + this._isEntering.set(true); + this.updateSearchResults(); + + // Focus input after animation + setTimeout(() => { + this._isEntering.set(false); + if (this.autoFocus && this.searchInput) { + this.searchInput.nativeElement.focus(); + } + this.opened.emit(); + }, 150); + } + + close(): void { + if (!this._isOpen()) return; + + this._isLeaving.set(true); + + setTimeout(() => { + this._isOpen.set(false); + this._isLeaving.set(false); + this._searchQuery.set(''); + this._selectedIndex.set(0); + this.restoreFocus(); + this.closed.emit(); + }, 150); + } + + toggle(): void { + if (this._isOpen()) { + this.close(); + } else { + this.open(); + } + } + + handleSearchInput(event: Event): void { + const target = event.target as HTMLInputElement; + this._searchQuery.set(target.value); + } + + clearSearch(): void { + this._searchQuery.set(''); + if (this.searchInput) { + this.searchInput.nativeElement.focus(); + } + } + + handleKeydown(event: KeyboardEvent): void { + const results = this._searchResults(); + + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + this.navigateDown(); + break; + + case 'ArrowUp': + event.preventDefault(); + this.navigateUp(); + break; + + case 'Enter': + event.preventDefault(); + this.executeSelectedCommand(); + break; + + case 'Escape': + event.preventDefault(); + this.close(); + break; + + case 'Tab': + event.preventDefault(); + // Tab could cycle through results or close + if (event.shiftKey) { + this.navigateUp(); + } else { + this.navigateDown(); + } + break; + } + } + + private navigateDown(): void { + const results = this._searchResults(); + if (results.length === 0) return; + + const currentIndex = this._selectedIndex(); + const nextIndex = currentIndex >= results.length - 1 ? 0 : currentIndex + 1; + this._selectedIndex.set(nextIndex); + } + + private navigateUp(): void { + const results = this._searchResults(); + if (results.length === 0) return; + + const currentIndex = this._selectedIndex(); + const prevIndex = currentIndex <= 0 ? results.length - 1 : currentIndex - 1; + this._selectedIndex.set(prevIndex); + } + + executeSelectedCommand(): void { + const results = this._searchResults(); + const selectedResult = results[this._selectedIndex()]; + + if (selectedResult) { + this.executeCommand(selectedResult); + } + } + + async executeCommand(result: CommandSearchResult): Promise { + const context: CommandExecutionContext = { + source: 'click', + timestamp: new Date() + }; + + try { + await this.commandService.executeCommand(result.command.id, context); + this.commandExecuted.emit({ commandId: result.command.id, context }); + this.close(); + } catch (error) { + console.error('Failed to execute command:', error); + } + } + + setSelectedIndex(index: number): void { + this._selectedIndex.set(index); + } + + getActiveDescendantId(): string | null { + const results = this._searchResults(); + const selectedResult = results[this._selectedIndex()]; + return selectedResult ? `command-${selectedResult.command.id}` : null; + } + + getCategoryIcon(category: CommandCategory) { + const icons = { + [CommandCategory.RECENT]: faClock, + [CommandCategory.NAVIGATION]: faFolder, + [CommandCategory.ACTIONS]: faTerminal, + [CommandCategory.SETTINGS]: faTerminal, + [CommandCategory.SEARCH]: faSearch, + [CommandCategory.HELP]: faTerminal, + [CommandCategory.FILE]: faFolder, + [CommandCategory.EDIT]: faTerminal, + [CommandCategory.VIEW]: faTerminal, + [CommandCategory.TOOLS]: faTerminal + }; + return icons[category] || faTerminal; + } + + getCategoryTitle(category: CommandCategory): string { + const titles = { + [CommandCategory.RECENT]: 'Recent', + [CommandCategory.NAVIGATION]: 'Navigation', + [CommandCategory.ACTIONS]: 'Actions', + [CommandCategory.SETTINGS]: 'Settings', + [CommandCategory.SEARCH]: 'Search', + [CommandCategory.HELP]: 'Help', + [CommandCategory.FILE]: 'File', + [CommandCategory.EDIT]: 'Edit', + [CommandCategory.VIEW]: 'View', + [CommandCategory.TOOLS]: 'Tools' + }; + return titles[category] || 'Other'; + } + + getCategoryOrder(category: CommandCategory): number { + const orders = { + [CommandCategory.RECENT]: 0, + [CommandCategory.NAVIGATION]: 1, + [CommandCategory.FILE]: 2, + [CommandCategory.EDIT]: 3, + [CommandCategory.VIEW]: 4, + [CommandCategory.ACTIONS]: 5, + [CommandCategory.TOOLS]: 6, + [CommandCategory.SEARCH]: 7, + [CommandCategory.SETTINGS]: 8, + [CommandCategory.HELP]: 9 + }; + return orders[category] || 99; + } + + getResultsForGroup(group: { category: CommandCategory }): CommandSearchResult[] { + return this._searchResults().filter(result => result.command.category === group.category); + } + + getGlobalIndex(group: { category: CommandCategory }, localIndex: number): number { + const results = this._searchResults(); + let globalIndex = 0; + + for (const result of results) { + if (result.command.category === group.category) { + if (localIndex === 0) { + return globalIndex; + } + localIndex--; + } + globalIndex++; + } + + return 0; + } + + private updateSearchResults(): void { + const query = this._searchQuery(); + const results = this.commandService.searchCommands(query); + this._searchResults.set(results); + this._selectedIndex.set(0); + } + + private storePreviousFocus(): void { + this.previousActiveElement = document.activeElement; + } + + private restoreFocus(): void { + if (this.previousActiveElement && 'focus' in this.previousActiveElement) { + (this.previousActiveElement as HTMLElement).focus(); + } + } +} \ No newline at end of file diff --git a/src/lib/components/overlays/command-palette/command-palette.service.ts b/src/lib/components/overlays/command-palette/command-palette.service.ts new file mode 100644 index 0000000..987358e --- /dev/null +++ b/src/lib/components/overlays/command-palette/command-palette.service.ts @@ -0,0 +1,353 @@ +import { Injectable, signal } from '@angular/core'; +import { + Command, + CommandGroup, + CommandSearchResult, + CommandPaletteConfig, + CommandCategory, + RecentCommand, + CommandExecutionContext +} from './command-palette.types'; + +@Injectable({ + providedIn: 'root' +}) +export class CommandPaletteService { + private _commands = signal([]); + private _recentCommands = signal([]); + private _config = signal({ + maxResults: 20, + showCategories: true, + showShortcuts: true, + showRecent: true, + recentLimit: 5, + placeholder: 'Search commands...', + noResultsMessage: 'No commands found', + fuzzySearchThreshold: 0.3 + }); + + readonly commands = this._commands.asReadonly(); + readonly recentCommands = this._recentCommands.asReadonly(); + readonly config = this._config.asReadonly(); + + private readonly RECENT_COMMANDS_KEY = 'command-palette-recent'; + + constructor() { + this.loadRecentCommands(); + } + + /** + * Register a new command + */ + registerCommand(command: Command): void { + const commands = this._commands(); + const existingIndex = commands.findIndex(c => c.id === command.id); + + if (existingIndex >= 0) { + // Update existing command + const updated = [...commands]; + updated[existingIndex] = command; + this._commands.set(updated); + } else { + // Add new command + this._commands.set([...commands, command]); + } + } + + /** + * Register multiple commands at once + */ + registerCommands(commands: Command[]): void { + commands.forEach(command => this.registerCommand(command)); + } + + /** + * Unregister a command by ID + */ + unregisterCommand(commandId: string): void { + const commands = this._commands(); + this._commands.set(commands.filter(c => c.id !== commandId)); + } + + /** + * Get command by ID + */ + getCommand(commandId: string): Command | undefined { + return this._commands().find(c => c.id === commandId); + } + + /** + * Get all commands grouped by category + */ + getCommandsByCategory(): CommandGroup[] { + const commands = this._commands().filter(c => c.visible !== false); + const groups = new Map(); + + commands.forEach(command => { + const categoryCommands = groups.get(command.category) || []; + categoryCommands.push(command); + groups.set(command.category, categoryCommands); + }); + + return Array.from(groups.entries()).map(([category, categoryCommands]) => ({ + category, + title: this.getCategoryTitle(category), + commands: categoryCommands.sort((a, b) => (a.order || 0) - (b.order || 0)), + order: this.getCategoryOrder(category) + })).sort((a, b) => (a.order || 0) - (b.order || 0)); + } + + /** + * Search commands with fuzzy matching + */ + searchCommands(query: string): CommandSearchResult[] { + if (!query.trim()) { + return this.getRecentCommandResults(); + } + + const commands = this._commands().filter(c => c.visible !== false && !c.disabled); + const results: CommandSearchResult[] = []; + const normalizedQuery = query.toLowerCase().trim(); + + for (const command of commands) { + const searchText = this.buildSearchText(command); + const score = this.calculateFuzzyScore(normalizedQuery, searchText); + + if (score >= (this._config().fuzzySearchThreshold || 0.3)) { + const highlightRanges = this.findHighlightRanges(normalizedQuery, command.title); + results.push({ + command, + score, + highlightRanges + }); + } + } + + return results + .sort((a, b) => b.score - a.score) + .slice(0, this._config().maxResults || 20); + } + + /** + * Execute a command and track usage + */ + async executeCommand(commandId: string, context: CommandExecutionContext): Promise { + const command = this.getCommand(commandId); + if (!command || command.disabled) { + return; + } + + try { + await command.handler(); + this.trackCommandUsage(commandId); + } catch (error) { + console.error('Command execution failed:', error); + throw error; + } + } + + /** + * Update configuration + */ + updateConfig(config: Partial): void { + this._config.set({ ...this._config(), ...config }); + } + + /** + * Clear all commands + */ + clearCommands(): void { + this._commands.set([]); + } + + /** + * Clear recent commands + */ + clearRecentCommands(): void { + this._recentCommands.set([]); + localStorage.removeItem(this.RECENT_COMMANDS_KEY); + } + + private buildSearchText(command: Command): string { + const parts = [ + command.title, + command.description || '', + ...command.keywords, + ...(command.aliases || []) + ]; + return parts.join(' ').toLowerCase(); + } + + private calculateFuzzyScore(query: string, text: string): number { + const queryChars = query.split(''); + const textLower = text.toLowerCase(); + let score = 0; + let queryIndex = 0; + let consecutiveMatches = 0; + + // Exact title match gets highest score + if (text.includes(query)) { + score += 1; + if (text.startsWith(query)) { + score += 0.5; // Bonus for prefix match + } + } + + // Character by character fuzzy matching + for (let i = 0; i < textLower.length && queryIndex < queryChars.length; i++) { + if (textLower[i] === queryChars[queryIndex]) { + score += 0.1; + consecutiveMatches++; + queryIndex++; + + // Bonus for consecutive matches + if (consecutiveMatches > 1) { + score += 0.05 * consecutiveMatches; + } + } else { + consecutiveMatches = 0; + } + } + + // Penalty for incomplete matches + const completionRatio = queryIndex / queryChars.length; + score *= completionRatio; + + return score; + } + + private findHighlightRanges(query: string, text: string): Array<{ start: number; end: number }> { + const ranges: Array<{ start: number; end: number }> = []; + const textLower = text.toLowerCase(); + const queryLower = query.toLowerCase(); + + let startIndex = 0; + let matchIndex = textLower.indexOf(queryLower, startIndex); + + while (matchIndex !== -1) { + ranges.push({ + start: matchIndex, + end: matchIndex + queryLower.length + }); + startIndex = matchIndex + 1; + matchIndex = textLower.indexOf(queryLower, startIndex); + } + + return ranges; + } + + private getRecentCommandResults(): CommandSearchResult[] { + if (!this._config().showRecent) { + return []; + } + + const recentCommands = this._recentCommands() + .sort((a, b) => b.lastUsed.getTime() - a.lastUsed.getTime()) + .slice(0, this._config().recentLimit || 5); + + const results: CommandSearchResult[] = []; + + for (const recent of recentCommands) { + const command = this.getCommand(recent.commandId); + if (command) { + results.push({ + command: { ...command, category: CommandCategory.RECENT }, + score: 1, + highlightRanges: [] + }); + } + } + + return results; + } + + private trackCommandUsage(commandId: string): void { + const recent = this._recentCommands(); + const existingIndex = recent.findIndex(r => r.commandId === commandId); + const now = new Date(); + + let updated: RecentCommand[]; + if (existingIndex >= 0) { + updated = [...recent]; + updated[existingIndex] = { + ...updated[existingIndex], + lastUsed: now, + useCount: updated[existingIndex].useCount + 1 + }; + } else { + updated = [...recent, { + commandId, + lastUsed: now, + useCount: 1 + }]; + } + + // Keep only the most recent commands + const maxRecent = (this._config().recentLimit || 5) * 2; + if (updated.length > maxRecent) { + updated = updated + .sort((a, b) => b.lastUsed.getTime() - a.lastUsed.getTime()) + .slice(0, maxRecent); + } + + this._recentCommands.set(updated); + this.saveRecentCommands(); + } + + private loadRecentCommands(): void { + try { + const stored = localStorage.getItem(this.RECENT_COMMANDS_KEY); + if (stored) { + const parsed = JSON.parse(stored); + const recent = parsed.map((item: any) => ({ + ...item, + lastUsed: new Date(item.lastUsed) + })); + this._recentCommands.set(recent); + } + } catch (error) { + console.warn('Failed to load recent commands:', error); + } + } + + private saveRecentCommands(): void { + try { + const recent = this._recentCommands(); + localStorage.setItem(this.RECENT_COMMANDS_KEY, JSON.stringify(recent)); + } catch (error) { + console.warn('Failed to save recent commands:', error); + } + } + + private getCategoryTitle(category: CommandCategory): string { + const titles: Record = { + [CommandCategory.RECENT]: 'Recent', + [CommandCategory.NAVIGATION]: 'Navigation', + [CommandCategory.ACTIONS]: 'Actions', + [CommandCategory.SETTINGS]: 'Settings', + [CommandCategory.SEARCH]: 'Search', + [CommandCategory.HELP]: 'Help', + [CommandCategory.FILE]: 'File', + [CommandCategory.EDIT]: 'Edit', + [CommandCategory.VIEW]: 'View', + [CommandCategory.TOOLS]: 'Tools' + }; + return titles[category]; + } + + private getCategoryOrder(category: CommandCategory): number { + const orders: Record = { + [CommandCategory.RECENT]: 0, + [CommandCategory.NAVIGATION]: 1, + [CommandCategory.FILE]: 2, + [CommandCategory.EDIT]: 3, + [CommandCategory.VIEW]: 4, + [CommandCategory.ACTIONS]: 5, + [CommandCategory.TOOLS]: 6, + [CommandCategory.SEARCH]: 7, + [CommandCategory.SETTINGS]: 8, + [CommandCategory.HELP]: 9 + }; + return orders[category] || 99; + } +} \ No newline at end of file diff --git a/src/lib/components/overlays/command-palette/command-palette.types.ts b/src/lib/components/overlays/command-palette/command-palette.types.ts new file mode 100644 index 0000000..3b28578 --- /dev/null +++ b/src/lib/components/overlays/command-palette/command-palette.types.ts @@ -0,0 +1,64 @@ +import { IconDefinition } from '@fortawesome/fontawesome-svg-core'; + +export interface Command { + id: string; + title: string; + description?: string; + icon?: IconDefinition; + category: CommandCategory; + keywords: string[]; + shortcut?: string[]; + handler: () => void | Promise; + visible?: boolean; + disabled?: boolean; + order?: number; + aliases?: string[]; +} + +export interface CommandGroup { + category: CommandCategory; + title: string; + commands: Command[]; + order?: number; +} + +export interface CommandSearchResult { + command: Command; + score: number; + highlightRanges: Array<{ start: number; end: number }>; +} + +export interface CommandPaletteConfig { + maxResults?: number; + showCategories?: boolean; + showShortcuts?: boolean; + showRecent?: boolean; + recentLimit?: number; + placeholder?: string; + noResultsMessage?: string; + fuzzySearchThreshold?: number; +} + +export enum CommandCategory { + NAVIGATION = 'navigation', + ACTIONS = 'actions', + SETTINGS = 'settings', + SEARCH = 'search', + HELP = 'help', + RECENT = 'recent', + FILE = 'file', + EDIT = 'edit', + VIEW = 'view', + TOOLS = 'tools' +} + +export interface RecentCommand { + commandId: string; + lastUsed: Date; + useCount: number; +} + +export interface CommandExecutionContext { + source: 'keyboard' | 'click' | 'programmatic'; + timestamp: Date; +} \ No newline at end of file diff --git a/src/lib/components/overlays/command-palette/global-keyboard.directive.ts b/src/lib/components/overlays/command-palette/global-keyboard.directive.ts new file mode 100644 index 0000000..c8fc5f8 --- /dev/null +++ b/src/lib/components/overlays/command-palette/global-keyboard.directive.ts @@ -0,0 +1,299 @@ +import { + Directive, + Input, + Output, + EventEmitter, + HostListener, + OnInit, + OnDestroy, + inject +} from '@angular/core'; +import { DOCUMENT } from '@angular/common'; + +export interface KeyboardShortcut { + key: string; + ctrlKey?: boolean; + metaKey?: boolean; + altKey?: boolean; + shiftKey?: boolean; + preventDefault?: boolean; + target?: string; // CSS selector for target elements +} + +export interface KeyboardEvent { + shortcut: KeyboardShortcut; + event: globalThis.KeyboardEvent; +} + +@Directive({ + selector: '[uiGlobalKeyboard]', + standalone: true +}) +export class GlobalKeyboardDirective implements OnInit, OnDestroy { + private document = inject(DOCUMENT) as Document; + + @Input() shortcuts: KeyboardShortcut[] = []; + @Input() enabled = true; + @Input() ignoreInputs = true; + @Input() ignoreContentEditable = true; + + @Output() shortcutTriggered = new EventEmitter(); + + private boundKeydownHandler = this.handleKeydown.bind(this); + + ngOnInit(): void { + if (this.enabled) { + this.document.addEventListener('keydown', this.boundKeydownHandler, true); + } + } + + ngOnDestroy(): void { + this.document.removeEventListener('keydown', this.boundKeydownHandler, true); + } + + private handleKeydown(event: globalThis.KeyboardEvent): void { + if (!this.enabled) return; + + // Skip if typing in form inputs (unless specifically disabled) + if (this.ignoreInputs && this.isTypingInInput(event.target as Element)) { + return; + } + + // Skip if editing content (unless specifically disabled) + if (this.ignoreContentEditable && this.isEditingContent(event.target as Element)) { + return; + } + + // Check each shortcut for a match + for (const shortcut of this.shortcuts) { + if (this.isShortcutMatch(event, shortcut)) { + // Check if target element matches (if specified) + if (shortcut.target && !this.isTargetMatch(event.target as Element, shortcut.target)) { + continue; + } + + // Prevent default behavior if specified + if (shortcut.preventDefault !== false) { + event.preventDefault(); + event.stopPropagation(); + } + + // Emit the shortcut event + this.shortcutTriggered.emit({ + shortcut, + event + }); + + // Stop after first match + break; + } + } + } + + private isShortcutMatch(event: globalThis.KeyboardEvent, shortcut: KeyboardShortcut): boolean { + // Normalize key comparison (case-insensitive for letters) + const eventKey = event.key.toLowerCase(); + const shortcutKey = shortcut.key.toLowerCase(); + + // Handle special cases for common keys + const keyMatch = this.normalizeKey(eventKey) === this.normalizeKey(shortcutKey); + + // Check modifier keys + const ctrlMatch = (shortcut.ctrlKey || false) === event.ctrlKey; + const metaMatch = (shortcut.metaKey || false) === event.metaKey; + const altMatch = (shortcut.altKey || false) === event.altKey; + const shiftMatch = (shortcut.shiftKey || false) === event.shiftKey; + + return keyMatch && ctrlMatch && metaMatch && altMatch && shiftMatch; + } + + private normalizeKey(key: string): string { + // Handle special key mappings + const keyMap: { [key: string]: string } = { + ' ': 'space', + 'arrowup': 'up', + 'arrowdown': 'down', + 'arrowleft': 'left', + 'arrowright': 'right', + 'delete': 'del', + 'escape': 'esc' + }; + + return keyMap[key.toLowerCase()] || key.toLowerCase(); + } + + private isTypingInInput(target: Element | null): boolean { + if (!target) return false; + + const tagName = target.tagName.toLowerCase(); + const inputTypes = ['input', 'textarea', 'select']; + + if (inputTypes.includes(tagName)) { + const element = target as HTMLInputElement | HTMLTextAreaElement; + + // Skip if it's a non-text input + if (tagName === 'input') { + const type = (element as HTMLInputElement).type.toLowerCase(); + const nonTextTypes = ['submit', 'reset', 'button', 'checkbox', 'radio', 'file', 'image']; + if (nonTextTypes.includes(type)) { + return false; + } + } + + // Check if element is disabled or readonly + return !element.disabled && !element.readOnly; + } + + return false; + } + + private isEditingContent(target: Element | null): boolean { + if (!target) return false; + + // Check if element or any parent has contenteditable + let element = target as Element; + while (element) { + if (element.getAttribute && element.getAttribute('contenteditable') === 'true') { + return true; + } + element = element.parentElement as Element; + + // Stop at body to avoid infinite loop + if (!element || element.tagName.toLowerCase() === 'body') { + break; + } + } + + return false; + } + + private isTargetMatch(target: Element | null, selector: string): boolean { + if (!target) return false; + + try { + return target.matches(selector) || !!target.closest(selector); + } catch (error) { + console.warn('Invalid CSS selector in keyboard shortcut:', selector, error); + return false; + } + } + + // Public API methods + addShortcut(shortcut: KeyboardShortcut): void { + this.shortcuts = [...this.shortcuts, shortcut]; + } + + removeShortcut(shortcut: KeyboardShortcut): void { + this.shortcuts = this.shortcuts.filter(s => + s.key !== shortcut.key || + s.ctrlKey !== shortcut.ctrlKey || + s.metaKey !== shortcut.metaKey || + s.altKey !== shortcut.altKey || + s.shiftKey !== shortcut.shiftKey + ); + } + + clearShortcuts(): void { + this.shortcuts = []; + } + + enable(): void { + if (!this.enabled) { + this.enabled = true; + this.document.addEventListener('keydown', this.boundKeydownHandler, true); + } + } + + disable(): void { + if (this.enabled) { + this.enabled = false; + this.document.removeEventListener('keydown', this.boundKeydownHandler, true); + } + } +} + +// Helper function to create common shortcuts +export function createShortcuts() { + return { + // Command palette + commandPalette: (): KeyboardShortcut => ({ + key: 'k', + ctrlKey: !isMac(), + metaKey: isMac(), + preventDefault: true + }), + + // Quick actions + quickSearch: (): KeyboardShortcut => ({ + key: '/', + preventDefault: true + }), + + // Navigation + goHome: (): KeyboardShortcut => ({ + key: 'h', + ctrlKey: !isMac(), + metaKey: isMac(), + preventDefault: true + }), + + goBack: (): KeyboardShortcut => ({ + key: '[', + ctrlKey: !isMac(), + metaKey: isMac(), + preventDefault: true + }), + + goForward: (): KeyboardShortcut => ({ + key: ']', + ctrlKey: !isMac(), + metaKey: isMac(), + preventDefault: true + }), + + // Common actions + save: (): KeyboardShortcut => ({ + key: 's', + ctrlKey: !isMac(), + metaKey: isMac(), + preventDefault: true + }), + + copy: (): KeyboardShortcut => ({ + key: 'c', + ctrlKey: !isMac(), + metaKey: isMac(), + preventDefault: false // Let browser handle copy + }), + + paste: (): KeyboardShortcut => ({ + key: 'v', + ctrlKey: !isMac(), + metaKey: isMac(), + preventDefault: false // Let browser handle paste + }), + + // Custom shortcut builder + create: (key: string, modifiers: { + ctrl?: boolean; + meta?: boolean; + alt?: boolean; + shift?: boolean; + preventDefault?: boolean; + target?: string; + } = {}): KeyboardShortcut => ({ + key, + ctrlKey: modifiers.ctrl, + metaKey: modifiers.meta, + altKey: modifiers.alt, + shiftKey: modifiers.shift, + preventDefault: modifiers.preventDefault ?? true, + target: modifiers.target + }) + }; +} + +// Platform detection helper +function isMac(): boolean { + return typeof navigator !== 'undefined' && navigator.platform.toUpperCase().indexOf('MAC') >= 0; +} \ No newline at end of file diff --git a/src/lib/components/overlays/command-palette/index.ts b/src/lib/components/overlays/command-palette/index.ts new file mode 100644 index 0000000..3f9d4c2 --- /dev/null +++ b/src/lib/components/overlays/command-palette/index.ts @@ -0,0 +1,5 @@ +export * from './command-palette.types'; +export * from './command-palette.service'; +export * from './command-palette.component'; +export * from './command-palette-item.component'; +export * from './global-keyboard.directive'; \ No newline at end of file diff --git a/src/lib/components/overlays/drawer/drawer.component.scss b/src/lib/components/overlays/drawer/drawer.component.scss new file mode 100644 index 0000000..4400dcc --- /dev/null +++ b/src/lib/components/overlays/drawer/drawer.component.scss @@ -0,0 +1,458 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-drawer { + // Drawer Backdrop + &__backdrop { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + z-index: $semantic-z-index-overlay; + opacity: 0; + transition: opacity $semantic-motion-duration-normal $semantic-motion-easing-ease; + backdrop-filter: blur(2px); + + &--visible { + opacity: 1; + } + + &--entering { + animation: drawer-backdrop-fade-in $semantic-motion-duration-normal $semantic-motion-easing-ease; + } + + &--leaving { + animation: drawer-backdrop-fade-out $semantic-motion-duration-normal $semantic-motion-easing-ease; + } + } + + // Drawer Container + &__container { + position: fixed; + background: $semantic-color-surface-primary; + box-shadow: $semantic-shadow-elevation-4; + z-index: $semantic-z-index-modal; + overflow: hidden; + display: flex; + flex-direction: column; + transform: translateX(-100%); + transition: transform $semantic-motion-duration-normal $semantic-motion-easing-ease; + + &--visible { + transform: translateX(0); + } + + &--entering { + animation: drawer-slide-in-left $semantic-motion-duration-normal $semantic-motion-easing-ease; + } + + &--leaving { + animation: drawer-slide-out-left $semantic-motion-duration-normal $semantic-motion-easing-ease; + } + } + + // Position Variants + &--left { + .ui-drawer__container { + top: 0; + left: 0; + height: 100vh; + border-right: 1px solid $semantic-color-outline; + transform: translateX(-100%); + + &--entering { + animation: drawer-slide-in-left $semantic-motion-duration-normal $semantic-motion-easing-ease; + } + + &--leaving { + animation: drawer-slide-out-left $semantic-motion-duration-normal $semantic-motion-easing-ease; + } + } + } + + &--right { + .ui-drawer__container { + top: 0; + right: 0; + height: 100vh; + border-left: 1px solid $semantic-color-outline; + transform: translateX(100%); + + &--visible { + transform: translateX(0); + } + + &--entering { + animation: drawer-slide-in-right $semantic-motion-duration-normal $semantic-motion-easing-ease; + } + + &--leaving { + animation: drawer-slide-out-right $semantic-motion-duration-normal $semantic-motion-easing-ease; + } + } + } + + &--top { + .ui-drawer__container { + top: 0; + left: 0; + width: 100vw; + border-bottom: 1px solid $semantic-color-outline; + transform: translateY(-100%); + + &--visible { + transform: translateY(0); + } + + &--entering { + animation: drawer-slide-in-top $semantic-motion-duration-normal $semantic-motion-easing-ease; + } + + &--leaving { + animation: drawer-slide-out-top $semantic-motion-duration-normal $semantic-motion-easing-ease; + } + } + } + + &--bottom { + .ui-drawer__container { + bottom: 0; + left: 0; + width: 100vw; + border-top: 1px solid $semantic-color-outline; + transform: translateY(100%); + + &--visible { + transform: translateY(0); + } + + &--entering { + animation: drawer-slide-in-bottom $semantic-motion-duration-normal $semantic-motion-easing-ease; + } + + &--leaving { + animation: drawer-slide-out-bottom $semantic-motion-duration-normal $semantic-motion-easing-ease; + } + } + } + + // Size Variants + &--sm { + .ui-drawer__container { + width: 280px; + } + + &.ui-drawer--top .ui-drawer__container, + &.ui-drawer--bottom .ui-drawer__container { + height: 200px; + } + } + + &--md { + .ui-drawer__container { + width: 360px; + } + + &.ui-drawer--top .ui-drawer__container, + &.ui-drawer--bottom .ui-drawer__container { + height: 300px; + } + } + + &--lg { + .ui-drawer__container { + width: 480px; + } + + &.ui-drawer--top .ui-drawer__container, + &.ui-drawer--bottom .ui-drawer__container { + height: 400px; + } + } + + &--xl { + .ui-drawer__container { + width: 640px; + } + + &.ui-drawer--top .ui-drawer__container, + &.ui-drawer--bottom .ui-drawer__container { + height: 500px; + } + } + + // Drawer Header + &__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: $semantic-spacing-component-lg; + border-bottom: 1px solid $semantic-color-outline; + background: $semantic-color-surface-primary; + flex-shrink: 0; + + &--no-border { + border-bottom: none; + } + } + + &__title { + font-size: $semantic-typography-heading-h4-size; + font-weight: $semantic-typography-font-weight-semibold; + color: $semantic-color-on-surface; + margin: 0; + line-height: 1.4; + } + + &__close-button { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border: none; + background: transparent; + border-radius: $semantic-border-radius-md; + color: $semantic-color-on-surface-variant; + cursor: pointer; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + + &:hover { + background: $semantic-color-surface-variant; + color: $semantic-color-on-surface; + } + + &:focus-visible { + outline: 2px solid $semantic-color-primary; + outline-offset: 2px; + } + + &:active { + background: $semantic-color-surface-container; + } + } + + // Drawer Body + &__body { + padding: $semantic-spacing-component-lg; + overflow-y: auto; + flex: 1; + + &--no-padding { + padding: 0; + } + + &--scrollable { + overflow-y: auto; + } + } + + &__content { + color: $semantic-color-on-surface; + font-size: $semantic-typography-font-size-md; + line-height: 1.6; + } + + // Drawer Footer + &__footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: $semantic-spacing-component-sm; + padding: $semantic-spacing-component-lg; + border-top: 1px solid $semantic-color-outline; + background: $semantic-color-surface-primary; + flex-shrink: 0; + + &--no-border { + border-top: none; + } + + &--center { + justify-content: center; + } + + &--start { + justify-content: flex-start; + } + + &--between { + justify-content: space-between; + } + } + + // State Variants + &--loading { + .ui-drawer__body { + display: flex; + align-items: center; + justify-content: center; + min-height: 200px; + } + } + + &--persistent { + .ui-drawer__backdrop { + display: none; + } + } + + // Loading Spinner + &__loader { + display: inline-block; + width: 24px; + height: 24px; + border: 2px solid $semantic-color-outline; + border-radius: 50%; + border-top-color: $semantic-color-primary; + animation: drawer-spin 1s linear infinite; + } + + // Dark Mode Support + :host-context(.dark-theme) & { + &__backdrop { + background: rgba(0, 0, 0, 0.7); + } + } + + // Responsive Design + @media (max-width: 768px) { + &--sm .ui-drawer__container, + &--md .ui-drawer__container, + &--lg .ui-drawer__container, + &--xl .ui-drawer__container { + width: 85vw; + max-width: 400px; + } + + &__header { + padding: $semantic-spacing-component-md; + } + + &__body { + padding: $semantic-spacing-component-md; + } + + &__footer { + padding: $semantic-spacing-component-md; + flex-direction: column; + gap: $semantic-spacing-component-xs; + + .ui-button { + width: 100%; + } + } + + &__title { + font-size: $semantic-typography-heading-h5-size; + } + } + + @media (max-width: 480px) { + &--sm .ui-drawer__container, + &--md .ui-drawer__container, + &--lg .ui-drawer__container, + &--xl .ui-drawer__container { + width: 100vw; + max-width: none; + } + } +} + +// Animations +@keyframes drawer-backdrop-fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes drawer-backdrop-fade-out { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +@keyframes drawer-slide-in-left { + from { + transform: translateX(-100%); + } + to { + transform: translateX(0); + } +} + +@keyframes drawer-slide-out-left { + from { + transform: translateX(0); + } + to { + transform: translateX(-100%); + } +} + +@keyframes drawer-slide-in-right { + from { + transform: translateX(100%); + } + to { + transform: translateX(0); + } +} + +@keyframes drawer-slide-out-right { + from { + transform: translateX(0); + } + to { + transform: translateX(100%); + } +} + +@keyframes drawer-slide-in-top { + from { + transform: translateY(-100%); + } + to { + transform: translateY(0); + } +} + +@keyframes drawer-slide-out-top { + from { + transform: translateY(0); + } + to { + transform: translateY(-100%); + } +} + +@keyframes drawer-slide-in-bottom { + from { + transform: translateY(100%); + } + to { + transform: translateY(0); + } +} + +@keyframes drawer-slide-out-bottom { + from { + transform: translateY(0); + } + to { + transform: translateY(100%); + } +} + +@keyframes drawer-spin { + to { + transform: rotate(360deg); + } +} \ No newline at end of file diff --git a/src/lib/components/overlays/drawer/drawer.component.ts b/src/lib/components/overlays/drawer/drawer.component.ts new file mode 100644 index 0000000..169b191 --- /dev/null +++ b/src/lib/components/overlays/drawer/drawer.component.ts @@ -0,0 +1,404 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation, OnInit, OnDestroy, inject, ElementRef, HostListener } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { faXmark } from '@fortawesome/free-solid-svg-icons'; +import { signal, computed } from '@angular/core'; + +type DrawerSize = 'sm' | 'md' | 'lg' | 'xl'; +type DrawerPosition = 'left' | 'right' | 'top' | 'bottom'; +type FooterAlignment = 'start' | 'center' | 'end' | 'between'; + +export interface DrawerConfig { + size?: DrawerSize; + position?: DrawerPosition; + closable?: boolean; + backdropClosable?: boolean; + escapeClosable?: boolean; + showHeader?: boolean; + showFooter?: boolean; + headerBorder?: boolean; + footerBorder?: boolean; + bodyPadding?: boolean; + bodyScrollable?: boolean; + footerAlignment?: FooterAlignment; + preventBodyScroll?: boolean; + focusTrap?: boolean; + persistent?: boolean; +} + +@Component({ + selector: 'ui-drawer', + standalone: true, + imports: [CommonModule, FontAwesomeModule], + changeDetection: ChangeDetectionStrategy.Default, + encapsulation: ViewEncapsulation.None, + template: ` + @if (isVisible()) { + + } + `, + styleUrl: './drawer.component.scss' +}) +export class DrawerComponent implements OnInit, OnDestroy { + // Dependencies + private elementRef = inject(ElementRef); + + // Icons + readonly faXmark = faXmark; + + // Inputs + @Input() size: DrawerSize = 'md'; + @Input() position: DrawerPosition = 'left'; + @Input() title = ''; + @Input() loading = false; + @Input() closable = true; + @Input() backdropClosable = true; + @Input() escapeClosable = true; + @Input() showHeader = true; + @Input() showFooter = false; + @Input() headerBorder = true; + @Input() footerBorder = true; + @Input() bodyPadding = true; + @Input() bodyScrollable = true; + @Input() footerAlignment: FooterAlignment = 'end'; + @Input() preventBodyScroll = true; + @Input() focusTrap = true; + @Input() persistent = false; + @Input() closeAriaLabel = 'Close drawer'; + + // State Inputs + @Input() set open(value: boolean) { + if (value !== this._open()) { + if (value) { + this.show(); + } else { + this.hide(); + } + } + } + + get open(): boolean { + return this._open(); + } + + // Outputs + @Output() openChange = new EventEmitter(); + @Output() opened = new EventEmitter(); + @Output() closed = new EventEmitter(); + @Output() backdropClicked = new EventEmitter(); + @Output() escapePressed = new EventEmitter(); + + // Internal state signals + private _open = signal(false); + private _entering = signal(false); + private _leaving = signal(false); + + // Computed signals + readonly isVisible = computed(() => this._open() || this._entering() || this._leaving()); + readonly isEntering = computed(() => this._entering()); + readonly isLeaving = computed(() => this._leaving()); + + // Internal properties + private originalBodyOverflow = ''; + private originalBodyPaddingRight = ''; + private focusableElements: HTMLElement[] = []; + private previousActiveElement: Element | null = null; + private currentFocusIndex = 0; + + // Unique IDs for accessibility + readonly titleId = `drawer-title-${Math.random().toString(36).substr(2, 9)}`; + readonly contentId = `drawer-content-${Math.random().toString(36).substr(2, 9)}`; + + ngOnInit(): void { + if (this.open) { + this.show(); + } + } + + ngOnDestroy(): void { + this.restoreBodyScroll(); + this.restoreFocus(); + } + + /** + * Shows the drawer with animation + */ + show(): void { + if (this._open()) return; + + if (this.preventBodyScroll && !this.persistent) { + this.preventBodyScrollAction(); + } + this.storePreviousFocus(); + + this._open.set(true); + this._entering.set(true); + this.openChange.emit(true); + + // Animation timing + setTimeout(() => { + this._entering.set(false); + this.setupFocusTrap(); + this.opened.emit(); + }, 300); + } + + /** + * Hides the drawer with animation + */ + hide(): void { + if (!this._open()) return; + + this._leaving.set(true); + + // Animation timing + setTimeout(() => { + this._open.set(false); + this._leaving.set(false); + this.restoreBodyScroll(); + this.restoreFocus(); + this.openChange.emit(false); + this.closed.emit(); + }, 300); + } + + /** + * Toggles the drawer open/closed state + */ + toggle(): void { + if (this.open) { + this.close(); + } else { + this.open = true; + } + } + + /** + * Closes the drawer + */ + close(): void { + this.open = false; + } + + /** + * Handles backdrop click events + */ + handleBackdropClick(event: MouseEvent): void { + event.stopPropagation(); + this.backdropClicked.emit(); + + if (this.backdropClosable && !this.persistent) { + this.close(); + } + } + + /** + * Handles keyboard events for accessibility + */ + @HostListener('keydown', ['$event']) + handleKeydown(event: KeyboardEvent): void { + if (event.key === 'Escape' && this.escapeClosable) { + event.preventDefault(); + this.escapePressed.emit(); + this.close(); + } + + if (this.focusTrap && this.isVisible()) { + this.handleFocusTrap(event); + } + } + + /** + * Prevents body scrolling by manipulating body styles + */ + private preventBodyScrollAction(): void { + const body = document.body; + this.originalBodyOverflow = body.style.overflow; + this.originalBodyPaddingRight = body.style.paddingRight; + + // Calculate scrollbar width to prevent layout shift + const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; + + body.style.overflow = 'hidden'; + if (scrollbarWidth > 0) { + body.style.paddingRight = `${scrollbarWidth}px`; + } + } + + /** + * Restores body scrolling + */ + private restoreBodyScroll(): void { + const body = document.body; + body.style.overflow = this.originalBodyOverflow; + body.style.paddingRight = this.originalBodyPaddingRight; + } + + /** + * Stores the currently focused element before showing drawer + */ + private storePreviousFocus(): void { + this.previousActiveElement = document.activeElement; + } + + /** + * Restores focus to the previously focused element + */ + private restoreFocus(): void { + if (this.previousActiveElement && this.previousActiveElement instanceof HTMLElement) { + setTimeout(() => { + if (this.previousActiveElement instanceof HTMLElement) { + this.previousActiveElement.focus(); + } + this.previousActiveElement = null; + }, 100); + } + } + + /** + * Sets up focus trap for accessibility + */ + private setupFocusTrap(): void { + if (!this.focusTrap) return; + + const drawerElement = this.elementRef.nativeElement.querySelector('.ui-drawer__container'); + if (drawerElement) { + this.focusableElements = this.getFocusableElements(drawerElement); + if (this.focusableElements.length > 0) { + this.focusableElements[0].focus(); + this.currentFocusIndex = 0; + } + } + } + + /** + * Gets all focusable elements within the drawer + */ + private getFocusableElements(container: Element): HTMLElement[] { + const focusableSelectors = [ + 'button:not([disabled])', + 'input:not([disabled])', + 'textarea:not([disabled])', + 'select:not([disabled])', + 'a[href]', + '[tabindex]:not([tabindex="-1"])', + '[contenteditable="true"]' + ]; + + return Array.from(container.querySelectorAll(focusableSelectors.join(', '))) as HTMLElement[]; + } + + /** + * Handles focus trap keyboard navigation + */ + private handleFocusTrap(event: KeyboardEvent): void { + if (event.key !== 'Tab' || this.focusableElements.length === 0) return; + + if (event.shiftKey) { + // Shift + Tab (backward) + this.currentFocusIndex = this.currentFocusIndex <= 0 + ? this.focusableElements.length - 1 + : this.currentFocusIndex - 1; + } else { + // Tab (forward) + this.currentFocusIndex = this.currentFocusIndex >= this.focusableElements.length - 1 + ? 0 + : this.currentFocusIndex + 1; + } + + event.preventDefault(); + this.focusableElements[this.currentFocusIndex].focus(); + } +} \ No newline at end of file diff --git a/src/lib/components/overlays/drawer/index.ts b/src/lib/components/overlays/drawer/index.ts new file mode 100644 index 0000000..3ba5e42 --- /dev/null +++ b/src/lib/components/overlays/drawer/index.ts @@ -0,0 +1 @@ +export * from './drawer.component'; \ No newline at end of file diff --git a/src/lib/components/overlays/floating-toolbar/floating-toolbar.component.scss b/src/lib/components/overlays/floating-toolbar/floating-toolbar.component.scss new file mode 100644 index 0000000..ffb8a52 --- /dev/null +++ b/src/lib/components/overlays/floating-toolbar/floating-toolbar.component.scss @@ -0,0 +1,364 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-floating-toolbar { + // Core Structure + position: absolute; + display: flex; + align-items: center; + gap: $semantic-spacing-component-xs; + opacity: 0; + transform: translateY(-8px); + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + z-index: $semantic-z-index-overlay; + pointer-events: none; + will-change: opacity, transform; + + // Visual Design + background: $semantic-color-surface-primary; + border: $semantic-border-width-1 solid $semantic-color-border-primary; + border-radius: $semantic-border-radius-lg; + box-shadow: $semantic-shadow-elevation-3; + + // Typography + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + color: $semantic-color-text-primary; + + // Size Variants + &--sm { + padding: $semantic-spacing-component-xs; + gap: $semantic-spacing-component-xs; + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + } + + &--md { + padding: $semantic-spacing-component-sm; + gap: $semantic-spacing-component-sm; + } + + &--lg { + padding: $semantic-spacing-component-md; + gap: $semantic-spacing-component-md; + font-family: map-get($semantic-typography-body-large, font-family); + font-size: map-get($semantic-typography-body-large, font-size); + font-weight: map-get($semantic-typography-body-large, font-weight); + line-height: map-get($semantic-typography-body-large, line-height); + } + + // Variant Styles + &--default { + background: $semantic-color-surface-primary; + border-color: $semantic-color-border-primary; + box-shadow: $semantic-shadow-elevation-2; + } + + &--elevated { + background: $semantic-color-surface-elevated; + border: none; + box-shadow: $semantic-shadow-elevation-4; + } + + &--floating { + background: $semantic-color-surface-secondary; + border-color: $semantic-color-border-subtle; + backdrop-filter: blur(8px); + box-shadow: $semantic-shadow-elevation-3; + } + + &--compact { + padding: $semantic-spacing-component-xs $semantic-spacing-component-sm; + gap: $semantic-spacing-component-xs; + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + } + + // Context Variants + &--contextual { + background: $semantic-color-primary; + color: $semantic-color-on-primary; + border: none; + } + + // State Variants + &--visible { + opacity: 1; + transform: translateY(0); + pointer-events: auto; + } + + &--entering { + opacity: 0; + transform: translateY(-8px); + } + + &--leaving { + opacity: 0; + transform: translateY(-8px); + transition-duration: $semantic-motion-duration-fast; + } + + &--disabled { + opacity: $semantic-opacity-disabled; + pointer-events: none; + } + + // Content Areas + &__actions { + display: flex; + align-items: center; + gap: $semantic-spacing-component-xs; + } + + &__action { + display: inline-flex; + align-items: center; + justify-content: center; + gap: $semantic-spacing-component-xs; + padding: $semantic-spacing-component-xs $semantic-spacing-component-sm; + background: transparent; + border: none; + border-radius: $semantic-border-button-radius; + color: $semantic-color-text-primary; + font-family: map-get($semantic-typography-button-small, font-family); + font-size: map-get($semantic-typography-button-small, font-size); + font-weight: map-get($semantic-typography-button-small, font-weight); + line-height: map-get($semantic-typography-button-small, line-height); + cursor: pointer; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + min-height: $semantic-sizing-button-height-sm; + white-space: nowrap; + user-select: none; + + &:hover:not(:disabled) { + background: $semantic-color-surface-elevated; + color: $semantic-color-text-primary; + } + + &:active:not(:disabled) { + background: $semantic-color-surface-pressed; + transform: translateY(1px); + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + + &--disabled, + &:disabled { + opacity: $semantic-opacity-disabled; + cursor: not-allowed; + pointer-events: none; + } + + // Contextual toolbar button styles + .ui-floating-toolbar--contextual & { + color: $semantic-color-on-primary; + + &:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.1); + color: $semantic-color-on-primary; + } + + &:active:not(:disabled) { + background: rgba(255, 255, 255, 0.2); + } + } + + // Icon styling + i { + flex-shrink: 0; + font-size: $semantic-typography-font-size-sm; + line-height: 1; + } + } + + &__action-label { + display: none; + + &--visible { + display: inline; + } + + // Show labels on small screens or when no icon + @media (max-width: $semantic-breakpoint-sm - 1) { + display: inline; + } + } + + &__shortcut { + display: inline-flex; + align-items: center; + padding: 2px 6px; + background: $semantic-color-surface-secondary; + color: $semantic-color-text-secondary; + font-family: $semantic-typography-font-family-mono; + font-size: $semantic-typography-font-size-xs; + font-weight: $semantic-typography-font-weight-medium; + border-radius: $semantic-border-radius-sm; + border: $semantic-border-width-1 solid $semantic-color-border-subtle; + margin-left: $semantic-spacing-component-xs; + + .ui-floating-toolbar--contextual & { + background: rgba(255, 255, 255, 0.15); + color: $semantic-color-on-primary; + border-color: rgba(255, 255, 255, 0.2); + opacity: $semantic-opacity-subtle; + } + } + + &__divider { + width: $semantic-border-width-1; + height: $semantic-sizing-button-height-sm; + background: $semantic-color-border-subtle; + margin: 0 $semantic-spacing-component-xs; + + .ui-floating-toolbar--contextual & { + background: rgba(255, 255, 255, 0.2); + } + } + + &__label { + color: $semantic-color-text-secondary; + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + white-space: nowrap; + + .ui-floating-toolbar--contextual & { + color: $semantic-color-on-primary; + opacity: $semantic-opacity-subtle; + } + } + + // Position Variants + &--top { + transform: translateY(8px); + + &.ui-floating-toolbar--visible { + transform: translateY(0); + } + } + + &--bottom { + transform: translateY(-8px); + + &.ui-floating-toolbar--visible { + transform: translateY(0); + } + } + + &--left { + transform: translateX(8px); + + &.ui-floating-toolbar--visible { + transform: translateX(0); + } + } + + &--right { + transform: translateX(-8px); + + &.ui-floating-toolbar--visible { + transform: translateX(0); + } + } + + // Animation Variants + &--slide-fade { + transition: opacity $semantic-motion-duration-normal $semantic-motion-easing-ease, + transform $semantic-motion-duration-normal $semantic-motion-easing-ease; + } + + &--bounce { + transition: opacity $semantic-motion-duration-fast $semantic-motion-easing-spring, + transform $semantic-motion-duration-fast $semantic-motion-easing-spring; + } + + // Interactive States + &:not(.ui-floating-toolbar--disabled) { + &:focus-within { + box-shadow: $semantic-shadow-elevation-4, 0 0 0 2px $semantic-color-focus; + } + } + + // Responsive Design + @media (max-width: $semantic-breakpoint-md - 1) { + padding: $semantic-spacing-component-xs $semantic-spacing-component-sm; + gap: $semantic-spacing-component-xs; + + &.ui-floating-toolbar--lg { + padding: $semantic-spacing-component-sm; + gap: $semantic-spacing-component-sm; + } + } + + @media (max-width: $semantic-breakpoint-sm - 1) { + padding: $semantic-spacing-component-xs; + gap: $semantic-spacing-component-xs; + border-radius: $semantic-border-radius-md; + + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + + &__actions { + gap: $semantic-spacing-component-xs; + } + + &__divider { + margin: 0 $semantic-spacing-component-xs; + } + } + + // Touch-friendly adjustments + @media (hover: none) and (pointer: coarse) { + min-height: $semantic-sizing-touch-target; + + &__actions { + min-height: $semantic-sizing-touch-minimum; + } + } + + // High contrast mode support + @media (prefers-contrast: high) { + border-width: $semantic-border-width-2; + box-shadow: none; + + &--elevated, + &--floating { + border: $semantic-border-width-2 solid $semantic-color-border-primary; + } + } + + // Reduced motion support + @media (prefers-reduced-motion: reduce) { + transition: none; + + &--slide-fade, + &--bounce { + transition: none; + } + } +} + +// Backdrop for modal-like behavior when needed +.ui-floating-toolbar-backdrop { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: transparent; + z-index: calc($semantic-z-index-overlay - 1); + pointer-events: auto; +} \ No newline at end of file diff --git a/src/lib/components/overlays/floating-toolbar/floating-toolbar.component.ts b/src/lib/components/overlays/floating-toolbar/floating-toolbar.component.ts new file mode 100644 index 0000000..b5966f6 --- /dev/null +++ b/src/lib/components/overlays/floating-toolbar/floating-toolbar.component.ts @@ -0,0 +1,889 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation, OnInit, OnDestroy, AfterViewInit, inject, ElementRef, Renderer2, ViewChild, HostListener, TemplateRef } from '@angular/core'; +import { CommonModule, DOCUMENT } from '@angular/common'; +import { signal, computed } from '@angular/core'; + +type ToolbarSize = 'sm' | 'md' | 'lg'; +type ToolbarVariant = 'default' | 'elevated' | 'floating' | 'compact' | 'contextual'; +type ToolbarPosition = 'top' | 'bottom' | 'left' | 'right'; +type ToolbarAnimationType = 'slide-fade' | 'bounce' | 'none'; + +export interface ToolbarAction { + id: string; + label: string; + icon?: string; + iconTemplate?: TemplateRef; + disabled?: boolean; + visible?: boolean; + divider?: boolean; + tooltip?: string; + shortcut?: string; + callback?: (action: ToolbarAction, event: Event) => void; +} + +export interface ToolbarContext { + type?: string; + selection?: any; + data?: any; + metadata?: Record; +} + +export interface ToolbarPositionData { + top: number; + left: number; + position: ToolbarPosition; +} + +export interface FloatingToolbarConfig { + size?: ToolbarSize; + variant?: ToolbarVariant; + position?: ToolbarPosition; + autoPosition?: boolean; + visible?: boolean; + actions?: ToolbarAction[]; + context?: ToolbarContext; + trigger?: 'manual' | 'selection' | 'hover' | 'contextmenu'; + autoHide?: boolean; + hideDelay?: number; + showAnimation?: ToolbarAnimationType; + backdrop?: boolean; + backdropClosable?: boolean; + escapeClosable?: boolean; + offset?: number; + label?: string; + showLabel?: boolean; + showShortcuts?: boolean; +} + +@Component({ + selector: 'ui-floating-toolbar', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` + @if (isVisible()) { + + @if (backdrop) { +
+ } + + +
+ + @if (label && showLabel) { +
{{ label }}
+
+ } + + +
+ @for (action of visibleActions(); track action.id) { + + @if (action.divider) { +
+ } + + + + } +
+ + + +
+ } + `, + styleUrl: './floating-toolbar.component.scss' +}) +export class FloatingToolbarComponent implements OnInit, OnDestroy, AfterViewInit { + // Dependencies + private elementRef = inject(ElementRef); + private renderer = inject(Renderer2); + private document = inject(DOCUMENT); + + // ViewChild references + @ViewChild('toolbarElement', { static: false }) toolbarElement?: ElementRef; + + // Core Inputs + @Input() size: ToolbarSize = 'md'; + @Input() variant: ToolbarVariant = 'default'; + @Input() position: ToolbarPosition = 'top'; + @Input() autoPosition = true; + @Input() disabled = false; + @Input() label = ''; + @Input() showLabel = false; + @Input() showShortcuts = false; + + // Behavior Inputs + @Input() trigger: 'manual' | 'selection' | 'hover' | 'contextmenu' = 'manual'; + @Input() autoHide = false; + @Input() hideDelay = 3000; + @Input() showAnimation: ToolbarAnimationType = 'slide-fade'; + @Input() backdrop = false; + @Input() backdropClosable = true; + @Input() escapeClosable = true; + @Input() offset = 12; + + // Context and Actions + @Input() actions: ToolbarAction[] = []; + @Input() context?: ToolbarContext; + + // Reference element (anchor point) + @Input() anchorElement?: HTMLElement; + @Input() anchorSelector?: string; + + // State Input + @Input() set visible(value: boolean) { + if (value !== this._visible()) { + this._visible.set(value); + if (value) { + this.show(); + } else { + this.hide(); + } + } + } + + get visible(): boolean { + return this._visible(); + } + + // Outputs + @Output() visibleChange = new EventEmitter(); + @Output() shown = new EventEmitter(); + @Output() hidden = new EventEmitter(); + @Output() actionClicked = new EventEmitter<{ action: ToolbarAction; event: Event }>(); + @Output() contextChanged = new EventEmitter(); + @Output() positionChanged = new EventEmitter(); + @Output() backdropClicked = new EventEmitter(); + @Output() escapePressed = new EventEmitter(); + + // Internal state signals + private _visible = signal(false); + private _entering = signal(false); + private _leaving = signal(false); + private _computedPosition = signal(this.position); + private _positionData = signal({ top: 0, left: 0, position: this.position }); + private _focusedActionIndex = signal(-1); + + // Computed signals + readonly isVisible = computed(() => this._visible() || this._entering() || this._leaving()); + readonly isEntering = computed(() => this._entering()); + readonly isLeaving = computed(() => this._leaving()); + readonly computedPosition = computed(() => this._computedPosition()); + readonly positionData = computed(() => this._positionData()); + readonly visibleActions = computed(() => + this.actions.filter(action => action.visible !== false) + ); + readonly focusedActionIndex = computed(() => this._focusedActionIndex()); + + // Internal properties + private hideTimer?: number; + private showTimer?: number; + private resizeObserver?: ResizeObserver; + private selectionObserver?: MutationObserver; + private currentSelection?: Selection | null; + private animationDuration = 200; // ms + + // Unique ID for accessibility + readonly toolbarId = `floating-toolbar-${Math.random().toString(36).substr(2, 9)}`; + + ngOnInit(): void { + if (this.visible) { + this.show(); + } + this.setupTriggerListeners(); + this.setupAnchorElement(); + } + + ngAfterViewInit(): void { + if (this.visible) { + setTimeout(() => this.updatePosition(), 0); + } + } + + ngOnDestroy(): void { + this.clearTimers(); + this.cleanup(); + } + + /** + * Shows the floating toolbar with animation and positioning + */ + show(): void { + if (this._visible() || this.disabled) return; + + console.log('FloatingToolbar: show() called'); + this.clearTimers(); + + this._visible.set(true); + this._entering.set(true); + this.visibleChange.emit(true); + console.log('FloatingToolbar: visibility signals updated', { + visible: this._visible(), + entering: this._entering(), + isVisible: this.isVisible() + }); + + this.updatePosition(); + this.setupPositionObservers(); + + // Animation timing + setTimeout(() => { + this._entering.set(false); + this.shown.emit(); + console.log('FloatingToolbar: animation completed'); + + // Auto-hide if enabled + if (this.autoHide && this.hideDelay > 0) { + this.scheduleAutoHide(); + } + }, this.animationDuration); + } + + /** + * Hides the floating toolbar with animation + */ + hide(): void { + if (!this._visible()) return; + + this.clearTimers(); + this.cleanup(); + + this._leaving.set(true); + + // Animation timing + setTimeout(() => { + this._visible.set(false); + this._leaving.set(false); + this.visibleChange.emit(false); + this.hidden.emit(); + }, this.animationDuration); + } + + /** + * Toggles toolbar visibility + */ + toggle(): void { + if (this._visible()) { + this.hide(); + } else { + this.show(); + } + } + + /** + * Updates toolbar actions based on context + */ + updateContext(context: ToolbarContext): void { + this.context = context; + this.contextChanged.emit(context); + + // Update action visibility and state based on context + this.updateActionsFromContext(); + } + + /** + * Adds an action to the toolbar + */ + addAction(action: ToolbarAction, index?: number): void { + if (index !== undefined && index >= 0 && index < this.actions.length) { + this.actions.splice(index, 0, action); + } else { + this.actions.push(action); + } + } + + /** + * Removes an action from the toolbar + */ + removeAction(actionId: string): void { + const index = this.actions.findIndex(action => action.id === actionId); + if (index !== -1) { + this.actions.splice(index, 1); + } + } + + /** + * Updates an existing action + */ + updateAction(actionId: string, updates: Partial): void { + const action = this.actions.find(a => a.id === actionId); + if (action) { + Object.assign(action, updates); + } + } + + /** + * Updates the toolbar position relative to the anchor element + */ + updatePosition(): void { + if (!this.anchorElement || !this.toolbarElement) { + console.log('FloatingToolbar: updatePosition() - missing elements', { + anchorElement: !!this.anchorElement, + toolbarElement: !!this.toolbarElement + }); + return; + } + + const anchorRect = this.anchorElement.getBoundingClientRect(); + const toolbarEl = this.toolbarElement.nativeElement; + const toolbarRect = toolbarEl.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft; + const scrollTop = window.pageYOffset || document.documentElement.scrollTop; + + let position = this.position; + let top = 0; + let left = 0; + + // Calculate initial position + const positions = this.calculatePositions(anchorRect, toolbarRect, scrollLeft, scrollTop); + const initialPos = positions[position]; + top = initialPos.top; + left = initialPos.left; + + // Auto-position if enabled and toolbar goes outside viewport + if (this.autoPosition) { + const bestPosition = this.findBestPosition(positions, viewportWidth, viewportHeight, toolbarRect); + position = bestPosition.position; + top = bestPosition.top; + left = bestPosition.left; + } + + // Ensure toolbar stays within viewport bounds + const finalPosition = this.constrainToViewport( + { top, left, position }, + toolbarRect, + viewportWidth, + viewportHeight + ); + + // Update state + this._computedPosition.set(finalPosition.position); + this._positionData.set(finalPosition); + + // Emit position change if it changed + if (finalPosition.position !== this.position) { + this.positionChanged.emit(finalPosition.position); + } + } + + /** + * Handles action button clicks + */ + handleActionClick(action: ToolbarAction, event: Event): void { + if (action.disabled) return; + + event.stopPropagation(); + + // Execute action callback if provided + if (action.callback) { + action.callback(action, event); + } + + // Emit action clicked event + this.actionClicked.emit({ action, event }); + + // Focus management + const button = event.target as HTMLButtonElement; + if (button) { + const actionIndex = this.visibleActions().findIndex(a => a.id === action.id); + this._focusedActionIndex.set(actionIndex); + } + } + + /** + * Handles keyboard navigation within actions + */ + handleActionKeydown(action: ToolbarAction, event: KeyboardEvent): void { + const visibleActions = this.visibleActions(); + const currentIndex = this._focusedActionIndex(); + + switch (event.key) { + case 'ArrowLeft': + case 'ArrowUp': + event.preventDefault(); + this.focusPreviousAction(); + break; + + case 'ArrowRight': + case 'ArrowDown': + event.preventDefault(); + this.focusNextAction(); + break; + + case 'Home': + event.preventDefault(); + this.focusFirstAction(); + break; + + case 'End': + event.preventDefault(); + this.focusLastAction(); + break; + + case 'Enter': + case ' ': + event.preventDefault(); + this.handleActionClick(action, event); + break; + } + } + + /** + * Handles toolbar-level keyboard events + */ + handleKeydown(event: KeyboardEvent): void { + switch (event.key) { + case 'Escape': + if (this.escapeClosable) { + event.preventDefault(); + this.hide(); + this.escapePressed.emit(event); + } + break; + + case 'Tab': + // Allow normal tab behavior within toolbar + break; + + default: + // Handle keyboard shortcuts + this.handleKeyboardShortcut(event); + break; + } + } + + /** + * Handles backdrop clicks + */ + handleBackdropClick(event: MouseEvent): void { + if (this.backdropClosable) { + this.hide(); + this.backdropClicked.emit(event); + } + } + + /** + * Document click listener to handle outside clicks + */ + @HostListener('document:click', ['$event']) + onDocumentClick(event: MouseEvent): void { + if (!this._visible()) return; + + const target = event.target as HTMLElement; + const isInsideToolbar = this.elementRef.nativeElement.contains(target); + const isInsideAnchor = this.anchorElement?.contains(target); + + if (!isInsideToolbar && !isInsideAnchor && this.trigger !== 'manual') { + this.hide(); + } + } + + /** + * Document selection change listener for text selection trigger + */ + @HostListener('document:selectionchange', ['$event']) + onDocumentSelectionChange(event: Event): void { + if (this.trigger !== 'selection') return; + + const selection = window.getSelection(); + + if (selection && selection.toString().trim()) { + // Text is selected + this.currentSelection = selection; + this.updateSelectionAnchor(); + this.show(); + } else { + // No text selected + this.currentSelection = null; + if (this.autoHide) { + this.scheduleAutoHide(); + } + } + } + + /** + * Focus management methods + */ + private focusFirstAction(): void { + const actions = this.visibleActions(); + if (actions.length > 0) { + this._focusedActionIndex.set(0); + this.focusActionButton(0); + } + } + + private focusLastAction(): void { + const actions = this.visibleActions(); + if (actions.length > 0) { + const lastIndex = actions.length - 1; + this._focusedActionIndex.set(lastIndex); + this.focusActionButton(lastIndex); + } + } + + private focusNextAction(): void { + const actions = this.visibleActions(); + const currentIndex = this._focusedActionIndex(); + const nextIndex = (currentIndex + 1) % actions.length; + this._focusedActionIndex.set(nextIndex); + this.focusActionButton(nextIndex); + } + + private focusPreviousAction(): void { + const actions = this.visibleActions(); + const currentIndex = this._focusedActionIndex(); + const prevIndex = currentIndex <= 0 ? actions.length - 1 : currentIndex - 1; + this._focusedActionIndex.set(prevIndex); + this.focusActionButton(prevIndex); + } + + private focusActionButton(index: number): void { + if (!this.toolbarElement) return; + + const buttons = this.toolbarElement.nativeElement.querySelectorAll('.ui-floating-toolbar__action'); + const button = buttons[index] as HTMLButtonElement; + if (button && !button.disabled) { + button.focus(); + } + } + + /** + * Handles keyboard shortcuts + */ + private handleKeyboardShortcut(event: KeyboardEvent): void { + const shortcutKey = this.getShortcutString(event); + const action = this.actions.find(a => a.shortcut === shortcutKey && !a.disabled && a.visible !== false); + + if (action) { + event.preventDefault(); + this.handleActionClick(action, event); + } + } + + private getShortcutString(event: KeyboardEvent): string { + const parts: string[] = []; + + if (event.ctrlKey) parts.push('Ctrl'); + if (event.altKey) parts.push('Alt'); + if (event.shiftKey) parts.push('Shift'); + if (event.metaKey) parts.push('Cmd'); + + if (event.key && event.key !== 'Control' && event.key !== 'Alt' && event.key !== 'Shift' && event.key !== 'Meta') { + parts.push(event.key); + } + + return parts.join('+'); + } + + /** + * Sets up trigger listeners based on trigger type + */ + private setupTriggerListeners(): void { + switch (this.trigger) { + case 'hover': + if (this.anchorElement) { + this.renderer.listen(this.anchorElement, 'mouseenter', () => this.show()); + this.renderer.listen(this.anchorElement, 'mouseleave', () => { + if (this.autoHide) { + this.scheduleAutoHide(); + } + }); + + // Keep toolbar open when hovering over it + this.renderer.listen(this.elementRef.nativeElement, 'mouseenter', () => { + this.clearTimers(); + }); + this.renderer.listen(this.elementRef.nativeElement, 'mouseleave', () => { + if (this.autoHide) { + this.scheduleAutoHide(); + } + }); + } + break; + + case 'contextmenu': + if (this.anchorElement) { + this.renderer.listen(this.anchorElement, 'contextmenu', (event: MouseEvent) => { + event.preventDefault(); + this.updatePositionFromEvent(event); + this.show(); + }); + } + break; + + case 'selection': + // Handled by document listener + break; + + case 'manual': + default: + // No automatic triggers + break; + } + } + + private setupAnchorElement(): void { + if (this.anchorSelector && !this.anchorElement) { + const element = this.document.querySelector(this.anchorSelector) as HTMLElement; + if (element) { + this.anchorElement = element; + this.setupTriggerListeners(); + } + } + } + + private updateSelectionAnchor(): void { + if (!this.currentSelection) return; + + const range = this.currentSelection.getRangeAt(0); + const rect = range.getBoundingClientRect(); + + // Create a temporary anchor element for positioning + if (rect.width > 0 && rect.height > 0) { + this.anchorElement = { + getBoundingClientRect: () => rect, + contains: () => false + } as unknown as HTMLElement; + } + } + + private updatePositionFromEvent(event: MouseEvent): void { + // Create a temporary anchor element at the event position + this.anchorElement = { + getBoundingClientRect: () => ({ + top: event.clientY, + left: event.clientX, + right: event.clientX, + bottom: event.clientY, + width: 0, + height: 0 + } as DOMRect), + contains: () => false + } as unknown as HTMLElement; + } + + private updateActionsFromContext(): void { + if (!this.context) return; + + // Update action visibility and state based on context + this.actions.forEach(action => { + // This could be enhanced with more sophisticated context-aware logic + if (this.context?.type && action.id.includes(this.context.type)) { + action.visible = true; + } + }); + } + + private scheduleAutoHide(): void { + this.clearTimers(); + this.hideTimer = window.setTimeout(() => { + this.hide(); + }, this.hideDelay); + } + + private clearTimers(): void { + if (this.hideTimer) { + clearTimeout(this.hideTimer); + this.hideTimer = undefined; + } + if (this.showTimer) { + clearTimeout(this.showTimer); + this.showTimer = undefined; + } + } + + private setupPositionObservers(): void { + // Resize observer for position updates + this.resizeObserver = new ResizeObserver(() => { + if (this._visible()) { + this.updatePosition(); + } + }); + this.resizeObserver.observe(document.body); + + // Listen for window scroll and resize + window.addEventListener('scroll', this.updatePositionThrottled, { passive: true }); + window.addEventListener('resize', this.updatePositionThrottled, { passive: true }); + } + + private updatePositionThrottled = this.throttle(() => { + if (this._visible()) { + this.updatePosition(); + } + }, 16); // ~60fps + + private cleanup(): void { + this.resizeObserver?.disconnect(); + this.selectionObserver?.disconnect(); + window.removeEventListener('scroll', this.updatePositionThrottled); + window.removeEventListener('resize', this.updatePositionThrottled); + } + + private calculatePositions( + anchorRect: DOMRect, + toolbarRect: DOMRect, + scrollLeft: number, + scrollTop: number + ): Record { + const anchorCenterX = anchorRect.left + anchorRect.width / 2; + const anchorCenterY = anchorRect.top + anchorRect.height / 2; + + return { + 'top': { + top: anchorRect.top - toolbarRect.height - this.offset + scrollTop, + left: anchorCenterX - toolbarRect.width / 2 + scrollLeft, + position: 'top' + }, + 'bottom': { + top: anchorRect.bottom + this.offset + scrollTop, + left: anchorCenterX - toolbarRect.width / 2 + scrollLeft, + position: 'bottom' + }, + 'left': { + top: anchorCenterY - toolbarRect.height / 2 + scrollTop, + left: anchorRect.left - toolbarRect.width - this.offset + scrollLeft, + position: 'left' + }, + 'right': { + top: anchorCenterY - toolbarRect.height / 2 + scrollTop, + left: anchorRect.right + this.offset + scrollLeft, + position: 'right' + } + }; + } + + private findBestPosition( + positions: Record, + viewportWidth: number, + viewportHeight: number, + toolbarRect: DOMRect + ): ToolbarPositionData { + const preferenceOrder: ToolbarPosition[] = [ + this.position, + ...Object.keys(positions).filter(p => p !== this.position) as ToolbarPosition[] + ]; + + for (const pos of preferenceOrder) { + const posData = positions[pos]; + const fitsHorizontally = posData.left >= 0 && posData.left + toolbarRect.width <= viewportWidth; + const fitsVertically = posData.top >= 0 && posData.top + toolbarRect.height <= viewportHeight; + + if (fitsHorizontally && fitsVertically) { + return posData; + } + } + + return positions[this.position]; + } + + private constrainToViewport( + position: ToolbarPositionData, + toolbarRect: DOMRect, + viewportWidth: number, + viewportHeight: number + ): ToolbarPositionData { + const margin = 8; + let { top, left } = position; + + // Constrain horizontally + if (left < margin) { + left = margin; + } else if (left + toolbarRect.width > viewportWidth - margin) { + left = viewportWidth - toolbarRect.width - margin; + } + + // Constrain vertically + if (top < margin) { + top = margin; + } else if (top + toolbarRect.height > viewportHeight - margin) { + top = viewportHeight - toolbarRect.height - margin; + } + + return { ...position, top, left }; + } + + private throttle void>(func: T, limit: number): T { + let inThrottle: boolean; + return ((...args: any[]) => { + if (!inThrottle) { + func.apply(this, args); + inThrottle = true; + setTimeout(() => inThrottle = false, limit); + } + }) as T; + } + + /** + * Public API method to apply configuration + */ + configure(config: FloatingToolbarConfig): void { + Object.assign(this, config); + } + + /** + * Public API method to set anchor element + */ + setAnchorElement(element: HTMLElement): void { + this.anchorElement = element; + this.setupTriggerListeners(); + } +} \ No newline at end of file diff --git a/src/lib/components/overlays/floating-toolbar/index.ts b/src/lib/components/overlays/floating-toolbar/index.ts new file mode 100644 index 0000000..8429786 --- /dev/null +++ b/src/lib/components/overlays/floating-toolbar/index.ts @@ -0,0 +1 @@ +export * from './floating-toolbar.component'; \ No newline at end of file diff --git a/src/lib/components/overlays/index.ts b/src/lib/components/overlays/index.ts new file mode 100644 index 0000000..cece9f2 --- /dev/null +++ b/src/lib/components/overlays/index.ts @@ -0,0 +1,7 @@ +export * from './modal'; +export * from './drawer'; +export * from './backdrop'; +export * from './overlay-container'; +export * from './popover'; +export * from './command-palette'; +export * from './floating-toolbar'; \ No newline at end of file diff --git a/src/lib/components/overlays/modal/index.ts b/src/lib/components/overlays/modal/index.ts new file mode 100644 index 0000000..9f26e35 --- /dev/null +++ b/src/lib/components/overlays/modal/index.ts @@ -0,0 +1 @@ +export * from './modal.component'; \ No newline at end of file diff --git a/src/lib/components/overlays/modal/modal.component.scss b/src/lib/components/overlays/modal/modal.component.scss new file mode 100644 index 0000000..e424f0f --- /dev/null +++ b/src/lib/components/overlays/modal/modal.component.scss @@ -0,0 +1,341 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-modal { + // Modal Backdrop + &__backdrop { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.6); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + padding: $semantic-spacing-layout-md; + backdrop-filter: blur(4px); + opacity: 0; + transition: opacity $semantic-motion-duration-normal $semantic-motion-easing-ease; + + &--visible { + opacity: 1; + } + + &--entering { + animation: backdrop-fade-in $semantic-motion-duration-normal $semantic-motion-easing-ease; + } + + &--leaving { + animation: backdrop-fade-out $semantic-motion-duration-normal $semantic-motion-easing-ease; + } + } + + // Modal Container + &__container { + background: $semantic-color-surface-primary; + border-radius: $semantic-border-radius-lg; + box-shadow: $semantic-shadow-elevation-3; + max-width: 90vw; + max-height: 90vh; + overflow: hidden; + transform: scale(0.8) translateY(20px); + opacity: 0; + transition: all $semantic-motion-duration-normal $semantic-motion-easing-ease; + + &--visible { + transform: scale(1) translateY(0); + opacity: 1; + } + + &--entering { + animation: modal-slide-in $semantic-motion-duration-normal $semantic-motion-easing-ease; + } + + &--leaving { + animation: modal-slide-out $semantic-motion-duration-normal $semantic-motion-easing-ease; + } + } + + // Size Variants + &--sm { + .ui-modal__container { + width: 400px; + } + } + + &--md { + .ui-modal__container { + width: 600px; + } + } + + &--lg { + .ui-modal__container { + width: 800px; + } + } + + &--xl { + .ui-modal__container { + width: 1000px; + } + } + + &--fullscreen { + .ui-modal__backdrop { + padding: 0; + } + + .ui-modal__container { + width: 100vw; + height: 100vh; + max-width: none; + max-height: none; + border-radius: 0; + } + } + + // Modal Header + &__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: $semantic-spacing-component-lg; + border-bottom: 1px solid $semantic-color-outline; + background: $semantic-color-surface-primary; + + &--no-border { + border-bottom: none; + } + } + + &__title { + font-size: $semantic-typography-heading-h4-size; + font-weight: $semantic-typography-font-weight-semibold; + color: $semantic-color-on-surface; + margin: 0; + line-height: 1.4; + } + + &__close-button { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border: none; + background: transparent; + border-radius: $semantic-border-radius-md; + color: $semantic-color-on-surface-variant; + cursor: pointer; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + + &:hover { + background: $semantic-color-surface-variant; + color: $semantic-color-on-surface; + } + + &:focus-visible { + outline: 2px solid $semantic-color-primary; + outline-offset: 2px; + } + + &:active { + background: $semantic-color-surface-container; + } + } + + // Modal Body + &__body { + padding: $semantic-spacing-component-lg; + overflow-y: auto; + max-height: calc(90vh - 200px); + + &--no-padding { + padding: 0; + } + + &--scrollable { + overflow-y: auto; + } + } + + &__content { + color: $semantic-color-on-surface; + font-size: $semantic-typography-font-size-md; + line-height: 1.6; + } + + // Modal Footer + &__footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: $semantic-spacing-component-sm; + padding: $semantic-spacing-component-lg; + border-top: 1px solid $semantic-color-outline; + background: $semantic-color-surface-primary; + + &--no-border { + border-top: none; + } + + &--center { + justify-content: center; + } + + &--start { + justify-content: flex-start; + } + + &--between { + justify-content: space-between; + } + } + + // State Variants + &--loading { + .ui-modal__body { + display: flex; + align-items: center; + justify-content: center; + min-height: 200px; + } + } + + &--danger { + .ui-modal__header { + background: $semantic-color-container-error; + border-bottom-color: $semantic-color-error; + } + + .ui-modal__title { + color: $semantic-color-on-container-error; + } + } + + &--warning { + .ui-modal__header { + background: rgba($semantic-color-warning, 0.1); + border-bottom-color: $semantic-color-warning; + } + + .ui-modal__title { + color: $semantic-color-on-warning; + } + } + + &--success { + .ui-modal__header { + background: rgba($semantic-color-success, 0.1); + border-bottom-color: $semantic-color-success; + } + + .ui-modal__title { + color: $semantic-color-on-success; + } + } + + // Loading Spinner + &__loader { + display: inline-block; + width: 24px; + height: 24px; + border: 2px solid $semantic-color-outline; + border-radius: 50%; + border-top-color: $semantic-color-primary; + animation: modal-spin 1s linear infinite; + } + + // Dark Mode Support + :host-context(.dark-theme) & { + &__backdrop { + background: rgba(0, 0, 0, 0.8); + } + } + + // Responsive Design + @media (max-width: 768px) { + &__backdrop { + padding: $semantic-spacing-component-sm; + } + + &__container { + width: 100% !important; + max-width: none; + margin: 0; + border-radius: $semantic-border-radius-md; + } + + &__header { + padding: $semantic-spacing-component-md; + } + + &__body { + padding: $semantic-spacing-component-md; + max-height: calc(90vh - 160px); + } + + &__footer { + padding: $semantic-spacing-component-md; + flex-direction: column; + gap: $semantic-spacing-component-xs; + + .ui-button { + width: 100%; + } + } + + &__title { + font-size: $semantic-typography-heading-h5-size; + } + } +} + +// Animations +@keyframes backdrop-fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes backdrop-fade-out { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +@keyframes modal-slide-in { + from { + transform: scale(0.8) translateY(20px); + opacity: 0; + } + to { + transform: scale(1) translateY(0); + opacity: 1; + } +} + +@keyframes modal-slide-out { + from { + transform: scale(1) translateY(0); + opacity: 1; + } + to { + transform: scale(0.8) translateY(20px); + opacity: 0; + } +} + +@keyframes modal-spin { + to { + transform: rotate(360deg); + } +} \ No newline at end of file diff --git a/src/lib/components/overlays/modal/modal.component.ts b/src/lib/components/overlays/modal/modal.component.ts new file mode 100644 index 0000000..42539f3 --- /dev/null +++ b/src/lib/components/overlays/modal/modal.component.ts @@ -0,0 +1,434 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation, OnInit, OnDestroy, inject, ElementRef, HostListener } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { faXmark } from '@fortawesome/free-solid-svg-icons'; +import { signal, computed } from '@angular/core'; + +type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'fullscreen'; +type ModalVariant = 'default' | 'danger' | 'warning' | 'success'; +type FooterAlignment = 'start' | 'center' | 'end' | 'between'; + +export interface ModalConfig { + size?: ModalSize; + variant?: ModalVariant; + closable?: boolean; + backdropClosable?: boolean; + escapeClosable?: boolean; + showHeader?: boolean; + showFooter?: boolean; + headerBorder?: boolean; + footerBorder?: boolean; + bodyPadding?: boolean; + bodyScrollable?: boolean; + footerAlignment?: FooterAlignment; + preventBodyScroll?: boolean; + focusTrap?: boolean; +} + +@Component({ + selector: 'ui-modal', + standalone: true, + imports: [CommonModule, FontAwesomeModule], + changeDetection: ChangeDetectionStrategy.Default, + encapsulation: ViewEncapsulation.None, + template: ` + @if (isVisible()) { + + } + `, + styleUrl: './modal.component.scss' +}) +export class ModalComponent implements OnInit, OnDestroy { + // Dependencies + private elementRef = inject(ElementRef); + + // Icons + readonly faXmark = faXmark; + + // Inputs + @Input() size: ModalSize = 'md'; + @Input() variant: ModalVariant = 'default'; + @Input() title = ''; + @Input() loading = false; + @Input() closable = true; + @Input() backdropClosable = true; + @Input() escapeClosable = true; + @Input() showHeader = true; + @Input() showFooter = false; + @Input() headerBorder = true; + @Input() footerBorder = true; + @Input() bodyPadding = true; + @Input() bodyScrollable = true; + @Input() footerAlignment: FooterAlignment = 'end'; + @Input() preventBodyScroll = true; + @Input() focusTrap = true; + @Input() closeAriaLabel = 'Close modal'; + + // State Inputs + @Input() set open(value: boolean) { + if (value !== this._open()) { + this._open.set(value); + if (value) { + this.show(); + } else { + this.hide(); + } + } + } + + get open(): boolean { + return this._open(); + } + + // Outputs + @Output() openChange = new EventEmitter(); + @Output() opened = new EventEmitter(); + @Output() closed = new EventEmitter(); + @Output() backdropClicked = new EventEmitter(); + @Output() escapePressed = new EventEmitter(); + + // Internal state signals + private _open = signal(false); + private _entering = signal(false); + private _leaving = signal(false); + + // Computed signals + readonly isVisible = computed(() => this._open() || this._entering() || this._leaving()); + readonly isEntering = computed(() => this._entering()); + readonly isLeaving = computed(() => this._leaving()); + + // Internal properties + private originalBodyOverflow = ''; + private originalBodyPaddingRight = ''; + private focusableElements: HTMLElement[] = []; + private previousActiveElement: Element | null = null; + private currentFocusIndex = 0; + + // Unique IDs for accessibility + readonly titleId = `modal-title-${Math.random().toString(36).substr(2, 9)}`; + readonly contentId = `modal-content-${Math.random().toString(36).substr(2, 9)}`; + + ngOnInit(): void { + if (this.open) { + this.show(); + } + } + + getComponentClasses(): string { + const classes = [ + 'ui-modal', + `ui-modal--${this.size}`, + `ui-modal--${this.variant}` + ]; + + if (this.loading) { + classes.push('ui-modal--loading'); + } + + return classes.join(' '); + } + + ngOnDestroy(): void { + this.restoreBodyScroll(); + this.restoreFocus(); + } + + /** + * Shows the modal with animation + */ + show(): void { + if (this._open()) return; + + this.preventBodyScrollIfEnabled(); + this.storePreviousFocus(); + + this._open.set(true); + this._entering.set(true); + this.openChange.emit(true); + + // Animation timing + setTimeout(() => { + this._entering.set(false); + this.setupFocusTrap(); + this.opened.emit(); + }, 300); + } + + /** + * Hides the modal with animation + */ + hide(): void { + if (!this._open()) return; + + this._leaving.set(true); + + // Animation timing + setTimeout(() => { + this._open.set(false); + this._leaving.set(false); + this.restoreBodyScroll(); + this.restoreFocus(); + this.openChange.emit(false); + this.closed.emit(); + }, 300); + } + + /** + * Closes the modal (alias for hide) + */ + close(): void { + if (this.closable) { + this.hide(); + } + } + + /** + * Toggles modal visibility + */ + toggle(): void { + if (this._open()) { + this.hide(); + } else { + this.show(); + } + } + + /** + * Handles backdrop click + */ + handleBackdropClick(event: Event): void { + if (this.backdropClosable) { + this.close(); + this.backdropClicked.emit(); + } + } + + /** + * Handles keyboard events + */ + handleKeydown(event: KeyboardEvent): void { + if (event.key === 'Escape' && this.escapeClosable) { + event.preventDefault(); + this.close(); + this.escapePressed.emit(); + return; + } + + // Focus trap handling + if (this.focusTrap && (event.key === 'Tab')) { + this.handleTabKeyForFocusTrap(event); + } + } + + /** + * Global keyboard listener for ESC key + */ + @HostListener('document:keydown', ['$event']) + onDocumentKeydown(event: KeyboardEvent): void { + if (this._open() && event.key === 'Escape' && this.escapeClosable) { + event.preventDefault(); + this.close(); + this.escapePressed.emit(); + } + } + + /** + * Prevents body scroll when modal is open + */ + private preventBodyScrollIfEnabled(): void { + if (!this.preventBodyScroll) return; + + const body = document.body; + const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; + + this.originalBodyOverflow = body.style.overflow; + this.originalBodyPaddingRight = body.style.paddingRight; + + body.style.overflow = 'hidden'; + if (scrollbarWidth > 0) { + body.style.paddingRight = `${scrollbarWidth}px`; + } + } + + /** + * Restores body scroll + */ + private restoreBodyScroll(): void { + if (!this.preventBodyScroll) return; + + const body = document.body; + body.style.overflow = this.originalBodyOverflow; + body.style.paddingRight = this.originalBodyPaddingRight; + } + + /** + * Stores the currently focused element + */ + private storePreviousFocus(): void { + this.previousActiveElement = document.activeElement; + } + + /** + * Restores focus to the previously focused element + */ + private restoreFocus(): void { + if (this.previousActiveElement && 'focus' in this.previousActiveElement) { + (this.previousActiveElement as HTMLElement).focus(); + } + } + + /** + * Sets up focus trap within modal + */ + private setupFocusTrap(): void { + if (!this.focusTrap) return; + + setTimeout(() => { + this.updateFocusableElements(); + if (this.focusableElements.length > 0) { + this.focusableElements[0].focus(); + this.currentFocusIndex = 0; + } + }, 100); + } + + /** + * Updates the list of focusable elements + */ + private updateFocusableElements(): void { + const modalElement = this.elementRef.nativeElement.querySelector('.ui-modal__container'); + if (!modalElement) return; + + const focusableSelectors = [ + 'button:not([disabled])', + 'input:not([disabled])', + 'textarea:not([disabled])', + 'select:not([disabled])', + 'a[href]', + '[tabindex]:not([tabindex="-1"])', + '[contenteditable="true"]' + ].join(', '); + + this.focusableElements = Array.from( + modalElement.querySelectorAll(focusableSelectors) + ) as HTMLElement[]; + } + + /** + * Handles Tab key for focus trapping + */ + private handleTabKeyForFocusTrap(event: KeyboardEvent): void { + if (this.focusableElements.length === 0) return; + + const isShiftTab = event.shiftKey; + + if (isShiftTab) { + // Shift + Tab - move to previous focusable element + this.currentFocusIndex = this.currentFocusIndex <= 0 + ? this.focusableElements.length - 1 + : this.currentFocusIndex - 1; + } else { + // Tab - move to next focusable element + this.currentFocusIndex = this.currentFocusIndex >= this.focusableElements.length - 1 + ? 0 + : this.currentFocusIndex + 1; + } + + event.preventDefault(); + this.focusableElements[this.currentFocusIndex].focus(); + } + + /** + * Public API method to apply configuration + */ + configure(config: ModalConfig): void { + Object.assign(this, config); + } +} \ No newline at end of file diff --git a/src/lib/components/overlays/overlay-container/index.ts b/src/lib/components/overlays/overlay-container/index.ts new file mode 100644 index 0000000..2ebe5b7 --- /dev/null +++ b/src/lib/components/overlays/overlay-container/index.ts @@ -0,0 +1 @@ +export * from './overlay-container.component'; \ No newline at end of file diff --git a/src/lib/components/overlays/overlay-container/overlay-container.component.scss b/src/lib/components/overlays/overlay-container/overlay-container.component.scss new file mode 100644 index 0000000..e645c3f --- /dev/null +++ b/src/lib/components/overlays/overlay-container/overlay-container.component.scss @@ -0,0 +1,331 @@ +@use 'ui-design-system/src/styles/semantic/index' as tokens; + +.ui-overlay-container { + // Core Structure + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: var(--overlay-z-index, tokens.$semantic-z-index-overlay); + + // Initial state (hidden) + visibility: hidden; + opacity: 0; + pointer-events: none; + + // Transitions + transition: opacity tokens.$semantic-motion-duration-fast tokens.$semantic-motion-easing-ease, + visibility tokens.$semantic-motion-duration-fast tokens.$semantic-motion-easing-ease; + + // Visible state + &--visible { + visibility: visible; + opacity: 1; + pointer-events: auto; + } + + // Animation states + &--entering { + visibility: visible; + opacity: 1; + pointer-events: auto; + + .ui-overlay-container__content { + animation: overlayEnter tokens.$semantic-motion-duration-fast tokens.$semantic-motion-easing-ease forwards; + } + } + + &--leaving { + .ui-overlay-container__content { + animation: overlayLeave tokens.$semantic-motion-duration-fast tokens.$semantic-motion-easing-ease forwards; + } + } + + // Position Variants + &--top { + align-items: flex-start; + padding-top: tokens.$semantic-spacing-layout-md; + } + + &--bottom { + align-items: flex-end; + padding-bottom: tokens.$semantic-spacing-layout-md; + } + + &--left { + justify-content: flex-start; + padding-left: tokens.$semantic-spacing-layout-md; + } + + &--right { + justify-content: flex-end; + padding-right: tokens.$semantic-spacing-layout-md; + } + + &--top-left { + align-items: flex-start; + justify-content: flex-start; + padding: tokens.$semantic-spacing-layout-md; + } + + &--top-right { + align-items: flex-start; + justify-content: flex-end; + padding: tokens.$semantic-spacing-layout-md; + } + + &--bottom-left { + align-items: flex-end; + justify-content: flex-start; + padding: tokens.$semantic-spacing-layout-md; + } + + &--bottom-right { + align-items: flex-end; + justify-content: flex-end; + padding: tokens.$semantic-spacing-layout-md; + } + + // Size Variants + &--sm .ui-overlay-container__content { + max-width: tokens.$semantic-sizing-modal-width-sm; + max-height: 50vh; + } + + &--md .ui-overlay-container__content { + max-width: tokens.$semantic-sizing-modal-width-md; + max-height: 70vh; + } + + &--lg .ui-overlay-container__content { + max-width: tokens.$semantic-sizing-modal-width-lg; + max-height: 80vh; + } + + &--xl .ui-overlay-container__content { + max-width: tokens.$semantic-sizing-modal-width-xl; + max-height: 90vh; + } + + &--fullscreen .ui-overlay-container__content { + width: 100vw; + height: 100vh; + max-width: none; + max-height: none; + } + + // Variant Styles + &--modal { + z-index: tokens.$semantic-z-index-modal; + + .ui-overlay-container__backdrop { + background: tokens.$semantic-color-backdrop; + } + } + + &--popover { + z-index: tokens.$semantic-z-index-popover; + + .ui-overlay-container__content { + box-shadow: tokens.$semantic-shadow-popover; + } + } + + &--elevated { + z-index: tokens.$semantic-z-index-elevated; + + .ui-overlay-container__content { + box-shadow: tokens.$semantic-shadow-elevation-3; + } + } + + &--floating { + z-index: tokens.$semantic-z-index-floating; + + .ui-overlay-container__content { + box-shadow: tokens.$semantic-shadow-elevation-2; + } + } + + // Backdrop with blur effect + &--backdrop-blur { + backdrop-filter: blur(tokens.$semantic-glass-blur-md); + -webkit-backdrop-filter: blur(tokens.$semantic-glass-blur-md); + } + + // Backdrop Element + &__backdrop { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: tokens.$semantic-color-backdrop; + opacity: 0; + transition: opacity tokens.$semantic-motion-duration-fast tokens.$semantic-motion-easing-ease; + z-index: -1; + + &--visible { + opacity: tokens.$semantic-opacity-backdrop; + } + + &--entering { + opacity: tokens.$semantic-opacity-backdrop; + } + + &--leaving { + opacity: 0; + } + + &--blur { + backdrop-filter: blur(tokens.$semantic-glass-blur-sm); + -webkit-backdrop-filter: blur(tokens.$semantic-glass-blur-sm); + } + } + + // Content Element + &__content { + position: relative; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + max-width: calc(100vw - #{tokens.$semantic-spacing-layout-lg}); + max-height: calc(100vh - #{tokens.$semantic-spacing-layout-lg}); + margin: tokens.$semantic-spacing-layout-sm; + + // Default styling + background: tokens.$semantic-color-surface-primary; + border: tokens.$semantic-border-card-width solid tokens.$semantic-color-border-primary; + border-radius: tokens.$semantic-border-card-radius; + box-shadow: tokens.$semantic-shadow-elevation-2; + color: tokens.$semantic-color-text-primary; + + // Transform and scale for animations + transform: scale(0.95) translateY(tokens.$semantic-spacing-component-md); + opacity: 0; + transition: transform tokens.$semantic-motion-duration-fast tokens.$semantic-motion-easing-ease, + opacity tokens.$semantic-motion-duration-fast tokens.$semantic-motion-easing-ease; + + &--visible { + transform: scale(1) translateY(0); + opacity: 1; + } + + &--entering { + transform: scale(1) translateY(0); + opacity: 1; + } + + &--leaving { + transform: scale(0.95) translateY(tokens.$semantic-spacing-component-md); + opacity: 0; + } + + // Offset positioning + .ui-overlay-container:not(.ui-overlay-container--center) & { + transform: translateX(var(--overlay-offset-x, 0)) translateY(var(--overlay-offset-y, 0)); + } + } + + // Dark Mode Support + :host-context(.dark-theme) & { + .ui-overlay-container__content { + background: tokens.$semantic-color-surface-primary; + border-color: tokens.$semantic-color-border-primary; + box-shadow: tokens.$semantic-shadow-elevation-2; + } + } + + // Responsive Design + @media (max-width: tokens.$semantic-breakpoint-md - 1) { + padding: tokens.$semantic-spacing-layout-sm; + + .ui-overlay-container__content { + margin: tokens.$semantic-spacing-component-xs; + max-width: calc(100vw - #{tokens.$semantic-spacing-layout-md}); + max-height: calc(100vh - #{tokens.$semantic-spacing-layout-md}); + } + + &--fullscreen .ui-overlay-container__content { + border-radius: 0; + margin: 0; + } + } + + @media (max-width: tokens.$semantic-breakpoint-sm - 1) { + // Mobile adjustments + &:not(.ui-overlay-container--fullscreen) { + align-items: flex-end; + + .ui-overlay-container__content { + width: 100%; + max-width: none; + margin: 0; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } + } + } +} + +// Keyframe Animations +@keyframes overlayEnter { + 0% { + transform: scale(0.95) translateY(tokens.$semantic-spacing-component-md); + opacity: 0; + } + 100% { + transform: scale(1) translateY(0); + opacity: 1; + } +} + +@keyframes overlayLeave { + 0% { + transform: scale(1) translateY(0); + opacity: 1; + } + 100% { + transform: scale(0.95) translateY(tokens.$semantic-spacing-component-md); + opacity: 0; + } +} + +// Focus trap styling +.ui-overlay-container:focus-within { + .ui-overlay-container__content { + outline: none; + } +} + +// Reduced motion support +@media (prefers-reduced-motion: reduce) { + .ui-overlay-container { + transition: none; + + .ui-overlay-container__backdrop, + .ui-overlay-container__content { + transition: none; + animation: none; + } + } + + @keyframes overlayEnter { + 0%, 100% { + transform: scale(1) translateY(0); + opacity: 1; + } + } + + @keyframes overlayLeave { + 0%, 100% { + transform: scale(1) translateY(0); + opacity: 0; + } + } +} \ No newline at end of file diff --git a/src/lib/components/overlays/overlay-container/overlay-container.component.ts b/src/lib/components/overlays/overlay-container/overlay-container.component.ts new file mode 100644 index 0000000..798242e --- /dev/null +++ b/src/lib/components/overlays/overlay-container/overlay-container.component.ts @@ -0,0 +1,413 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation, OnInit, OnDestroy, HostListener, inject, ElementRef } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { signal, computed } from '@angular/core'; + +type OverlayPosition = 'center' | 'top' | 'bottom' | 'left' | 'right' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; +type OverlaySize = 'auto' | 'sm' | 'md' | 'lg' | 'xl' | 'fullscreen'; +type OverlayVariant = 'default' | 'elevated' | 'floating' | 'modal' | 'popover'; + +export interface OverlayContainerConfig { + position?: OverlayPosition; + size?: OverlaySize; + variant?: OverlayVariant; + visible?: boolean; + closable?: boolean; + backdropClosable?: boolean; + escapeClosable?: boolean; + showBackdrop?: boolean; + backdropBlur?: boolean; + preventBodyScroll?: boolean; + focusTrap?: boolean; + autoFocus?: boolean; + restoreFocus?: boolean; + zIndex?: number; + offsetX?: number; + offsetY?: number; +} + +@Component({ + selector: 'ui-overlay-container', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` + @if (isVisible()) { +
+ + @if (showBackdrop) { +
+ } + + +
+ +
+
+ } + `, + styleUrl: './overlay-container.component.scss' +}) +export class OverlayContainerComponent implements OnInit, OnDestroy { + // Dependencies + private elementRef = inject(ElementRef); + + // Inputs + @Input() position: OverlayPosition = 'center'; + @Input() size: OverlaySize = 'auto'; + @Input() variant: OverlayVariant = 'default'; + @Input() closable = true; + @Input() backdropClosable = true; + @Input() escapeClosable = true; + @Input() showBackdrop = true; + @Input() backdropBlur = false; + @Input() preventBodyScroll = true; + @Input() focusTrap = false; + @Input() autoFocus = false; + @Input() restoreFocus = true; + @Input() zIndex = 1000; + @Input() offsetX = 0; + @Input() offsetY = 0; + + // State Inputs + @Input() set visible(value: boolean) { + if (value !== this._visible()) { + this._visible.set(value); + if (value) { + this.show(); + } else { + this.hide(); + } + } + } + + get visible(): boolean { + return this._visible(); + } + + // Outputs + @Output() visibleChange = new EventEmitter(); + @Output() shown = new EventEmitter(); + @Output() hidden = new EventEmitter(); + @Output() backdropClicked = new EventEmitter(); + @Output() escapePressed = new EventEmitter(); + + // Internal state signals + private _visible = signal(false); + private _entering = signal(false); + private _leaving = signal(false); + + // Computed signals + readonly isVisible = computed(() => this._visible() || this._entering() || this._leaving()); + readonly isEntering = computed(() => this._entering()); + readonly isLeaving = computed(() => this._leaving()); + + // Internal properties + private originalBodyOverflow = ''; + private originalBodyPaddingRight = ''; + private focusableElements: HTMLElement[] = []; + private previousActiveElement: Element | null = null; + private currentFocusIndex = 0; + private animationDuration = 200; // ms + + ngOnInit(): void { + if (this.visible) { + this.show(); + } + + // Set appropriate z-index based on variant + if (!this.zIndex || this.zIndex === 1000) { + this.setVariantZIndex(); + } + } + + ngOnDestroy(): void { + this.restoreBodyScroll(); + this.restoreFocusIfNeeded(); + } + + /** + * Shows the overlay with animation + */ + show(): void { + if (this._visible()) return; + + this.preventBodyScrollIfEnabled(); + this.storePreviousFocusIfNeeded(); + + this._visible.set(true); + this._entering.set(true); + this.visibleChange.emit(true); + + // Animation timing + setTimeout(() => { + this._entering.set(false); + this.setupFocusTrapIfEnabled(); + this.autoFocusIfEnabled(); + this.shown.emit(); + }, this.animationDuration); + } + + /** + * Hides the overlay with animation + */ + hide(): void { + if (!this._visible()) return; + + this._leaving.set(true); + + // Animation timing + setTimeout(() => { + this._visible.set(false); + this._leaving.set(false); + this.restoreBodyScroll(); + this.restoreFocusIfNeeded(); + this.visibleChange.emit(false); + this.hidden.emit(); + }, this.animationDuration); + } + + /** + * Closes the overlay (alias for hide) + */ + close(): void { + if (this.closable) { + this.hide(); + } + } + + /** + * Toggles overlay visibility + */ + toggle(): void { + if (this._visible()) { + this.hide(); + } else { + this.show(); + } + } + + /** + * Handles backdrop click + */ + handleBackdropClick(event: MouseEvent): void { + if (this.backdropClosable) { + this.close(); + this.backdropClicked.emit(event); + } + } + + /** + * Handles keyboard events + */ + handleKeydown(event: KeyboardEvent): void { + if (event.key === 'Escape' && this.escapeClosable) { + event.preventDefault(); + this.close(); + this.escapePressed.emit(event); + return; + } + + // Focus trap handling + if (this.focusTrap && event.key === 'Tab') { + this.handleTabKeyForFocusTrap(event); + } + } + + /** + * Global keyboard listener for ESC key + */ + @HostListener('document:keydown', ['$event']) + onDocumentKeydown(event: KeyboardEvent): void { + if (this._visible() && event.key === 'Escape' && this.escapeClosable) { + event.preventDefault(); + this.close(); + this.escapePressed.emit(event); + } + } + + /** + * Sets z-index based on variant + */ + private setVariantZIndex(): void { + switch (this.variant) { + case 'modal': + this.zIndex = 1050; // Higher than backdrop + break; + case 'popover': + this.zIndex = 1030; + break; + case 'elevated': + this.zIndex = 1020; + break; + case 'floating': + this.zIndex = 1010; + break; + default: + this.zIndex = 1000; + break; + } + } + + /** + * Prevents body scroll when overlay is open + */ + private preventBodyScrollIfEnabled(): void { + if (!this.preventBodyScroll) return; + + const body = document.body; + const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; + + this.originalBodyOverflow = body.style.overflow; + this.originalBodyPaddingRight = body.style.paddingRight; + + body.style.overflow = 'hidden'; + if (scrollbarWidth > 0) { + body.style.paddingRight = `${scrollbarWidth}px`; + } + } + + /** + * Restores body scroll + */ + private restoreBodyScroll(): void { + if (!this.preventBodyScroll) return; + + const body = document.body; + body.style.overflow = this.originalBodyOverflow; + body.style.paddingRight = this.originalBodyPaddingRight; + } + + /** + * Stores the currently focused element + */ + private storePreviousFocusIfNeeded(): void { + if (this.restoreFocus) { + this.previousActiveElement = document.activeElement; + } + } + + /** + * Restores focus to the previously focused element + */ + private restoreFocusIfNeeded(): void { + if (this.restoreFocus && this.previousActiveElement && 'focus' in this.previousActiveElement) { + (this.previousActiveElement as HTMLElement).focus(); + } + } + + /** + * Auto focuses the first focusable element if enabled + */ + private autoFocusIfEnabled(): void { + if (!this.autoFocus) return; + + setTimeout(() => { + this.updateFocusableElements(); + if (this.focusableElements.length > 0) { + this.focusableElements[0].focus(); + this.currentFocusIndex = 0; + } + }, 100); + } + + /** + * Sets up focus trap within overlay + */ + private setupFocusTrapIfEnabled(): void { + if (!this.focusTrap) return; + + setTimeout(() => { + this.updateFocusableElements(); + if (this.focusableElements.length > 0) { + this.focusableElements[0].focus(); + this.currentFocusIndex = 0; + } + }, 100); + } + + /** + * Updates the list of focusable elements + */ + private updateFocusableElements(): void { + const overlayElement = this.elementRef.nativeElement.querySelector('.ui-overlay-container__content'); + if (!overlayElement) return; + + const focusableSelectors = [ + 'button:not([disabled])', + 'input:not([disabled])', + 'textarea:not([disabled])', + 'select:not([disabled])', + 'a[href]', + '[tabindex]:not([tabindex="-1"])', + '[contenteditable="true"]' + ].join(', '); + + this.focusableElements = Array.from( + overlayElement.querySelectorAll(focusableSelectors) + ) as HTMLElement[]; + } + + /** + * Handles Tab key for focus trapping + */ + private handleTabKeyForFocusTrap(event: KeyboardEvent): void { + if (this.focusableElements.length === 0) return; + + const isShiftTab = event.shiftKey; + + if (isShiftTab) { + // Shift + Tab - move to previous focusable element + this.currentFocusIndex = this.currentFocusIndex <= 0 + ? this.focusableElements.length - 1 + : this.currentFocusIndex - 1; + } else { + // Tab - move to next focusable element + this.currentFocusIndex = this.currentFocusIndex >= this.focusableElements.length - 1 + ? 0 + : this.currentFocusIndex + 1; + } + + event.preventDefault(); + this.focusableElements[this.currentFocusIndex].focus(); + } + + /** + * Public API method to apply configuration + */ + configure(config: OverlayContainerConfig): void { + Object.assign(this, config); + } +} \ No newline at end of file diff --git a/src/lib/components/overlays/popover/index.ts b/src/lib/components/overlays/popover/index.ts new file mode 100644 index 0000000..5c1711b --- /dev/null +++ b/src/lib/components/overlays/popover/index.ts @@ -0,0 +1 @@ +export * from './popover.component'; \ No newline at end of file diff --git a/src/lib/components/overlays/popover/popover.component.scss b/src/lib/components/overlays/popover/popover.component.scss new file mode 100644 index 0000000..090df0a --- /dev/null +++ b/src/lib/components/overlays/popover/popover.component.scss @@ -0,0 +1,384 @@ +@use 'ui-design-system/src/styles/semantic/index' as *; + +.ui-popover { + position: absolute; + display: block; + opacity: 0; + transform: scale(0.95); + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + z-index: $semantic-layer-dropdown; + will-change: opacity, transform; + + // Core Structure + background: $semantic-color-surface-primary; + border: $semantic-border-card-width solid $semantic-color-border-primary; + border-radius: $semantic-border-card-radius; + box-shadow: $semantic-shadow-popover; + + // Typography + font-size: $semantic-typography-font-size-sm; + color: $semantic-color-text-primary; + line-height: $semantic-typography-line-height-normal; + + // Size Variants + &--sm { + min-width: $semantic-sizing-card-width-sm; + max-width: $semantic-sizing-modal-width-sm; + padding: $semantic-spacing-component-xs; + } + + &--md { + min-width: $semantic-sizing-card-width-md; + max-width: $semantic-sizing-modal-width-md; + padding: $semantic-spacing-component-sm; + } + + &--lg { + min-width: $semantic-sizing-card-width-lg; + max-width: $semantic-sizing-modal-width-lg; + padding: $semantic-spacing-component-md; + } + + // Position Variants + &--top { + transform-origin: bottom center; + margin-bottom: $semantic-spacing-component-sm; + } + + &--bottom { + transform-origin: top center; + margin-top: $semantic-spacing-component-sm; + } + + &--left { + transform-origin: center right; + margin-right: $semantic-spacing-component-sm; + } + + &--right { + transform-origin: center left; + margin-left: $semantic-spacing-component-sm; + } + + &--top-start { + transform-origin: bottom left; + margin-bottom: $semantic-spacing-component-sm; + } + + &--top-end { + transform-origin: bottom right; + margin-bottom: $semantic-spacing-component-sm; + } + + &--bottom-start { + transform-origin: top left; + margin-top: $semantic-spacing-component-sm; + } + + &--bottom-end { + transform-origin: top right; + margin-top: $semantic-spacing-component-sm; + } + + // Variant Styles + &--default { + background: $semantic-color-surface-primary; + border-color: $semantic-color-border-primary; + } + + &--elevated { + background: $semantic-color-surface-primary; + border: none; + box-shadow: $semantic-shadow-elevation-3; + } + + &--floating { + background: $semantic-color-surface-secondary; + border-color: $semantic-color-border-subtle; + backdrop-filter: blur(8px); + } + + &--menu { + padding: $semantic-spacing-content-line-tight; + background: $semantic-color-surface-primary; + } + + &--tooltip { + padding: $semantic-spacing-component-xs $semantic-spacing-component-sm; + background: $semantic-color-surface-dim; + color: $semantic-color-text-inverse; + border: none; + font-size: $semantic-typography-font-size-xs; + box-shadow: $semantic-shadow-elevation-2; + } + + // State Variants + &--visible { + opacity: 1; + transform: scale(1); + pointer-events: auto; + } + + &--entering { + opacity: 0; + transform: scale(0.95); + } + + &--leaving { + opacity: 0; + transform: scale(0.95); + transition-duration: $semantic-motion-duration-fast; + } + + &--disabled { + opacity: 0.5; + pointer-events: none; + } + + // Arrow/Triangle + &__arrow { + position: absolute; + width: 0; + height: 0; + border-style: solid; + pointer-events: none; + + // Arrow positioning and styling based on popover position + .ui-popover--top & { + bottom: calc(-1 * $semantic-spacing-component-sm); + left: 50%; + transform: translateX(-50%); + border-width: $semantic-spacing-component-sm $semantic-spacing-component-sm 0 $semantic-spacing-component-sm; + border-color: $semantic-color-surface-primary transparent transparent transparent; + + &::before { + content: ''; + position: absolute; + top: calc(-1 * $semantic-spacing-component-sm - 1px); + left: calc(-1 * $semantic-spacing-component-sm); + border-width: $semantic-spacing-component-sm $semantic-spacing-component-sm 0 $semantic-spacing-component-sm; + border-color: $semantic-color-border-primary transparent transparent transparent; + border-style: solid; + } + } + + .ui-popover--bottom & { + top: calc(-1 * $semantic-spacing-component-sm); + left: 50%; + transform: translateX(-50%); + border-width: 0 $semantic-spacing-component-sm $semantic-spacing-component-sm $semantic-spacing-component-sm; + border-color: transparent transparent $semantic-color-surface-primary transparent; + + &::before { + content: ''; + position: absolute; + top: calc(-1px); + left: calc(-1 * $semantic-spacing-component-sm); + border-width: 0 $semantic-spacing-component-sm $semantic-spacing-component-sm $semantic-spacing-component-sm; + border-color: transparent transparent $semantic-color-border-primary transparent; + border-style: solid; + } + } + + .ui-popover--left & { + right: calc(-1 * $semantic-spacing-component-sm); + top: 50%; + transform: translateY(-50%); + border-width: $semantic-spacing-component-sm 0 $semantic-spacing-component-sm $semantic-spacing-component-sm; + border-color: transparent transparent transparent $semantic-color-surface-primary; + + &::before { + content: ''; + position: absolute; + top: calc(-1 * $semantic-spacing-component-sm); + left: calc(-1 * $semantic-spacing-component-sm - 1px); + border-width: $semantic-spacing-component-sm 0 $semantic-spacing-component-sm $semantic-spacing-component-sm; + border-color: transparent transparent transparent $semantic-color-border-primary; + border-style: solid; + } + } + + .ui-popover--right & { + left: calc(-1 * $semantic-spacing-component-sm); + top: 50%; + transform: translateY(-50%); + border-width: $semantic-spacing-component-sm $semantic-spacing-component-sm $semantic-spacing-component-sm 0; + border-color: transparent $semantic-color-surface-primary transparent transparent; + + &::before { + content: ''; + position: absolute; + top: calc(-1 * $semantic-spacing-component-sm); + left: calc(-1px); + border-width: $semantic-spacing-component-sm $semantic-spacing-component-sm $semantic-spacing-component-sm 0; + border-color: transparent $semantic-color-border-primary transparent transparent; + border-style: solid; + } + } + + // Hide arrow for elevated and floating variants + .ui-popover--elevated &, + .ui-popover--floating & { + display: none; + } + } + + // Content Area + &__content { + position: relative; + width: 100%; + max-height: $semantic-sizing-modal-height-max; + overflow: auto; + + // Scrollbar styling + scrollbar-width: thin; + scrollbar-color: $semantic-color-border-subtle $semantic-color-surface-secondary; + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background: $semantic-color-surface-secondary; + border-radius: $semantic-border-input-radius; + } + + &::-webkit-scrollbar-thumb { + background: $semantic-color-border-subtle; + border-radius: $semantic-border-input-radius; + + &:hover { + background: $semantic-color-border-primary; + } + } + } + + // Header (optional) + &__header { + padding: $semantic-spacing-component-sm; + border-bottom: $semantic-border-card-width solid $semantic-color-border-subtle; + margin-bottom: $semantic-spacing-content-line-tight; + + &:last-child { + margin-bottom: 0; + } + } + + &__title { + font-size: $semantic-typography-font-size-md; + font-weight: $semantic-typography-font-weight-semibold; + color: $semantic-color-text-primary; + margin: 0; + } + + // Footer (optional) + &__footer { + padding: $semantic-spacing-component-sm; + border-top: $semantic-border-card-width solid $semantic-color-border-subtle; + margin-top: $semantic-spacing-content-line-tight; + display: flex; + gap: $semantic-spacing-content-line-tight; + justify-content: flex-end; + + &:first-child { + margin-top: 0; + } + + &--start { + justify-content: flex-start; + } + + &--center { + justify-content: center; + } + + &--between { + justify-content: space-between; + } + } + + // Interactive states for focusable content + &:not(.ui-popover--disabled) { + &:focus-within { + box-shadow: $semantic-shadow-popover, 0 0 0 2px $semantic-color-focus; + } + } + + // Responsive adjustments + @media (max-width: $semantic-breakpoint-sm - 1) { + // On mobile, popovers should be more prominent + &:not(.ui-popover--tooltip) { + min-width: auto; + max-width: calc(100vw - #{$semantic-spacing-layout-section-xs}); + + &.ui-popover--sm { + padding: $semantic-spacing-component-sm; + } + } + } + + // Dark mode support + :host-context(.dark-theme) & { + &--default { + background: $semantic-color-surface-primary; + border-color: $semantic-color-border-primary; + } + + &--elevated { + background: $semantic-color-surface-primary; + box-shadow: $semantic-shadow-elevation-3; + } + + &--floating { + background: $semantic-color-surface-secondary; + border-color: $semantic-color-border-subtle; + } + } + + // Animation variants for different entrance effects + &--slide-in { + &.ui-popover--top { + transform: translateY(-10px) scale(0.95); + + &.ui-popover--visible { + transform: translateY(0) scale(1); + } + } + + &.ui-popover--bottom { + transform: translateY(10px) scale(0.95); + + &.ui-popover--visible { + transform: translateY(0) scale(1); + } + } + + &.ui-popover--left { + transform: translateX(-10px) scale(0.95); + + &.ui-popover--visible { + transform: translateX(0) scale(1); + } + } + + &.ui-popover--right { + transform: translateX(10px) scale(0.95); + + &.ui-popover--visible { + transform: translateX(0) scale(1); + } + } + } +} + +// Popover backdrop (if needed for modal-like behavior) +.ui-popover-backdrop { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: transparent; + z-index: calc($semantic-layer-dropdown - 1); + pointer-events: auto; +} \ No newline at end of file diff --git a/src/lib/components/overlays/popover/popover.component.ts b/src/lib/components/overlays/popover/popover.component.ts new file mode 100644 index 0000000..d4431cb --- /dev/null +++ b/src/lib/components/overlays/popover/popover.component.ts @@ -0,0 +1,645 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation, OnInit, OnDestroy, AfterViewInit, inject, ElementRef, Renderer2, ViewChild, HostListener } from '@angular/core'; +import { CommonModule, DOCUMENT } from '@angular/common'; +import { signal, computed } from '@angular/core'; + +type PopoverPosition = 'top' | 'bottom' | 'left' | 'right' | 'top-start' | 'top-end' | 'bottom-start' | 'bottom-end'; +type PopoverSize = 'sm' | 'md' | 'lg'; +type PopoverVariant = 'default' | 'elevated' | 'floating' | 'menu' | 'tooltip'; +type PopoverTrigger = 'click' | 'hover' | 'focus' | 'manual'; + +export interface PopoverConfig { + position?: PopoverPosition; + size?: PopoverSize; + variant?: PopoverVariant; + trigger?: PopoverTrigger; + visible?: boolean; + closable?: boolean; + backdropClosable?: boolean; + escapeClosable?: boolean; + showArrow?: boolean; + showBackdrop?: boolean; + autoPosition?: boolean; + offset?: number; + delay?: number; + hoverDelay?: number; + focusable?: boolean; + preventBodyScroll?: boolean; +} + +export interface PopoverPositionData { + top: number; + left: number; + position: PopoverPosition; +} + +@Component({ + selector: 'ui-popover', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` + @if (isVisible()) { + + @if (showBackdrop) { +
+ } + + +
+ + @if (showArrow && variant !== 'elevated' && variant !== 'floating') { +
+ } + + + @if (title || headerSlot) { +
+ @if (title) { +

{{ title }}

+ } @else { + + } +
+ } + + +
+ +
+ + + @if (footerSlot) { + + } +
+ } + `, + styleUrl: './popover.component.scss' +}) +export class PopoverComponent implements OnInit, OnDestroy, AfterViewInit { + // Dependencies + private elementRef = inject(ElementRef); + private renderer = inject(Renderer2); + private document = inject(DOCUMENT); + + // ViewChild references + @ViewChild('popoverElement', { static: false }) popoverElement?: ElementRef; + + // Inputs + @Input() position: PopoverPosition = 'bottom'; + @Input() size: PopoverSize = 'md'; + @Input() variant: PopoverVariant = 'default'; + @Input() trigger: PopoverTrigger = 'click'; + @Input() title = ''; + @Input() disabled = false; + @Input() closable = true; + @Input() backdropClosable = false; + @Input() escapeClosable = true; + @Input() showArrow = true; + @Input() showBackdrop = false; + @Input() autoPosition = true; + @Input() offset = 8; + @Input() delay = 0; + @Input() hoverDelay = 300; + @Input() focusable = true; + @Input() preventBodyScroll = false; + @Input() animationType: 'scale' | 'slide' = 'scale'; + @Input() footerAlignment: 'start' | 'center' | 'end' | 'between' = 'end'; + + // Reference element (trigger) + @Input() triggerElement?: HTMLElement; + + // State Inputs + @Input() set visible(value: boolean) { + if (value !== this._visible()) { + this._visible.set(value); + if (value) { + this.show(); + } else { + this.hide(); + } + } + } + + get visible(): boolean { + return this._visible(); + } + + // Outputs + @Output() visibleChange = new EventEmitter(); + @Output() shown = new EventEmitter(); + @Output() hidden = new EventEmitter(); + @Output() positionChange = new EventEmitter(); + @Output() backdropClicked = new EventEmitter(); + @Output() escapePressed = new EventEmitter(); + + // Internal state signals + private _visible = signal(false); + private _entering = signal(false); + private _leaving = signal(false); + private _computedPosition = signal(this.position); + private _positionData = signal({ top: 0, left: 0, position: this.position }); + + // Computed signals + readonly isVisible = computed(() => this._visible() || this._entering() || this._leaving()); + readonly isEntering = computed(() => this._entering()); + readonly isLeaving = computed(() => this._leaving()); + readonly computedPosition = computed(() => this._computedPosition()); + readonly positionData = computed(() => this._positionData()); + + // Internal properties + private showTimer?: number; + private hideTimer?: number; + private resizeObserver?: ResizeObserver; + private mutationObserver?: MutationObserver; + private originalBodyOverflow = ''; + private animationDuration = 200; // ms + private positionUpdateCounter = 0; + + // Template helpers + readonly headerSlot = false; // Could be enhanced to detect slotted content + readonly footerSlot = false; // Could be enhanced to detect slotted content + + // Unique IDs for accessibility + readonly titleId = `popover-title-${Math.random().toString(36).substr(2, 9)}`; + readonly contentId = `popover-content-${Math.random().toString(36).substr(2, 9)}`; + + readonly transformOrigin = computed(() => { + const position = this.computedPosition(); + switch (position) { + case 'top': return 'bottom center'; + case 'top-start': return 'bottom left'; + case 'top-end': return 'bottom right'; + case 'bottom': return 'top center'; + case 'bottom-start': return 'top left'; + case 'bottom-end': return 'top right'; + case 'left': return 'center right'; + case 'right': return 'center left'; + default: return 'center'; + } + }); + + ngOnInit(): void { + if (this.visible) { + this.show(); + } + this.setupTriggerListeners(); + } + + ngAfterViewInit(): void { + if (this.visible) { + setTimeout(() => this.updatePosition(), 0); + } + } + + ngOnDestroy(): void { + this.clearTimers(); + this.cleanup(); + this.restoreBodyScroll(); + } + + /** + * Shows the popover with animation and positioning + */ + show(): void { + if (this._visible() || this.disabled) return; + + this.clearTimers(); + + this.showTimer = window.setTimeout(() => { + this.preventBodyScrollIfEnabled(); + this._visible.set(true); + this._entering.set(true); + this.visibleChange.emit(true); + + this.updatePosition(); + this.setupPositionObservers(); + + // Animation timing + setTimeout(() => { + this._entering.set(false); + this.shown.emit(); + }, this.animationDuration); + }, this.delay); + } + + /** + * Hides the popover with animation + */ + hide(): void { + if (!this._visible()) return; + + this.clearTimers(); + this.cleanup(); + + this._leaving.set(true); + + // Animation timing + setTimeout(() => { + this._visible.set(false); + this._leaving.set(false); + this.restoreBodyScroll(); + this.visibleChange.emit(false); + this.hidden.emit(); + }, this.animationDuration); + } + + /** + * Toggles popover visibility + */ + toggle(): void { + if (this._visible()) { + this.hide(); + } else { + this.show(); + } + } + + /** + * Updates the popover position relative to the trigger element + */ + updatePosition(): void { + if (!this.triggerElement || !this.popoverElement) return; + + const triggerRect = this.triggerElement.getBoundingClientRect(); + const popoverEl = this.popoverElement.nativeElement; + const popoverRect = popoverEl.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft; + const scrollTop = window.pageYOffset || document.documentElement.scrollTop; + + let position = this.position; + let top = 0; + let left = 0; + + // Calculate initial position + const positions = this.calculatePositions(triggerRect, popoverRect, scrollLeft, scrollTop); + const initialPos = positions[position]; + top = initialPos.top; + left = initialPos.left; + + // Auto-position if enabled and popover goes outside viewport + if (this.autoPosition) { + const bestPosition = this.findBestPosition(positions, viewportWidth, viewportHeight, popoverRect); + position = bestPosition.position; + top = bestPosition.top; + left = bestPosition.left; + } + + // Ensure popover stays within viewport bounds + const finalPosition = this.constrainToViewport( + { top, left, position }, + popoverRect, + viewportWidth, + viewportHeight + ); + + // Update state + this._computedPosition.set(finalPosition.position); + this._positionData.set(finalPosition); + + // Emit position change if it changed + if (finalPosition.position !== this.position && this.positionUpdateCounter > 0) { + this.positionChange.emit(finalPosition.position); + } + + this.positionUpdateCounter++; + } + + /** + * Calculates all possible positions for the popover + */ + private calculatePositions( + triggerRect: DOMRect, + popoverRect: DOMRect, + scrollLeft: number, + scrollTop: number + ): Record { + const triggerCenterX = triggerRect.left + triggerRect.width / 2; + const triggerCenterY = triggerRect.top + triggerRect.height / 2; + + return { + 'top': { + top: triggerRect.top - popoverRect.height - this.offset + scrollTop, + left: triggerCenterX - popoverRect.width / 2 + scrollLeft, + position: 'top' + }, + 'top-start': { + top: triggerRect.top - popoverRect.height - this.offset + scrollTop, + left: triggerRect.left + scrollLeft, + position: 'top-start' + }, + 'top-end': { + top: triggerRect.top - popoverRect.height - this.offset + scrollTop, + left: triggerRect.right - popoverRect.width + scrollLeft, + position: 'top-end' + }, + 'bottom': { + top: triggerRect.bottom + this.offset + scrollTop, + left: triggerCenterX - popoverRect.width / 2 + scrollLeft, + position: 'bottom' + }, + 'bottom-start': { + top: triggerRect.bottom + this.offset + scrollTop, + left: triggerRect.left + scrollLeft, + position: 'bottom-start' + }, + 'bottom-end': { + top: triggerRect.bottom + this.offset + scrollTop, + left: triggerRect.right - popoverRect.width + scrollLeft, + position: 'bottom-end' + }, + 'left': { + top: triggerCenterY - popoverRect.height / 2 + scrollTop, + left: triggerRect.left - popoverRect.width - this.offset + scrollLeft, + position: 'left' + }, + 'right': { + top: triggerCenterY - popoverRect.height / 2 + scrollTop, + left: triggerRect.right + this.offset + scrollLeft, + position: 'right' + } + }; + } + + /** + * Finds the best position that fits within the viewport + */ + private findBestPosition( + positions: Record, + viewportWidth: number, + viewportHeight: number, + popoverRect: DOMRect + ): PopoverPositionData { + const preferenceOrder: PopoverPosition[] = [ + this.position, + ...Object.keys(positions).filter(p => p !== this.position) as PopoverPosition[] + ]; + + for (const pos of preferenceOrder) { + const posData = positions[pos]; + const fitsHorizontally = posData.left >= 0 && posData.left + popoverRect.width <= viewportWidth; + const fitsVertically = posData.top >= 0 && posData.top + popoverRect.height <= viewportHeight; + + if (fitsHorizontally && fitsVertically) { + return posData; + } + } + + // If no position fits perfectly, return the preferred position + return positions[this.position]; + } + + /** + * Constrains the popover position to stay within viewport + */ + private constrainToViewport( + position: PopoverPositionData, + popoverRect: DOMRect, + viewportWidth: number, + viewportHeight: number + ): PopoverPositionData { + const margin = 8; // Keep some margin from viewport edges + + let { top, left } = position; + + // Constrain horizontally + if (left < margin) { + left = margin; + } else if (left + popoverRect.width > viewportWidth - margin) { + left = viewportWidth - popoverRect.width - margin; + } + + // Constrain vertically + if (top < margin) { + top = margin; + } else if (top + popoverRect.height > viewportHeight - margin) { + top = viewportHeight - popoverRect.height - margin; + } + + return { ...position, top, left }; + } + + /** + * Sets up trigger event listeners based on trigger type + */ + private setupTriggerListeners(): void { + if (!this.triggerElement) return; + + switch (this.trigger) { + case 'click': + this.renderer.listen(this.triggerElement, 'click', (e) => { + e.preventDefault(); + this.toggle(); + }); + break; + + case 'hover': + this.renderer.listen(this.triggerElement, 'mouseenter', () => { + this.clearTimers(); + this.showTimer = window.setTimeout(() => this.show(), this.hoverDelay); + }); + this.renderer.listen(this.triggerElement, 'mouseleave', () => { + this.clearTimers(); + this.hideTimer = window.setTimeout(() => this.hide(), this.hoverDelay); + }); + // Keep popover open when hovering over it + this.renderer.listen(this.elementRef.nativeElement, 'mouseenter', () => { + this.clearTimers(); + }); + this.renderer.listen(this.elementRef.nativeElement, 'mouseleave', () => { + this.hideTimer = window.setTimeout(() => this.hide(), this.hoverDelay); + }); + break; + + case 'focus': + this.renderer.listen(this.triggerElement, 'focus', () => this.show()); + this.renderer.listen(this.triggerElement, 'blur', () => this.hide()); + break; + + case 'manual': + // No automatic triggers, controlled programmatically + break; + } + } + + /** + * Sets up observers for position updates + */ + private setupPositionObservers(): void { + // Resize observer to update position on window resize + this.resizeObserver = new ResizeObserver(() => { + if (this._visible()) { + this.updatePosition(); + } + }); + this.resizeObserver.observe(document.body); + + // Listen for window scroll + window.addEventListener('scroll', this.updatePositionThrottled, { passive: true }); + window.addEventListener('resize', this.updatePositionThrottled, { passive: true }); + } + + private updatePositionThrottled = this.throttle(() => { + if (this._visible()) { + this.updatePosition(); + } + }, 16); // ~60fps + + /** + * Handles backdrop click + */ + handleBackdropClick(event: MouseEvent): void { + if (this.backdropClosable) { + this.hide(); + this.backdropClicked.emit(event); + } + } + + /** + * Handles keyboard events + */ + handleKeydown(event: KeyboardEvent): void { + if (event.key === 'Escape' && this.escapeClosable) { + event.preventDefault(); + this.hide(); + this.escapePressed.emit(event); + } + } + + /** + * Global keyboard listener for ESC key + */ + @HostListener('document:keydown', ['$event']) + onDocumentKeydown(event: KeyboardEvent): void { + if (this._visible() && event.key === 'Escape' && this.escapeClosable) { + event.preventDefault(); + this.hide(); + this.escapePressed.emit(event); + } + } + + /** + * Global click listener to close popover on outside clicks + */ + @HostListener('document:click', ['$event']) + onDocumentClick(event: MouseEvent): void { + if (!this._visible() || !this.closable) return; + + const target = event.target as HTMLElement; + const isInsidePopover = this.elementRef.nativeElement.contains(target); + const isInsideTrigger = this.triggerElement?.contains(target); + + if (!isInsidePopover && !isInsideTrigger && this.trigger === 'click') { + this.hide(); + } + } + + /** + * Prevents body scroll when popover is modal-like + */ + private preventBodyScrollIfEnabled(): void { + if (!this.preventBodyScroll) return; + + this.originalBodyOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + } + + /** + * Restores body scroll + */ + private restoreBodyScroll(): void { + if (!this.preventBodyScroll) return; + + document.body.style.overflow = this.originalBodyOverflow; + } + + /** + * Clears any active timers + */ + private clearTimers(): void { + if (this.showTimer) { + clearTimeout(this.showTimer); + this.showTimer = undefined; + } + if (this.hideTimer) { + clearTimeout(this.hideTimer); + this.hideTimer = undefined; + } + } + + /** + * Cleanup observers and listeners + */ + private cleanup(): void { + this.resizeObserver?.disconnect(); + window.removeEventListener('scroll', this.updatePositionThrottled); + window.removeEventListener('resize', this.updatePositionThrottled); + } + + /** + * Throttle function for performance + */ + private throttle void>(func: T, limit: number): T { + let inThrottle: boolean; + return ((...args: any[]) => { + if (!inThrottle) { + func.apply(this, args); + inThrottle = true; + setTimeout(() => inThrottle = false, limit); + } + }) as T; + } + + /** + * Public API method to apply configuration + */ + configure(config: PopoverConfig): void { + Object.assign(this, config); + } + + /** + * Public API method to set trigger element + */ + setTriggerElement(element: HTMLElement): void { + this.triggerElement = element; + this.setupTriggerListeners(); + } +} \ No newline at end of file diff --git a/src/lib/layouts/index.ts b/src/lib/layouts/index.ts new file mode 100644 index 0000000..ee262f9 --- /dev/null +++ b/src/lib/layouts/index.ts @@ -0,0 +1,5 @@ +// Layout Components Barrel Export +// Components removed for recreation + +// Remaining container components +export * from './loading-state-container.component'; \ No newline at end of file diff --git a/src/lib/layouts/loading-state-container.component.ts b/src/lib/layouts/loading-state-container.component.ts new file mode 100644 index 0000000..4c439da --- /dev/null +++ b/src/lib/layouts/loading-state-container.component.ts @@ -0,0 +1,473 @@ +import { + Component, + Input, + Output, + EventEmitter, + OnInit, + OnDestroy, + ChangeDetectionStrategy, + signal, + computed, + TemplateRef, + ContentChild, + ViewChild, + ElementRef +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Subject, timer } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; + +export type LoadingState = 'idle' | 'loading' | 'success' | 'error'; +export type LoadingVariant = 'spinner' | 'skeleton' | 'pulse' | 'shimmer'; +export type LoadingSize = 'sm' | 'md' | 'lg' | 'xl'; + +export interface ErrorState { + message: string; + code?: string; + details?: any; + retryable?: boolean; +} + +export interface LoadingConfig { + variant: LoadingVariant; + size: LoadingSize; + message?: string; + showProgress?: boolean; + progress?: number; + timeout?: number; +} + +@Component({ + selector: 'ui-loading-state-container', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ + + @if (state === 'loading') { +
+
+ + @switch (config().variant) { + @case ('spinner') { +
+
+ @if (config().message) { +

{{ config().message }}

+ } + @if (config().showProgress && config().progress !== undefined) { +
+
+
+
+ {{ config().progress }}% + } +
+ } + + @case ('skeleton') { + @if (skeletonTemplate) { + + + } @else { +
+ @for (item of skeletonItems(); track $index) { +
+
+ } +
+ } + } + + @case ('pulse') { +
+
+
+
+
+ @if (config().message) { +

{{ config().message }}

+ } + } + + @case ('shimmer') { +
+
+
+
+
+ } + } + + @if (showCancelButton && state === 'loading') { + + } +
+
+ } + + + @if (state === 'error') { +
+
+ + @if (errorTemplate) { + + + } @else { + +

{{ errorTitle }}

+

{{ error()?.message || defaultErrorMessage }}

+ + @if (error()?.details && showErrorDetails) { +
+ Technical Details +
{{ error()?.details | json }}
+
+ } + +
+ @if (error()?.retryable !== false && showRetryButton) { + + } + + @if (showReportButton) { + + } +
+ } +
+
+ } + + +
+ + @if (state === 'success' && successTemplate && showSuccessState) { +
+ +
+ } @else { + + } +
+
+ `, + styles: [` + .loading-state-container { + position: relative; + width: 100%; + height: 100%; + } + + .loading-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + } + + .spinner { + width: 40px; + height: 40px; + border: 3px solid #f3f3f3; + border-top: 3px solid #007bff; + border-radius: 50%; + animation: spin 1s linear infinite; + } + + @keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + } + + .error-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.95); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + } + + .main-content { + width: 100%; + height: 100%; + } + `] +}) +export class LoadingStateContainerComponent implements OnInit, OnDestroy { + @ViewChild('container') containerElement!: ElementRef; + + @ContentChild('skeleton') skeletonTemplate?: TemplateRef; + @ContentChild('error') errorTemplate?: TemplateRef<{ error: ErrorState | null }>; + @ContentChild('success') successTemplate?: TemplateRef; + + // State management + @Input() state: LoadingState = 'idle'; + @Input() error = signal(null); + @Input() config = signal({ + variant: 'spinner', + size: 'md', + showProgress: false + }); + + // Loading behavior + @Input() showContentWhileLoading = false; + @Input() transparentOverlay = false; + @Input() minimumLoadingTime = 500; // ms + @Input() automaticTimeout = 0; // ms, 0 = no timeout + + // Loading cancellation + @Input() showCancelButton = false; + @Input() cancelButtonText = 'Cancel'; + + // Error handling + @Input() errorTitle = 'Something went wrong'; + @Input() defaultErrorMessage = 'An unexpected error occurred. Please try again.'; + @Input() showRetryButton = true; + @Input() retryButtonText = 'Try Again'; + @Input() showReportButton = false; + @Input() reportButtonText = 'Report Issue'; + @Input() showErrorDetails = false; + + // Success state + @Input() showSuccessState = false; + @Input() successStateDuration = 2000; // ms + + // Skeleton loading + @Input() skeletonItems = signal([ + { width: '100%', height: '24px' }, + { width: '80%', height: '20px' }, + { width: '60%', height: '20px' } + ]); + + // Events + @Output() cancel = new EventEmitter(); + @Output() retryClicked = new EventEmitter(); + @Output() reportErrorClicked = new EventEmitter(); + @Output() timeout = new EventEmitter(); + @Output() stateChange = new EventEmitter(); + + private destroy$ = new Subject(); + private loadingStartTime = 0; + private timeoutTimer?: any; + private successTimer?: any; + + // Computed properties + protected containerClasses = computed(() => { + const classes = ['loading-state-container']; + + if (this.transparentOverlay) classes.push('transparent-overlay'); + if (this.showContentWhileLoading) classes.push('show-content-while-loading'); + + return classes.join(' '); + }); + + ngOnInit(): void { + this.setupStateWatching(); + } + + ngOnDestroy(): void { + this.cleanup(); + this.destroy$.next(); + this.destroy$.complete(); + } + + /** + * Sets the loading state with optional configuration + */ + setLoadingState(config?: Partial): void { + if (config) { + this.config.update(current => ({ ...current, ...config })); + } + + this.loadingStartTime = Date.now(); + this.setState('loading'); + + // Set up automatic timeout if configured + if (this.automaticTimeout > 0) { + this.timeoutTimer = setTimeout(() => { + this.handleTimeout(); + }, this.automaticTimeout); + } + } + + /** + * Sets the error state + */ + setErrorState(error: ErrorState): void { + this.error.set(error); + this.setState('error'); + } + + /** + * Sets the success state + */ + setSuccessState(): void { + this.setState('success'); + + if (this.showSuccessState && this.successStateDuration > 0) { + this.successTimer = setTimeout(() => { + this.setState('idle'); + }, this.successStateDuration); + } + } + + /** + * Sets the idle state + */ + setIdleState(): void { + this.setState('idle'); + } + + /** + * Cancels the current loading operation + */ + cancelLoading(): void { + if (this.state !== 'loading') return; + + this.cleanup(); + this.setState('idle'); + this.cancel.emit(); + } + + /** + * Retries the failed operation + */ + handleRetry(): void { + if (this.state !== 'error') return; + + this.error.set(null); + this.retryClicked.emit(); + } + + /** + * Reports the current error + */ + handleReportError(): void { + const currentError = this.error(); + if (currentError) { + this.reportErrorClicked.emit(currentError); + } + } + + /** + * Updates the loading progress (for progress variant) + */ + updateProgress(progress: number): void { + this.config.update(config => ({ + ...config, + progress: Math.max(0, Math.min(100, progress)) + })); + } + + /** + * Updates the loading message + */ + updateMessage(message: string): void { + this.config.update(config => ({ ...config, message })); + } + + private setState(newState: LoadingState): void { + const previousState = this.state; + this.state = newState; + + // Ensure minimum loading time + if (previousState === 'loading' && newState !== 'loading') { + const elapsedTime = Date.now() - this.loadingStartTime; + const remainingTime = this.minimumLoadingTime - elapsedTime; + + if (remainingTime > 0) { + setTimeout(() => { + this.finalizeStateChange(newState); + }, remainingTime); + return; + } + } + + this.finalizeStateChange(newState); + } + + private finalizeStateChange(state: LoadingState): void { + this.state = state; + this.stateChange.emit(state); + this.cleanup(); + } + + private handleTimeout(): void { + this.setState('error'); + this.error.set({ + message: 'The operation timed out. Please try again.', + code: 'TIMEOUT', + retryable: true + }); + this.timeout.emit(); + } + + private setupStateWatching(): void { + // Watch for external state changes + timer(0, 100) + .pipe(takeUntil(this.destroy$)) + .subscribe(() => { + // This allows for reactive updates if state is changed externally + }); + } + + private cleanup(): void { + if (this.timeoutTimer) { + clearTimeout(this.timeoutTimer); + this.timeoutTimer = undefined; + } + + if (this.successTimer) { + clearTimeout(this.successTimer); + this.successTimer = undefined; + } + } +} \ No newline at end of file diff --git a/src/public-api.ts b/src/public-api.ts new file mode 100644 index 0000000..5951d63 --- /dev/null +++ b/src/public-api.ts @@ -0,0 +1,71 @@ +/* + * Public API Surface of ui-essentials + */ + +// ========================================================================== +// BUTTON COMPONENTS +// ========================================================================== +export { ButtonComponent, ButtonVariant, ButtonSize, IconPosition } from './lib/components/buttons/button.component'; +export { TextButtonComponent } from './lib/components/buttons/text-button.component'; +export { GhostButtonComponent } from './lib/components/buttons/ghost-button.component'; +export { FabComponent } from './lib/components/buttons/fab.component'; +export * from './lib/components/buttons/fab-menu'; +export * from './lib/components/buttons/icon-button'; +export * from './lib/components/buttons/split-button'; + +// ========================================================================== +// FORM COMPONENTS +// ========================================================================== +export { TextInputComponent, TextInputSize, TextInputVariant, TextInputType, TextInputState } from './lib/components/forms/input/text-input.component'; +export { TextareaComponent } from './lib/components/forms/input/textarea.component'; +export * from './lib/components/forms/checkbox'; +export * from './lib/components/forms/radio'; +export * from './lib/components/forms/search'; +export * from './lib/components/forms/switch'; +export { SelectComponent } from './lib/components/forms/select/select.component'; +export * from './lib/components/forms/autocomplete'; +export * from './lib/components/forms/date-picker'; +export * from './lib/components/forms/time-picker'; +export * from './lib/components/forms/file-upload'; +export * from './lib/components/forms/form-field'; +export * from './lib/components/forms/range-slider'; +export * from './lib/components/forms/color-picker'; +export * from './lib/components/forms/tag-input'; + +// ========================================================================== +// DATA DISPLAY COMPONENTS +// ========================================================================== +export * from './lib/components/data-display/index'; + +// ========================================================================== +// NAVIGATION COMPONENTS +// ========================================================================== +export * from './lib/components/navigation/index'; + +// ========================================================================== +// MEDIA COMPONENTS +// ========================================================================== +export * from './lib/components/media/index'; + +// ========================================================================== +// FEEDBACK COMPONENTS +// ========================================================================== +export * from './lib/components/feedback/index'; + +// ========================================================================== +// OVERLAY COMPONENTS +// ========================================================================== +export * from './lib/components/overlays/index'; + +// ========================================================================== +// LAYOUT COMPONENTS +// ========================================================================== +export * from './lib/components/layout/index'; + +// Layout Components (avoiding conflicts with navigation tab components) +// Layout components removed for recreation +export { LoadingStateContainerComponent } from './lib/layouts/loading-state-container.component'; +export type { LoadingState, ErrorState } from './lib/layouts/loading-state-container.component'; + +// Split View Component Types +export type { PanelConfig, ResizeEvent, SplitDirection, SplitSize, PanelVariant } from './lib/components/layout/split-view/split-view.component'; diff --git a/tsconfig.lib.json b/tsconfig.lib.json new file mode 100644 index 0000000..2359bf6 --- /dev/null +++ b/tsconfig.lib.json @@ -0,0 +1,15 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "../../out-tsc/lib", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "exclude": [ + "**/*.spec.ts" + ] +} diff --git a/tsconfig.lib.prod.json b/tsconfig.lib.prod.json new file mode 100644 index 0000000..9215caa --- /dev/null +++ b/tsconfig.lib.prod.json @@ -0,0 +1,11 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.lib.json", + "compilerOptions": { + "declarationMap": false + }, + "angularCompilerOptions": { + "compilationMode": "partial" + } +} diff --git a/tsconfig.spec.json b/tsconfig.spec.json new file mode 100644 index 0000000..254686d --- /dev/null +++ b/tsconfig.spec.json @@ -0,0 +1,15 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "../../out-tsc/spec", + "types": [ + "jasmine" + ] + }, + "include": [ + "**/*.spec.ts", + "**/*.d.ts" + ] +}