@bug-on/md3-react 2.0.3 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +33 -0
- package/CHANGELOG.md +55 -0
- package/dist/index.css.d.ts +2 -0
- package/dist/index.d.mts +6127 -0
- package/dist/index.d.ts +6127 -71
- package/dist/index.js +1653 -614
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1566 -547
- package/dist/index.mjs.map +1 -1
- package/dist/material-symbols-cdn.css.d.ts +2 -0
- package/dist/material-symbols-self-hosted.css.d.ts +2 -0
- package/dist/typography.css.d.ts +2 -0
- package/package.json +22 -19
- package/scripts/copy-assets.js +82 -0
- package/src/assets/fonts/GoogleSansFlex-VariableFont.woff2 +0 -0
- package/src/assets/fonts/MaterialSymbolsOutlined-VariableFont_FILL,GRAD,opsz,wght.ttf +0 -0
- package/src/assets/fonts/MaterialSymbolsRounded-VariableFont_FILL,GRAD,opsz,wght.ttf +0 -0
- package/src/assets/fonts/MaterialSymbolsSharp-VariableFont_FILL,GRAD,opsz,wght.ttf +0 -0
- package/src/assets/loading-indicator.svg +19 -0
- package/src/assets/material-symbols-cdn.css +65 -0
- package/src/assets/material-symbols-self-hosted.css +90 -0
- package/src/css.d.ts +20 -0
- package/src/hooks/useClickOutside.ts +37 -0
- package/src/hooks/useMediaQuery.ts +28 -0
- package/src/hooks/useRipple.ts +88 -0
- package/src/index.css +23 -0
- package/src/index.ts +349 -0
- package/src/lib/material-symbols-preconnect.tsx +82 -0
- package/src/lib/theme-utils.ts +180 -0
- package/src/lib/utils.ts +6 -0
- package/src/test/button.test.tsx +59 -0
- package/src/test/icon.test.tsx +91 -0
- package/src/test/loading-indicator.test.tsx +128 -0
- package/src/test/progress-indicator.test.tsx +306 -0
- package/src/test/setup.ts +80 -0
- package/src/test/typography.test.tsx +206 -0
- package/src/types/index.ts +7 -0
- package/src/types/md3.ts +31 -0
- package/src/ui/Text.tsx +60 -0
- package/src/ui/__snapshots__/divider.test.tsx.snap +63 -0
- package/src/ui/app-bar/app-bar-column.tsx +99 -0
- package/src/ui/app-bar/app-bar-item-button.tsx +71 -0
- package/src/ui/app-bar/app-bar-items.test.tsx +89 -0
- package/src/ui/app-bar/app-bar-overflow-indicator.tsx +108 -0
- package/src/ui/app-bar/app-bar-row.tsx +104 -0
- package/src/ui/app-bar/app-bar.test.tsx +87 -0
- package/src/ui/app-bar/app-bar.tokens.ts +223 -0
- package/src/ui/app-bar/app-bar.types.ts +441 -0
- package/src/ui/app-bar/bottom-app-bar.test.tsx +42 -0
- package/src/ui/app-bar/bottom-app-bar.tsx +84 -0
- package/src/ui/app-bar/docked-toolbar.test.tsx +34 -0
- package/src/ui/app-bar/docked-toolbar.tsx +54 -0
- package/src/ui/app-bar/flexible-app-bar.test.tsx +75 -0
- package/src/ui/app-bar/hooks/use-app-bar-scroll.ts +110 -0
- package/src/ui/app-bar/hooks/use-flexible-app-bar.ts +123 -0
- package/{dist/ui/app-bar/index.d.ts → src/ui/app-bar/index.ts} +35 -2
- package/src/ui/app-bar/large-flexible-app-bar.tsx +165 -0
- package/src/ui/app-bar/medium-flexible-app-bar.tsx +167 -0
- package/src/ui/app-bar/search-app-bar.test.tsx +49 -0
- package/src/ui/app-bar/search-app-bar.tsx +176 -0
- package/src/ui/app-bar/search-view.tsx +227 -0
- package/src/ui/app-bar/small-app-bar.test.tsx +48 -0
- package/src/ui/app-bar/small-app-bar.tsx +203 -0
- package/src/ui/badge.test.tsx +345 -0
- package/src/ui/badge.tsx +282 -0
- package/src/ui/button-group.test.tsx +71 -0
- package/src/ui/button-group.tsx +350 -0
- package/src/ui/button.test.tsx +297 -0
- package/src/ui/button.tsx +669 -0
- package/src/ui/card.test.tsx +187 -0
- package/src/ui/card.tsx +259 -0
- package/src/ui/checkbox.test.tsx +423 -0
- package/src/ui/checkbox.tsx +525 -0
- package/src/ui/chip.test.tsx +292 -0
- package/src/ui/chip.tsx +548 -0
- package/src/ui/code-block.tsx +219 -0
- package/src/ui/dialog.test.tsx +300 -0
- package/src/ui/dialog.tsx +384 -0
- package/src/ui/divider.test.tsx +314 -0
- package/src/ui/divider.tsx +412 -0
- package/src/ui/drawer.tsx +240 -0
- package/src/ui/fab-menu.test.tsx +494 -0
- package/src/ui/fab-menu.tsx +739 -0
- package/src/ui/fab.test.tsx +232 -0
- package/src/ui/fab.tsx +505 -0
- package/src/ui/icon-button.test.tsx +515 -0
- package/src/ui/icon-button.tsx +525 -0
- package/src/ui/icon.test.tsx +197 -0
- package/src/ui/icon.tsx +179 -0
- package/src/ui/loading-indicator.test.tsx +73 -0
- package/src/ui/loading-indicator.tsx +312 -0
- package/src/ui/menu/context-menu.tsx +275 -0
- package/src/ui/menu/index.ts +77 -0
- package/src/ui/menu/menu-animations.ts +102 -0
- package/src/ui/menu/menu-context.tsx +99 -0
- package/src/ui/menu/menu-divider.tsx +47 -0
- package/src/ui/menu/menu-group.tsx +200 -0
- package/src/ui/menu/menu-item.tsx +294 -0
- package/src/ui/menu/menu-tokens.ts +208 -0
- package/src/ui/menu/menu-types.ts +313 -0
- package/src/ui/menu/menu.test.tsx +624 -0
- package/src/ui/menu/menu.tsx +289 -0
- package/src/ui/menu/sub-menu.tsx +223 -0
- package/src/ui/menu/vertical-menu.tsx +382 -0
- package/src/ui/navigation-rail.test.tsx +404 -0
- package/src/ui/navigation-rail.tsx +604 -0
- package/src/ui/progress-indicator/circular.tsx +248 -0
- package/src/ui/progress-indicator/hooks.ts +51 -0
- package/{dist/ui/progress-indicator/index.d.ts → src/ui/progress-indicator/index.tsx} +20 -2
- package/src/ui/progress-indicator/linear-flat.tsx +83 -0
- package/src/ui/progress-indicator/linear-wavy.tsx +243 -0
- package/src/ui/progress-indicator/linear.tsx +143 -0
- package/src/ui/progress-indicator/types.ts +158 -0
- package/src/ui/progress-indicator/utils.ts +73 -0
- package/src/ui/radio-button.test.tsx +407 -0
- package/src/ui/radio-button.tsx +551 -0
- package/src/ui/ripple.test.tsx +72 -0
- package/src/ui/ripple.tsx +234 -0
- package/src/ui/scroll-area.test.tsx +58 -0
- package/src/ui/scroll-area.tsx +139 -0
- package/src/ui/search/animated-placeholder.tsx +145 -0
- package/src/ui/search/hooks/use-search-keyboard.test.ts +202 -0
- package/src/ui/search/hooks/use-search-keyboard.ts +104 -0
- package/src/ui/search/hooks/use-search-view-focus.test.ts +96 -0
- package/src/ui/search/hooks/use-search-view-focus.ts +24 -0
- package/src/ui/search/index.ts +44 -0
- package/src/ui/search/search-bar.tsx +220 -0
- package/src/ui/search/search-context.tsx +42 -0
- package/src/ui/search/search-view-docked.tsx +194 -0
- package/src/ui/search/search-view-fullscreen.tsx +247 -0
- package/src/ui/search/search.test.tsx +233 -0
- package/src/ui/search/search.tokens.ts +134 -0
- package/src/ui/search/search.tsx +131 -0
- package/src/ui/search/search.types.ts +154 -0
- package/src/ui/search/trailing-action.tsx +49 -0
- package/src/ui/shared/constants.ts +122 -0
- package/{dist/ui/shared/touch-target.d.ts → src/ui/shared/touch-target.tsx} +13 -1
- package/src/ui/slider/hooks/useSliderMath.ts +195 -0
- package/{dist/ui/slider/index.d.ts → src/ui/slider/index.ts} +12 -1
- package/src/ui/slider/range-slider.tsx +561 -0
- package/src/ui/slider/slider-thumb.tsx +379 -0
- package/src/ui/slider/slider-track.tsx +912 -0
- package/src/ui/slider/slider.tokens.ts +189 -0
- package/src/ui/slider/slider.tsx +259 -0
- package/src/ui/slider/slider.types.ts +288 -0
- package/src/ui/snackbar/index.ts +20 -0
- package/src/ui/snackbar/snackbar.test.tsx +338 -0
- package/src/ui/snackbar/snackbar.tsx +476 -0
- package/{dist/ui/switch/index.d.ts → src/ui/switch/index.ts} +1 -0
- package/src/ui/switch/switch.stories.tsx +309 -0
- package/src/ui/switch/switch.test.tsx +243 -0
- package/src/ui/switch/switch.tokens.ts +89 -0
- package/src/ui/switch/switch.tsx +504 -0
- package/src/ui/switch/switch.types.ts +62 -0
- package/{dist/ui/tabs/index.d.ts → src/ui/tabs/index.ts} +8 -1
- package/src/ui/tabs/tab.tsx +407 -0
- package/src/ui/tabs/tabs-content.tsx +89 -0
- package/src/ui/tabs/tabs-list.tsx +146 -0
- package/src/ui/tabs/tabs.test.tsx +290 -0
- package/src/ui/tabs/tabs.tokens.ts +121 -0
- package/src/ui/tabs/tabs.tsx +229 -0
- package/src/ui/tabs/tabs.types.ts +185 -0
- package/{dist/ui/text-field/index.d.ts → src/ui/text-field/index.ts} +8 -1
- package/src/ui/text-field/subcomponents/active-indicator.tsx +67 -0
- package/src/ui/text-field/subcomponents/floating-label.tsx +161 -0
- package/src/ui/text-field/subcomponents/leading-icon.tsx +46 -0
- package/src/ui/text-field/subcomponents/outline-container.tsx +170 -0
- package/src/ui/text-field/subcomponents/prefix-suffix.tsx +59 -0
- package/src/ui/text-field/subcomponents/supporting-text.tsx +145 -0
- package/src/ui/text-field/subcomponents/trailing-icon.tsx +199 -0
- package/src/ui/text-field/text-field.test.tsx +454 -0
- package/src/ui/text-field/text-field.tokens.ts +104 -0
- package/src/ui/text-field/text-field.tsx +548 -0
- package/src/ui/text-field/text-field.types.ts +180 -0
- package/src/ui/theme-provider/index.tsx +190 -0
- package/src/ui/toc.test.tsx +108 -0
- package/src/ui/toc.tsx +172 -0
- package/src/ui/tooltip/plain-tooltip.tsx +63 -0
- package/src/ui/tooltip/rich-tooltip.tsx +94 -0
- package/src/ui/tooltip/tooltip-box.tsx +266 -0
- package/src/ui/tooltip/tooltip-caret-shape.tsx +68 -0
- package/src/ui/tooltip/tooltip.tokens.ts +26 -0
- package/src/ui/tooltip/tooltip.types.ts +70 -0
- package/src/ui/tooltip/use-tooltip-position.ts +208 -0
- package/src/ui/tooltip/use-tooltip-state.ts +41 -0
- package/src/ui/typography/__tests__/typography.test.tsx +170 -0
- package/{dist/ui/typography/index.d.ts → src/ui/typography/index.ts} +21 -3
- package/src/ui/typography/type-scale-tokens.ts +205 -0
- package/src/ui/typography/typography-key-tokens.ts +43 -0
- package/src/ui/typography/typography-tokens.ts +360 -0
- package/src/ui/typography/typography.css +22 -0
- package/src/ui/typography/typography.tsx +559 -0
- package/test-render.tsx +4 -0
- package/test-shadow.html +26 -0
- package/test_output.txt +164 -0
- package/test_output_v2.txt +5 -0
- package/tsconfig.build.json +10 -0
- package/tsconfig.json +18 -0
- package/tsup.config.ts +20 -0
- package/vitest.config.ts +11 -0
- package/dist/hooks/useClickOutside.d.ts +0 -8
- package/dist/hooks/useMediaQuery.d.ts +0 -11
- package/dist/hooks/useRipple.d.ts +0 -26
- package/dist/lib/material-symbols-preconnect.d.ts +0 -42
- package/dist/lib/theme-utils.d.ts +0 -63
- package/dist/lib/utils.d.ts +0 -2
- package/dist/types/index.d.ts +0 -1
- package/dist/types/md3.d.ts +0 -14
- package/dist/ui/app-bar/app-bar-column.d.ts +0 -28
- package/dist/ui/app-bar/app-bar-item-button.d.ts +0 -16
- package/dist/ui/app-bar/app-bar-overflow-indicator.d.ts +0 -18
- package/dist/ui/app-bar/app-bar-row.d.ts +0 -36
- package/dist/ui/app-bar/app-bar.tokens.d.ts +0 -184
- package/dist/ui/app-bar/app-bar.types.d.ts +0 -392
- package/dist/ui/app-bar/bottom-app-bar.d.ts +0 -31
- package/dist/ui/app-bar/docked-toolbar.d.ts +0 -25
- package/dist/ui/app-bar/hooks/use-app-bar-scroll.d.ts +0 -42
- package/dist/ui/app-bar/hooks/use-flexible-app-bar.d.ts +0 -37
- package/dist/ui/app-bar/large-flexible-app-bar.d.ts +0 -26
- package/dist/ui/app-bar/medium-flexible-app-bar.d.ts +0 -28
- package/dist/ui/app-bar/search-app-bar.d.ts +0 -43
- package/dist/ui/app-bar/search-view.d.ts +0 -54
- package/dist/ui/app-bar/small-app-bar.d.ts +0 -37
- package/dist/ui/badge.d.ts +0 -125
- package/dist/ui/button-group.d.ts +0 -59
- package/dist/ui/button.d.ts +0 -148
- package/dist/ui/card.d.ts +0 -62
- package/dist/ui/checkbox.d.ts +0 -82
- package/dist/ui/chip.d.ts +0 -110
- package/dist/ui/code-block.d.ts +0 -14
- package/dist/ui/dialog.d.ts +0 -111
- package/dist/ui/divider.d.ts +0 -164
- package/dist/ui/drawer.d.ts +0 -39
- package/dist/ui/dropdown.d.ts +0 -29
- package/dist/ui/fab-menu.d.ts +0 -204
- package/dist/ui/fab.d.ts +0 -162
- package/dist/ui/icon-button.d.ts +0 -131
- package/dist/ui/icon.d.ts +0 -88
- package/dist/ui/loading-indicator.d.ts +0 -42
- package/dist/ui/navigation-rail.d.ts +0 -29
- package/dist/ui/progress-indicator/circular.d.ts +0 -3
- package/dist/ui/progress-indicator/hooks.d.ts +0 -3
- package/dist/ui/progress-indicator/linear-flat.d.ts +0 -10
- package/dist/ui/progress-indicator/linear-wavy.d.ts +0 -18
- package/dist/ui/progress-indicator/linear.d.ts +0 -3
- package/dist/ui/progress-indicator/types.d.ts +0 -151
- package/dist/ui/progress-indicator/utils.d.ts +0 -3
- package/dist/ui/radio-button.d.ts +0 -106
- package/dist/ui/ripple.d.ts +0 -126
- package/dist/ui/scroll-area.d.ts +0 -27
- package/dist/ui/search/animated-placeholder.d.ts +0 -54
- package/dist/ui/search/hooks/use-search-keyboard.d.ts +0 -32
- package/dist/ui/search/hooks/use-search-view-focus.d.ts +0 -6
- package/dist/ui/search/index.d.ts +0 -27
- package/dist/ui/search/search-bar.d.ts +0 -32
- package/dist/ui/search/search-context.d.ts +0 -24
- package/dist/ui/search/search-view-docked.d.ts +0 -25
- package/dist/ui/search/search-view-fullscreen.d.ts +0 -36
- package/dist/ui/search/search.d.ts +0 -50
- package/dist/ui/search/search.tokens.d.ts +0 -112
- package/dist/ui/search/search.types.d.ts +0 -131
- package/dist/ui/search/trailing-action.d.ts +0 -9
- package/dist/ui/shared/constants.d.ts +0 -86
- package/dist/ui/slider/hooks/useSliderMath.d.ts +0 -101
- package/dist/ui/slider/range-slider.d.ts +0 -47
- package/dist/ui/slider/slider-thumb.d.ts +0 -33
- package/dist/ui/slider/slider-track.d.ts +0 -25
- package/dist/ui/slider/slider.d.ts +0 -60
- package/dist/ui/slider/slider.tokens.d.ts +0 -151
- package/dist/ui/slider/slider.types.d.ts +0 -259
- package/dist/ui/snackbar/index.d.ts +0 -6
- package/dist/ui/snackbar/snackbar.d.ts +0 -197
- package/dist/ui/switch/switch.d.ts +0 -30
- package/dist/ui/switch/switch.stories.d.ts +0 -48
- package/dist/ui/switch/switch.tokens.d.ts +0 -67
- package/dist/ui/switch/switch.types.d.ts +0 -59
- package/dist/ui/tabs/tab.d.ts +0 -43
- package/dist/ui/tabs/tabs-content.d.ts +0 -36
- package/dist/ui/tabs/tabs-list.d.ts +0 -40
- package/dist/ui/tabs/tabs.d.ts +0 -60
- package/dist/ui/tabs/tabs.tokens.d.ts +0 -94
- package/dist/ui/tabs/tabs.types.d.ts +0 -172
- package/dist/ui/text-field/subcomponents/active-indicator.d.ts +0 -24
- package/dist/ui/text-field/subcomponents/floating-label.d.ts +0 -43
- package/dist/ui/text-field/subcomponents/leading-icon.d.ts +0 -23
- package/dist/ui/text-field/subcomponents/outline-container.d.ts +0 -42
- package/dist/ui/text-field/subcomponents/prefix-suffix.d.ts +0 -24
- package/dist/ui/text-field/subcomponents/supporting-text.d.ts +0 -37
- package/dist/ui/text-field/subcomponents/trailing-icon.d.ts +0 -41
- package/dist/ui/text-field/text-field.d.ts +0 -49
- package/dist/ui/text-field/text-field.tokens.d.ts +0 -76
- package/dist/ui/text-field/text-field.types.d.ts +0 -126
- package/dist/ui/theme-provider/index.d.ts +0 -48
- package/dist/ui/toc.d.ts +0 -80
- package/dist/ui/tooltip/plain-tooltip.d.ts +0 -2
- package/dist/ui/tooltip/rich-tooltip.d.ts +0 -2
- package/dist/ui/tooltip/tooltip-box.d.ts +0 -2
- package/dist/ui/tooltip/tooltip-caret-shape.d.ts +0 -9
- package/dist/ui/tooltip/tooltip.tokens.d.ts +0 -26
- package/dist/ui/tooltip/tooltip.types.d.ts +0 -56
- package/dist/ui/tooltip/use-tooltip-position.d.ts +0 -8
- package/dist/ui/tooltip/use-tooltip-state.d.ts +0 -2
- package/dist/ui/typography/type-scale-tokens.d.ts +0 -162
- package/dist/ui/typography/typography-key-tokens.d.ts +0 -40
- package/dist/ui/typography/typography-tokens.d.ts +0 -220
- package/dist/ui/typography/typography.d.ts +0 -265
- /package/{dist/hooks/index.d.ts → src/hooks/index.ts} +0 -0
- /package/{dist/ui/tooltip/index.d.ts → src/ui/tooltip/index.ts} +0 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cn } from "../lib/utils";
|
|
3
|
+
import type { ButtonProps } from "./button";
|
|
4
|
+
import { Icon } from "./icon";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Thuộc tính truyền vào cho thành phần nhóm nút (Button Group).
|
|
8
|
+
*/
|
|
9
|
+
export interface ButtonGroupProps
|
|
10
|
+
extends React.FieldsetHTMLAttributes<HTMLFieldSetElement> {
|
|
11
|
+
/**
|
|
12
|
+
* Cấu trúc hiển thị của nhóm nút:
|
|
13
|
+
* - `standard`: Các nút cách xa và có khoảng cách độc lập với nhau (gap).
|
|
14
|
+
* - `connected`: Các nút nối liền khung viền với nhau để tạo thành dạng Segmented Button.
|
|
15
|
+
* @default "standard"
|
|
16
|
+
*/
|
|
17
|
+
variant?: "standard" | "connected";
|
|
18
|
+
/**
|
|
19
|
+
* Hướng sắp xếp của các nút trong nhóm.
|
|
20
|
+
* @default "horizontal"
|
|
21
|
+
*/
|
|
22
|
+
orientation?: "horizontal" | "vertical";
|
|
23
|
+
/**
|
|
24
|
+
* Đặt thành `true` nếu bạn muốn nhóm hiển thị dạng `standard` giãn đều lấp đầy toàn bộ khu vực chứa (container).
|
|
25
|
+
* @default false
|
|
26
|
+
*/
|
|
27
|
+
fullWidth?: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Áp dụng thống nhất chung một kích thước (`size`) cho tất cả các con trong nhóm (ghi đè kích thước lẻ từng nút).
|
|
30
|
+
*/
|
|
31
|
+
size?: "xs" | "sm" | "md" | "lg" | "xl";
|
|
32
|
+
/**
|
|
33
|
+
* Bật/tắt hiệu ứng thu phóng độ rộng / khoảng đệm (Morphing Width) khi nhấn vào các nút (áp dụng cho nhóm `standard`).
|
|
34
|
+
* @default true
|
|
35
|
+
*/
|
|
36
|
+
morphingWidth?: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Tự động hiển thị biểu tượng (icon) Check khi một nút trạng thái nằm trong nhóm được chỉ định là `selected={true}`.
|
|
39
|
+
* @default false
|
|
40
|
+
*/
|
|
41
|
+
showCheck?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Bảng ánh xạ padding mặc định tương ứng với từng size trong button.tsx
|
|
45
|
+
const SIZE_PADDING_MAP: Record<string, string> = {
|
|
46
|
+
xs: "0.75rem",
|
|
47
|
+
sm: "1rem",
|
|
48
|
+
md: "1.5rem",
|
|
49
|
+
lg: "3rem",
|
|
50
|
+
xl: "3rem",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Bảng ánh xạ Pill Radius (h/2) để animation mượt mà
|
|
54
|
+
const PILL_RADIUS_MAP: Record<string, number> = {
|
|
55
|
+
xs: 16,
|
|
56
|
+
sm: 20,
|
|
57
|
+
md: 28,
|
|
58
|
+
lg: 48,
|
|
59
|
+
xl: 68,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// ĐỒNG BỘ SPECS MD3: Bảng ánh xạ góc bo bên trong (Inner Radius) của Connected Group
|
|
63
|
+
const INNER_RADIUS_MAP: Record<string, number> = {
|
|
64
|
+
xs: 4,
|
|
65
|
+
sm: 8,
|
|
66
|
+
md: 8,
|
|
67
|
+
lg: 16,
|
|
68
|
+
xl: 20,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// Bảng ánh xạ Pressed Radius để morphing shape từ pill sang square
|
|
72
|
+
const PRESSED_RADIUS_MAP: Record<string, number> = {
|
|
73
|
+
xs: 8,
|
|
74
|
+
sm: 10,
|
|
75
|
+
md: 16,
|
|
76
|
+
lg: 28,
|
|
77
|
+
xl: 40,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const ButtonGroupComponent = React.forwardRef<
|
|
81
|
+
HTMLFieldSetElement,
|
|
82
|
+
ButtonGroupProps
|
|
83
|
+
>(
|
|
84
|
+
(
|
|
85
|
+
{
|
|
86
|
+
className,
|
|
87
|
+
variant = "standard",
|
|
88
|
+
orientation = "horizontal",
|
|
89
|
+
fullWidth = false,
|
|
90
|
+
size,
|
|
91
|
+
morphingWidth = true,
|
|
92
|
+
showCheck = false,
|
|
93
|
+
children,
|
|
94
|
+
...props
|
|
95
|
+
},
|
|
96
|
+
ref,
|
|
97
|
+
) => {
|
|
98
|
+
const [pressedIndex, setPressedIndex] = React.useState<number | null>(null);
|
|
99
|
+
|
|
100
|
+
const childrenArray = React.useMemo(
|
|
101
|
+
() => React.Children.toArray(children).filter(React.isValidElement),
|
|
102
|
+
[children],
|
|
103
|
+
);
|
|
104
|
+
const count = childrenArray.length;
|
|
105
|
+
|
|
106
|
+
const handlePointerLeaveAndUp = React.useCallback(() => {
|
|
107
|
+
setPressedIndex(null);
|
|
108
|
+
}, []);
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<fieldset
|
|
112
|
+
ref={ref}
|
|
113
|
+
className={cn(
|
|
114
|
+
"inline-flex p-0 m-0 border-none max-w-full overflow-x-auto overflow-y-hidden [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
|
115
|
+
orientation === "vertical" ? "flex-col items-stretch" : "flex-row",
|
|
116
|
+
// SPECS: Standard = 8px (gap-2), Connected = chính xác 2px
|
|
117
|
+
variant === "standard" ? "gap-2" : "gap-0.5",
|
|
118
|
+
fullWidth && (orientation === "horizontal" ? "w-full" : "h-full"),
|
|
119
|
+
className,
|
|
120
|
+
)}
|
|
121
|
+
onPointerLeave={handlePointerLeaveAndUp}
|
|
122
|
+
onPointerUp={handlePointerLeaveAndUp}
|
|
123
|
+
{...props}
|
|
124
|
+
>
|
|
125
|
+
{childrenArray.map((child, index) => {
|
|
126
|
+
const isFirst = index === 0;
|
|
127
|
+
const isLast = index === count - 1;
|
|
128
|
+
|
|
129
|
+
const element = child as React.ReactElement<ButtonProps>;
|
|
130
|
+
|
|
131
|
+
// Lấy kích thước hiện tại để tính toán Specs
|
|
132
|
+
// Ưu tiên prop size của Group, nếu không có thì lấy của nút con, mặc định là "sm"
|
|
133
|
+
const itemSize = size || element.props.size || "sm";
|
|
134
|
+
const basePx = SIZE_PADDING_MAP[itemSize] || "1rem";
|
|
135
|
+
const innerRadius = INNER_RADIUS_MAP[itemSize] || 8;
|
|
136
|
+
|
|
137
|
+
// Inject style động
|
|
138
|
+
const dynamicStyle: React.CSSProperties = {
|
|
139
|
+
...element.props.style,
|
|
140
|
+
"--m3-inner-rad": `${innerRadius}px`,
|
|
141
|
+
} as React.CSSProperties;
|
|
142
|
+
|
|
143
|
+
let motionPropsToOverride: Partial<ButtonProps> = {};
|
|
144
|
+
let explicitIcon = element.props.icon;
|
|
145
|
+
const isSelected = element.props.selected === true;
|
|
146
|
+
|
|
147
|
+
if (showCheck && isSelected && !explicitIcon) {
|
|
148
|
+
explicitIcon = <Icon name="check" aria-hidden="true" />;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 1. STANDARD GROUP: Xử lý hiệu ứng Morphing Width khi press
|
|
152
|
+
if (
|
|
153
|
+
variant === "standard" &&
|
|
154
|
+
orientation === "horizontal" &&
|
|
155
|
+
morphingWidth
|
|
156
|
+
) {
|
|
157
|
+
const isPressed = pressedIndex === index;
|
|
158
|
+
const isNeighbor =
|
|
159
|
+
pressedIndex !== null && Math.abs(pressedIndex - index) === 1;
|
|
160
|
+
|
|
161
|
+
if (fullWidth) {
|
|
162
|
+
if (isPressed) {
|
|
163
|
+
dynamicStyle.flex = "1.15";
|
|
164
|
+
} else if (pressedIndex !== null) {
|
|
165
|
+
dynamicStyle.flex = isNeighbor ? "0.925" : "1";
|
|
166
|
+
} else {
|
|
167
|
+
dynamicStyle.flex = "1";
|
|
168
|
+
}
|
|
169
|
+
dynamicStyle.transition = "flex 0.3s cubic-bezier(0.2, 0, 0, 1)";
|
|
170
|
+
} else {
|
|
171
|
+
if (isPressed) {
|
|
172
|
+
dynamicStyle.paddingInline = `calc(${basePx} + 0.6rem)`;
|
|
173
|
+
} else if (isNeighbor) {
|
|
174
|
+
dynamicStyle.paddingInline = `calc(${basePx} - 0.3rem)`;
|
|
175
|
+
} else {
|
|
176
|
+
dynamicStyle.paddingInline = basePx;
|
|
177
|
+
}
|
|
178
|
+
dynamicStyle.transition =
|
|
179
|
+
"padding 0.2s cubic-bezier(0.2, 0, 0, 1)";
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const pressedRadius = PRESSED_RADIUS_MAP[itemSize] || 10;
|
|
183
|
+
|
|
184
|
+
motionPropsToOverride = {
|
|
185
|
+
whileTap: {
|
|
186
|
+
scale: 0.98,
|
|
187
|
+
borderRadius: pressedRadius,
|
|
188
|
+
},
|
|
189
|
+
transition: { type: "spring", stiffness: 400, damping: 25 },
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// 2. CONNECTED GROUP: Xử lý trạng thái Selected và Border Radius
|
|
194
|
+
let connectedClasses = "";
|
|
195
|
+
if (variant === "connected") {
|
|
196
|
+
// Ưu tiên z-index khi selected/hover để đè viền lên nhau
|
|
197
|
+
if (isSelected) {
|
|
198
|
+
connectedClasses = "z-20";
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const r = PILL_RADIUS_MAP[itemSize] || 20;
|
|
202
|
+
const i = innerRadius;
|
|
203
|
+
let tl = r,
|
|
204
|
+
tr = r,
|
|
205
|
+
br = r,
|
|
206
|
+
bl = r;
|
|
207
|
+
|
|
208
|
+
if (!isSelected) {
|
|
209
|
+
if (orientation === "horizontal") {
|
|
210
|
+
if (isFirst && !isLast) {
|
|
211
|
+
tl = r;
|
|
212
|
+
tr = i;
|
|
213
|
+
br = i;
|
|
214
|
+
bl = r;
|
|
215
|
+
} else if (!isFirst && isLast) {
|
|
216
|
+
tl = i;
|
|
217
|
+
tr = r;
|
|
218
|
+
br = r;
|
|
219
|
+
bl = i;
|
|
220
|
+
} else if (!isFirst && !isLast) {
|
|
221
|
+
tl = i;
|
|
222
|
+
tr = i;
|
|
223
|
+
br = i;
|
|
224
|
+
bl = i;
|
|
225
|
+
}
|
|
226
|
+
} else {
|
|
227
|
+
// vertical
|
|
228
|
+
if (isFirst && !isLast) {
|
|
229
|
+
tl = r;
|
|
230
|
+
tr = r;
|
|
231
|
+
br = i;
|
|
232
|
+
bl = i;
|
|
233
|
+
} else if (!isFirst && isLast) {
|
|
234
|
+
tl = i;
|
|
235
|
+
tr = i;
|
|
236
|
+
br = r;
|
|
237
|
+
bl = r;
|
|
238
|
+
} else if (!isFirst && !isLast) {
|
|
239
|
+
tl = i;
|
|
240
|
+
tr = i;
|
|
241
|
+
br = i;
|
|
242
|
+
bl = i;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Set inline style (fallback/SSR)
|
|
248
|
+
dynamicStyle.borderTopLeftRadius = `${tl}px`;
|
|
249
|
+
dynamicStyle.borderTopRightRadius = `${tr}px`;
|
|
250
|
+
dynamicStyle.borderBottomRightRadius = `${br}px`;
|
|
251
|
+
dynamicStyle.borderBottomLeftRadius = `${bl}px`;
|
|
252
|
+
|
|
253
|
+
// Transition CSS thuần
|
|
254
|
+
dynamicStyle.transition =
|
|
255
|
+
"border-top-left-radius 0.25s cubic-bezier(0.2, 0, 0, 1), border-top-right-radius 0.25s cubic-bezier(0.2, 0, 0, 1), border-bottom-right-radius 0.25s cubic-bezier(0.2, 0, 0, 1), border-bottom-left-radius 0.25s cubic-bezier(0.2, 0, 0, 1), padding 0.2s cubic-bezier(0.2, 0, 0, 1), flex 0.2s cubic-bezier(0.2, 0, 0, 1)";
|
|
256
|
+
|
|
257
|
+
// VÔ HIỆU HÓA Framer Motion borderRadius trên children Button bằng cách override explicit corners
|
|
258
|
+
const animateProps =
|
|
259
|
+
typeof element.props.animate === "object" &&
|
|
260
|
+
!Array.isArray(element.props.animate)
|
|
261
|
+
? element.props.animate
|
|
262
|
+
: {};
|
|
263
|
+
const whileTapProps =
|
|
264
|
+
typeof element.props.whileTap === "object" &&
|
|
265
|
+
!Array.isArray(element.props.whileTap)
|
|
266
|
+
? element.props.whileTap
|
|
267
|
+
: {};
|
|
268
|
+
|
|
269
|
+
// Tính toán corners khi pressed: tất cả corners đều thu về innerRadius hoặc pressedRadius
|
|
270
|
+
// Theo MD3, khi nhấn Segmented Button, nó nên morph về dạng vuông hơn.
|
|
271
|
+
const pr = INNER_RADIUS_MAP[itemSize] || 8;
|
|
272
|
+
|
|
273
|
+
motionPropsToOverride.animate = {
|
|
274
|
+
...animateProps,
|
|
275
|
+
borderRadius: undefined,
|
|
276
|
+
borderTopLeftRadius: tl,
|
|
277
|
+
borderTopRightRadius: tr,
|
|
278
|
+
borderBottomRightRadius: br,
|
|
279
|
+
borderBottomLeftRadius: bl,
|
|
280
|
+
} as ButtonProps["animate"];
|
|
281
|
+
motionPropsToOverride.whileTap = {
|
|
282
|
+
...whileTapProps,
|
|
283
|
+
borderRadius: undefined,
|
|
284
|
+
borderTopLeftRadius: pr,
|
|
285
|
+
borderTopRightRadius: pr,
|
|
286
|
+
borderBottomRightRadius: pr,
|
|
287
|
+
borderBottomLeftRadius: pr,
|
|
288
|
+
} as ButtonProps["whileTap"];
|
|
289
|
+
|
|
290
|
+
motionPropsToOverride.transition = {
|
|
291
|
+
type: "spring",
|
|
292
|
+
bounce: 0,
|
|
293
|
+
duration: 0.2,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return React.cloneElement(element, {
|
|
298
|
+
key: element.key ?? index,
|
|
299
|
+
tabIndex: isFirst ? 0 : -1,
|
|
300
|
+
size: size || element.props.size, // Push size group down to children
|
|
301
|
+
icon: explicitIcon, // Override icon if showCheck injected it
|
|
302
|
+
// Automatically apply unselected/selected color semantics for connected group
|
|
303
|
+
...(variant === "connected" && {
|
|
304
|
+
colorStyle: element.props.colorStyle || "tonal",
|
|
305
|
+
selectedColorStyle: element.props.selectedColorStyle || "filled",
|
|
306
|
+
}),
|
|
307
|
+
className: cn(
|
|
308
|
+
element.props.className,
|
|
309
|
+
connectedClasses,
|
|
310
|
+
"focus-visible:z-10 hover:z-10 relative",
|
|
311
|
+
),
|
|
312
|
+
style: dynamicStyle,
|
|
313
|
+
onPointerDown: (e: React.PointerEvent<HTMLButtonElement>) => {
|
|
314
|
+
setPressedIndex(index);
|
|
315
|
+
if (element.props.onPointerDown) {
|
|
316
|
+
element.props.onPointerDown(e);
|
|
317
|
+
}
|
|
318
|
+
},
|
|
319
|
+
...motionPropsToOverride,
|
|
320
|
+
});
|
|
321
|
+
})}
|
|
322
|
+
</fieldset>
|
|
323
|
+
);
|
|
324
|
+
},
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
ButtonGroupComponent.displayName = "ButtonGroup";
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Component Nhóm Nút (Button Group) được sử dụng để gom nhóm nhiều nút có công năng tương tự lại với nhau.
|
|
331
|
+
* Hỗ trợ tạo các bộ nút độc lập (Standard) hoặc khối liên kết liền mạch (Connected/Segmented Buttons).
|
|
332
|
+
* Kế thừa cơ chế chuyển động kết hợp liền mạch của MD3 Expressive.
|
|
333
|
+
*
|
|
334
|
+
* @example
|
|
335
|
+
* ```tsx
|
|
336
|
+
* // Nhóm nút tiêu chuẩn rời rạc (Standard)
|
|
337
|
+
* <ButtonGroup variant="standard">
|
|
338
|
+
* <Button>Lựa chọn 1</Button>
|
|
339
|
+
* <Button>Lựa chọn 2</Button>
|
|
340
|
+
* </ButtonGroup>
|
|
341
|
+
*
|
|
342
|
+
* // Nhóm nút liền khối (Connected Segmented Button)
|
|
343
|
+
* <ButtonGroup variant="connected" showCheck fullWidth>
|
|
344
|
+
* <Button variant="toggle" selected={view === "day"} onClick={() => setView("day")}>Ngày</Button>
|
|
345
|
+
* <Button variant="toggle" selected={view === "week"} onClick={() => setView("week")}>Tuần</Button>
|
|
346
|
+
* <Button variant="toggle" selected={view === "month"} onClick={() => setView("month")}>Tháng</Button>
|
|
347
|
+
* </ButtonGroup>
|
|
348
|
+
* ```
|
|
349
|
+
*/
|
|
350
|
+
export const ButtonGroup = React.memo(ButtonGroupComponent);
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { Button } from "./button";
|
|
6
|
+
|
|
7
|
+
// ── Inline SVG test icon (no external deps) ───────────────────────────────────
|
|
8
|
+
const TestIcon = () => (
|
|
9
|
+
<svg data-testid="test-icon" aria-hidden="true" viewBox="0 0 24 24" />
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
describe("Button Component", () => {
|
|
13
|
+
// ── Existing tests (preserved) ─────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
it("renders correctly with default props", () => {
|
|
16
|
+
render(<Button>Click me</Button>);
|
|
17
|
+
const button = screen.getByRole("button");
|
|
18
|
+
expect(button).toBeInTheDocument();
|
|
19
|
+
expect(button).toHaveTextContent("Click me");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("handles onClick event", () => {
|
|
23
|
+
const handleClick = vi.fn();
|
|
24
|
+
render(<Button onClick={handleClick}>Click me</Button>);
|
|
25
|
+
fireEvent.click(screen.getByRole("button"));
|
|
26
|
+
expect(handleClick).toHaveBeenCalledTimes(1);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("respects colorStyle variant classes", () => {
|
|
30
|
+
render(<Button colorStyle="outlined">Outlined</Button>);
|
|
31
|
+
const button = screen.getByRole("button");
|
|
32
|
+
expect(button.className).toContain("bg-transparent");
|
|
33
|
+
expect(button.className).toContain("border");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("applies toggle 'selected' behavior", () => {
|
|
37
|
+
const { rerender } = render(
|
|
38
|
+
<Button variant="toggle" selected={false}>
|
|
39
|
+
Toggle
|
|
40
|
+
</Button>,
|
|
41
|
+
);
|
|
42
|
+
const button = screen.getByRole("button");
|
|
43
|
+
expect(button).toHaveAttribute("aria-pressed", "false");
|
|
44
|
+
|
|
45
|
+
rerender(
|
|
46
|
+
<Button variant="toggle" selected={true}>
|
|
47
|
+
Toggle
|
|
48
|
+
</Button>,
|
|
49
|
+
);
|
|
50
|
+
expect(button).toHaveAttribute("aria-pressed", "true");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("shows ripple effect wrapper layer (visual verification representation)", () => {
|
|
54
|
+
render(<Button>Ripple Test</Button>);
|
|
55
|
+
const button = screen.getByRole("button");
|
|
56
|
+
fireEvent.pointerDown(button, { clientX: 10, clientY: 10 });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// ── New: Disabled state ────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
describe("disabled state", () => {
|
|
62
|
+
it("has disabled attribute when disabled prop is passed", () => {
|
|
63
|
+
render(<Button disabled>Disabled</Button>);
|
|
64
|
+
expect(screen.getByRole("button")).toBeDisabled();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("does not trigger onClick when disabled", () => {
|
|
68
|
+
const handleClick = vi.fn();
|
|
69
|
+
render(
|
|
70
|
+
<Button disabled onClick={handleClick}>
|
|
71
|
+
Disabled
|
|
72
|
+
</Button>,
|
|
73
|
+
);
|
|
74
|
+
fireEvent.click(screen.getByRole("button"));
|
|
75
|
+
expect(handleClick).not.toHaveBeenCalled();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// ── New: Keyboard navigation ───────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
describe("keyboard navigation", () => {
|
|
82
|
+
it("triggers onClick when Enter is pressed", () => {
|
|
83
|
+
const handleClick = vi.fn();
|
|
84
|
+
render(<Button onClick={handleClick}>Enter Key</Button>);
|
|
85
|
+
const button = screen.getByRole("button");
|
|
86
|
+
button.focus();
|
|
87
|
+
fireEvent.keyDown(button, { key: "Enter" });
|
|
88
|
+
expect(handleClick).toHaveBeenCalledTimes(1);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("triggers onClick when Space is pressed", () => {
|
|
92
|
+
const handleClick = vi.fn();
|
|
93
|
+
render(<Button onClick={handleClick}>Space Key</Button>);
|
|
94
|
+
const button = screen.getByRole("button");
|
|
95
|
+
button.focus();
|
|
96
|
+
fireEvent.keyDown(button, { key: " " });
|
|
97
|
+
expect(handleClick).toHaveBeenCalledTimes(1);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("does not trigger onClick for unrelated keys", () => {
|
|
101
|
+
const handleClick = vi.fn();
|
|
102
|
+
render(<Button onClick={handleClick}>Tab Key</Button>);
|
|
103
|
+
fireEvent.keyDown(screen.getByRole("button"), { key: "Tab" });
|
|
104
|
+
expect(handleClick).not.toHaveBeenCalled();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// ── New: Icon position ─────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
describe("icon position", () => {
|
|
111
|
+
it("renders leading icon before label in DOM order", () => {
|
|
112
|
+
render(
|
|
113
|
+
<Button icon={<TestIcon />} iconPosition="leading">
|
|
114
|
+
Label
|
|
115
|
+
</Button>,
|
|
116
|
+
);
|
|
117
|
+
const button = screen.getByRole("button");
|
|
118
|
+
const spans = Array.from(button.querySelectorAll("span"));
|
|
119
|
+
const iconIndex = spans.findIndex((s) =>
|
|
120
|
+
s.querySelector("[data-testid='test-icon']"),
|
|
121
|
+
);
|
|
122
|
+
const labelIndex = spans.findIndex((s) =>
|
|
123
|
+
s.textContent?.includes("Label"),
|
|
124
|
+
);
|
|
125
|
+
expect(iconIndex).toBeGreaterThanOrEqual(0);
|
|
126
|
+
expect(labelIndex).toBeGreaterThanOrEqual(0);
|
|
127
|
+
expect(iconIndex).toBeLessThan(labelIndex);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("renders trailing icon after label in DOM order", () => {
|
|
131
|
+
render(
|
|
132
|
+
<Button icon={<TestIcon />} iconPosition="trailing">
|
|
133
|
+
Label
|
|
134
|
+
</Button>,
|
|
135
|
+
);
|
|
136
|
+
const button = screen.getByRole("button");
|
|
137
|
+
const spans = Array.from(button.querySelectorAll("span"));
|
|
138
|
+
const iconIndex = spans.findIndex((s) =>
|
|
139
|
+
s.querySelector("[data-testid='test-icon']"),
|
|
140
|
+
);
|
|
141
|
+
const labelIndex = spans.findIndex((s) =>
|
|
142
|
+
s.textContent?.includes("Label"),
|
|
143
|
+
);
|
|
144
|
+
expect(iconIndex).toBeGreaterThanOrEqual(0);
|
|
145
|
+
expect(labelIndex).toBeGreaterThanOrEqual(0);
|
|
146
|
+
expect(iconIndex).toBeGreaterThan(labelIndex);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("marks icon span with aria-hidden", () => {
|
|
150
|
+
render(
|
|
151
|
+
<Button icon={<TestIcon />} iconPosition="leading">
|
|
152
|
+
With Icon
|
|
153
|
+
</Button>,
|
|
154
|
+
);
|
|
155
|
+
const button = screen.getByRole("button");
|
|
156
|
+
// Icon wrapper span should be aria-hidden
|
|
157
|
+
const iconWrapper = button.querySelector(
|
|
158
|
+
"span[aria-hidden='true']:has([data-testid='test-icon'])",
|
|
159
|
+
);
|
|
160
|
+
expect(iconWrapper).toBeInTheDocument();
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// ── New: Sentence-case label ───────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
describe("sentence-case label", () => {
|
|
167
|
+
it("converts ALL CAPS to sentence-case", () => {
|
|
168
|
+
render(<Button>HELLO WORLD</Button>);
|
|
169
|
+
expect(screen.getByRole("button")).toHaveTextContent("Hello world");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("converts all-lowercase to sentence-case", () => {
|
|
173
|
+
render(<Button>hello world</Button>);
|
|
174
|
+
expect(screen.getByRole("button")).toHaveTextContent("Hello world");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("handles mixed case correctly", () => {
|
|
178
|
+
render(<Button>hElLo WoRlD</Button>);
|
|
179
|
+
expect(screen.getByRole("button")).toHaveTextContent("Hello world");
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// ── New: RTL layout ────────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
describe("RTL layout", () => {
|
|
186
|
+
it("renders without crash inside dir=rtl container", () => {
|
|
187
|
+
const { container } = render(
|
|
188
|
+
<div dir="rtl">
|
|
189
|
+
<Button>RTL Button</Button>
|
|
190
|
+
</div>,
|
|
191
|
+
);
|
|
192
|
+
expect(container.querySelector("button")).toBeInTheDocument();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("applies flex-row class regardless of text direction", () => {
|
|
196
|
+
render(
|
|
197
|
+
<div dir="rtl">
|
|
198
|
+
<Button>Button RTL</Button>
|
|
199
|
+
</div>,
|
|
200
|
+
);
|
|
201
|
+
expect(screen.getByRole("button").className).toContain("flex-row");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("renders trailing icon after label in RTL context", () => {
|
|
205
|
+
render(
|
|
206
|
+
<div dir="rtl">
|
|
207
|
+
<Button icon={<TestIcon />} iconPosition="trailing">
|
|
208
|
+
RTL Trailing
|
|
209
|
+
</Button>
|
|
210
|
+
</div>,
|
|
211
|
+
);
|
|
212
|
+
const button = screen.getByRole("button");
|
|
213
|
+
const spans = Array.from(button.querySelectorAll("span"));
|
|
214
|
+
const iconIndex = spans.findIndex((s) =>
|
|
215
|
+
s.querySelector("[data-testid='test-icon']"),
|
|
216
|
+
);
|
|
217
|
+
const labelIndex = spans.findIndex((s) =>
|
|
218
|
+
s.textContent?.includes("Rtl trailing"),
|
|
219
|
+
);
|
|
220
|
+
// DOM order is still trailing (CSS handles visual flip in RTL via flex)
|
|
221
|
+
expect(iconIndex).toBeGreaterThan(labelIndex);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// ── New: aria-label ────────────────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
describe("aria-label", () => {
|
|
228
|
+
it("sets aria-label to children string as fallback", () => {
|
|
229
|
+
render(<Button>Submit</Button>);
|
|
230
|
+
expect(screen.getByRole("button")).toHaveAttribute(
|
|
231
|
+
"aria-label",
|
|
232
|
+
"Submit",
|
|
233
|
+
);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("uses explicit aria-label prop when provided", () => {
|
|
237
|
+
render(<Button aria-label="Custom Label">Submit</Button>);
|
|
238
|
+
expect(screen.getByRole("button")).toHaveAttribute(
|
|
239
|
+
"aria-label",
|
|
240
|
+
"Custom Label",
|
|
241
|
+
);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("does not set aria-label when children is not a string", () => {
|
|
245
|
+
render(
|
|
246
|
+
<Button>
|
|
247
|
+
<span>Node child</span>
|
|
248
|
+
</Button>,
|
|
249
|
+
);
|
|
250
|
+
// aria-label should be undefined (not set) for non-string children
|
|
251
|
+
// unless explicitly provided
|
|
252
|
+
expect(screen.getByRole("button")).not.toHaveAttribute("aria-label");
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// ── New: asChild prop ──────────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
describe("asChild prop", () => {
|
|
259
|
+
it("renders children as the root element", () => {
|
|
260
|
+
render(
|
|
261
|
+
<Button asChild>
|
|
262
|
+
<a href="/test">Link Button</a>
|
|
263
|
+
</Button>,
|
|
264
|
+
);
|
|
265
|
+
const link = screen.getByRole("link");
|
|
266
|
+
expect(link).toBeInTheDocument();
|
|
267
|
+
expect(link.tagName.toLowerCase()).toBe("a");
|
|
268
|
+
expect(link).toHaveAttribute("href", "/test");
|
|
269
|
+
expect(link.className).toContain(
|
|
270
|
+
"transition-[background-color,color,border-color,box-shadow,opacity,filter]",
|
|
271
|
+
);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// ── New: loading prop ──────────────────────────────────────────────────────
|
|
276
|
+
|
|
277
|
+
describe("loading prop", () => {
|
|
278
|
+
it("disables interaction and sets aria-busy", () => {
|
|
279
|
+
const handleClick = vi.fn();
|
|
280
|
+
render(
|
|
281
|
+
<Button loading onClick={handleClick}>
|
|
282
|
+
Submit
|
|
283
|
+
</Button>,
|
|
284
|
+
);
|
|
285
|
+
const button = screen.getByRole("button");
|
|
286
|
+
expect(button).toHaveAttribute("aria-busy", "true");
|
|
287
|
+
|
|
288
|
+
fireEvent.click(button);
|
|
289
|
+
expect(handleClick).not.toHaveBeenCalled();
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("renders the loading indicator", () => {
|
|
293
|
+
render(<Button loading>Submit</Button>);
|
|
294
|
+
expect(screen.getByLabelText("Loading")).toBeInTheDocument();
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
});
|