@mindlogic-ai/logician-ui 5.0.0-alpha.19 → 5.0.0-alpha.20
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/dist/components/Button/Button.d.ts.map +1 -1
- package/dist/components/Button/Button.js +22 -2
- package/dist/components/Button/Button.js.map +1 -1
- package/dist/components/Button/Button.mjs +22 -2
- package/dist/components/Button/Button.mjs.map +1 -1
- package/dist/components/Button/Button.types.d.ts +38 -1
- package/dist/components/Button/Button.types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/Button/Button.focusableWhenDisabled.test.tsx +218 -0
- package/src/components/Button/Button.stories.tsx +66 -1
- package/src/components/Button/Button.tsx +31 -1
- package/src/components/Button/Button.types.ts +38 -1
- package/src/components/Button/Button.usage.mdx +17 -3
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Button.d.ts","sourceRoot":"","sources":["../../../src/components/Button/Button.tsx"],"names":[],"mappings":"AAcA,OAAO,EAAE,cAAc,EAA8B,MAAM,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"Button.d.ts","sourceRoot":"","sources":["../../../src/components/Button/Button.tsx"],"names":[],"mappings":"AAcA,OAAO,EAAE,cAAc,EAA8B,MAAM,gBAAgB,CAAC;AAwM5E,eAAO,MAAM,MAAM,gEAA0C,CAAC"}
|
|
@@ -28,7 +28,7 @@ var Button_styles = require('./Button.styles.js');
|
|
|
28
28
|
* <Button as="a" href="/docs" target="_blank">문서</Button>
|
|
29
29
|
* ```
|
|
30
30
|
*/
|
|
31
|
-
const ButtonImpl = React.forwardRef(({ variant: variantProp, isDestructive: isDestructiveProp = false, size, lift, children, _hover: hoverProp, _active: activeProp, _disabled: disabledProp, css: cssProp, ...restWithLegacy }, ref) => {
|
|
31
|
+
const ButtonImpl = React.forwardRef(({ variant: variantProp, isDestructive: isDestructiveProp = false, size, lift, disabled, focusableWhenDisabled = false, onDisabledClick, onClick, children, _hover: hoverProp, _active: activeProp, _disabled: disabledProp, css: cssProp, ...restWithLegacy }, ref) => {
|
|
32
32
|
// 4.x callers (untyped JS, `as any`) can still hand us `colorPalette` and
|
|
33
33
|
// an old variant name. Strip it so it never reaches Chakra's own recipe,
|
|
34
34
|
// and map the pair onto a 5.0 role — see `Button.legacy.ts`.
|
|
@@ -49,6 +49,23 @@ const ButtonImpl = React.forwardRef(({ variant: variantProp, isDestructive: isDe
|
|
|
49
49
|
isDestructive = isDestructive || resolved.isDestructive;
|
|
50
50
|
}
|
|
51
51
|
const base = Button_styles.getButtonStyles(variant, isDestructive);
|
|
52
|
+
// `focusableWhenDisabled`: disabled by ARIA, not by the attribute, so the
|
|
53
|
+
// button stays focusable and can answer a press. `aria-disabled=true` is
|
|
54
|
+
// in Chakra's `_disabled` condition, so the disabled colours and cursor
|
|
55
|
+
// still apply; `data-disabled` is what `_hover` / `_active` exclude, so
|
|
56
|
+
// without it the button would still react to the pointer. `loading` keeps
|
|
57
|
+
// the native attribute (Chakra sets it) — see `focusableWhenDisabled`.
|
|
58
|
+
const softDisabled = !!disabled && focusableWhenDisabled && !rest.loading;
|
|
59
|
+
const handleClick = softDisabled
|
|
60
|
+
? (event) => {
|
|
61
|
+
// Also what stops a `type="submit"` button from submitting.
|
|
62
|
+
event.preventDefault();
|
|
63
|
+
// A native disabled button sends no click to its ancestors either —
|
|
64
|
+
// a clickable row around it must not run its own action.
|
|
65
|
+
event.stopPropagation();
|
|
66
|
+
onDisabledClick?.(event);
|
|
67
|
+
}
|
|
68
|
+
: onClick;
|
|
52
69
|
// Merged rather than spread after: each variant already owns `_hover` and
|
|
53
70
|
// `_active` for its colours, and replacing either would drop them.
|
|
54
71
|
const styles = lift
|
|
@@ -112,7 +129,10 @@ const ButtonImpl = React.forwardRef(({ variant: variantProp, isDestructive: isDe
|
|
|
112
129
|
// Two clocks on one element — the press is faster than the colour
|
|
113
130
|
// change — so the shorthand is written out and only the reduced-motion
|
|
114
131
|
// guard comes from the vocabulary.
|
|
115
|
-
transition: Button_styles.buttonTransition, animationStyle: "composite", ref: ref, "data-variant": variant, "data-destructive": isDestructive || undefined, loadingText: loadingText, ...rest,
|
|
132
|
+
transition: Button_styles.buttonTransition, animationStyle: "composite", ref: ref, "data-variant": variant, "data-destructive": isDestructive || undefined, loadingText: loadingText, disabled: softDisabled ? undefined : disabled, onClick: handleClick, ...rest, ...(softDisabled && {
|
|
133
|
+
'aria-disabled': true,
|
|
134
|
+
'data-disabled': '',
|
|
135
|
+
}),
|
|
116
136
|
// The `action` icon rule lives in `css` (a nested selector cannot be a
|
|
117
137
|
// style prop); merged so a call site's `css` adds to it.
|
|
118
138
|
css: mergeCss.mergeCss(base.css, cssProp), children: children }));
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Button.js","sources":["../../../src/components/Button/Button.tsx"],"sourcesContent":[null],"names":["forwardRef","useTranslate","isLegacyButtonProps","resolveLegacyButtonVariant","warnLegacyButtonProps","getButtonStyles","_jsx","ChakraButton","mergeCss","focusRing","buttonTransition","polymorphic"],"mappings":";;;;;;;;;;;;;AAgBA;;;;;;;;;;;;;;;;AAgBG;AACH,MAAM,UAAU,GAAGA,gBAAU,CAC3B,CACE,EACE,OAAO,EAAE,WAAW,EACpB,aAAa,EAAE,iBAAiB,GAAG,KAAK,EACxC,IAAI,EACJ,IAAI,EACJ,QAAQ,EACR,MAAM,EAAE,SAAS,EACjB,OAAO,EAAE,UAAU,EACnB,SAAS,EAAE,YAAY,EACvB,GAAG,EAAE,OAAO,EACZ,GAAG,cAAc,EAClB,EACD,GAAG,KACD;;;;AAIF,IAAA,MAAM,EACJ,YAAY,EAAE,aAAa,EAC3B,WAAW,EAAE,eAAe,EAC5B,GAAG,IAAI,EACR,GAAG,cAAoE;;;;AAIxE,IAAA,MAAM,SAAS,GAAGC,yBAAY,EAAE;AAChC,IAAA,MAAM,WAAW,GACf,eAAe,KAAK;AAClB,UAAE,SAAS,CAAC,YAAY;AACxB,WAAG,eAAe,IAAI,SAAS,CAAC;AAEpC,IAAA,IAAI,OAAO,GAAkB,WAAW,IAAI,QAAQ;IACpD,IAAI,aAAa,GAAG,iBAAiB;IACrC,IAAIC,iCAAmB,CAAC,QAAQ,EAAE,aAAa,EAAE,WAAW,CAAC,EAAE;QAC7D,MAAM,QAAQ,GAAGC,wCAA0B,CACzC,QAAQ,EACR,aAAa,EACb,WAAW,CACZ;QACDC,mCAAqB,CAAC,QAAQ,EAAE,aAAa,EAAE,WAAW,EAAE,QAAQ,CAAC;AACrE,QAAA,OAAO,GAAG,QAAQ,CAAC,OAAO;AAC1B,QAAA,aAAa,GAAG,aAAa,IAAI,QAAQ,CAAC,aAAa;IACzD;IAEA,MAAM,IAAI,GAAGC,6BAAe,CAAC,OAAO,EAAE,aAAa,CAAC;;;
|
|
1
|
+
{"version":3,"file":"Button.js","sources":["../../../src/components/Button/Button.tsx"],"sourcesContent":[null],"names":["forwardRef","useTranslate","isLegacyButtonProps","resolveLegacyButtonVariant","warnLegacyButtonProps","getButtonStyles","_jsx","ChakraButton","mergeCss","focusRing","buttonTransition","polymorphic"],"mappings":"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"}
|
|
@@ -26,7 +26,7 @@ import { getButtonStyles, buttonTransition } from './Button.styles.mjs';
|
|
|
26
26
|
* <Button as="a" href="/docs" target="_blank">문서</Button>
|
|
27
27
|
* ```
|
|
28
28
|
*/
|
|
29
|
-
const ButtonImpl = forwardRef(({ variant: variantProp, isDestructive: isDestructiveProp = false, size, lift, children, _hover: hoverProp, _active: activeProp, _disabled: disabledProp, css: cssProp, ...restWithLegacy }, ref) => {
|
|
29
|
+
const ButtonImpl = forwardRef(({ variant: variantProp, isDestructive: isDestructiveProp = false, size, lift, disabled, focusableWhenDisabled = false, onDisabledClick, onClick, children, _hover: hoverProp, _active: activeProp, _disabled: disabledProp, css: cssProp, ...restWithLegacy }, ref) => {
|
|
30
30
|
// 4.x callers (untyped JS, `as any`) can still hand us `colorPalette` and
|
|
31
31
|
// an old variant name. Strip it so it never reaches Chakra's own recipe,
|
|
32
32
|
// and map the pair onto a 5.0 role — see `Button.legacy.ts`.
|
|
@@ -47,6 +47,23 @@ const ButtonImpl = forwardRef(({ variant: variantProp, isDestructive: isDestruct
|
|
|
47
47
|
isDestructive = isDestructive || resolved.isDestructive;
|
|
48
48
|
}
|
|
49
49
|
const base = getButtonStyles(variant, isDestructive);
|
|
50
|
+
// `focusableWhenDisabled`: disabled by ARIA, not by the attribute, so the
|
|
51
|
+
// button stays focusable and can answer a press. `aria-disabled=true` is
|
|
52
|
+
// in Chakra's `_disabled` condition, so the disabled colours and cursor
|
|
53
|
+
// still apply; `data-disabled` is what `_hover` / `_active` exclude, so
|
|
54
|
+
// without it the button would still react to the pointer. `loading` keeps
|
|
55
|
+
// the native attribute (Chakra sets it) — see `focusableWhenDisabled`.
|
|
56
|
+
const softDisabled = !!disabled && focusableWhenDisabled && !rest.loading;
|
|
57
|
+
const handleClick = softDisabled
|
|
58
|
+
? (event) => {
|
|
59
|
+
// Also what stops a `type="submit"` button from submitting.
|
|
60
|
+
event.preventDefault();
|
|
61
|
+
// A native disabled button sends no click to its ancestors either —
|
|
62
|
+
// a clickable row around it must not run its own action.
|
|
63
|
+
event.stopPropagation();
|
|
64
|
+
onDisabledClick?.(event);
|
|
65
|
+
}
|
|
66
|
+
: onClick;
|
|
50
67
|
// Merged rather than spread after: each variant already owns `_hover` and
|
|
51
68
|
// `_active` for its colours, and replacing either would drop them.
|
|
52
69
|
const styles = lift
|
|
@@ -110,7 +127,10 @@ const ButtonImpl = forwardRef(({ variant: variantProp, isDestructive: isDestruct
|
|
|
110
127
|
// Two clocks on one element — the press is faster than the colour
|
|
111
128
|
// change — so the shorthand is written out and only the reduced-motion
|
|
112
129
|
// guard comes from the vocabulary.
|
|
113
|
-
transition: buttonTransition, animationStyle: "composite", ref: ref, "data-variant": variant, "data-destructive": isDestructive || undefined, loadingText: loadingText, ...rest,
|
|
130
|
+
transition: buttonTransition, animationStyle: "composite", ref: ref, "data-variant": variant, "data-destructive": isDestructive || undefined, loadingText: loadingText, disabled: softDisabled ? undefined : disabled, onClick: handleClick, ...rest, ...(softDisabled && {
|
|
131
|
+
'aria-disabled': true,
|
|
132
|
+
'data-disabled': '',
|
|
133
|
+
}),
|
|
114
134
|
// The `action` icon rule lives in `css` (a nested selector cannot be a
|
|
115
135
|
// style prop); merged so a call site's `css` adds to it.
|
|
116
136
|
css: mergeCss(base.css, cssProp), children: children }));
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Button.mjs","sources":["../../../src/components/Button/Button.tsx"],"sourcesContent":[null],"names":["_jsx","ChakraButton"],"mappings":";;;;;;;;;;;AAgBA;;;;;;;;;;;;;;;;AAgBG;AACH,MAAM,UAAU,GAAG,UAAU,CAC3B,CACE,EACE,OAAO,EAAE,WAAW,EACpB,aAAa,EAAE,iBAAiB,GAAG,KAAK,EACxC,IAAI,EACJ,IAAI,EACJ,QAAQ,EACR,MAAM,EAAE,SAAS,EACjB,OAAO,EAAE,UAAU,EACnB,SAAS,EAAE,YAAY,EACvB,GAAG,EAAE,OAAO,EACZ,GAAG,cAAc,EAClB,EACD,GAAG,KACD;;;;AAIF,IAAA,MAAM,EACJ,YAAY,EAAE,aAAa,EAC3B,WAAW,EAAE,eAAe,EAC5B,GAAG,IAAI,EACR,GAAG,cAAoE;;;;AAIxE,IAAA,MAAM,SAAS,GAAG,YAAY,EAAE;AAChC,IAAA,MAAM,WAAW,GACf,eAAe,KAAK;AAClB,UAAE,SAAS,CAAC,YAAY;AACxB,WAAG,eAAe,IAAI,SAAS,CAAC;AAEpC,IAAA,IAAI,OAAO,GAAkB,WAAW,IAAI,QAAQ;IACpD,IAAI,aAAa,GAAG,iBAAiB;IACrC,IAAI,mBAAmB,CAAC,QAAQ,EAAE,aAAa,EAAE,WAAW,CAAC,EAAE;QAC7D,MAAM,QAAQ,GAAG,0BAA0B,CACzC,QAAQ,EACR,aAAa,EACb,WAAW,CACZ;QACD,qBAAqB,CAAC,QAAQ,EAAE,aAAa,EAAE,WAAW,EAAE,QAAQ,CAAC;AACrE,QAAA,OAAO,GAAG,QAAQ,CAAC,OAAO;AAC1B,QAAA,aAAa,GAAG,aAAa,IAAI,QAAQ,CAAC,aAAa;IACzD;IAEA,MAAM,IAAI,GAAG,eAAe,CAAC,OAAO,EAAE,aAAa,CAAC;;;
|
|
1
|
+
{"version":3,"file":"Button.mjs","sources":["../../../src/components/Button/Button.tsx"],"sourcesContent":[null],"names":["_jsx","ChakraButton"],"mappings":"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"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ElementType } from 'react';
|
|
1
|
+
import type { ElementType, MouseEvent } from 'react';
|
|
2
2
|
import { ButtonProps as ChakraButtonProps } from '@chakra-ui/react';
|
|
3
3
|
import type { PolymorphicProps } from '../../types/polymorphic';
|
|
4
4
|
/**
|
|
@@ -81,6 +81,43 @@ export type ButtonOwnProps = Omit<ChakraButtonProps, 'variant' | 'colorScheme' |
|
|
|
81
81
|
* @default false
|
|
82
82
|
*/
|
|
83
83
|
lift?: boolean;
|
|
84
|
+
/**
|
|
85
|
+
* Keeps a `disabled` button in the Tab order and lets it receive clicks, so
|
|
86
|
+
* the screen can say *why* it is disabled.
|
|
87
|
+
*
|
|
88
|
+
* A natively `disabled` `<button>` cannot be focused and swallows every
|
|
89
|
+
* click: a keyboard or screen reader user may never find it, and a mouse
|
|
90
|
+
* user who presses it gets no answer at all. With this prop, `disabled`
|
|
91
|
+
* renders as `aria-disabled="true"` (plus `data-disabled`) instead of the
|
|
92
|
+
* native attribute:
|
|
93
|
+
*
|
|
94
|
+
* - the disabled colours, the `not-allowed` cursor and the "no hover, no
|
|
95
|
+
* press" behaviour are the same as a native disabled button;
|
|
96
|
+
* - Tab still reaches it and the focus ring shows;
|
|
97
|
+
* - activating it (click, Enter, Space) does **not** call `onClick`, and its
|
|
98
|
+
* default action is cancelled, so a `type="submit"` button does not submit
|
|
99
|
+
* its form; the click does not bubble to ancestors; `onDisabledClick` is
|
|
100
|
+
* called instead.
|
|
101
|
+
*
|
|
102
|
+
* Only activation is blocked: other handlers (`onKeyDown`, `onPointerDown`,
|
|
103
|
+
* …) still run, as they would on any focusable element. Inside a trigger
|
|
104
|
+
* (`Menu.Trigger asChild`, `Popover.Trigger asChild`) disable the root as
|
|
105
|
+
* well. Meant for `<button>`: with `as="a"` the `href` stays, so middle-click
|
|
106
|
+
* still opens it.
|
|
107
|
+
*
|
|
108
|
+
* Has no effect unless `disabled` is set. `loading` keeps the native
|
|
109
|
+
* `disabled` — a busy button is working, not refusing.
|
|
110
|
+
*
|
|
111
|
+
* @default false
|
|
112
|
+
*/
|
|
113
|
+
focusableWhenDisabled?: boolean;
|
|
114
|
+
/**
|
|
115
|
+
* Called when a `disabled` + `focusableWhenDisabled` button is activated
|
|
116
|
+
* (click, Enter, Space) — where `onClick` would have been. Use it to explain
|
|
117
|
+
* the reason or move focus to what is missing (an unticked consent box).
|
|
118
|
+
* Never called on a natively disabled button, and never while `loading`.
|
|
119
|
+
*/
|
|
120
|
+
onDisabledClick?: (event: MouseEvent<HTMLButtonElement>) => void;
|
|
84
121
|
};
|
|
85
122
|
/**
|
|
86
123
|
* Props for `Button`, typed for whatever element `as` renders.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Button.types.d.ts","sourceRoot":"","sources":["../../../src/components/Button/Button.types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"Button.types.d.ts","sourceRoot":"","sources":["../../../src/components/Button/Button.types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,OAAO,CAAC;AACrD,OAAO,EAAE,WAAW,IAAI,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAOpE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAEhE;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,MAAM,aAAa,GACrB,SAAS,GACT,QAAQ,GACR,QAAQ,GACR,WAAW,GACX,QAAQ,GACR,OAAO,GACP,QAAQ,CAAC;AAEb;;;;;;;;;GASG;AACH,MAAM,MAAM,kBAAkB,GAC1B,SAAS,GACT,WAAW,GACX,QAAQ,GACR,SAAS,GACT,SAAS,GACT,SAAS,CAAC;AAEd;;;GAGG;AACH,MAAM,MAAM,mBAAmB,GAAG,OAAO,GAAG,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC;AAEzE;;;;;;;;;;;;;GAaG;AACH,MAAM,MAAM,cAAc,GAAG,IAAI,CAC/B,iBAAiB,EACjB,SAAS,GAAG,aAAa,GAAG,cAAc,GAAG,UAAU,GAAG,WAAW,GAAG,IAAI,CAC7E,GAAG;IACF;;;;OAIG;IACH,OAAO,CAAC,EAAE,aAAa,CAAC;IACxB;;;;;;OAMG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB;;;;;;;;;;;;;;OAcG;IACH,IAAI,CAAC,EAAE,OAAO,CAAC;IACf;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA4BG;IACH,qBAAqB,CAAC,EAAE,OAAO,CAAC;IAChC;;;;;OAKG;IACH,eAAe,CAAC,EAAE,CAAC,KAAK,EAAE,UAAU,CAAC,iBAAiB,CAAC,KAAK,IAAI,CAAC;CAClE,CAAC;AAEF;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,MAAM,WAAW,CAAC,QAAQ,SAAS,WAAW,GAAG,QAAQ,IAC7D,gBAAgB,CAAC,QAAQ,EAAE,cAAc,CAAC,CAAC"}
|
package/package.json
CHANGED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { ChakraProvider } from '@chakra-ui/react';
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import userEvent from '@testing-library/user-event';
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import { system } from '../../theme';
|
|
7
|
+
import { Button } from './Button';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `focusableWhenDisabled` — a disabled button that can still be reached and
|
|
11
|
+
* pressed, so the screen can say why it is disabled.
|
|
12
|
+
*
|
|
13
|
+
* A native `disabled` button leaves the Tab order and swallows every click.
|
|
14
|
+
* FactChat's checkout pay button sat locked until a consent box was ticked,
|
|
15
|
+
* and pressing it did nothing at all — the reason was off screen. With the
|
|
16
|
+
* prop the button is disabled by `aria-disabled` instead: it looks and refuses
|
|
17
|
+
* the same, but focus and a press reach it, and `onDisabledClick` answers.
|
|
18
|
+
*/
|
|
19
|
+
const wrap = (ui: React.ReactNode) =>
|
|
20
|
+
render(<ChakraProvider value={system}>{ui}</ChakraProvider>);
|
|
21
|
+
|
|
22
|
+
describe('Button focusableWhenDisabled', () => {
|
|
23
|
+
it('drops native disabled for aria-disabled and data-disabled', () => {
|
|
24
|
+
wrap(
|
|
25
|
+
<Button disabled focusableWhenDisabled>
|
|
26
|
+
결제
|
|
27
|
+
</Button>
|
|
28
|
+
);
|
|
29
|
+
const button = screen.getByRole('button', { name: '결제' });
|
|
30
|
+
expect(button).not.toHaveAttribute('disabled');
|
|
31
|
+
expect(button).toHaveAttribute('aria-disabled', 'true');
|
|
32
|
+
expect(button).toHaveAttribute('data-disabled');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('keeps the disabled look and cursor', () => {
|
|
36
|
+
wrap(
|
|
37
|
+
<>
|
|
38
|
+
<Button variant="primary" disabled>
|
|
39
|
+
native
|
|
40
|
+
</Button>
|
|
41
|
+
<Button variant="primary" disabled focusableWhenDisabled>
|
|
42
|
+
soft
|
|
43
|
+
</Button>
|
|
44
|
+
</>
|
|
45
|
+
);
|
|
46
|
+
const native = getComputedStyle(
|
|
47
|
+
screen.getByRole('button', { name: 'native' })
|
|
48
|
+
);
|
|
49
|
+
const soft = getComputedStyle(screen.getByRole('button', { name: 'soft' }));
|
|
50
|
+
expect(soft.cursor).toBe('not-allowed');
|
|
51
|
+
expect(soft.backgroundColor).toBe(native.backgroundColor);
|
|
52
|
+
expect(soft.color).toBe(native.color);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('stays in the Tab order', async () => {
|
|
56
|
+
const user = userEvent.setup();
|
|
57
|
+
wrap(
|
|
58
|
+
<>
|
|
59
|
+
<Button>before</Button>
|
|
60
|
+
<Button disabled focusableWhenDisabled>
|
|
61
|
+
결제
|
|
62
|
+
</Button>
|
|
63
|
+
</>
|
|
64
|
+
);
|
|
65
|
+
await user.tab();
|
|
66
|
+
await user.tab();
|
|
67
|
+
expect(screen.getByRole('button', { name: '결제' })).toHaveFocus();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('click, Enter and Space call onDisabledClick, never onClick', async () => {
|
|
71
|
+
const user = userEvent.setup();
|
|
72
|
+
const onClick = vi.fn();
|
|
73
|
+
const onDisabledClick = vi.fn();
|
|
74
|
+
wrap(
|
|
75
|
+
<Button
|
|
76
|
+
disabled
|
|
77
|
+
focusableWhenDisabled
|
|
78
|
+
onClick={onClick}
|
|
79
|
+
onDisabledClick={onDisabledClick}
|
|
80
|
+
>
|
|
81
|
+
결제
|
|
82
|
+
</Button>
|
|
83
|
+
);
|
|
84
|
+
const button = screen.getByRole('button', { name: '결제' });
|
|
85
|
+
await user.click(button);
|
|
86
|
+
button.focus();
|
|
87
|
+
await user.keyboard('{Enter}');
|
|
88
|
+
await user.keyboard(' ');
|
|
89
|
+
expect(onClick).not.toHaveBeenCalled();
|
|
90
|
+
expect(onDisabledClick).toHaveBeenCalledTimes(3);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('a type="submit" button does not submit its form', async () => {
|
|
94
|
+
const user = userEvent.setup();
|
|
95
|
+
const onSubmit = vi.fn((event: React.FormEvent) => event.preventDefault());
|
|
96
|
+
wrap(
|
|
97
|
+
<form onSubmit={onSubmit}>
|
|
98
|
+
<input aria-label="이름" />
|
|
99
|
+
<Button type="submit" disabled focusableWhenDisabled>
|
|
100
|
+
보내기
|
|
101
|
+
</Button>
|
|
102
|
+
</form>
|
|
103
|
+
);
|
|
104
|
+
await user.click(screen.getByRole('button', { name: '보내기' }));
|
|
105
|
+
// Implicit submission: Enter in a field clicks the default button.
|
|
106
|
+
await user.type(screen.getByRole('textbox', { name: '이름' }), '{Enter}');
|
|
107
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('once enabled, behaves like any button', async () => {
|
|
111
|
+
const user = userEvent.setup();
|
|
112
|
+
const onClick = vi.fn();
|
|
113
|
+
const onDisabledClick = vi.fn();
|
|
114
|
+
wrap(
|
|
115
|
+
<Button
|
|
116
|
+
disabled={false}
|
|
117
|
+
focusableWhenDisabled
|
|
118
|
+
onClick={onClick}
|
|
119
|
+
onDisabledClick={onDisabledClick}
|
|
120
|
+
>
|
|
121
|
+
결제
|
|
122
|
+
</Button>
|
|
123
|
+
);
|
|
124
|
+
const button = screen.getByRole('button', { name: '결제' });
|
|
125
|
+
expect(button).not.toHaveAttribute('aria-disabled');
|
|
126
|
+
expect(button).not.toHaveAttribute('data-disabled');
|
|
127
|
+
await user.click(button);
|
|
128
|
+
expect(onClick).toHaveBeenCalledTimes(1);
|
|
129
|
+
expect(onDisabledClick).not.toHaveBeenCalled();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('loading keeps the native disabled attribute', () => {
|
|
133
|
+
const onDisabledClick = vi.fn();
|
|
134
|
+
wrap(
|
|
135
|
+
<Button
|
|
136
|
+
disabled
|
|
137
|
+
loading
|
|
138
|
+
focusableWhenDisabled
|
|
139
|
+
onDisabledClick={onDisabledClick}
|
|
140
|
+
>
|
|
141
|
+
결제
|
|
142
|
+
</Button>
|
|
143
|
+
);
|
|
144
|
+
const button = screen.getByRole('button');
|
|
145
|
+
expect(button).toBeDisabled();
|
|
146
|
+
expect(button).not.toHaveAttribute('aria-disabled');
|
|
147
|
+
expect(getComputedStyle(button).cursor).toBe('progress');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('the click does not reach an ancestor', async () => {
|
|
151
|
+
const user = userEvent.setup();
|
|
152
|
+
const onRowClick = vi.fn();
|
|
153
|
+
const onDisabledClick = vi.fn();
|
|
154
|
+
wrap(
|
|
155
|
+
<div onClick={onRowClick}>
|
|
156
|
+
<Button
|
|
157
|
+
disabled
|
|
158
|
+
focusableWhenDisabled
|
|
159
|
+
onDisabledClick={onDisabledClick}
|
|
160
|
+
>
|
|
161
|
+
결제
|
|
162
|
+
</Button>
|
|
163
|
+
</div>
|
|
164
|
+
);
|
|
165
|
+
await user.click(screen.getByRole('button', { name: '결제' }));
|
|
166
|
+
expect(onDisabledClick).toHaveBeenCalledTimes(1);
|
|
167
|
+
expect(onRowClick).not.toHaveBeenCalled();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('a call-site aria-disabled / data-disabled cannot undo it', () => {
|
|
171
|
+
wrap(
|
|
172
|
+
<Button
|
|
173
|
+
disabled
|
|
174
|
+
focusableWhenDisabled
|
|
175
|
+
aria-disabled={false}
|
|
176
|
+
data-disabled={undefined}
|
|
177
|
+
>
|
|
178
|
+
결제
|
|
179
|
+
</Button>
|
|
180
|
+
);
|
|
181
|
+
const button = screen.getByRole('button', { name: '결제' });
|
|
182
|
+
expect(button).toHaveAttribute('aria-disabled', 'true');
|
|
183
|
+
expect(button).toHaveAttribute('data-disabled');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe('without the prop nothing changes', () => {
|
|
187
|
+
it('disabled is the native attribute, out of the Tab order', async () => {
|
|
188
|
+
const user = userEvent.setup();
|
|
189
|
+
const onClick = vi.fn();
|
|
190
|
+
const onDisabledClick = vi.fn();
|
|
191
|
+
wrap(
|
|
192
|
+
<>
|
|
193
|
+
<Button>before</Button>
|
|
194
|
+
<Button disabled onClick={onClick} onDisabledClick={onDisabledClick}>
|
|
195
|
+
결제
|
|
196
|
+
</Button>
|
|
197
|
+
</>
|
|
198
|
+
);
|
|
199
|
+
const button = screen.getByRole('button', { name: '결제' });
|
|
200
|
+
expect(button).toBeDisabled();
|
|
201
|
+
expect(button).not.toHaveAttribute('aria-disabled');
|
|
202
|
+
await user.tab();
|
|
203
|
+
await user.tab();
|
|
204
|
+
expect(button).not.toHaveFocus();
|
|
205
|
+
await user.click(button);
|
|
206
|
+
expect(onClick).not.toHaveBeenCalled();
|
|
207
|
+
expect(onDisabledClick).not.toHaveBeenCalled();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('an enabled button calls onClick', async () => {
|
|
211
|
+
const user = userEvent.setup();
|
|
212
|
+
const onClick = vi.fn();
|
|
213
|
+
wrap(<Button onClick={onClick}>결제</Button>);
|
|
214
|
+
await user.click(screen.getByRole('button', { name: '결제' }));
|
|
215
|
+
expect(onClick).toHaveBeenCalledTimes(1);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type ReactNode, useId, useRef, useState } from 'react';
|
|
2
2
|
import { Box, Flex, Grid, HStack, Text, VStack } from '@chakra-ui/react';
|
|
3
3
|
import { Meta, StoryObj } from '@storybook/react';
|
|
4
4
|
|
|
@@ -264,6 +264,71 @@ export const AsLink: Story = {
|
|
|
264
264
|
),
|
|
265
265
|
};
|
|
266
266
|
|
|
267
|
+
/**
|
|
268
|
+
* `focusableWhenDisabled` keeps a disabled button reachable so it can explain
|
|
269
|
+
* itself. Tab to 결제 with the box unticked, then press it: `onClick` does not
|
|
270
|
+
* run, `onDisabledClick` moves focus to the box and the hint turns red.
|
|
271
|
+
*
|
|
272
|
+
* It is disabled by `aria-disabled="true"` instead of the native attribute, so
|
|
273
|
+
* it looks the same as the native disabled button next to it, ignores hover
|
|
274
|
+
* and press, and does not submit a form.
|
|
275
|
+
*/
|
|
276
|
+
export const FocusableWhenDisabled: Story = {
|
|
277
|
+
render: function Render() {
|
|
278
|
+
const [agreed, setAgreed] = useState(false);
|
|
279
|
+
const [nudged, setNudged] = useState(false);
|
|
280
|
+
const [paid, setPaid] = useState(0);
|
|
281
|
+
const checkboxRef = useRef<HTMLInputElement>(null);
|
|
282
|
+
const hintId = useId();
|
|
283
|
+
return (
|
|
284
|
+
<VStack align="flex-start" gap={3} p={4} maxW="360px">
|
|
285
|
+
<label style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
|
286
|
+
<input
|
|
287
|
+
ref={checkboxRef}
|
|
288
|
+
type="checkbox"
|
|
289
|
+
checked={agreed}
|
|
290
|
+
onChange={(event) => {
|
|
291
|
+
setAgreed(event.target.checked);
|
|
292
|
+
setNudged(false);
|
|
293
|
+
}}
|
|
294
|
+
/>
|
|
295
|
+
<Text fontSize="sm">이용 및 환불 안내에 동의합니다</Text>
|
|
296
|
+
</label>
|
|
297
|
+
{!agreed && (
|
|
298
|
+
<Text
|
|
299
|
+
id={hintId}
|
|
300
|
+
fontSize="sm"
|
|
301
|
+
color={nudged ? 'danger.fg' : 'fg.muted'}
|
|
302
|
+
>
|
|
303
|
+
안내에 동의하면 결제할 수 있습니다.
|
|
304
|
+
</Text>
|
|
305
|
+
)}
|
|
306
|
+
<HStack gap={3}>
|
|
307
|
+
<Button
|
|
308
|
+
variant="primary"
|
|
309
|
+
disabled={!agreed}
|
|
310
|
+
focusableWhenDisabled
|
|
311
|
+
aria-describedby={agreed ? undefined : hintId}
|
|
312
|
+
onDisabledClick={() => {
|
|
313
|
+
setNudged(true);
|
|
314
|
+
checkboxRef.current?.focus();
|
|
315
|
+
}}
|
|
316
|
+
onClick={() => setPaid((n) => n + 1)}
|
|
317
|
+
>
|
|
318
|
+
결제
|
|
319
|
+
</Button>
|
|
320
|
+
<Button variant="primary" disabled>
|
|
321
|
+
native disabled
|
|
322
|
+
</Button>
|
|
323
|
+
</HStack>
|
|
324
|
+
<Text fontSize="sm" color="fg.muted">
|
|
325
|
+
onClick: {paid}
|
|
326
|
+
</Text>
|
|
327
|
+
</VStack>
|
|
328
|
+
);
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
|
|
267
332
|
// ───────────────────────── Usage-page specimens ─────────────────────────
|
|
268
333
|
// Pulled into `Button.usage.mdx` with <Canvas of={…} />. Hidden from the
|
|
269
334
|
// sidebar (`!dev`); being stories, they type-check with the rest of the code.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { forwardRef } from 'react';
|
|
1
|
+
import { forwardRef, MouseEvent } from 'react';
|
|
2
2
|
import { Button as ChakraButton, SystemStyleObject } from '@chakra-ui/react';
|
|
3
3
|
|
|
4
4
|
import { useTranslate } from '@/hooks/useTranslate';
|
|
@@ -38,6 +38,10 @@ const ButtonImpl = forwardRef<HTMLButtonElement, ButtonProps>(
|
|
|
38
38
|
isDestructive: isDestructiveProp = false,
|
|
39
39
|
size,
|
|
40
40
|
lift,
|
|
41
|
+
disabled,
|
|
42
|
+
focusableWhenDisabled = false,
|
|
43
|
+
onDisabledClick,
|
|
44
|
+
onClick,
|
|
41
45
|
children,
|
|
42
46
|
_hover: hoverProp,
|
|
43
47
|
_active: activeProp,
|
|
@@ -79,6 +83,24 @@ const ButtonImpl = forwardRef<HTMLButtonElement, ButtonProps>(
|
|
|
79
83
|
|
|
80
84
|
const base = getButtonStyles(variant, isDestructive);
|
|
81
85
|
|
|
86
|
+
// `focusableWhenDisabled`: disabled by ARIA, not by the attribute, so the
|
|
87
|
+
// button stays focusable and can answer a press. `aria-disabled=true` is
|
|
88
|
+
// in Chakra's `_disabled` condition, so the disabled colours and cursor
|
|
89
|
+
// still apply; `data-disabled` is what `_hover` / `_active` exclude, so
|
|
90
|
+
// without it the button would still react to the pointer. `loading` keeps
|
|
91
|
+
// the native attribute (Chakra sets it) — see `focusableWhenDisabled`.
|
|
92
|
+
const softDisabled = !!disabled && focusableWhenDisabled && !rest.loading;
|
|
93
|
+
const handleClick = softDisabled
|
|
94
|
+
? (event: MouseEvent<HTMLButtonElement>) => {
|
|
95
|
+
// Also what stops a `type="submit"` button from submitting.
|
|
96
|
+
event.preventDefault();
|
|
97
|
+
// A native disabled button sends no click to its ancestors either —
|
|
98
|
+
// a clickable row around it must not run its own action.
|
|
99
|
+
event.stopPropagation();
|
|
100
|
+
onDisabledClick?.(event);
|
|
101
|
+
}
|
|
102
|
+
: onClick;
|
|
103
|
+
|
|
82
104
|
// Merged rather than spread after: each variant already owns `_hover` and
|
|
83
105
|
// `_active` for its colours, and replacing either would drop them.
|
|
84
106
|
const styles = lift
|
|
@@ -162,7 +184,15 @@ const ButtonImpl = forwardRef<HTMLButtonElement, ButtonProps>(
|
|
|
162
184
|
data-variant={variant}
|
|
163
185
|
data-destructive={isDestructive || undefined}
|
|
164
186
|
loadingText={loadingText}
|
|
187
|
+
disabled={softDisabled ? undefined : disabled}
|
|
188
|
+
onClick={handleClick}
|
|
165
189
|
{...rest}
|
|
190
|
+
// After `rest`, so a stray `aria-disabled` / `data-disabled` from the
|
|
191
|
+
// call site or a merging `asChild` parent cannot undo it.
|
|
192
|
+
{...(softDisabled && {
|
|
193
|
+
'aria-disabled': true,
|
|
194
|
+
'data-disabled': '',
|
|
195
|
+
})}
|
|
166
196
|
// The `action` icon rule lives in `css` (a nested selector cannot be a
|
|
167
197
|
// style prop); merged so a call site's `css` adds to it.
|
|
168
198
|
css={mergeCss(base.css as SystemStyleObject | undefined, cssProp)}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ElementType } from 'react';
|
|
1
|
+
import type { ElementType, MouseEvent } from 'react';
|
|
2
2
|
import { ButtonProps as ChakraButtonProps } from '@chakra-ui/react';
|
|
3
3
|
|
|
4
4
|
// Relative, NOT `@/types/polymorphic`. Path aliases survive into the emitted
|
|
@@ -107,6 +107,43 @@ export type ButtonOwnProps = Omit<
|
|
|
107
107
|
* @default false
|
|
108
108
|
*/
|
|
109
109
|
lift?: boolean;
|
|
110
|
+
/**
|
|
111
|
+
* Keeps a `disabled` button in the Tab order and lets it receive clicks, so
|
|
112
|
+
* the screen can say *why* it is disabled.
|
|
113
|
+
*
|
|
114
|
+
* A natively `disabled` `<button>` cannot be focused and swallows every
|
|
115
|
+
* click: a keyboard or screen reader user may never find it, and a mouse
|
|
116
|
+
* user who presses it gets no answer at all. With this prop, `disabled`
|
|
117
|
+
* renders as `aria-disabled="true"` (plus `data-disabled`) instead of the
|
|
118
|
+
* native attribute:
|
|
119
|
+
*
|
|
120
|
+
* - the disabled colours, the `not-allowed` cursor and the "no hover, no
|
|
121
|
+
* press" behaviour are the same as a native disabled button;
|
|
122
|
+
* - Tab still reaches it and the focus ring shows;
|
|
123
|
+
* - activating it (click, Enter, Space) does **not** call `onClick`, and its
|
|
124
|
+
* default action is cancelled, so a `type="submit"` button does not submit
|
|
125
|
+
* its form; the click does not bubble to ancestors; `onDisabledClick` is
|
|
126
|
+
* called instead.
|
|
127
|
+
*
|
|
128
|
+
* Only activation is blocked: other handlers (`onKeyDown`, `onPointerDown`,
|
|
129
|
+
* …) still run, as they would on any focusable element. Inside a trigger
|
|
130
|
+
* (`Menu.Trigger asChild`, `Popover.Trigger asChild`) disable the root as
|
|
131
|
+
* well. Meant for `<button>`: with `as="a"` the `href` stays, so middle-click
|
|
132
|
+
* still opens it.
|
|
133
|
+
*
|
|
134
|
+
* Has no effect unless `disabled` is set. `loading` keeps the native
|
|
135
|
+
* `disabled` — a busy button is working, not refusing.
|
|
136
|
+
*
|
|
137
|
+
* @default false
|
|
138
|
+
*/
|
|
139
|
+
focusableWhenDisabled?: boolean;
|
|
140
|
+
/**
|
|
141
|
+
* Called when a `disabled` + `focusableWhenDisabled` button is activated
|
|
142
|
+
* (click, Enter, Space) — where `onClick` would have been. Use it to explain
|
|
143
|
+
* the reason or move focus to what is missing (an unticked consent box).
|
|
144
|
+
* Never called on a natively disabled button, and never while `loading`.
|
|
145
|
+
*/
|
|
146
|
+
onDisabledClick?: (event: MouseEvent<HTMLButtonElement>) => void;
|
|
110
147
|
};
|
|
111
148
|
|
|
112
149
|
/**
|
|
@@ -69,7 +69,7 @@ A control that does something on this screen: save, cancel, run, delete. One
|
|
|
69
69
|
|
|
70
70
|
</Ko>
|
|
71
71
|
|
|
72
|
-
<ArgTypes of={ButtonStories} include={['variant', 'isDestructive', 'lift']} />
|
|
72
|
+
<ArgTypes of={ButtonStories} include={['variant', 'isDestructive', 'lift', 'focusableWhenDisabled', 'onDisabledClick']} />
|
|
73
73
|
|
|
74
74
|
<En>
|
|
75
75
|
|
|
@@ -203,6 +203,13 @@ other actions, and keep the red fill for the final confirmation.
|
|
|
203
203
|
ko="loadingText 없이 loading만 주지 마세요. 라벨이 스피너 뒤로 숨어 눈에 보이는 글자가 없어지고, 화면 낭독기에도 이름이 전달되지 않을 수 있습니다."
|
|
204
204
|
/>
|
|
205
205
|
|
|
206
|
+
<Canvas of={ButtonStories.FocusableWhenDisabled} />
|
|
207
|
+
<Verdict
|
|
208
|
+
kind="do"
|
|
209
|
+
en="When the reason a button is disabled can be out of view, add focusableWhenDisabled and answer onDisabledClick by moving focus to what is missing. Without it the button cannot be reached by Tab and a press does nothing."
|
|
210
|
+
ko="버튼이 비활성인 이유가 화면 밖에 있을 수 있다면 focusableWhenDisabled를 붙이고, onDisabledClick에서 빠진 항목으로 초점을 옮깁니다. 붙이지 않으면 Tab으로 닿지 않고, 눌러도 아무 반응이 없습니다."
|
|
211
|
+
/>
|
|
212
|
+
|
|
206
213
|
<En>
|
|
207
214
|
|
|
208
215
|
## Content
|
|
@@ -244,7 +251,11 @@ What you still have to do:
|
|
|
244
251
|
the spinner.
|
|
245
252
|
- **Say why it is disabled.** A disabled `<button>` cannot be focused, so a
|
|
246
253
|
keyboard or screen reader user may never find it. Put the reason in text
|
|
247
|
-
near it
|
|
254
|
+
near it. When the reason is out of view (a consent box at the bottom of a
|
|
255
|
+
long form), add `focusableWhenDisabled`: the button stays in the Tab order
|
|
256
|
+
as `aria-disabled="true"`, looks and refuses the same, and calls
|
|
257
|
+
`onDisabledClick` instead of `onClick` when pressed — use it to move focus
|
|
258
|
+
to what is missing. Point `aria-describedby` at the reason text.
|
|
248
259
|
- **Only phrasing content inside** (ACCESSIBILITY.md §5). No `<div>` or `<p>`
|
|
249
260
|
inside a Button; wrap with `as="span"` and set `display`.
|
|
250
261
|
- **No button inside another control.** Don't put a Button inside a clickable
|
|
@@ -300,7 +311,10 @@ Button이 알아서 하는 일
|
|
|
300
311
|
- **`loading`에는 `loadingText`를 함께 줍니다.** 없으면 라벨이 스피너 뒤로 숨습니다.
|
|
301
312
|
- **왜 비활성인지 알립니다.** 비활성 `<button>`은 초점을 받지 못해 키보드나 화면
|
|
302
313
|
낭독기 사용자는 버튼이 있다는 것조차 모를 수 있습니다. 가까운 곳에 이유를 글로
|
|
303
|
-
|
|
314
|
+
적습니다. 이유가 화면 밖에 있다면(긴 폼 맨 아래의 동의 체크박스) `focusableWhenDisabled`를
|
|
315
|
+
붙입니다. 버튼이 `aria-disabled="true"`로 Tab 순서에 남고, 모양과 막히는 동작은
|
|
316
|
+
같으며, 누르면 `onClick` 대신 `onDisabledClick`을 부릅니다. 여기서 빠진 항목으로
|
|
317
|
+
초점을 옮기고, `aria-describedby`로 이유 문구를 가리킵니다.
|
|
304
318
|
- **안에는 구문 콘텐츠만 넣습니다**(ACCESSIBILITY.md 5절). Button 안에 `<div>`나
|
|
305
319
|
`<p>`를 넣지 않습니다. 감싸야 한다면 `as="span"`에 `display`를 지정합니다.
|
|
306
320
|
- **컨트롤 안에 버튼을 넣지 않습니다.** 누를 수 있는 카드나 `role="button"` 래퍼 안에
|