hlmnhkl-ui 0.19.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/package.json +1 -1
- package/src/components/Accordion/Accordion.module.css +5 -0
- package/src/components/Accordion/Accordion.tsx +1 -1
- package/src/components/Alert/Alert.module.css +20 -6
- package/src/components/Badge/Badge.module.css +6 -0
- package/src/components/Breadcrumb/Breadcrumb.tsx +7 -5
- package/src/components/Button/Button.module.css +29 -0
- package/src/components/Button/Button.tsx +6 -2
- package/src/components/Collapsible/Collapsible.module.css +8 -0
- package/src/components/Collapsible/Collapsible.tsx +3 -1
- package/src/components/CopyButton/CopyButton.module.css +19 -6
- package/src/components/CopyButton/CopyButton.tsx +15 -9
- package/src/components/Dialog/Dialog.module.css +3 -1
- package/src/components/EmptyState/EmptyState.module.css +1 -0
- package/src/components/IconButton/IconButton.module.css +17 -1
- package/src/components/Input/Input.module.css +7 -0
- package/src/components/Input/Input.tsx +20 -1
- package/src/components/Link/Link.module.css +8 -5
- package/src/components/NumberInput/NumberInput.tsx +13 -4
- package/src/components/Pagination/Pagination.module.css +1 -1
- package/src/components/Pagination/Pagination.tsx +13 -10
- package/src/components/ProgressBar/ProgressBar.tsx +1 -1
- package/src/components/ScrollArea/ScrollArea.tsx +6 -2
- package/src/components/SearchInput/SearchInput.module.css +1 -1
- package/src/components/SearchInput/SearchInput.tsx +4 -1
- package/src/components/SegmentedControl/SegmentedControl.module.css +29 -2
- package/src/components/SegmentedControl/SegmentedControl.tsx +7 -1
- package/src/components/Select/Select.module.css +2 -2
- package/src/components/Select/Select.tsx +1 -1
- package/src/components/Sheet/Sheet.module.css +1 -0
- package/src/components/Spinner/Spinner.tsx +7 -1
- package/src/components/Switch/Switch.tsx +5 -0
- package/src/components/Tag/Tag.module.css +3 -0
- package/src/components/Tag/Tag.tsx +16 -6
- package/src/components/Textarea/Textarea.tsx +16 -1
- package/src/components/Toast/Toast.tsx +4 -2
- package/src/tokens.css +8 -0
package/README.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# hlmnhkl/ui
|
|
2
2
|
|
|
3
|
-
React components
|
|
3
|
+
React components, measured down to the part you never see: the 44px tap area, the 2px focus ring, the timing of every move. Black, white and grey, and copied into your app so the code is yours.
|
|
4
4
|
|
|
5
5
|
<picture>
|
|
6
6
|
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/hilmanhaikal/hlmnhkl-ui/main/.github/assets/home-dark.png">
|
|
7
|
-
<img alt="The hlmnhkl/ui homepage:
|
|
7
|
+
<img alt="The hlmnhkl/ui homepage: a real Switch drawn large, with thin lines reading its live tap area, focus ring and timing, under the line 'Measured down to the part you never see.'" src="https://raw.githubusercontent.com/hilmanhaikal/hlmnhkl-ui/main/.github/assets/home-light.png">
|
|
8
8
|
</picture>
|
|
9
9
|
|
|
10
10
|
**[Docs](https://ui.hlmnhkl.my)** · [Installation](https://ui.hlmnhkl.my/installation) · [Components](https://ui.hlmnhkl.my/button) · [What's new](https://ui.hlmnhkl.my/changelog)
|
|
@@ -58,7 +58,7 @@ Every copied file starts with one line naming hlmnhkl/ui and the MIT licence, so
|
|
|
58
58
|
- **50 free components**, from Button, Dialog, Select and Table to Toast and Retime, one speed setting for every animation. See [COMPONENTS.md](COMPONENTS.md).
|
|
59
59
|
- **One look, set by tokens.** Every colour, size and timing comes from one file, `tokens.css`. Colour appears only to mean something: good, caution or bad.
|
|
60
60
|
- **Light and dark.** Dark follows the system, or set `data-theme="dark"` on `<html>`.
|
|
61
|
-
- **Accessible.** Keyboard support per the WAI-ARIA patterns, visible focus,
|
|
61
|
+
- **Accessible.** Keyboard support per the WAI-ARIA patterns, visible focus, screen reader labels, and reduced motion. On touch screens every control gives at least a 44px target, even the small sizes; only the six PIN boxes and the seven calendar days get narrower when the row has no room, about 36px wide on a 320px phone.
|
|
62
62
|
- **Small.** React 19 and CSS Modules. No other runtime packages: no animation library, no icon package.
|
|
63
63
|
- **Works in** Vite, Next.js (App Router) and React Router.
|
|
64
64
|
|
package/package.json
CHANGED
|
@@ -105,7 +105,7 @@ export function AccordionItem({ value, title, disabled, children }: AccordionIte
|
|
|
105
105
|
disabled={disabled}
|
|
106
106
|
onClick={() => ctx.toggle(value)}
|
|
107
107
|
>
|
|
108
|
-
<span>{title}</span>
|
|
108
|
+
<span className={styles.title}>{title}</span>
|
|
109
109
|
<ChevronDownIcon className={styles.chevron} />
|
|
110
110
|
</button>
|
|
111
111
|
</Heading>
|
|
@@ -29,18 +29,32 @@
|
|
|
29
29
|
.error .title { font-weight: var(--hl-weight-semibold); }
|
|
30
30
|
|
|
31
31
|
.banner {
|
|
32
|
+
--strip: var(--hl-color-inverse);
|
|
33
|
+
--strip-fg: var(--hl-color-inverse-fg);
|
|
32
34
|
border-radius: 0;
|
|
33
35
|
border-width: 0 0 var(--hl-line);
|
|
34
|
-
background: var(--
|
|
35
|
-
color: var(--
|
|
36
|
+
background: var(--strip);
|
|
37
|
+
color: var(--strip-fg);
|
|
36
38
|
align-items: center;
|
|
37
39
|
padding-block: var(--hl-space-3);
|
|
38
40
|
}
|
|
39
41
|
|
|
40
|
-
|
|
41
|
-
.banner
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
/* Swapped on the children, not the strip, so the strip still reads the page's own values. Links and buttons in the action stay visible. */
|
|
43
|
+
.banner > * {
|
|
44
|
+
--hl-color-bg: var(--strip);
|
|
45
|
+
--hl-color-surface: var(--strip);
|
|
46
|
+
--hl-color-surface-hover: color-mix(in srgb, var(--strip-fg) 14%, var(--strip));
|
|
47
|
+
--hl-color-surface-active: color-mix(in srgb, var(--strip-fg) 20%, var(--strip));
|
|
48
|
+
--hl-color-fg: var(--strip-fg);
|
|
49
|
+
--hl-color-fg-muted: color-mix(in srgb, var(--strip-fg) 80%, var(--strip));
|
|
50
|
+
--hl-color-border: color-mix(in srgb, var(--strip-fg) 24%, var(--strip));
|
|
51
|
+
--hl-color-border-strong: color-mix(in srgb, var(--strip-fg) 55%, var(--strip));
|
|
52
|
+
--hl-color-inverse: var(--strip-fg);
|
|
53
|
+
--hl-color-inverse-fg: var(--strip);
|
|
54
|
+
--hl-color-inverse-hover: color-mix(in srgb, var(--strip-fg) 82%, var(--strip));
|
|
55
|
+
--hl-color-accent: var(--strip-fg);
|
|
56
|
+
--hl-color-accent-fg: var(--strip);
|
|
57
|
+
--hl-focus-ring: 0 0 0 var(--hl-line-focus) var(--strip), 0 0 0 calc(var(--hl-line-focus) * 2) var(--strip-fg);
|
|
44
58
|
}
|
|
45
59
|
|
|
46
60
|
.icon {
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
display: inline-flex;
|
|
3
3
|
align-items: center;
|
|
4
4
|
gap: var(--hl-space-2);
|
|
5
|
+
max-width: 100%;
|
|
5
6
|
height: var(--hl-badge-h);
|
|
6
7
|
padding: 0 var(--hl-space-3);
|
|
7
8
|
border: var(--hl-line) solid transparent;
|
|
@@ -26,13 +27,18 @@
|
|
|
26
27
|
.outline[data-tone] { border-color: var(--tone); color: var(--tone); }
|
|
27
28
|
.subtle[data-tone] { background: var(--tone-soft); color: var(--tone); }
|
|
28
29
|
|
|
30
|
+
/* Clip only the x axis: line-height is 1, so hiding y would cut descenders. */
|
|
29
31
|
.text {
|
|
30
32
|
display: inline-block;
|
|
33
|
+
min-width: 0;
|
|
34
|
+
overflow-x: clip;
|
|
35
|
+
text-overflow: ellipsis;
|
|
31
36
|
animation: pop var(--hl-motion-slow) var(--hl-ease-spring);
|
|
32
37
|
}
|
|
33
38
|
|
|
34
39
|
.dot {
|
|
35
40
|
position: relative;
|
|
41
|
+
flex: none;
|
|
36
42
|
width: var(--hl-dot);
|
|
37
43
|
height: var(--hl-dot);
|
|
38
44
|
border-radius: var(--hl-radius-full);
|
|
@@ -17,7 +17,7 @@ export type BreadcrumbItem = {
|
|
|
17
17
|
export type BreadcrumbProps = {
|
|
18
18
|
/** Path from the top level down. The last item is the current page. */
|
|
19
19
|
items: BreadcrumbItem[]
|
|
20
|
-
/** Above this many items, the middle collapses into a "…" button. */
|
|
20
|
+
/** Above this many items, the middle collapses into a "…" button. With 2 or less, only "…" and the current page show. */
|
|
21
21
|
maxItems?: number
|
|
22
22
|
/** Name of the nav landmark. */
|
|
23
23
|
label?: string
|
|
@@ -31,14 +31,16 @@ export function Breadcrumb({ items, maxItems = 4, label = 'Breadcrumb', expandLa
|
|
|
31
31
|
const [expanded, setExpanded] = useState(false)
|
|
32
32
|
const listRef = useRef<HTMLOListElement>(null)
|
|
33
33
|
const collapse = !expanded && items.length > maxItems
|
|
34
|
-
|
|
35
|
-
const
|
|
34
|
+
// Under 3 there is no room for the first item, so only "…" and the current page stay.
|
|
35
|
+
const head = maxItems < 3 ? 0 : 1
|
|
36
|
+
const tail = Math.max(1, maxItems - head - 1)
|
|
37
|
+
const visible = collapse ? [...items.slice(0, head), null, ...items.slice(-tail)] : items
|
|
36
38
|
|
|
37
39
|
// The "…" button is gone after expanding, so focus moves to the first link it revealed.
|
|
38
40
|
useLayoutEffect(() => {
|
|
39
41
|
if (!expanded) return
|
|
40
|
-
listRef.current?.querySelector<HTMLElement>(
|
|
41
|
-
}, [expanded])
|
|
42
|
+
listRef.current?.querySelector<HTMLElement>(`:scope > li:nth-child(${head + 1}) > :is(a, button)`)?.focus()
|
|
43
|
+
}, [expanded, head])
|
|
42
44
|
|
|
43
45
|
return (
|
|
44
46
|
<nav aria-label={label} className={cx(styles.root, className)}>
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
--h: var(--hl-control-h-md);
|
|
3
3
|
position: relative;
|
|
4
4
|
display: inline-grid;
|
|
5
|
+
grid-template-columns: minmax(0, auto);
|
|
5
6
|
place-items: center;
|
|
7
|
+
max-width: 100%;
|
|
6
8
|
min-height: var(--h);
|
|
7
9
|
padding: 0 var(--hl-space-6);
|
|
8
10
|
border: var(--hl-line) solid transparent;
|
|
@@ -31,6 +33,14 @@
|
|
|
31
33
|
inset-inline: 0;
|
|
32
34
|
}
|
|
33
35
|
|
|
36
|
+
@media (pointer: coarse) {
|
|
37
|
+
.sm::after { inset-inline: min(0px, calc((100% - var(--hl-hit-min)) / 2)); }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/* Beside another button, the hit area sinks below every box in the row, so it only fills the empty gap. */
|
|
41
|
+
:has(> button + .root, > .root + button) { isolation: isolate; }
|
|
42
|
+
:has(> button + .root, > .root + button) > .root::after { z-index: -1; }
|
|
43
|
+
|
|
34
44
|
.root:focus-visible {
|
|
35
45
|
outline: none;
|
|
36
46
|
box-shadow: var(--hl-focus-ring);
|
|
@@ -105,11 +115,30 @@
|
|
|
105
115
|
filter var(--hl-motion-base) var(--hl-ease-out);
|
|
106
116
|
}
|
|
107
117
|
|
|
118
|
+
.content { max-width: 100%; }
|
|
119
|
+
|
|
120
|
+
/* Clip only the x axis: line-height is 1, so hiding y would cut descenders. */
|
|
121
|
+
.label {
|
|
122
|
+
min-width: 0;
|
|
123
|
+
overflow-x: clip;
|
|
124
|
+
text-overflow: ellipsis;
|
|
125
|
+
}
|
|
126
|
+
|
|
108
127
|
.icon {
|
|
109
128
|
display: inline-flex;
|
|
129
|
+
flex: none;
|
|
110
130
|
font-size: var(--hl-icon-md);
|
|
111
131
|
}
|
|
112
132
|
|
|
133
|
+
.srOnly {
|
|
134
|
+
position: absolute;
|
|
135
|
+
width: 1px;
|
|
136
|
+
height: 1px;
|
|
137
|
+
overflow: hidden;
|
|
138
|
+
clip-path: inset(50%);
|
|
139
|
+
white-space: nowrap;
|
|
140
|
+
}
|
|
141
|
+
|
|
113
142
|
/* The spinner shares the label's grid cell, so the width never changes. */
|
|
114
143
|
.spinner {
|
|
115
144
|
opacity: 0;
|
|
@@ -66,8 +66,12 @@ export function Button({
|
|
|
66
66
|
<span className={styles.label}>{children}</span>
|
|
67
67
|
{trailingIcon && <span className={styles.icon}>{trailingIcon}</span>}
|
|
68
68
|
</span>
|
|
69
|
-
<span className={styles.spinner} aria-hidden=
|
|
70
|
-
{loading && <Spinner size="sm" label={
|
|
69
|
+
<span className={styles.spinner} aria-hidden="true">
|
|
70
|
+
{loading && <Spinner size="sm" label={null} />}
|
|
71
|
+
</span>
|
|
72
|
+
{/* Stays mounted, because screen readers skip a live region that appears with its text. */}
|
|
73
|
+
<span className={styles.srOnly} role="status">
|
|
74
|
+
{loading ? loadingLabel : null}
|
|
71
75
|
</span>
|
|
72
76
|
</>
|
|
73
77
|
)
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
display: inline-flex;
|
|
9
9
|
align-items: center;
|
|
10
10
|
align-self: flex-start;
|
|
11
|
+
max-width: 100%;
|
|
11
12
|
gap: var(--hl-space-3);
|
|
12
13
|
min-height: var(--hl-hit-min);
|
|
13
14
|
padding: 0 var(--hl-space-4);
|
|
@@ -31,7 +32,14 @@
|
|
|
31
32
|
.trigger:disabled { opacity: 0.45; cursor: not-allowed; }
|
|
32
33
|
.trigger:focus-visible { outline: none; box-shadow: var(--hl-focus-ring); }
|
|
33
34
|
|
|
35
|
+
.label {
|
|
36
|
+
min-width: 0;
|
|
37
|
+
overflow-wrap: anywhere;
|
|
38
|
+
text-align: start;
|
|
39
|
+
}
|
|
40
|
+
|
|
34
41
|
.chevron {
|
|
42
|
+
flex: none;
|
|
35
43
|
width: var(--hl-icon-md);
|
|
36
44
|
height: var(--hl-icon-md);
|
|
37
45
|
color: var(--hl-color-fg-muted);
|
|
@@ -40,6 +40,8 @@ export function Collapsible({ label, trigger, open: openProp, defaultOpen = fals
|
|
|
40
40
|
cloneElement(trigger, {
|
|
41
41
|
'aria-expanded': open,
|
|
42
42
|
'aria-controls': `${id}-panel`,
|
|
43
|
+
// Only set when disabled, so a trigger's own disabled prop is kept otherwise.
|
|
44
|
+
...(disabled ? { disabled: true, 'aria-disabled': true } : null),
|
|
43
45
|
onClick: (e: MouseEvent<HTMLElement>) => {
|
|
44
46
|
trigger.props.onClick?.(e)
|
|
45
47
|
toggle()
|
|
@@ -54,7 +56,7 @@ export function Collapsible({ label, trigger, open: openProp, defaultOpen = fals
|
|
|
54
56
|
disabled={disabled}
|
|
55
57
|
onClick={toggle}
|
|
56
58
|
>
|
|
57
|
-
<span>{label}</span>
|
|
59
|
+
<span className={styles.label}>{label}</span>
|
|
58
60
|
<ChevronDownIcon className={styles.chevron} />
|
|
59
61
|
</button>
|
|
60
62
|
)}
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
.root:active { transform: scale(var(--hl-press-scale-strong)); transition-duration: var(--hl-motion-instant); }
|
|
37
37
|
.root:focus-visible { outline: none; box-shadow: var(--hl-focus-ring); }
|
|
38
38
|
.root[data-copied] { border-color: var(--hl-color-fg); }
|
|
39
|
+
.root[data-failed] { border-color: var(--hl-color-danger); color: var(--hl-color-danger); }
|
|
39
40
|
|
|
40
41
|
.icons {
|
|
41
42
|
display: grid;
|
|
@@ -44,13 +45,15 @@
|
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
.copy,
|
|
47
|
-
.check
|
|
48
|
+
.check,
|
|
49
|
+
.fail {
|
|
48
50
|
grid-area: 1 / 1;
|
|
49
51
|
width: 100%;
|
|
50
52
|
height: 100%;
|
|
51
53
|
}
|
|
52
54
|
|
|
53
|
-
.copy
|
|
55
|
+
.copy,
|
|
56
|
+
.fail {
|
|
54
57
|
transition:
|
|
55
58
|
opacity var(--hl-motion-fast) var(--hl-ease-out),
|
|
56
59
|
transform var(--hl-motion-slow) var(--hl-ease-spring);
|
|
@@ -68,7 +71,11 @@
|
|
|
68
71
|
transition: stroke-dashoffset var(--hl-motion-base) var(--hl-ease-in);
|
|
69
72
|
}
|
|
70
73
|
|
|
71
|
-
.
|
|
74
|
+
.fail { opacity: 0; transform: scale(0.4); }
|
|
75
|
+
|
|
76
|
+
.root[data-copied] .copy,
|
|
77
|
+
.root[data-failed] .copy { opacity: 0; transform: scale(0.4) rotate(-45deg); }
|
|
78
|
+
.root[data-failed] .fail { opacity: 1; transform: none; }
|
|
72
79
|
|
|
73
80
|
.root[data-copied] .check {
|
|
74
81
|
stroke-dashoffset: 0;
|
|
@@ -81,7 +88,8 @@
|
|
|
81
88
|
}
|
|
82
89
|
|
|
83
90
|
.labelIdle,
|
|
84
|
-
.labelDone
|
|
91
|
+
.labelDone,
|
|
92
|
+
.labelFailed {
|
|
85
93
|
grid-area: 1 / 1;
|
|
86
94
|
transition:
|
|
87
95
|
transform var(--hl-motion-slow) var(--hl-ease-spring),
|
|
@@ -89,8 +97,13 @@
|
|
|
89
97
|
}
|
|
90
98
|
|
|
91
99
|
.labelDone { transform: translateY(100%); opacity: 0; }
|
|
92
|
-
|
|
93
|
-
.
|
|
100
|
+
/* Out of the layout until a copy fails, so the longer words do not widen every button. */
|
|
101
|
+
.labelFailed { display: none; }
|
|
102
|
+
.root[data-failed] .labelFailed { display: block; }
|
|
103
|
+
.root[data-copied] .labelIdle,
|
|
104
|
+
.root[data-failed] .labelIdle { transform: translateY(-100%); opacity: 0; }
|
|
105
|
+
.root[data-copied] .labelDone,
|
|
106
|
+
.root[data-failed] .labelFailed { transform: none; opacity: 1; }
|
|
94
107
|
|
|
95
108
|
.srOnly {
|
|
96
109
|
position: absolute;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect, useRef, useState } from 'react'
|
|
4
4
|
import { cx } from '../../internal/cx'
|
|
5
|
-
import { CopyIcon } from '../../internal/icons'
|
|
5
|
+
import { CopyIcon, XIcon } from '../../internal/icons'
|
|
6
6
|
import styles from './CopyButton.module.css'
|
|
7
7
|
|
|
8
8
|
export type CopyButtonProps = {
|
|
@@ -12,11 +12,13 @@ export type CopyButtonProps = {
|
|
|
12
12
|
label?: string
|
|
13
13
|
/** Shown and spoken after a successful copy. */
|
|
14
14
|
copiedLabel?: string
|
|
15
|
+
/** Shown and spoken when the browser blocks the copy. */
|
|
16
|
+
failedLabel?: string
|
|
15
17
|
/** Shows the label as text next to the icon. */
|
|
16
18
|
showLabel?: boolean
|
|
17
19
|
/** Size of the button. */
|
|
18
20
|
size?: 'sm' | 'md'
|
|
19
|
-
/** How long the check stays, in ms. */
|
|
21
|
+
/** How long the check or the failed state stays, in ms. */
|
|
20
22
|
resetAfter?: number
|
|
21
23
|
/** Called after the text is copied, with the copied text. Not called if copying fails. */
|
|
22
24
|
onCopy?: (value: string) => void
|
|
@@ -49,29 +51,31 @@ export function CopyButton({
|
|
|
49
51
|
value,
|
|
50
52
|
label = 'Copy',
|
|
51
53
|
copiedLabel = 'Copied',
|
|
54
|
+
failedLabel = 'Could not copy',
|
|
52
55
|
showLabel = false,
|
|
53
56
|
size = 'md',
|
|
54
57
|
resetAfter = 1800,
|
|
55
58
|
onCopy,
|
|
56
59
|
className,
|
|
57
60
|
}: CopyButtonProps) {
|
|
58
|
-
const [
|
|
61
|
+
const [status, setStatus] = useState<'idle' | 'copied' | 'failed'>('idle')
|
|
59
62
|
const timer = useRef<number | undefined>(undefined)
|
|
60
63
|
useEffect(() => () => window.clearTimeout(timer.current), [])
|
|
61
64
|
|
|
62
65
|
const copy = async () => {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
onCopy?.(value)
|
|
66
|
+
const ok = await writeText(value)
|
|
67
|
+
setStatus(ok ? 'copied' : 'failed')
|
|
68
|
+
if (ok) onCopy?.(value)
|
|
66
69
|
window.clearTimeout(timer.current)
|
|
67
|
-
timer.current = window.setTimeout(() =>
|
|
70
|
+
timer.current = window.setTimeout(() => setStatus('idle'), resetAfter)
|
|
68
71
|
}
|
|
69
72
|
|
|
70
73
|
return (
|
|
71
74
|
<button
|
|
72
75
|
type="button"
|
|
73
76
|
className={cx(styles.root, styles[size], showLabel && styles.withLabel, className)}
|
|
74
|
-
data-copied={copied || undefined}
|
|
77
|
+
data-copied={status === 'copied' || undefined}
|
|
78
|
+
data-failed={status === 'failed' || undefined}
|
|
75
79
|
aria-label={showLabel ? undefined : label}
|
|
76
80
|
onClick={copy}
|
|
77
81
|
>
|
|
@@ -80,14 +84,16 @@ export function CopyButton({
|
|
|
80
84
|
<svg viewBox="0 0 24 24" className={styles.check}>
|
|
81
85
|
<path d="M5 12.5l4.5 4.5L19 7.5" pathLength="1" />
|
|
82
86
|
</svg>
|
|
87
|
+
<XIcon className={styles.fail} />
|
|
83
88
|
</span>
|
|
84
89
|
{showLabel && (
|
|
85
90
|
<span className={styles.labels}>
|
|
86
91
|
<span className={styles.labelIdle}>{label}</span>
|
|
87
92
|
<span className={styles.labelDone} aria-hidden="true">{copiedLabel}</span>
|
|
93
|
+
<span className={styles.labelFailed} aria-hidden="true">{failedLabel}</span>
|
|
88
94
|
</span>
|
|
89
95
|
)}
|
|
90
|
-
<span className={styles.srOnly} aria-live="polite">{copied ? copiedLabel : ''}</span>
|
|
96
|
+
<span className={styles.srOnly} aria-live="polite">{status === 'copied' ? copiedLabel : status === 'failed' ? failedLabel : ''}</span>
|
|
91
97
|
</button>
|
|
92
98
|
)
|
|
93
99
|
}
|
|
@@ -36,7 +36,9 @@
|
|
|
36
36
|
animation: panelIn var(--hl-motion-slow) var(--hl-ease-spring);
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
/* A closing panel takes no clicks, so a double click cannot run a footer action twice.
|
|
40
|
+
The overlay still catches them, so they never reach the page behind. */
|
|
41
|
+
.panel[data-state="closed"] { animation: panelOut var(--hl-motion-base) var(--hl-ease-in) forwards; pointer-events: none; }
|
|
40
42
|
|
|
41
43
|
.sm { max-width: var(--hl-panel-w-sm); }
|
|
42
44
|
.lg { max-width: var(--hl-panel-w-lg); }
|
|
@@ -23,7 +23,23 @@
|
|
|
23
23
|
.root::after {
|
|
24
24
|
content: "";
|
|
25
25
|
position: absolute;
|
|
26
|
-
inset: min(0px, calc((100% - var(--hl-hit-min)) / 2));
|
|
26
|
+
inset-block: min(0px, calc((100% - var(--hl-hit-min)) / 2));
|
|
27
|
+
inset-inline: 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/* Wider only for touch, as in Button: a mouse does not need it, and dense desktop rows have no gap to spare. */
|
|
31
|
+
@media (pointer: coarse) {
|
|
32
|
+
.root::after { inset-inline: min(0px, calc((100% - var(--hl-hit-min)) / 2)); }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/* Beside another button, the hit area sinks below every box in the row, so it only fills the empty gap.
|
|
36
|
+
Links are left out: a card link with this button laid over it must not swallow the hit area. */
|
|
37
|
+
:has(> button + .root, > .root + button) { isolation: isolate; }
|
|
38
|
+
:has(> button + .root, > .root + button) > .root::after { z-index: -1; }
|
|
39
|
+
|
|
40
|
+
/* A narrow gap leaves no room to share, so a ghost button beside another one takes the full width as its own box. */
|
|
41
|
+
@media (pointer: coarse) {
|
|
42
|
+
.ghost:is(.root + .root, :has(+ .root)) { min-inline-size: var(--hl-hit-min); }
|
|
27
43
|
}
|
|
28
44
|
|
|
29
45
|
.sm { --s: var(--hl-control-h-sm); }
|
|
@@ -38,6 +38,13 @@
|
|
|
38
38
|
.lg { --h: var(--hl-control-h-lg); }
|
|
39
39
|
.floating { --h: var(--hl-field-h-floating); }
|
|
40
40
|
|
|
41
|
+
/* On touch the input reaches over the field border, so the whole 44px box is the input's own target. */
|
|
42
|
+
@media (pointer: coarse) {
|
|
43
|
+
.field { min-height: max(var(--h), var(--hl-hit-min)); }
|
|
44
|
+
.control { margin-block: calc(var(--hl-line) * -1); }
|
|
45
|
+
.field[data-invalid] .control { margin-block: calc(var(--hl-line-thick) * -1); }
|
|
46
|
+
}
|
|
47
|
+
|
|
41
48
|
@media (hover: hover) {
|
|
42
49
|
.field:hover { border-color: var(--hl-color-fg-muted); }
|
|
43
50
|
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useId, useState, type ComponentPropsWithRef, type ReactNode } from 'react'
|
|
3
|
+
import { useEffect, useId, useRef, useState, type ComponentPropsWithRef, type ReactNode } from 'react'
|
|
4
4
|
import { cx } from '../../internal/cx'
|
|
5
5
|
import { FieldMessage } from '../../internal/FieldMessage'
|
|
6
|
+
import { mergeRefs } from '../../internal/mergeRefs'
|
|
6
7
|
import styles from './Input.module.css'
|
|
7
8
|
|
|
8
9
|
export type InputProps = Omit<ComponentPropsWithRef<'input'>, 'size'> & {
|
|
@@ -49,6 +50,7 @@ export function Input({
|
|
|
49
50
|
defaultValue,
|
|
50
51
|
onChange,
|
|
51
52
|
labels: labelsProp,
|
|
53
|
+
ref,
|
|
52
54
|
'aria-describedby': describedByProp,
|
|
53
55
|
...rest
|
|
54
56
|
}: InputProps) {
|
|
@@ -60,6 +62,22 @@ export function Input({
|
|
|
60
62
|
const [innerFilled, setInnerFilled] = useState(() => String(defaultValue ?? '') !== '')
|
|
61
63
|
const filled = value !== undefined ? String(value) !== '' : innerFilled
|
|
62
64
|
const floating = labelPlacement === 'floating'
|
|
65
|
+
const inner = useRef<HTMLInputElement>(null)
|
|
66
|
+
|
|
67
|
+
// A native form reset changes the value without an onChange
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
const form = inner.current?.form
|
|
70
|
+
if (!form) return
|
|
71
|
+
let frame = 0
|
|
72
|
+
const onReset = () => {
|
|
73
|
+
frame = requestAnimationFrame(() => setInnerFilled(inner.current?.value !== ''))
|
|
74
|
+
}
|
|
75
|
+
form.addEventListener('reset', onReset)
|
|
76
|
+
return () => {
|
|
77
|
+
form.removeEventListener('reset', onReset)
|
|
78
|
+
cancelAnimationFrame(frame)
|
|
79
|
+
}
|
|
80
|
+
}, [])
|
|
63
81
|
// The caller's ids join ours, so help and error stay linked.
|
|
64
82
|
const describedBy = [help && !error ? helpId : null, error ? errorId : null, describedByProp]
|
|
65
83
|
.filter(Boolean)
|
|
@@ -89,6 +107,7 @@ export function Input({
|
|
|
89
107
|
)}
|
|
90
108
|
<input
|
|
91
109
|
id={inputId}
|
|
110
|
+
ref={mergeRefs(inner, ref)}
|
|
92
111
|
className={styles.input}
|
|
93
112
|
value={value}
|
|
94
113
|
defaultValue={defaultValue}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
|
+
/* Inline, not inline-flex, so a long link wraps with the sentence and each line keeps its underline. */
|
|
1
2
|
.root {
|
|
2
3
|
position: relative;
|
|
3
|
-
display: inline
|
|
4
|
-
align-items: baseline;
|
|
5
|
-
gap: var(--hl-space-1);
|
|
4
|
+
display: inline;
|
|
6
5
|
color: var(--hl-color-fg);
|
|
7
6
|
font-weight: var(--hl-weight-medium);
|
|
8
7
|
text-decoration: none;
|
|
9
8
|
border-radius: var(--hl-radius-xs);
|
|
9
|
+
-webkit-box-decoration-break: clone;
|
|
10
|
+
box-decoration-break: clone;
|
|
10
11
|
-webkit-tap-highlight-color: transparent;
|
|
11
12
|
}
|
|
12
13
|
|
|
@@ -32,12 +33,14 @@
|
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
.standalone {
|
|
35
|
-
|
|
36
|
+
display: inline-flex;
|
|
36
37
|
align-items: center;
|
|
38
|
+
min-height: var(--hl-hit-min);
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
.arrow {
|
|
40
|
-
|
|
42
|
+
margin-inline-start: var(--hl-space-1);
|
|
43
|
+
vertical-align: middle;
|
|
41
44
|
width: 0.85em;
|
|
42
45
|
height: 0.85em;
|
|
43
46
|
transition: transform var(--hl-motion-base) var(--hl-ease-spring);
|
|
@@ -109,6 +109,9 @@ export function NumberInput({
|
|
|
109
109
|
const [text, setText] = useState('')
|
|
110
110
|
const valueRef = useRef(value)
|
|
111
111
|
valueRef.current = value
|
|
112
|
+
// The hold timer keeps calling the bump from its first render, so it reads the typed text from a ref
|
|
113
|
+
const typed = useRef<string | null>(null)
|
|
114
|
+
typed.current = editing ? text : null
|
|
112
115
|
const hold = useRef<{ timer?: number; count: number }>({ count: 0 })
|
|
113
116
|
const decimals = (String(step).split('.')[1] ?? '').length
|
|
114
117
|
const fmt = useMemo(() => new Intl.NumberFormat(locale, format), [locale, format])
|
|
@@ -121,11 +124,16 @@ export function NumberInput({
|
|
|
121
124
|
valueRef.current = next
|
|
122
125
|
setValue(next)
|
|
123
126
|
}
|
|
124
|
-
if (editing)
|
|
127
|
+
if (editing) {
|
|
128
|
+
const shown = next === null ? '' : plain.format(next)
|
|
129
|
+
typed.current = shown
|
|
130
|
+
setText(shown)
|
|
131
|
+
}
|
|
125
132
|
}
|
|
126
133
|
const bump = (dir: 1 | -1, size = step) => {
|
|
127
|
-
const
|
|
128
|
-
|
|
134
|
+
const current = (typed.current === null ? null : parseNumber(typed.current, locale)) ?? valueRef.current
|
|
135
|
+
const base = current ?? (min > -Infinity ? min : 0)
|
|
136
|
+
commit(current === null ? base : base + dir * size)
|
|
129
137
|
}
|
|
130
138
|
|
|
131
139
|
const stopHold = () => {
|
|
@@ -133,7 +141,8 @@ export function NumberInput({
|
|
|
133
141
|
hold.current.count = 0
|
|
134
142
|
}
|
|
135
143
|
const startHold = (dir: 1 | -1) => (e: PointerEvent<HTMLButtonElement>) => {
|
|
136
|
-
|
|
144
|
+
// Chromium still sends pointer events to a disabled button
|
|
145
|
+
if (e.button !== 0 || e.currentTarget.disabled) return
|
|
137
146
|
e.preventDefault()
|
|
138
147
|
bump(dir)
|
|
139
148
|
// A disabled button gets no pointerup, so listen on the window
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
@media (hover: hover) {
|
|
69
69
|
.btn:hover:not(:disabled):not([aria-current]) { background: var(--hl-color-surface-hover); }
|
|
70
70
|
}
|
|
71
|
-
.btn:active:not(:disabled) { transform: scale(
|
|
71
|
+
.btn:active:not(:disabled) { transform: scale(var(--hl-press-scale-strong)); }
|
|
72
72
|
.btn[aria-current="page"] { color: var(--hl-color-accent-fg); }
|
|
73
73
|
.btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
|
74
74
|
.btn:focus-visible { outline: none; box-shadow: var(--hl-focus-ring); }
|
|
@@ -29,17 +29,18 @@ export type PaginationProps = {
|
|
|
29
29
|
className?: string
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
const span = (from: number, to: number) => Array.from({ length: to - from + 1 }, (_, i) => from + i)
|
|
33
|
+
|
|
34
|
+
// Always the same number of slots, so the row keeps its width. A gap hides at least two pages.
|
|
32
35
|
function range(page: number, count: number, siblings: number): Array<number | 'gap'> {
|
|
33
36
|
const total = siblings * 2 + 5
|
|
34
|
-
if (count <= total) return
|
|
35
|
-
const
|
|
36
|
-
const
|
|
37
|
-
const
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
out.push(count)
|
|
42
|
-
return out
|
|
37
|
+
if (count <= total) return span(1, count)
|
|
38
|
+
const side = total - 2
|
|
39
|
+
const leftGap = page - siblings > 3
|
|
40
|
+
const rightGap = page + siblings < count - 2
|
|
41
|
+
if (!leftGap) return [...span(1, side), 'gap', count]
|
|
42
|
+
if (!rightGap) return [1, 'gap', ...span(count - side + 1, count)]
|
|
43
|
+
return [1, 'gap', ...span(page - siblings, page + siblings), 'gap', count]
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
export function Pagination({
|
|
@@ -54,7 +55,9 @@ export function Pagination({
|
|
|
54
55
|
pageLabel = (p) => `Page ${p}`,
|
|
55
56
|
className,
|
|
56
57
|
}: PaginationProps) {
|
|
57
|
-
const [
|
|
58
|
+
const [rawPage, setPage] = useControllable(pageProp, defaultPage, onPageChange)
|
|
59
|
+
// A stored page can sit past the end after pageCount drops.
|
|
60
|
+
const page = Math.min(Math.max(rawPage, 1), Math.max(pageCount, 1))
|
|
58
61
|
const listRef = useRef<HTMLUListElement>(null)
|
|
59
62
|
const [pill, setPill] = useState<{ x: number; w: number } | null>(null)
|
|
60
63
|
const [ready, setReady] = useState(false)
|
|
@@ -37,7 +37,7 @@ export function ProgressBar({ label, value, max = 100, showValue = true, hideLab
|
|
|
37
37
|
aria-labelledby={id}
|
|
38
38
|
aria-valuemin={determinate ? 0 : undefined}
|
|
39
39
|
aria-valuemax={determinate ? max : undefined}
|
|
40
|
-
aria-valuenow={determinate ? value : undefined}
|
|
40
|
+
aria-valuenow={determinate ? Math.min(Math.max(value, 0), max) : undefined}
|
|
41
41
|
className={cx(styles.track, styles[size])}
|
|
42
42
|
data-indeterminate={!determinate || undefined}
|
|
43
43
|
>
|
|
@@ -90,13 +90,17 @@ export function ScrollArea({ label, maxHeight, orientation = 'vertical', classNa
|
|
|
90
90
|
e.preventDefault()
|
|
91
91
|
e.stopPropagation()
|
|
92
92
|
const track = e.currentTarget.parentElement as HTMLElement
|
|
93
|
+
const size = axis === 'y' ? vp.clientHeight : vp.clientWidth
|
|
93
94
|
const full = axis === 'y' ? vp.scrollHeight : vp.scrollWidth
|
|
94
|
-
const
|
|
95
|
+
const pad = parseFloat(getComputedStyle(track).paddingTop) || 0
|
|
96
|
+
const trackLen = (axis === 'y' ? track.clientHeight : track.clientWidth) - pad * 2
|
|
97
|
+
const thumbLen = axis === 'y' ? e.currentTarget.offsetHeight : e.currentTarget.offsetWidth
|
|
98
|
+
// The thumb travels the track minus its own length, which is all the scroll there is.
|
|
95
99
|
drag.current = {
|
|
96
100
|
axis,
|
|
97
101
|
start: axis === 'y' ? e.clientY : e.clientX,
|
|
98
102
|
scroll: axis === 'y' ? vp.scrollTop : vp.scrollLeft,
|
|
99
|
-
ratio: full / trackLen,
|
|
103
|
+
ratio: (full - size) / Math.max(1, trackLen - thumbLen),
|
|
100
104
|
pointer: e.pointerId,
|
|
101
105
|
}
|
|
102
106
|
e.currentTarget.setPointerCapture(e.pointerId)
|
|
@@ -42,11 +42,14 @@ export function SearchInput({
|
|
|
42
42
|
const [value, setValue] = useControllable(valueProp, defaultValue, onValueChange)
|
|
43
43
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
44
44
|
|
|
45
|
-
// Ignored while another field has focus, so it never steals typed text
|
|
45
|
+
// Ignored while another field has focus, so it never steals typed text, and while a modal
|
|
46
|
+
// this field is not in is open, so focus never leaves the modal.
|
|
46
47
|
useEffect(() => {
|
|
47
48
|
if (!shortcut) return
|
|
48
49
|
const onKey = (e: KeyboardEvent) => {
|
|
49
50
|
if (e.key !== shortcut || e.metaKey || e.ctrlKey || e.altKey || isTyping(document.activeElement)) return
|
|
51
|
+
const modals = document.querySelectorAll('[aria-modal="true"]')
|
|
52
|
+
if (modals.length && !modals[modals.length - 1].contains(inputRef.current)) return
|
|
50
53
|
e.preventDefault()
|
|
51
54
|
inputRef.current?.focus()
|
|
52
55
|
inputRef.current?.select()
|
|
@@ -36,8 +36,9 @@
|
|
|
36
36
|
align-items: center;
|
|
37
37
|
justify-content: center;
|
|
38
38
|
gap: var(--hl-space-3);
|
|
39
|
+
--pad: var(--hl-space-5);
|
|
39
40
|
min-height: calc(var(--hl-control-h-md) - var(--hl-space-1) * 2);
|
|
40
|
-
padding: 0 var(--
|
|
41
|
+
padding: 0 var(--pad);
|
|
41
42
|
border: 0;
|
|
42
43
|
border-radius: var(--hl-radius-control);
|
|
43
44
|
background: transparent;
|
|
@@ -60,8 +61,8 @@
|
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
.sm .item {
|
|
64
|
+
--pad: var(--hl-space-4);
|
|
63
65
|
min-height: calc(var(--hl-control-h-sm) - var(--hl-space-1) * 2);
|
|
64
|
-
padding: 0 var(--hl-space-4);
|
|
65
66
|
font-size: var(--hl-text-xs);
|
|
66
67
|
}
|
|
67
68
|
|
|
@@ -79,5 +80,31 @@
|
|
|
79
80
|
|
|
80
81
|
.icon {
|
|
81
82
|
display: inline-flex;
|
|
83
|
+
flex: none;
|
|
82
84
|
font-size: var(--hl-icon-md);
|
|
83
85
|
}
|
|
86
|
+
|
|
87
|
+
/* Too wide for its box (a narrow phone): less padding first, then long labels end in an ellipsis */
|
|
88
|
+
.root[data-tight] .item {
|
|
89
|
+
flex: 1 1 auto;
|
|
90
|
+
min-width: 0;
|
|
91
|
+
--pad: var(--hl-space-3);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.text {
|
|
95
|
+
min-width: 0;
|
|
96
|
+
overflow: hidden;
|
|
97
|
+
text-overflow: ellipsis;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/* Segments touch, so the box itself is the 44px target. The floor sits on the content, so the
|
|
101
|
+
overflow check still sees the natural width, and tight mode shrinks no further than 44px. */
|
|
102
|
+
@media (pointer: coarse) {
|
|
103
|
+
.text,
|
|
104
|
+
.icon:only-child {
|
|
105
|
+
min-width: calc(var(--hl-hit-min) - var(--pad) * 2);
|
|
106
|
+
justify-content: center;
|
|
107
|
+
text-align: center;
|
|
108
|
+
}
|
|
109
|
+
.root[data-tight] .item { min-width: var(--hl-hit-min); }
|
|
110
|
+
}
|
|
@@ -47,6 +47,12 @@ export function SegmentedControl({
|
|
|
47
47
|
|
|
48
48
|
useLayoutEffect(() => {
|
|
49
49
|
const measure = () => {
|
|
50
|
+
// Tight mode only when the segments overflow at their normal size, so a wide screen keeps the normal look
|
|
51
|
+
const root = rootRef.current
|
|
52
|
+
if (root) {
|
|
53
|
+
root.removeAttribute('data-tight')
|
|
54
|
+
if (root.scrollWidth > root.clientWidth) root.setAttribute('data-tight', '')
|
|
55
|
+
}
|
|
50
56
|
const el = refs.current[index]
|
|
51
57
|
if (el) setPill({ x: el.offsetLeft, w: el.offsetWidth })
|
|
52
58
|
}
|
|
@@ -119,7 +125,7 @@ export function SegmentedControl({
|
|
|
119
125
|
onClick={() => setValue(o.value)}
|
|
120
126
|
>
|
|
121
127
|
{o.icon && <span className={styles.icon}>{o.icon}</span>}
|
|
122
|
-
{o.label}
|
|
128
|
+
{o.label && <span className={styles.text}>{o.label}</span>}
|
|
123
129
|
</button>
|
|
124
130
|
)
|
|
125
131
|
})}
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
|
|
55
55
|
.trigger:focus-visible { box-shadow: var(--hl-focus-ring); }
|
|
56
56
|
.trigger[data-invalid] { border-color: var(--hl-color-danger); border-width: var(--hl-line-thick); }
|
|
57
|
-
.trigger:disabled { opacity:
|
|
57
|
+
.trigger:disabled { opacity: var(--hl-disabled-opacity); cursor: not-allowed; background: var(--hl-color-surface-sunken); }
|
|
58
58
|
|
|
59
59
|
.value {
|
|
60
60
|
min-width: 0;
|
|
@@ -149,7 +149,7 @@
|
|
|
149
149
|
width: var(--hl-icon-md);
|
|
150
150
|
height: var(--hl-icon-md);
|
|
151
151
|
opacity: 0;
|
|
152
|
-
transform: scale(
|
|
152
|
+
transform: scale(var(--hl-check-scale));
|
|
153
153
|
transition:
|
|
154
154
|
opacity var(--hl-motion-fast) var(--hl-ease-out),
|
|
155
155
|
transform var(--hl-motion-slow) var(--hl-ease-spring);
|
|
@@ -127,7 +127,7 @@ export function Select({
|
|
|
127
127
|
} else if (k === 'ArrowUp') {
|
|
128
128
|
e.preventDefault()
|
|
129
129
|
openList(enabled(selectedIndex) ? selectedIndex : last())
|
|
130
|
-
} else if (k.length === 1) {
|
|
130
|
+
} else if (k.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
|
131
131
|
openList()
|
|
132
132
|
runTypeahead(k)
|
|
133
133
|
}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
1
4
|
import { cx } from '../../internal/cx'
|
|
2
5
|
import styles from './Spinner.module.css'
|
|
3
6
|
|
|
@@ -11,13 +14,16 @@ export type SpinnerProps = {
|
|
|
11
14
|
}
|
|
12
15
|
|
|
13
16
|
export function Spinner({ size = 'md', label = 'Loading', className }: SpinnerProps) {
|
|
17
|
+
// The label lands after the empty live region is mounted, so screen readers hear it.
|
|
18
|
+
const [mounted, setMounted] = useState(false)
|
|
19
|
+
useEffect(() => setMounted(true), [])
|
|
14
20
|
return (
|
|
15
21
|
<span className={cx(styles.root, styles[size], className)} role={label ? 'status' : undefined}>
|
|
16
22
|
<svg className={styles.svg} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
|
17
23
|
<circle className={styles.track} cx="12" cy="12" r="9.5" />
|
|
18
24
|
<circle className={styles.arc} cx="12" cy="12" r="9.5" pathLength="100" />
|
|
19
25
|
</svg>
|
|
20
|
-
{label && <span className={styles.srOnly}>{label}</span>}
|
|
26
|
+
{label && <span className={styles.srOnly}>{mounted ? label : null}</span>}
|
|
21
27
|
</span>
|
|
22
28
|
)
|
|
23
29
|
}
|
|
@@ -18,6 +18,8 @@ export type SwitchProps = Omit<ComponentPropsWithRef<'button'>, 'onChange' | 'va
|
|
|
18
18
|
onCheckedChange?: (checked: boolean) => void
|
|
19
19
|
/** Puts the label before the switch, like a settings row. */
|
|
20
20
|
labelPosition?: 'start' | 'end'
|
|
21
|
+
/** Form field name. The form gets "on" while the switch is on, and nothing while it is off. */
|
|
22
|
+
name?: string
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
export function Switch({
|
|
@@ -31,6 +33,7 @@ export function Switch({
|
|
|
31
33
|
className,
|
|
32
34
|
disabled,
|
|
33
35
|
onClick,
|
|
36
|
+
name,
|
|
34
37
|
'aria-describedby': describedByProp,
|
|
35
38
|
...rest
|
|
36
39
|
}: SwitchProps) {
|
|
@@ -61,6 +64,8 @@ export function Switch({
|
|
|
61
64
|
<label htmlFor={switchId} className={styles.label}>{label}</label>
|
|
62
65
|
{description && <span id={`${switchId}-desc`} className={styles.description}>{description}</span>}
|
|
63
66
|
</span>
|
|
67
|
+
{/* Submits like a checkbox: "on" when on, nothing when off */}
|
|
68
|
+
{name && checked && <input type="hidden" name={name} value="on" />}
|
|
64
69
|
</div>
|
|
65
70
|
)
|
|
66
71
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
.root {
|
|
2
2
|
display: inline-flex;
|
|
3
3
|
align-items: center;
|
|
4
|
+
max-width: 100%;
|
|
4
5
|
height: var(--hl-control-h-sm);
|
|
5
6
|
border: var(--hl-line) solid var(--hl-color-border-strong);
|
|
6
7
|
border-radius: var(--hl-radius-control);
|
|
@@ -26,6 +27,7 @@
|
|
|
26
27
|
display: inline-flex;
|
|
27
28
|
align-items: center;
|
|
28
29
|
gap: var(--hl-space-2);
|
|
30
|
+
min-width: 0;
|
|
29
31
|
height: 100%;
|
|
30
32
|
padding: 0 var(--hl-space-4);
|
|
31
33
|
}
|
|
@@ -85,6 +87,7 @@
|
|
|
85
87
|
position: relative;
|
|
86
88
|
display: grid;
|
|
87
89
|
place-items: center;
|
|
90
|
+
flex: none;
|
|
88
91
|
width: var(--hl-mini-button);
|
|
89
92
|
height: var(--hl-mini-button);
|
|
90
93
|
margin-right: var(--hl-space-1);
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useRef, useState, type ReactNode } from 'react'
|
|
4
4
|
import { cx } from '../../internal/cx'
|
|
5
|
+
import { focusables } from '../../internal/focus'
|
|
5
6
|
import { CheckIcon, XIcon } from '../../internal/icons'
|
|
6
7
|
import { easing, motionMs } from '../../internal/motion'
|
|
7
8
|
import styles from './Tag.module.css'
|
|
@@ -43,16 +44,25 @@ export function Tag({ children, onRemove, removeLabel, selected, onSelectedChang
|
|
|
43
44
|
const [leaving, setLeaving] = useState(false)
|
|
44
45
|
const selectable = selected !== undefined
|
|
45
46
|
|
|
47
|
+
// The remove button is about to unmount, so focus moves to the next control (or the one before) instead of the body.
|
|
48
|
+
const finish = () => {
|
|
49
|
+
const el = ref.current
|
|
50
|
+
if (el?.contains(document.activeElement)) {
|
|
51
|
+
const others = focusables(document.body).filter((n) => !el.contains(n))
|
|
52
|
+
const next = others.find((n) => el.compareDocumentPosition(n) & Node.DOCUMENT_POSITION_FOLLOWING)
|
|
53
|
+
const prev = [...others].reverse().find((n) => el.compareDocumentPosition(n) & Node.DOCUMENT_POSITION_PRECEDING)
|
|
54
|
+
;(next ?? prev)?.focus({ preventScroll: true })
|
|
55
|
+
}
|
|
56
|
+
onRemove?.()
|
|
57
|
+
}
|
|
58
|
+
|
|
46
59
|
// Collapse first so neighbours slide in, then tell the parent
|
|
47
60
|
const remove = () => {
|
|
48
61
|
const el = ref.current
|
|
49
62
|
if (!el || leaving) return
|
|
50
63
|
setLeaving(true)
|
|
51
64
|
const ms = motionMs('base')
|
|
52
|
-
if (ms === 0 || !el.animate)
|
|
53
|
-
onRemove?.()
|
|
54
|
-
return
|
|
55
|
-
}
|
|
65
|
+
if (ms === 0 || !el.animate) return finish()
|
|
56
66
|
const w = el.offsetWidth
|
|
57
67
|
const anim = el.animate(
|
|
58
68
|
[
|
|
@@ -61,7 +71,7 @@ export function Tag({ children, onRemove, removeLabel, selected, onSelectedChang
|
|
|
61
71
|
],
|
|
62
72
|
{ duration: ms, easing: easing('in-out'), fill: 'forwards' },
|
|
63
73
|
)
|
|
64
|
-
anim.onfinish =
|
|
74
|
+
anim.onfinish = finish
|
|
65
75
|
}
|
|
66
76
|
|
|
67
77
|
const label = typeof children === 'string' ? children : labels.item
|
|
@@ -88,7 +98,7 @@ export function Tag({ children, onRemove, removeLabel, selected, onSelectedChang
|
|
|
88
98
|
<span className={styles.static}>{content}</span>
|
|
89
99
|
)}
|
|
90
100
|
{onRemove && (
|
|
91
|
-
<button type="button" className={styles.remove} aria-label={removeLabel ?? labels.remove(label)} onClick={remove}
|
|
101
|
+
<button type="button" className={styles.remove} aria-label={removeLabel ?? labels.remove(label)} onClick={remove}>
|
|
92
102
|
<XIcon />
|
|
93
103
|
</button>
|
|
94
104
|
)}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useCallback, useId, useLayoutEffect, useRef, type ComponentPropsWithRef, type ReactNode } from 'react'
|
|
3
|
+
import { useCallback, useEffect, useId, useLayoutEffect, useRef, type ComponentPropsWithRef, type ReactNode } from 'react'
|
|
4
4
|
import { cx } from '../../internal/cx'
|
|
5
5
|
import { FieldMessage } from '../../internal/FieldMessage'
|
|
6
6
|
import { mergeRefs } from '../../internal/mergeRefs'
|
|
@@ -74,6 +74,21 @@ export function Textarea({
|
|
|
74
74
|
|
|
75
75
|
useLayoutEffect(resize, [resize, value])
|
|
76
76
|
|
|
77
|
+
// A native form reset changes the value without an onChange
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
const form = inner.current?.form
|
|
80
|
+
if (!form) return
|
|
81
|
+
let frame = 0
|
|
82
|
+
const onReset = () => {
|
|
83
|
+
frame = requestAnimationFrame(resize)
|
|
84
|
+
}
|
|
85
|
+
form.addEventListener('reset', onReset)
|
|
86
|
+
return () => {
|
|
87
|
+
form.removeEventListener('reset', onReset)
|
|
88
|
+
cancelAnimationFrame(frame)
|
|
89
|
+
}
|
|
90
|
+
}, [resize])
|
|
91
|
+
|
|
77
92
|
const describedBy = [help && !error ? helpId : null, error ? errorId : null, showCount && maxLength ? countId : null, describedByProp]
|
|
78
93
|
.filter(Boolean)
|
|
79
94
|
.join(' ')
|
|
@@ -76,8 +76,10 @@ export function ToastProvider({ children, visible = 3, label = 'Notifications',
|
|
|
76
76
|
|
|
77
77
|
const say = (opts: Partial<ToastOptions>, variant?: ToastVariant) => {
|
|
78
78
|
const text = `${opts.title}${opts.description ? `. ${opts.description}` : ''}`
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
// The same text twice is not read again, so a trailing space makes it change.
|
|
80
|
+
const fresh = (prev: string) => (prev === text ? `${text} ` : text)
|
|
81
|
+
if (variant === 'error') setUrgent(fresh)
|
|
82
|
+
else setAnnounce(fresh)
|
|
81
83
|
}
|
|
82
84
|
|
|
83
85
|
const remove = useCallback((id: number, dir: Item['leaving'] = 'down') => {
|
package/src/tokens.css
CHANGED
|
@@ -187,6 +187,12 @@
|
|
|
187
187
|
/* Small controls (icon buttons, checkboxes, steppers) press deeper so the tap is felt. */
|
|
188
188
|
--hl-press-scale-strong: 0.9;
|
|
189
189
|
--hl-pop-scale: 0.96;
|
|
190
|
+
/* A check mark or small icon grows in from this size, and a ticked box bumps up to the peak. */
|
|
191
|
+
--hl-check-scale: 0.4;
|
|
192
|
+
--hl-check-peak-scale: 1.12;
|
|
193
|
+
|
|
194
|
+
/* One look for every disabled control */
|
|
195
|
+
--hl-disabled-opacity: 0.5;
|
|
190
196
|
|
|
191
197
|
/* Layers */
|
|
192
198
|
--hl-z-sticky: 10;
|
|
@@ -299,6 +305,8 @@
|
|
|
299
305
|
--hl-press-scale: 1;
|
|
300
306
|
--hl-press-scale-strong: 1;
|
|
301
307
|
--hl-pop-scale: 1;
|
|
308
|
+
--hl-check-scale: 1;
|
|
309
|
+
--hl-check-peak-scale: 1;
|
|
302
310
|
}
|
|
303
311
|
}
|
|
304
312
|
|