hlmnhkl-ui 0.32.1 → 0.33.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 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
- - **63 free components**, from Button, Dialog, Select and Table to Toast and Retime, one speed setting for every animation. See [COMPONENTS.md](COMPONENTS.md).
68
+ - **65 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.32.1",
3
+ "version": "0.33.0",
4
4
  "description": "React components in black, white and grey with one blue accent. Copy them into your app with npx hlmnhkl-ui add <name>.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://ui.hlmnhkl.my",
@@ -0,0 +1,166 @@
1
+ .root {
2
+ display: flex;
3
+ flex-direction: column;
4
+ align-items: flex-start;
5
+ gap: var(--hl-space-3);
6
+ min-width: 0;
7
+ }
8
+
9
+ .shell {
10
+ position: relative;
11
+ display: flex;
12
+ align-items: center;
13
+ max-width: 100%;
14
+ height: var(--hl-control-h-lg);
15
+ overflow: hidden;
16
+ border-radius: calc(var(--hl-control-h-lg) / 2);
17
+ background: var(--hl-color-surface);
18
+ box-shadow: inset 0 0 0 var(--hl-line-thick) var(--hl-color-border-strong);
19
+ transition:
20
+ width var(--hl-motion-base) var(--hl-ease-liquid),
21
+ height var(--hl-motion-base) var(--hl-ease-liquid),
22
+ border-radius var(--hl-motion-base) var(--hl-ease-liquid),
23
+ box-shadow var(--hl-motion-fast) var(--hl-ease-out);
24
+ }
25
+
26
+ .shell:has(.main:focus-visible) {
27
+ box-shadow: inset 0 0 0 var(--hl-line-thick) var(--hl-color-border-strong), var(--hl-focus-ring);
28
+ }
29
+
30
+ .shell[data-phase="done"] { box-shadow: inset 0 0 0 var(--hl-hold-stroke) var(--hl-color-fg); }
31
+ .shell[data-disabled] { opacity: var(--hl-disabled-opacity); }
32
+
33
+ .main {
34
+ position: relative;
35
+ flex: none;
36
+ height: 100%;
37
+ padding: 0 var(--hl-space-7);
38
+ border: 0;
39
+ border-radius: var(--hl-radius-full);
40
+ background: transparent;
41
+ color: var(--hl-color-fg);
42
+ font: inherit;
43
+ font-weight: var(--hl-weight-medium);
44
+ white-space: nowrap;
45
+ cursor: pointer;
46
+ user-select: none;
47
+ -webkit-user-select: none;
48
+ -webkit-touch-callout: none;
49
+ -webkit-tap-highlight-color: transparent;
50
+ touch-action: manipulation;
51
+ outline: none;
52
+ }
53
+
54
+ .shell[data-phase="step"] .main,
55
+ .shell[data-phase="done"] .main { cursor: default; }
56
+ .shell[data-disabled] .main { cursor: not-allowed; }
57
+
58
+ .shell[data-stack] {
59
+ flex-direction: column;
60
+ align-items: flex-start;
61
+ }
62
+
63
+ .shell[data-stack][data-phase="step"] { border-radius: var(--hl-radius-panel); }
64
+ .shell[data-stack] .main { height: var(--hl-control-h-lg); }
65
+ .shell[data-stack] .step { padding: 0 var(--hl-space-2) var(--hl-space-2); }
66
+
67
+ .faces { display: grid; }
68
+
69
+ .face {
70
+ grid-area: 1 / 1;
71
+ display: inline-flex;
72
+ align-items: center;
73
+ justify-content: center;
74
+ gap: var(--hl-space-3);
75
+ transition: opacity var(--hl-motion-fast) var(--hl-ease-out);
76
+ }
77
+
78
+ .face svg {
79
+ width: var(--hl-icon-md);
80
+ height: var(--hl-icon-md);
81
+ }
82
+
83
+ .face[data-face="done"],
84
+ .shell[data-phase="done"] .face[data-face="idle"] { opacity: 0; }
85
+ .shell[data-phase="done"] .face[data-face="done"] { opacity: 1; }
86
+
87
+ .fuse {
88
+ position: absolute;
89
+ inset: 0;
90
+ overflow: visible;
91
+ pointer-events: none;
92
+ }
93
+
94
+ .run {
95
+ fill: none;
96
+ stroke: var(--hl-color-fg);
97
+ stroke-width: var(--hl-hold-stroke);
98
+ stroke-dasharray: 0 1;
99
+ opacity: 0;
100
+ }
101
+
102
+ .step {
103
+ display: flex;
104
+ flex: none;
105
+ align-items: center;
106
+ gap: var(--hl-space-2);
107
+ padding-right: var(--hl-space-1);
108
+ }
109
+
110
+ .step[inert] {
111
+ visibility: hidden;
112
+ transition: visibility 0s linear var(--hl-motion-base);
113
+ }
114
+
115
+ .cancel,
116
+ .confirm {
117
+ height: var(--hl-hit-min);
118
+ padding: 0 var(--hl-space-5);
119
+ border: 0;
120
+ border-radius: var(--hl-radius-full);
121
+ font: inherit;
122
+ font-weight: var(--hl-weight-medium);
123
+ white-space: nowrap;
124
+ cursor: pointer;
125
+ outline: none;
126
+ transition: background-color var(--hl-motion-fast) var(--hl-ease-out);
127
+ }
128
+
129
+ .cancel {
130
+ background: transparent;
131
+ color: var(--hl-color-fg);
132
+ }
133
+
134
+ .confirm {
135
+ background: var(--hl-color-danger-solid);
136
+ color: var(--hl-color-on-danger);
137
+ }
138
+
139
+ @media (hover: hover) {
140
+ .cancel:hover { background: var(--hl-color-surface-hover); }
141
+ .confirm:hover { background: var(--hl-color-danger-solid-hover); }
142
+ }
143
+
144
+ .cancel:focus-visible,
145
+ .confirm:focus-visible {
146
+ box-shadow:
147
+ inset 0 0 0 var(--hl-line-focus) var(--hl-color-fg),
148
+ inset 0 0 0 calc(var(--hl-line-focus) * 2) var(--hl-color-surface);
149
+ }
150
+
151
+ .hint {
152
+ min-height: calc(var(--hl-text-sm) * var(--hl-leading-snug) * 2);
153
+ margin: 0;
154
+ color: var(--hl-color-fg-muted);
155
+ font-size: var(--hl-text-sm);
156
+ line-height: var(--hl-leading-snug);
157
+ }
158
+
159
+ .srOnly {
160
+ position: absolute;
161
+ width: 1px;
162
+ height: 1px;
163
+ overflow: hidden;
164
+ clip-path: inset(50%);
165
+ white-space: nowrap;
166
+ }
@@ -0,0 +1,332 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useId, useLayoutEffect, useRef, useState, type KeyboardEvent, type MouseEvent, type PointerEvent, type ReactNode } from 'react'
4
+ import { cx } from '../../internal/cx'
5
+ import { CheckIcon } from '../../internal/icons'
6
+ import { prefersReducedMotion, readMotionMs } from '../../internal/motion'
7
+ import styles from './HoldToConfirm.module.css'
8
+
9
+ export type HoldToConfirmProps = {
10
+ /** The action, as the button's label, such as "Delete workspace". */
11
+ children: ReactNode
12
+ /** Called once the hold runs to the end, or Confirm is pressed in the confirm step. */
13
+ onConfirm: () => void
14
+ /** Called when a hold is let go early, or the confirm step is cancelled. */
15
+ onCancel?: () => void
16
+ /** The confirm step's button, such as "Delete". Keep it short. */
17
+ confirmLabel?: string
18
+ /** The confirm step's way back. */
19
+ cancelLabel?: string
20
+ /** Shown in place of the label once it is done, such as "Workspace deleted". */
21
+ doneLabel?: ReactNode
22
+ /** The line under the button. Defaults to how to use it. */
23
+ hint?: ReactNode
24
+ /** The line under the button while the confirm step is open. */
25
+ stepHint?: ReactNode
26
+ /** Blocks the button. Say why in the hint. */
27
+ disabled?: boolean
28
+ /** Extra class for the outer box. */
29
+ className?: string
30
+ }
31
+
32
+ type Phase = 'idle' | 'step' | 'done'
33
+
34
+ // A press shorter than this is a tap: it opens the confirm step, like one key press does.
35
+ const TAP = 250
36
+ // Held this long, the hint changes to "Keep holding".
37
+ const HINT_AFTER = 300
38
+ // A finger that slides this far off the button lets go.
39
+ const LEAVE = 16
40
+ // How long the done state shows before the button can be used again.
41
+ const DONE_FOR = 2400
42
+
43
+ // The pill's outline as one closed path that starts on the bottom edge at x and runs left first. The
44
+ // line is drawn at both ends of the path at once, so it burns out both ways from x and meets opposite.
45
+ function outline(w: number, h: number, inset: number, x: number) {
46
+ const r = h / 2 - inset
47
+ const t = inset
48
+ const b = h - inset
49
+ const l = h / 2
50
+ const rr = w - h / 2
51
+ const px = Math.max(l, Math.min(rr, x))
52
+ return `M${px},${b} H${l} A${r},${r} 0 0 1 ${l},${t} H${rr} A${r},${r} 0 0 1 ${rr},${b} Z`
53
+ }
54
+
55
+ const ms = (el: Element | null, name: string, fallback: number) => {
56
+ const raw = el ? getComputedStyle(el).getPropertyValue(name).trim() : ''
57
+ const v = parseFloat(raw)
58
+ if (!Number.isFinite(v)) return fallback
59
+ return raw.endsWith('ms') ? v : raw.endsWith('s') ? v * 1000 : v
60
+ }
61
+
62
+ export function HoldToConfirm({
63
+ children,
64
+ onConfirm,
65
+ onCancel,
66
+ confirmLabel = 'Confirm',
67
+ cancelLabel = 'Cancel',
68
+ doneLabel = 'Done',
69
+ hint = 'Hold to confirm, or tap for a confirm step.',
70
+ stepHint,
71
+ disabled = false,
72
+ className,
73
+ }: HoldToConfirmProps) {
74
+ const id = useId()
75
+ const rootRef = useRef<HTMLDivElement>(null)
76
+ const shellRef = useRef<HTMLDivElement>(null)
77
+ const mainRef = useRef<HTMLButtonElement>(null)
78
+ const stepRef = useRef<HTMLDivElement>(null)
79
+ const cancelRef = useRef<HTMLButtonElement>(null)
80
+ const runRef = useRef<SVGPathElement>(null)
81
+ const [phase, setPhase] = useState<Phase>('idle')
82
+ const [size, setSize] = useState({ w: 0, h: 0, step: 0, stepH: 0, room: 0, inset: 1 })
83
+ const [path, setPath] = useState('')
84
+ const [keep, setKeep] = useState(false)
85
+ const [said, setSaid] = useState('')
86
+
87
+ const p = useRef(0)
88
+ const raf = useRef(0)
89
+ const tapTimer = useRef(0)
90
+ const press = useRef<{ t0: number; pointer: number | 'key'; key?: string } | null>(null)
91
+ const phaseRef = useRef(phase)
92
+ phaseRef.current = phase
93
+ const calls = useRef({ onConfirm, onCancel })
94
+ calls.current = { onConfirm, onCancel }
95
+
96
+ useLayoutEffect(() => {
97
+ const root = rootRef.current
98
+ const main = mainRef.current
99
+ const step = stepRef.current
100
+ if (!root || !main || !step) return
101
+ const measure = () => {
102
+ const w = main.offsetWidth
103
+ const h = main.offsetHeight
104
+ const inset = parseFloat(getComputedStyle(root).getPropertyValue('--hl-hold-stroke')) / 2 || 1
105
+ setSize({ w, h, step: step.offsetWidth, stepH: step.offsetHeight, room: root.clientWidth, inset })
106
+ setPath(outline(w, h, inset, w / 2))
107
+ }
108
+ measure()
109
+ const ro = new ResizeObserver(measure)
110
+ ro.observe(main)
111
+ ro.observe(step)
112
+ ro.observe(root)
113
+ return () => ro.disconnect()
114
+ }, [])
115
+
116
+ const paint = (v: number) => {
117
+ p.current = v
118
+ const run = runRef.current
119
+ if (!run) return
120
+ run.style.strokeDasharray = `${v / 2} ${1 - v} ${v / 2} 0`
121
+ run.style.opacity = v > 0 ? '1' : '0'
122
+ }
123
+
124
+ const finish = () => {
125
+ cancelAnimationFrame(raf.current)
126
+ press.current = null
127
+ paint(1)
128
+ setKeep(false)
129
+ // From the step, focus goes back to the button: the step closes under it. Safari never focused Confirm.
130
+ if (phaseRef.current === 'step') mainRef.current?.focus({ preventScroll: true })
131
+ setPhase('done')
132
+ setSaid(typeof doneLabel === 'string' ? doneLabel : 'Done')
133
+ if (!prefersReducedMotion()) navigator.vibrate?.(12)
134
+ calls.current.onConfirm()
135
+ }
136
+
137
+ // Forward at an even speed: it is a clock.
138
+ const run = () => {
139
+ cancelAnimationFrame(raf.current)
140
+ const total = ms(shellRef.current, '--hl-hold-time', 1500)
141
+ let last = performance.now()
142
+ const step = (now: number) => {
143
+ const next = Math.min(1, p.current + (now - last) / total)
144
+ last = now
145
+ paint(next)
146
+ if (press.current && now - press.current.t0 > HINT_AFTER) setKeep(true)
147
+ if (next >= 1) return finish()
148
+ raf.current = requestAnimationFrame(step)
149
+ }
150
+ raf.current = requestAnimationFrame(step)
151
+ }
152
+
153
+ // Back fast and easing out: a rubber band, not a clock.
154
+ const rewind = () => {
155
+ cancelAnimationFrame(raf.current)
156
+ const from = p.current
157
+ const length = readMotionMs('fast')
158
+ if (!length || !from) return paint(0)
159
+ const t0 = performance.now()
160
+ const step = (now: number) => {
161
+ const t = Math.min(1, (now - t0) / length)
162
+ paint(from * Math.pow(1 - t, 3))
163
+ if (t < 1) raf.current = requestAnimationFrame(step)
164
+ }
165
+ raf.current = requestAnimationFrame(step)
166
+ }
167
+
168
+ const openStep = () => {
169
+ window.clearTimeout(tapTimer.current)
170
+ tapTimer.current = 0
171
+ cancelAnimationFrame(raf.current)
172
+ paint(0)
173
+ setPhase('step')
174
+ }
175
+
176
+ const closeStep = () => {
177
+ setPhase('idle')
178
+ mainRef.current?.focus({ preventScroll: true })
179
+ calls.current.onCancel?.()
180
+ }
181
+
182
+ useEffect(() => {
183
+ if (phase === 'step') cancelRef.current?.focus({ preventScroll: true })
184
+ if (phase !== 'done') return
185
+ const t = window.setTimeout(() => {
186
+ paint(0)
187
+ setPhase('idle')
188
+ setSaid('')
189
+ }, DONE_FOR)
190
+ return () => window.clearTimeout(t)
191
+ }, [phase])
192
+
193
+ useEffect(
194
+ () => () => {
195
+ cancelAnimationFrame(raf.current)
196
+ window.clearTimeout(tapTimer.current)
197
+ },
198
+ [],
199
+ )
200
+
201
+ const start = (pointer: number | 'key', x: number, key?: string) => {
202
+ press.current = { t0: performance.now(), pointer, key }
203
+ setPath(outline(size.w, size.h, size.inset, x))
204
+ shellRef.current?.setAttribute('data-held', '')
205
+ run()
206
+ }
207
+
208
+ const letGo = (tapAllowed: boolean) => {
209
+ const pr = press.current
210
+ press.current = null
211
+ shellRef.current?.removeAttribute('data-held')
212
+ setKeep(false)
213
+ if (!pr || phaseRef.current !== 'idle') return
214
+ const tap = tapAllowed && performance.now() - pr.t0 < TAP
215
+ rewind()
216
+ if (!tap) return calls.current.onCancel?.()
217
+ if (pr.pointer === 'key') return openStep()
218
+ // A tapped step opens on the click that follows, after the browser has focused the button, so focus
219
+ // can then move to Cancel. If no click comes, it opens anyway.
220
+ tapTimer.current = window.setTimeout(openStep, TAP)
221
+ }
222
+
223
+ const onPointerDown = (e: PointerEvent<HTMLButtonElement>) => {
224
+ if (disabled || phaseRef.current !== 'idle' || press.current || (e.pointerType === 'mouse' && e.button !== 0)) return
225
+ e.currentTarget.setPointerCapture?.(e.pointerId)
226
+ start(e.pointerId, e.clientX - e.currentTarget.getBoundingClientRect().left)
227
+ }
228
+
229
+ const onPointerMove = (e: PointerEvent<HTMLButtonElement>) => {
230
+ const pr = press.current
231
+ if (!pr || pr.pointer !== e.pointerId) return
232
+ const r = e.currentTarget.getBoundingClientRect()
233
+ if (e.clientX < r.left - LEAVE || e.clientX > r.right + LEAVE || e.clientY < r.top - LEAVE || e.clientY > r.bottom + LEAVE) letGo(false)
234
+ }
235
+
236
+ const onPointerEnd = (e: PointerEvent<HTMLButtonElement>) => {
237
+ if (press.current?.pointer === e.pointerId) letGo(e.type === 'pointerup')
238
+ }
239
+
240
+ // Held Enter or Space works like a held finger, from the middle of the button. One press opens the
241
+ // confirm step, since holding a key is hard for many people.
242
+ const onKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
243
+ if (e.key !== 'Enter' && e.key !== ' ') return
244
+ e.preventDefault()
245
+ if (e.repeat || disabled || phaseRef.current !== 'idle' || press.current) return
246
+ start('key', size.w / 2, e.key)
247
+ }
248
+
249
+ const onKeyUp = (e: KeyboardEvent<HTMLButtonElement>) => {
250
+ if (e.key !== 'Enter' && e.key !== ' ') return
251
+ e.preventDefault()
252
+ if (press.current?.key === e.key) letGo(true)
253
+ }
254
+
255
+ // A screen reader or voice control sends a click with no press: it opens the confirm step.
256
+ const onClick = (e: MouseEvent<HTMLButtonElement>) => {
257
+ if (disabled || phaseRef.current !== 'idle' || press.current) return
258
+ if (e.detail === 0 || tapTimer.current) openStep()
259
+ }
260
+
261
+ const onStepKey = (e: KeyboardEvent<HTMLDivElement>) => {
262
+ if (e.key !== 'Escape') return
263
+ e.stopPropagation()
264
+ closeStep()
265
+ }
266
+
267
+ // No room beside the label, as on a phone: the step opens under it and the pill grows into a card.
268
+ const stack = size.room > 0 && size.w + size.step > size.room
269
+ const open = phase === 'step'
270
+ const shellW = open ? (stack ? Math.max(size.w, size.step) : size.w + size.step) : size.w
271
+ const shellH = open && stack ? size.h + size.stepH : size.h
272
+ const line = open ? (stepHint ?? `Choose ${cancelLabel} or ${confirmLabel}.`) : phase === 'done' ? '' : keep ? 'Keep holding' : hint
273
+
274
+ return (
275
+ <div ref={rootRef} className={cx(styles.root, className)}>
276
+ <div
277
+ ref={shellRef}
278
+ className={styles.shell}
279
+ data-phase={phase}
280
+ data-stack={stack || undefined}
281
+ data-disabled={disabled || undefined}
282
+ style={{ width: shellW || undefined, height: shellH || undefined }}
283
+ >
284
+ <button
285
+ ref={mainRef}
286
+ type="button"
287
+ className={styles.main}
288
+ aria-describedby={`${id}-hint`}
289
+ aria-expanded={open}
290
+ aria-controls={`${id}-step`}
291
+ aria-disabled={disabled || undefined}
292
+ onPointerDown={onPointerDown}
293
+ onPointerMove={onPointerMove}
294
+ onPointerUp={onPointerEnd}
295
+ onPointerCancel={onPointerEnd}
296
+ onContextMenu={(e) => e.preventDefault()}
297
+ onKeyDown={onKeyDown}
298
+ onKeyUp={onKeyUp}
299
+ onBlur={() => press.current?.pointer === 'key' && letGo(false)}
300
+ onClick={onClick}
301
+ >
302
+ <span className={styles.faces}>
303
+ <span className={styles.face} data-face="idle" aria-hidden={phase === 'done' || undefined}>
304
+ {children}
305
+ </span>
306
+ <span className={styles.face} data-face="done" aria-hidden={phase !== 'done' || undefined}>
307
+ <CheckIcon />
308
+ {doneLabel}
309
+ </span>
310
+ </span>
311
+ <svg className={styles.fuse} width={size.w} height={size.h} aria-hidden="true">
312
+ <path ref={runRef} d={path} className={styles.run} pathLength={1} />
313
+ </svg>
314
+ </button>
315
+ <div ref={stepRef} id={`${id}-step`} className={styles.step} role="group" aria-labelledby={`${id}-hint`} inert={!open} onKeyDown={onStepKey}>
316
+ <button ref={cancelRef} type="button" className={styles.cancel} onClick={closeStep}>
317
+ {cancelLabel}
318
+ </button>
319
+ <button type="button" className={styles.confirm} onClick={finish}>
320
+ {confirmLabel}
321
+ </button>
322
+ </div>
323
+ </div>
324
+ <p id={`${id}-hint`} className={styles.hint}>
325
+ {line}
326
+ </p>
327
+ <p className={styles.srOnly} aria-live="polite">
328
+ {said}
329
+ </p>
330
+ </div>
331
+ )
332
+ }
@@ -0,0 +1 @@
1
+ export * from './HoldToConfirm'
@@ -0,0 +1,151 @@
1
+ /* The anchor keeps the button's place in the page. The shell is the panel's final box, laid out from
2
+ the start, so nothing inside it reflows while it grows. */
3
+ .anchor {
4
+ position: relative;
5
+ display: inline-block;
6
+ vertical-align: top;
7
+ }
8
+
9
+ .shell {
10
+ position: absolute;
11
+ z-index: var(--hl-z-popover);
12
+ width: min(var(--hl-panel-w-sm), calc(100vw - var(--hl-space-5) * 2));
13
+ }
14
+
15
+ .shell[data-width="md"] { width: min(var(--hl-panel-w-md), calc(100vw - var(--hl-space-5) * 2)); }
16
+
17
+ /* Shut, only the button takes the pointer: the rest of the box is empty space over the page. */
18
+ .shell:not([data-open]) { pointer-events: none; }
19
+ .shell:not([data-open]) .trigger { pointer-events: auto; }
20
+
21
+ .shell[data-corner="tl"] { top: 0; left: 0; }
22
+ .shell[data-corner="tr"] { top: 0; right: 0; }
23
+ .shell[data-corner="bl"] { bottom: 0; left: 0; }
24
+ .shell[data-corner="br"] { bottom: 0; right: 0; }
25
+
26
+ /* Opening, the edges travel on the liquid curve and settle on the panel's box. Closing is quicker. */
27
+ .surface {
28
+ position: absolute;
29
+ inset: var(--t) var(--r) var(--b) var(--l);
30
+ border: var(--hl-line) solid var(--hl-color-border);
31
+ border-radius: var(--pill);
32
+ background: var(--hl-color-surface);
33
+ box-shadow: var(--hl-shadow-1);
34
+ transition:
35
+ inset var(--hl-motion-fast) var(--hl-ease-out),
36
+ border-radius var(--hl-motion-fast) var(--hl-ease-out),
37
+ box-shadow var(--hl-motion-fast) var(--hl-ease-out);
38
+ }
39
+
40
+ .content {
41
+ position: relative;
42
+ clip-path: inset(var(--t) var(--r) var(--b) var(--l) round var(--pill));
43
+ transition: clip-path var(--hl-motion-fast) var(--hl-ease-out);
44
+ }
45
+
46
+ .shell[data-open] .surface {
47
+ border-radius: var(--hl-radius-panel);
48
+ box-shadow: var(--hl-shadow-3);
49
+ transition:
50
+ inset var(--hl-motion-base) var(--hl-ease-liquid),
51
+ border-radius var(--hl-motion-base) var(--hl-ease-liquid),
52
+ box-shadow var(--hl-motion-base) var(--hl-ease-out);
53
+ }
54
+
55
+ .shell[data-open] .content {
56
+ clip-path: inset(var(--t) var(--r) var(--b) var(--l) round var(--hl-radius-panel));
57
+ transition: clip-path var(--hl-motion-base) var(--hl-ease-liquid);
58
+ }
59
+
60
+ .shell:not([data-open]):has(.trigger:focus-visible) .surface { box-shadow: var(--hl-focus-ring); }
61
+
62
+ @media (hover: hover) {
63
+ .shell:not([data-open]):has(.trigger:hover) .surface { background: var(--hl-color-surface-hover); }
64
+ }
65
+
66
+ .trigger {
67
+ position: absolute;
68
+ z-index: 1;
69
+ display: inline-flex;
70
+ align-items: center;
71
+ gap: var(--hl-space-3);
72
+ height: var(--hl-control-h-lg);
73
+ padding: 0 var(--hl-space-6);
74
+ border: 0;
75
+ border-radius: var(--hl-radius-full);
76
+ background: transparent;
77
+ color: var(--hl-color-fg);
78
+ font: inherit;
79
+ font-weight: var(--hl-weight-medium);
80
+ white-space: nowrap;
81
+ cursor: pointer;
82
+ outline: none;
83
+ -webkit-tap-highlight-color: transparent;
84
+ }
85
+
86
+ .icon {
87
+ display: inline-grid;
88
+ font-size: var(--hl-icon-md);
89
+ }
90
+
91
+ .icon svg {
92
+ width: var(--hl-icon-md);
93
+ height: var(--hl-icon-md);
94
+ }
95
+
96
+ .shell[data-corner="tl"] .trigger { top: 0; left: 0; }
97
+ .shell[data-corner="tr"] .trigger { top: 0; right: 0; }
98
+ .shell[data-corner="bl"] .trigger { bottom: 0; left: 0; }
99
+ .shell[data-corner="br"] .trigger { bottom: 0; right: 0; }
100
+
101
+ .shell[data-open] .trigger:focus-visible { box-shadow: inset 0 0 0 var(--hl-line-focus) var(--hl-color-fg); }
102
+
103
+ /* The panel's content clears the button's row, which is now its title, above or below it. */
104
+ .panel {
105
+ position: relative;
106
+ display: flex;
107
+ flex-direction: column;
108
+ gap: var(--hl-space-3);
109
+ padding: calc(var(--hl-control-h-lg) + var(--hl-space-3)) var(--hl-space-6) var(--hl-space-6);
110
+ outline: none;
111
+ }
112
+
113
+ /* Shut, the panel leaves the accessibility tree and the tab order, once the fold has finished. */
114
+ .shell:not([data-open]) .panel {
115
+ visibility: hidden;
116
+ transition: visibility 0s linear var(--hl-motion-fast);
117
+ }
118
+
119
+ .shell[data-corner^="b"] .panel { padding: var(--hl-space-6) var(--hl-space-6) calc(var(--hl-control-h-lg) + var(--hl-space-3)); }
120
+
121
+ .close {
122
+ position: absolute;
123
+ top: var(--hl-space-1);
124
+ right: var(--hl-space-1);
125
+ display: grid;
126
+ place-items: center;
127
+ width: var(--hl-hit-min);
128
+ height: var(--hl-hit-min);
129
+ padding: 0;
130
+ border: 0;
131
+ border-radius: var(--hl-radius-full);
132
+ background: transparent;
133
+ color: var(--hl-color-fg-muted);
134
+ cursor: pointer;
135
+ }
136
+
137
+ .close svg {
138
+ width: var(--hl-icon-md);
139
+ height: var(--hl-icon-md);
140
+ }
141
+
142
+ .shell[data-corner="tr"] .close,
143
+ .shell[data-corner="br"] .close { right: auto; left: var(--hl-space-1); }
144
+
145
+ .shell[data-corner^="b"] .close { top: auto; bottom: var(--hl-space-1); }
146
+
147
+ @media (hover: hover) {
148
+ .close:hover { background: var(--hl-color-surface-hover); color: var(--hl-color-fg); }
149
+ }
150
+
151
+ .close:focus-visible { outline: none; box-shadow: inset 0 0 0 var(--hl-line-focus) var(--hl-color-fg); }