hlmnhkl-ui 0.24.0 → 0.27.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.
Files changed (56) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/scripts/pro-components.json +6 -2
  4. package/src/components/AlertDialog/AlertDialog.module.css +2 -0
  5. package/src/components/AlertDialog/AlertDialog.tsx +106 -0
  6. package/src/components/AlertDialog/index.ts +1 -0
  7. package/src/components/Avatar/Avatar.module.css +0 -31
  8. package/src/components/Avatar/Avatar.tsx +5 -36
  9. package/src/components/Avatar/index.ts +2 -0
  10. package/src/components/AvatarGroup/AvatarGroup.module.css +53 -0
  11. package/src/components/AvatarGroup/AvatarGroup.tsx +43 -0
  12. package/src/components/AvatarGroup/index.ts +1 -0
  13. package/src/components/ButtonGroup/ButtonGroup.module.css +51 -0
  14. package/src/components/ButtonGroup/ButtonGroup.tsx +20 -0
  15. package/src/components/ButtonGroup/index.ts +1 -0
  16. package/src/components/Card/Card.module.css +6 -1
  17. package/src/components/CheckboxGroup/CheckboxGroup.module.css +30 -0
  18. package/src/components/CheckboxGroup/CheckboxGroup.tsx +88 -0
  19. package/src/components/CheckboxGroup/index.ts +1 -0
  20. package/src/components/CodeBlock/CodeBlock.module.css +74 -0
  21. package/src/components/CodeBlock/CodeBlock.tsx +53 -0
  22. package/src/components/CodeBlock/highlight.ts +73 -0
  23. package/src/components/CodeBlock/index.ts +1 -0
  24. package/src/components/CopyButton/CopyButton.module.css +2 -0
  25. package/src/components/Dialog/Dialog.module.css +8 -5
  26. package/src/components/Fab/Fab.module.css +172 -0
  27. package/src/components/Fab/Fab.tsx +117 -0
  28. package/src/components/Fab/index.ts +1 -0
  29. package/src/components/Fieldset/Fieldset.module.css +34 -0
  30. package/src/components/Fieldset/Fieldset.tsx +41 -0
  31. package/src/components/Fieldset/index.ts +1 -0
  32. package/src/components/IconButton/IconButton.module.css +2 -0
  33. package/src/components/Input/Input.module.css +3 -1
  34. package/src/components/InputGroup/InputGroup.module.css +146 -0
  35. package/src/components/InputGroup/InputGroup.tsx +90 -0
  36. package/src/components/InputGroup/index.ts +1 -0
  37. package/src/components/Meter/Meter.module.css +91 -0
  38. package/src/components/Meter/Meter.tsx +109 -0
  39. package/src/components/Meter/index.ts +1 -0
  40. package/src/components/NumberInput/NumberInput.module.css +3 -0
  41. package/src/components/Pagination/Pagination.module.css +3 -0
  42. package/src/components/Prose/Prose.module.css +115 -0
  43. package/src/components/Prose/Prose.tsx +14 -0
  44. package/src/components/Prose/index.ts +1 -0
  45. package/src/components/ScrollArea/ScrollArea.module.css +5 -2
  46. package/src/components/TableOfContents/TableOfContents.module.css +107 -0
  47. package/src/components/TableOfContents/TableOfContents.tsx +93 -0
  48. package/src/components/TableOfContents/index.ts +1 -0
  49. package/src/components/ThemeToggle/ThemeToggle.module.css +21 -0
  50. package/src/components/ThemeToggle/ThemeToggle.tsx +69 -0
  51. package/src/components/ThemeToggle/index.ts +1 -0
  52. package/src/components/Toggle/Toggle.module.css +94 -0
  53. package/src/components/Toggle/Toggle.tsx +50 -0
  54. package/src/components/Toggle/index.ts +1 -0
  55. package/src/internal/scrollFrame.ts +47 -114
  56. package/src/tokens.css +83 -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
- - **50 free components**, from Button, Dialog, Select and Table to Toast and Retime, one speed setting for every animation. See [COMPONENTS.md](COMPONENTS.md).
68
+ - **63 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hlmnhkl-ui",
3
- "version": "0.24.0",
3
+ "version": "0.27.0",
4
4
  "description": "React components in a monochrome style. Copy them into your app with npx hlmnhkl-ui add <name>.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://ui.hlmnhkl.my",
@@ -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
- "ScrollRuler",
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,2 @@
1
+ /* Dialog's header leaves room on the right for its close button. An alert dialog has none: Cancel is the way out. */
2
+ .header { padding-right: var(--hl-space-7); }
@@ -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 { Children, useState, type ReactElement, type ReactNode } from 'react'
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
- }
@@ -1 +1,3 @@
1
1
  export * from './Avatar'
2
+ // AvatarGroup has its own folder now; this keeps old imports from Avatar working.
3
+ export { AvatarGroup, type AvatarGroupProps } from '../AvatarGroup'
@@ -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; }