hlmnhkl-ui 0.24.0 → 0.25.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/scripts/pro-components.json +6 -2
- package/src/components/AlertDialog/AlertDialog.module.css +2 -0
- package/src/components/AlertDialog/AlertDialog.tsx +106 -0
- package/src/components/AlertDialog/index.ts +1 -0
- package/src/components/Avatar/Avatar.module.css +0 -31
- package/src/components/Avatar/Avatar.tsx +5 -36
- package/src/components/Avatar/index.ts +2 -0
- package/src/components/AvatarGroup/AvatarGroup.module.css +53 -0
- package/src/components/AvatarGroup/AvatarGroup.tsx +43 -0
- package/src/components/AvatarGroup/index.ts +1 -0
- package/src/components/ButtonGroup/ButtonGroup.module.css +51 -0
- package/src/components/ButtonGroup/ButtonGroup.tsx +20 -0
- package/src/components/ButtonGroup/index.ts +1 -0
- package/src/components/Card/Card.module.css +6 -1
- package/src/components/CheckboxGroup/CheckboxGroup.module.css +30 -0
- package/src/components/CheckboxGroup/CheckboxGroup.tsx +88 -0
- package/src/components/CheckboxGroup/index.ts +1 -0
- package/src/components/CodeBlock/CodeBlock.module.css +74 -0
- package/src/components/CodeBlock/CodeBlock.tsx +53 -0
- package/src/components/CodeBlock/highlight.ts +73 -0
- package/src/components/CodeBlock/index.ts +1 -0
- package/src/components/CopyButton/CopyButton.module.css +2 -0
- package/src/components/Dialog/Dialog.module.css +8 -5
- package/src/components/Fieldset/Fieldset.module.css +34 -0
- package/src/components/Fieldset/Fieldset.tsx +41 -0
- package/src/components/Fieldset/index.ts +1 -0
- package/src/components/IconButton/IconButton.module.css +2 -0
- package/src/components/Input/Input.module.css +3 -1
- package/src/components/InputGroup/InputGroup.module.css +146 -0
- package/src/components/InputGroup/InputGroup.tsx +90 -0
- package/src/components/InputGroup/index.ts +1 -0
- package/src/components/Meter/Meter.module.css +91 -0
- package/src/components/Meter/Meter.tsx +109 -0
- package/src/components/Meter/index.ts +1 -0
- package/src/components/NumberInput/NumberInput.module.css +3 -0
- package/src/components/Pagination/Pagination.module.css +3 -0
- package/src/components/ScrollArea/ScrollArea.module.css +5 -2
- package/src/components/Toggle/Toggle.module.css +94 -0
- package/src/components/Toggle/Toggle.tsx +50 -0
- package/src/components/Toggle/index.ts +1 -0
- package/src/internal/scrollFrame.ts +47 -114
- package/src/tokens.css +75 -6
package/README.md
CHANGED
|
@@ -65,7 +65,7 @@ Setup for other tools: [MCP server](https://ui.hlmnhkl.my/mcp).
|
|
|
65
65
|
## What you get
|
|
66
66
|
|
|
67
67
|
- **The source, not a package.** The command copies plain files into your app. Change anything; there is no update to wait for.
|
|
68
|
-
- **
|
|
68
|
+
- **59 free components**, from Button, Dialog, Select and Table to Toast and Retime, one speed setting for every animation. See [COMPONENTS.md](COMPONENTS.md).
|
|
69
69
|
- **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.
|
|
70
70
|
- **Light and dark.** Dark follows the system, or set `data-theme="dark"` on `<html>`.
|
|
71
71
|
- **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. CalendarHeatmap's days are small by design, since a year must fit on screen; its month table gives the same numbers at full size.
|
package/package.json
CHANGED
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
"Navbar",
|
|
38
38
|
"NotificationInbox",
|
|
39
39
|
"OnionSkin",
|
|
40
|
+
"Parallax",
|
|
40
41
|
"PhoneInput",
|
|
41
42
|
"PinInput",
|
|
42
43
|
"ProgressRing",
|
|
@@ -50,12 +51,15 @@
|
|
|
50
51
|
"RichTextEditor",
|
|
51
52
|
"Route",
|
|
52
53
|
"ScrollDraft",
|
|
53
|
-
"
|
|
54
|
+
"ScrollExpand",
|
|
55
|
+
"ScrollStory",
|
|
56
|
+
"ScrollTone",
|
|
57
|
+
"ScrollTrack",
|
|
54
58
|
"Sidebar",
|
|
55
59
|
"SignaturePad",
|
|
56
|
-
"SmoothScroll",
|
|
57
60
|
"SortableList",
|
|
58
61
|
"Sparkline",
|
|
62
|
+
"StackScroll",
|
|
59
63
|
"Stat",
|
|
60
64
|
"Stepper",
|
|
61
65
|
"SwipeActions",
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useId, useRef, type ReactNode } from 'react'
|
|
4
|
+
import { cx } from '../../internal/cx'
|
|
5
|
+
import { useFocusTrap } from '../../internal/focus'
|
|
6
|
+
import { useDismiss, useScrollLock } from '../../internal/layers'
|
|
7
|
+
import { Portal } from '../../internal/Portal'
|
|
8
|
+
import { usePresence } from '../../internal/usePresence'
|
|
9
|
+
import { Button } from '../Button'
|
|
10
|
+
import dialog from '../Dialog/Dialog.module.css'
|
|
11
|
+
import styles from './AlertDialog.module.css'
|
|
12
|
+
|
|
13
|
+
export type AlertDialogProps = {
|
|
14
|
+
/** Shows the dialog. You always control it. */
|
|
15
|
+
open: boolean
|
|
16
|
+
/** Called with false after Confirm, Cancel or Esc. A tap outside never closes it. */
|
|
17
|
+
onOpenChange: (open: boolean) => void
|
|
18
|
+
/** The question, such as "Delete this project?". Also the accessible name. */
|
|
19
|
+
title: ReactNode
|
|
20
|
+
/** What happens if they confirm. Read out with the title when the dialog opens. */
|
|
21
|
+
description: ReactNode
|
|
22
|
+
/** Extra content under the description, such as a list of what goes away. */
|
|
23
|
+
children?: ReactNode
|
|
24
|
+
/** Text of the confirm button. Name the action, such as "Delete project", not "OK". */
|
|
25
|
+
confirmLabel: string
|
|
26
|
+
/** Text of the cancel button. */
|
|
27
|
+
cancelLabel?: string
|
|
28
|
+
/** Called when they confirm, before the dialog closes. */
|
|
29
|
+
onConfirm: () => void
|
|
30
|
+
/** Called when they cancel with the button or Esc, before the dialog closes. */
|
|
31
|
+
onCancel?: () => void
|
|
32
|
+
/** "danger" gives the confirm button the solid red look, for actions that destroy something. */
|
|
33
|
+
tone?: 'default' | 'danger'
|
|
34
|
+
/** Extra class for the dialog panel. */
|
|
35
|
+
className?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function AlertDialog({
|
|
39
|
+
open,
|
|
40
|
+
onOpenChange,
|
|
41
|
+
title,
|
|
42
|
+
description,
|
|
43
|
+
children,
|
|
44
|
+
confirmLabel,
|
|
45
|
+
cancelLabel = 'Cancel',
|
|
46
|
+
onConfirm,
|
|
47
|
+
onCancel,
|
|
48
|
+
tone = 'default',
|
|
49
|
+
className,
|
|
50
|
+
}: AlertDialogProps) {
|
|
51
|
+
const id = useId()
|
|
52
|
+
const panelRef = useRef<HTMLDivElement>(null)
|
|
53
|
+
const cancelRef = useRef<HTMLButtonElement>(null)
|
|
54
|
+
const { mounted, state } = usePresence(open, 'base')
|
|
55
|
+
|
|
56
|
+
const cancel = () => {
|
|
57
|
+
onCancel?.()
|
|
58
|
+
onOpenChange(false)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
useScrollLock(open)
|
|
62
|
+
useDismiss(open, [panelRef], cancel, { outside: false })
|
|
63
|
+
// Focus starts on the least destructive choice, so a stray Enter never confirms.
|
|
64
|
+
useFocusTrap(panelRef, open && mounted, { initialFocus: cancelRef })
|
|
65
|
+
|
|
66
|
+
if (!mounted) return null
|
|
67
|
+
return (
|
|
68
|
+
<Portal>
|
|
69
|
+
<div className={dialog.overlay} data-state={state} aria-hidden="true" />
|
|
70
|
+
<div className={dialog.positioner}>
|
|
71
|
+
<div
|
|
72
|
+
ref={panelRef}
|
|
73
|
+
role="alertdialog"
|
|
74
|
+
aria-modal="true"
|
|
75
|
+
aria-labelledby={`${id}-title`}
|
|
76
|
+
aria-describedby={`${id}-desc`}
|
|
77
|
+
tabIndex={-1}
|
|
78
|
+
className={cx(dialog.panel, dialog.sm, className)}
|
|
79
|
+
data-state={state}
|
|
80
|
+
>
|
|
81
|
+
<header className={cx(dialog.header, styles.header)}>
|
|
82
|
+
<div className={dialog.titles}>
|
|
83
|
+
<h2 id={`${id}-title`} className={dialog.title}>{title}</h2>
|
|
84
|
+
<p id={`${id}-desc`} className={dialog.description}>{description}</p>
|
|
85
|
+
</div>
|
|
86
|
+
</header>
|
|
87
|
+
{children && <div className={dialog.body}>{children}</div>}
|
|
88
|
+
<footer className={dialog.footer}>
|
|
89
|
+
<Button ref={cancelRef} variant="secondary" onClick={cancel}>
|
|
90
|
+
{cancelLabel}
|
|
91
|
+
</Button>
|
|
92
|
+
<Button
|
|
93
|
+
variant={tone === 'danger' ? 'danger' : 'primary'}
|
|
94
|
+
onClick={() => {
|
|
95
|
+
onConfirm()
|
|
96
|
+
onOpenChange(false)
|
|
97
|
+
}}
|
|
98
|
+
>
|
|
99
|
+
{confirmLabel}
|
|
100
|
+
</Button>
|
|
101
|
+
</footer>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
</Portal>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './AlertDialog'
|
|
@@ -60,34 +60,3 @@
|
|
|
60
60
|
/* Without colour, status reads by fill: solid, half, hollow. */
|
|
61
61
|
.status[data-status="away"] { background: linear-gradient(90deg, var(--hl-color-fg) 50%, var(--hl-color-bg) 50%); box-shadow: inset 0 0 0 var(--hl-line-thick) var(--hl-color-fg); }
|
|
62
62
|
.status[data-status="offline"] { background: var(--hl-color-bg); box-shadow: inset 0 0 0 var(--hl-line-thick) var(--hl-color-fg-muted); }
|
|
63
|
-
|
|
64
|
-
.group {
|
|
65
|
-
display: inline-flex;
|
|
66
|
-
align-items: center;
|
|
67
|
-
padding-left: var(--hl-space-3);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
.slot {
|
|
71
|
-
display: inline-flex;
|
|
72
|
-
margin-left: calc(var(--hl-space-3) * -1);
|
|
73
|
-
border-radius: var(--hl-radius-full);
|
|
74
|
-
box-shadow: 0 0 0 var(--hl-line-focus) var(--hl-color-bg);
|
|
75
|
-
animation: slotIn var(--hl-motion-slow) var(--hl-ease-spring) backwards;
|
|
76
|
-
animation-delay: calc(var(--i) * var(--hl-motion-stagger) * 2);
|
|
77
|
-
transition: margin var(--hl-motion-slow) var(--hl-ease-spring), transform var(--hl-motion-base) var(--hl-ease-spring);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
@media (hover: hover) {
|
|
81
|
-
.group:hover .slot { margin-left: calc(var(--hl-space-1) * -1); }
|
|
82
|
-
.slot:hover { transform: translateY(calc(var(--hl-shift-sm) * -1)); }
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
.more {
|
|
86
|
-
background: var(--hl-color-surface);
|
|
87
|
-
box-shadow: inset 0 0 0 var(--hl-line) var(--hl-color-border-strong);
|
|
88
|
-
font-size: calc(var(--size) * 0.32);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
@keyframes slotIn {
|
|
92
|
-
from { opacity: 0; transform: translateX(calc(var(--hl-shift-md) * -1)) scale(0.8); }
|
|
93
|
-
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { useState } from 'react'
|
|
4
4
|
import { cx } from '../../internal/cx'
|
|
5
5
|
import styles from './Avatar.module.css'
|
|
6
6
|
|
|
@@ -41,6 +41,10 @@ export function Avatar({ name, src, size = 'md', status, className }: AvatarProp
|
|
|
41
41
|
src={src}
|
|
42
42
|
alt=""
|
|
43
43
|
data-loaded={state === 'loaded' || undefined}
|
|
44
|
+
// A server-rendered image can finish before React hydrates, so its load event is gone by then.
|
|
45
|
+
ref={(img) => {
|
|
46
|
+
if (img?.complete && state === 'loading') setState(img.naturalWidth ? 'loaded' : 'error')
|
|
47
|
+
}}
|
|
44
48
|
onLoad={() => setState('loaded')}
|
|
45
49
|
onError={() => setState('error')}
|
|
46
50
|
/>
|
|
@@ -49,38 +53,3 @@ export function Avatar({ name, src, size = 'md', status, className }: AvatarProp
|
|
|
49
53
|
</span>
|
|
50
54
|
)
|
|
51
55
|
}
|
|
52
|
-
|
|
53
|
-
export type AvatarGroupProps = {
|
|
54
|
-
/** The Avatar elements to stack. */
|
|
55
|
-
children: ReactNode
|
|
56
|
-
/** Avatars shown before the "+N" counter. */
|
|
57
|
-
max?: number
|
|
58
|
-
/** Size of the "+N" counter. Set it to match the avatars inside. */
|
|
59
|
-
size?: AvatarProps['size']
|
|
60
|
-
/** Screen reader name for the group. Undefined says "N people". */
|
|
61
|
-
label?: string
|
|
62
|
-
/** Extra class for the root element. */
|
|
63
|
-
className?: string
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export function AvatarGroup({ children, max = 4, size = 'md', label, className }: AvatarGroupProps) {
|
|
67
|
-
const all = Children.toArray(children) as ReactElement<AvatarProps>[]
|
|
68
|
-
const shown = all.slice(0, max)
|
|
69
|
-
const extra = all.length - shown.length
|
|
70
|
-
return (
|
|
71
|
-
<div className={cx(styles.group, className)} role="group" aria-label={label ?? `${all.length} people`}>
|
|
72
|
-
{shown.map((child, i) => (
|
|
73
|
-
<span key={i} className={styles.slot} style={{ ['--i' as string]: i }}>
|
|
74
|
-
{child}
|
|
75
|
-
</span>
|
|
76
|
-
))}
|
|
77
|
-
{extra > 0 && (
|
|
78
|
-
<span className={styles.slot} style={{ ['--i' as string]: shown.length }}>
|
|
79
|
-
<span className={cx(styles.root, styles[size], styles.more)} role="img" aria-label={`and ${extra} more`}>
|
|
80
|
-
<span className={styles.fallback} aria-hidden="true">+{extra}</span>
|
|
81
|
-
</span>
|
|
82
|
-
</span>
|
|
83
|
-
)}
|
|
84
|
-
</div>
|
|
85
|
-
)
|
|
86
|
-
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
.root {
|
|
2
|
+
display: inline-flex;
|
|
3
|
+
align-items: center;
|
|
4
|
+
padding-left: var(--hl-space-3);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/* The slot takes the hover and never moves; only the face inside lifts. A lifted slot would slide out
|
|
8
|
+
from under the pointer and drop back, and at an overlap two neighbours would take turns. */
|
|
9
|
+
.slot {
|
|
10
|
+
display: inline-flex;
|
|
11
|
+
margin-left: calc(var(--hl-space-3) * -1);
|
|
12
|
+
border-radius: var(--hl-radius-full);
|
|
13
|
+
animation: slotIn var(--hl-motion-slow) var(--hl-ease-spring) backwards;
|
|
14
|
+
animation-delay: calc(var(--i) * var(--hl-motion-stagger) * 2);
|
|
15
|
+
transition: margin var(--hl-motion-slow) var(--hl-ease-spring);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.face {
|
|
19
|
+
display: inline-flex;
|
|
20
|
+
border-radius: var(--hl-radius-full);
|
|
21
|
+
box-shadow: 0 0 0 var(--hl-line-focus) var(--hl-color-bg);
|
|
22
|
+
transition: transform var(--hl-motion-base) var(--hl-ease-spring);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@media (hover: hover) {
|
|
26
|
+
.root:hover .slot { margin-left: calc(var(--hl-space-1) * -1); }
|
|
27
|
+
.slot:hover .face { transform: translateY(calc(var(--hl-shift-sm) * -1)); }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.more {
|
|
31
|
+
--size: var(--hl-avatar-md);
|
|
32
|
+
display: inline-grid;
|
|
33
|
+
place-items: center;
|
|
34
|
+
width: var(--size);
|
|
35
|
+
height: var(--size);
|
|
36
|
+
border-radius: var(--hl-radius-full);
|
|
37
|
+
background: var(--hl-color-surface);
|
|
38
|
+
box-shadow: inset 0 0 0 var(--hl-line) var(--hl-color-border-strong), 0 0 0 var(--hl-line-focus) var(--hl-color-bg);
|
|
39
|
+
color: var(--hl-color-fg);
|
|
40
|
+
font-size: calc(var(--size) * 0.32);
|
|
41
|
+
font-weight: var(--hl-weight-medium);
|
|
42
|
+
font-variant-numeric: tabular-nums;
|
|
43
|
+
letter-spacing: var(--hl-tracking-tight);
|
|
44
|
+
user-select: none;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.sm { --size: var(--hl-avatar-sm); }
|
|
48
|
+
.lg { --size: var(--hl-avatar-lg); }
|
|
49
|
+
.xl { --size: var(--hl-avatar-xl); }
|
|
50
|
+
|
|
51
|
+
@keyframes slotIn {
|
|
52
|
+
from { opacity: 0; transform: translateX(calc(var(--hl-shift-md) * -1)) scale(0.8); }
|
|
53
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Children, isValidElement, type ReactElement, type ReactNode } from 'react'
|
|
2
|
+
import { cx } from '../../internal/cx'
|
|
3
|
+
import type { AvatarProps } from '../Avatar'
|
|
4
|
+
import styles from './AvatarGroup.module.css'
|
|
5
|
+
|
|
6
|
+
export type AvatarGroupProps = {
|
|
7
|
+
/** The Avatar elements to stack, in order. */
|
|
8
|
+
children: ReactNode
|
|
9
|
+
/** Avatars shown before the "+N" counter. */
|
|
10
|
+
max?: number
|
|
11
|
+
/** Size of the "+N" counter. Set it to match the avatars inside. */
|
|
12
|
+
size?: AvatarProps['size']
|
|
13
|
+
/** What the people are, such as "Project members". Screen readers hear it with every name. */
|
|
14
|
+
label?: string
|
|
15
|
+
/** Extra class for the root element. */
|
|
16
|
+
className?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const list = (names: string[]) => (names.length < 2 ? names.join('') : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`)
|
|
20
|
+
|
|
21
|
+
export function AvatarGroup({ children, max = 4, size = 'md', label, className }: AvatarGroupProps) {
|
|
22
|
+
const all = Children.toArray(children).filter(isValidElement) as ReactElement<AvatarProps>[]
|
|
23
|
+
const shown = all.slice(0, max)
|
|
24
|
+
const extra = all.length - shown.length
|
|
25
|
+
// The names of the hidden people too, so a screen reader hears everyone the "+N" stands for.
|
|
26
|
+
const everyone = list(all.map((a) => a.props.name))
|
|
27
|
+
return (
|
|
28
|
+
<div className={cx(styles.root, className)} role="group" aria-label={label ? `${label}: ${everyone}` : everyone}>
|
|
29
|
+
{shown.map((child, i) => (
|
|
30
|
+
<span key={child.key ?? i} className={styles.slot} style={{ ['--i' as string]: i }}>
|
|
31
|
+
<span className={styles.face}>{child}</span>
|
|
32
|
+
</span>
|
|
33
|
+
))}
|
|
34
|
+
{extra > 0 && (
|
|
35
|
+
<span className={styles.slot} style={{ ['--i' as string]: shown.length }}>
|
|
36
|
+
<span className={cx(styles.face, styles.more, styles[size])} role="img" aria-label={`${extra} more`} title={list(all.slice(max).map((a) => a.props.name))}>
|
|
37
|
+
<span aria-hidden="true">+{extra}</span>
|
|
38
|
+
</span>
|
|
39
|
+
</span>
|
|
40
|
+
)}
|
|
41
|
+
</div>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './AvatarGroup'
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
.root {
|
|
2
|
+
display: inline-flex;
|
|
3
|
+
width: fit-content;
|
|
4
|
+
max-width: 100%;
|
|
5
|
+
isolation: isolate;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.vertical {
|
|
9
|
+
flex-direction: column;
|
|
10
|
+
align-items: stretch;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/* Both classes, so these win over each button's own radius whatever order the styles load in. */
|
|
14
|
+
.root.root > * {
|
|
15
|
+
position: relative;
|
|
16
|
+
flex: none;
|
|
17
|
+
margin: 0;
|
|
18
|
+
border-radius: 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/* Neighbours overlap by one line, so two borders read as one. */
|
|
22
|
+
.horizontal.horizontal > * + * { margin-inline-start: calc(var(--hl-line) * -1); }
|
|
23
|
+
.vertical.vertical > * + * { margin-block-start: calc(var(--hl-line) * -1); }
|
|
24
|
+
/* min-width, not width: a button keeps its own size, and the narrower ones grow to the widest. */
|
|
25
|
+
.vertical.vertical > * { min-width: 100%; }
|
|
26
|
+
|
|
27
|
+
.horizontal.horizontal > :first-child {
|
|
28
|
+
border-start-start-radius: var(--hl-radius-control);
|
|
29
|
+
border-end-start-radius: var(--hl-radius-control);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.horizontal.horizontal > :last-child {
|
|
33
|
+
border-start-end-radius: var(--hl-radius-control);
|
|
34
|
+
border-end-end-radius: var(--hl-radius-control);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.vertical.vertical > :first-child {
|
|
38
|
+
border-start-start-radius: var(--hl-radius-md);
|
|
39
|
+
border-start-end-radius: var(--hl-radius-md);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.vertical.vertical > :last-child {
|
|
43
|
+
border-end-start-radius: var(--hl-radius-md);
|
|
44
|
+
border-end-end-radius: var(--hl-radius-md);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/* The hovered and the focused button rise above their neighbours, so the full edge and focus ring show. */
|
|
48
|
+
@media (hover: hover) {
|
|
49
|
+
.root > :hover { z-index: 1; }
|
|
50
|
+
}
|
|
51
|
+
.root > :focus-visible { z-index: 2; }
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ComponentPropsWithRef, ReactNode } from 'react'
|
|
2
|
+
import { cx } from '../../internal/cx'
|
|
3
|
+
import styles from './ButtonGroup.module.css'
|
|
4
|
+
|
|
5
|
+
export type ButtonGroupProps = Omit<ComponentPropsWithRef<'div'>, 'children'> & {
|
|
6
|
+
/** Accessible name for the group, such as "Text alignment". */
|
|
7
|
+
label: string
|
|
8
|
+
/** The Button, IconButton or Toggle elements to join. Secondary ones show the shared borders best. */
|
|
9
|
+
children: ReactNode
|
|
10
|
+
/** A row, or a column where each button takes the full width. */
|
|
11
|
+
orientation?: 'horizontal' | 'vertical'
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function ButtonGroup({ label, children, orientation = 'horizontal', className, ...rest }: ButtonGroupProps) {
|
|
15
|
+
return (
|
|
16
|
+
<div {...rest} role="group" aria-label={label} className={cx(styles.root, styles[orientation], className)} data-orientation={orientation}>
|
|
17
|
+
{children}
|
|
18
|
+
</div>
|
|
19
|
+
)
|
|
20
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './ButtonGroup'
|
|
@@ -7,7 +7,6 @@
|
|
|
7
7
|
border-radius: var(--hl-radius-panel);
|
|
8
8
|
background: var(--hl-color-surface);
|
|
9
9
|
box-shadow: var(--hl-shadow-1);
|
|
10
|
-
overflow: hidden;
|
|
11
10
|
transition:
|
|
12
11
|
transform var(--hl-motion-slow) var(--hl-ease-spring),
|
|
13
12
|
box-shadow var(--hl-motion-slow) var(--hl-ease-out),
|
|
@@ -20,6 +19,11 @@
|
|
|
20
19
|
box-shadow: var(--hl-shadow-3);
|
|
21
20
|
border-color: var(--hl-color-border-strong);
|
|
22
21
|
}
|
|
22
|
+
|
|
23
|
+
/* The lift moves the card off a pointer on its lower edge, and it would drop back and lift again.
|
|
24
|
+
This fills the gap below while it is up: twice the lift, since the spring overshoots. The card
|
|
25
|
+
does not clip, so the strip can reach out. */
|
|
26
|
+
.interactive:hover::after { content: ""; position: absolute; inset: 100% 0 calc(var(--hl-shift-sm) * -2); }
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
.interactive:active { transform: translateY(0) scale(0.995); transition-duration: var(--hl-motion-fast); }
|
|
@@ -31,6 +35,7 @@
|
|
|
31
35
|
.media {
|
|
32
36
|
overflow: hidden;
|
|
33
37
|
border-bottom: var(--hl-line) solid var(--hl-color-border);
|
|
38
|
+
border-radius: calc(var(--hl-radius-panel) - var(--hl-line)) calc(var(--hl-radius-panel) - var(--hl-line)) 0 0;
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
.media > * { transition: transform var(--hl-motion-slower) var(--hl-ease-out); }
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
.group {
|
|
2
|
+
min-width: 0;
|
|
3
|
+
margin: 0;
|
|
4
|
+
padding: 0;
|
|
5
|
+
border: 0;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.legend {
|
|
9
|
+
padding: 0;
|
|
10
|
+
margin-bottom: var(--hl-space-2);
|
|
11
|
+
font-size: var(--hl-text-sm);
|
|
12
|
+
font-weight: var(--hl-weight-medium);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.description {
|
|
16
|
+
margin: 0 0 var(--hl-space-2);
|
|
17
|
+
font-size: var(--hl-text-sm);
|
|
18
|
+
color: var(--hl-color-fg-muted);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.items { display: flex; }
|
|
22
|
+
.vertical { flex-direction: column; }
|
|
23
|
+
.horizontal { flex-wrap: wrap; column-gap: var(--hl-space-7); }
|
|
24
|
+
|
|
25
|
+
/* Native disabled reaches every box, and each Checkbox dims itself; only the group's own text dims
|
|
26
|
+
here, so no box fades twice. */
|
|
27
|
+
.group[data-disabled] > :is(.legend, .description) { opacity: var(--hl-disabled-opacity); }
|
|
28
|
+
|
|
29
|
+
/* Each Checkbox's drawn box: red edges on the empty ones, like a RadioGroup with an error. */
|
|
30
|
+
.group[data-invalid] [data-state="off"] { border-color: var(--hl-color-danger); }
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useId, type ReactNode } from 'react'
|
|
4
|
+
import { cx } from '../../internal/cx'
|
|
5
|
+
import { FieldMessage } from '../../internal/FieldMessage'
|
|
6
|
+
import { useControllable } from '../../internal/useControllable'
|
|
7
|
+
import { Checkbox } from '../Checkbox'
|
|
8
|
+
import styles from './CheckboxGroup.module.css'
|
|
9
|
+
|
|
10
|
+
export type CheckboxOption = {
|
|
11
|
+
value: string
|
|
12
|
+
label: ReactNode
|
|
13
|
+
description?: ReactNode
|
|
14
|
+
disabled?: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type CheckboxGroupProps = {
|
|
18
|
+
/** Visible name of the group, rendered as the legend. */
|
|
19
|
+
label: ReactNode
|
|
20
|
+
/** The boxes, in order. */
|
|
21
|
+
options: CheckboxOption[]
|
|
22
|
+
/** Values of the ticked boxes, when you control it. */
|
|
23
|
+
value?: string[]
|
|
24
|
+
/** Starting value when you do not control it. */
|
|
25
|
+
defaultValue?: string[]
|
|
26
|
+
/** Called when a box is ticked or cleared, with the new list of values in option order. */
|
|
27
|
+
onValueChange?: (value: string[]) => void
|
|
28
|
+
/** Muted text under the label, read with the group. */
|
|
29
|
+
description?: ReactNode
|
|
30
|
+
/** Error under the boxes. Marks every box invalid and is read with the group. */
|
|
31
|
+
error?: ReactNode
|
|
32
|
+
/** Form field name shared by every box. Each ticked box sends its value. */
|
|
33
|
+
name?: string
|
|
34
|
+
/** Turns off every box in the group. */
|
|
35
|
+
disabled?: boolean
|
|
36
|
+
/** Stacks the boxes or puts them in a row that wraps. */
|
|
37
|
+
orientation?: 'vertical' | 'horizontal'
|
|
38
|
+
/** Extra class for the root element. */
|
|
39
|
+
className?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function CheckboxGroup({
|
|
43
|
+
label,
|
|
44
|
+
options,
|
|
45
|
+
value: valueProp,
|
|
46
|
+
defaultValue = [],
|
|
47
|
+
onValueChange,
|
|
48
|
+
description,
|
|
49
|
+
error,
|
|
50
|
+
name,
|
|
51
|
+
disabled,
|
|
52
|
+
orientation = 'vertical',
|
|
53
|
+
className,
|
|
54
|
+
}: CheckboxGroupProps) {
|
|
55
|
+
const auto = useId()
|
|
56
|
+
const [value, setValue] = useControllable(valueProp, defaultValue, onValueChange)
|
|
57
|
+
const descId = `${auto}-desc`
|
|
58
|
+
const errorId = `${auto}-error`
|
|
59
|
+
const describedBy = [description ? descId : null, error ? errorId : null].filter(Boolean).join(' ')
|
|
60
|
+
const toggle = (v: string, on: boolean) => {
|
|
61
|
+
const next = new Set(value)
|
|
62
|
+
if (on) next.add(v)
|
|
63
|
+
else next.delete(v)
|
|
64
|
+
setValue(options.map((o) => o.value).filter((x) => next.has(x)))
|
|
65
|
+
}
|
|
66
|
+
return (
|
|
67
|
+
<fieldset className={cx(styles.group, className)} disabled={disabled} aria-describedby={describedBy || undefined} data-disabled={disabled || undefined} data-invalid={error ? true : undefined}>
|
|
68
|
+
<legend className={styles.legend}>{label}</legend>
|
|
69
|
+
{description && <p id={descId} className={styles.description}>{description}</p>}
|
|
70
|
+
<div className={cx(styles.items, styles[orientation])}>
|
|
71
|
+
{options.map((o) => (
|
|
72
|
+
<Checkbox
|
|
73
|
+
key={o.value}
|
|
74
|
+
label={o.label}
|
|
75
|
+
description={o.description}
|
|
76
|
+
name={name}
|
|
77
|
+
value={o.value}
|
|
78
|
+
disabled={o.disabled}
|
|
79
|
+
checked={value.includes(o.value)}
|
|
80
|
+
onCheckedChange={(on) => toggle(o.value, on)}
|
|
81
|
+
aria-invalid={error ? true : undefined}
|
|
82
|
+
/>
|
|
83
|
+
))}
|
|
84
|
+
</div>
|
|
85
|
+
<FieldMessage helpId={`${auto}-help`} errorId={errorId} error={error} />
|
|
86
|
+
</fieldset>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './CheckboxGroup'
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/* One card: a header row (title or tabs, and copy) over the code. */
|
|
2
|
+
.root {
|
|
3
|
+
min-width: 0;
|
|
4
|
+
overflow: hidden;
|
|
5
|
+
border: var(--hl-line) solid var(--hl-color-border);
|
|
6
|
+
border-radius: var(--hl-radius-md);
|
|
7
|
+
background: var(--hl-code-bg);
|
|
8
|
+
color: var(--hl-code-fg);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.head {
|
|
12
|
+
display: flex;
|
|
13
|
+
align-items: center;
|
|
14
|
+
justify-content: space-between;
|
|
15
|
+
gap: var(--hl-space-3);
|
|
16
|
+
min-height: var(--hl-control-h-md);
|
|
17
|
+
padding: 0 var(--hl-space-2) 0 var(--hl-space-4);
|
|
18
|
+
border-bottom: var(--hl-line) solid var(--hl-color-border);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.title {
|
|
22
|
+
min-width: 0;
|
|
23
|
+
overflow: hidden;
|
|
24
|
+
text-overflow: ellipsis;
|
|
25
|
+
white-space: nowrap;
|
|
26
|
+
font-family: var(--hl-font-mono);
|
|
27
|
+
font-size: var(--hl-text-xs);
|
|
28
|
+
color: var(--hl-color-fg-subtle);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.copy { flex: none; margin-left: auto; }
|
|
32
|
+
|
|
33
|
+
.pre {
|
|
34
|
+
margin: 0;
|
|
35
|
+
padding: var(--hl-space-4) var(--hl-space-5);
|
|
36
|
+
overflow-x: auto;
|
|
37
|
+
font-family: var(--hl-font-mono);
|
|
38
|
+
font-size: var(--hl-text-sm);
|
|
39
|
+
line-height: var(--hl-leading-normal);
|
|
40
|
+
tab-size: 2;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/* The ring goes on the whole block: the root clips its corners, so a ring on the pre would be cut. */
|
|
44
|
+
.pre:focus-visible { outline: none; }
|
|
45
|
+
.root:has(.pre:focus-visible) { box-shadow: var(--hl-focus-ring); }
|
|
46
|
+
|
|
47
|
+
.pre[data-lines] {
|
|
48
|
+
display: grid;
|
|
49
|
+
grid-template-columns: auto minmax(0, 1fr);
|
|
50
|
+
column-gap: var(--hl-space-5);
|
|
51
|
+
padding-left: 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/* Sticks to the left edge while long lines scroll, and is left out when the code is selected. */
|
|
55
|
+
.gutter {
|
|
56
|
+
position: sticky;
|
|
57
|
+
left: 0;
|
|
58
|
+
padding: 0 var(--hl-space-4) 0 var(--hl-space-5);
|
|
59
|
+
border-right: var(--hl-line) solid var(--hl-color-border);
|
|
60
|
+
background: var(--hl-code-bg);
|
|
61
|
+
color: var(--hl-code-gutter);
|
|
62
|
+
text-align: right;
|
|
63
|
+
font-variant-numeric: tabular-nums;
|
|
64
|
+
user-select: none;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.code { font: inherit; }
|
|
68
|
+
|
|
69
|
+
.keyword { color: var(--hl-code-keyword); font-weight: var(--hl-weight-semibold); }
|
|
70
|
+
.tag { color: var(--hl-code-tag); font-weight: var(--hl-weight-semibold); }
|
|
71
|
+
.attr { color: var(--hl-code-attr); }
|
|
72
|
+
.string { color: var(--hl-code-string); }
|
|
73
|
+
.number { color: var(--hl-code-number); }
|
|
74
|
+
.comment { color: var(--hl-code-comment); font-style: italic; }
|