11// Tabs
22import { Tabs } from "@kobalte/core/tabs"
3+ import { useClipboard } from "bagon-hooks"
34import { clsx } from "clsx"
45import {
56 type ComponentProps ,
@@ -8,9 +9,13 @@ import {
89 type FlowProps ,
910 type JSX ,
1011 mergeProps ,
12+ onCleanup ,
13+ onMount ,
1114 Show ,
1215 splitProps ,
1316} from "solid-js"
17+ import { cn } from "@/utils/cn"
18+ import { MDXContent } from "dev/components/mdx-content"
1419
1520// TODO: Dropdown (use @kobalte)
1621// Dropdown
@@ -43,7 +48,7 @@ export type DemoProps = {
4348 children : JSX . Element
4449 class ?: string
4550 defaultValue ?: TabValue
46- code ?: JSX . Element
51+ code ?: string
4752 minHeight ?: string
4853 title ?: JSX . Element
4954}
@@ -64,6 +69,8 @@ function Demo(props: FlowProps<Props>) {
6469 const [ knowsToClick , setKnowsToClick ] = createSignal ( false )
6570 const [ active , setActive ] = createSignal ( _props . defaultValue )
6671
72+ const { copy, copied } = useClipboard ( )
73+
6774 function handleClick ( ) {
6875 if ( ! _props . onClick ) return
6976
@@ -87,25 +94,25 @@ function Demo(props: FlowProps<Props>) {
8794 return (
8895 < Tabs
8996 ref = { _props . ref }
90- class = { clsx ( active ( ) === "code" && "dark" , " Demo not-prose relative isolate text-primary") } // reset text color if inside prose
97+ class = { clsx ( " Demo not-prose relative flex flex-col text-primary") } // reset text color if inside prose
9198 value = { active ( ) }
9299 onChange = { ( val ) => setActive ( val as TabValue ) }
93100 // onValueChange={(val) => setActive(val as TabValue)}
94101 >
95102 < Show when = { _props . code } >
96103 { /* <MotionConfig transition={{ layout: { type: 'spring', duration: 0.25, bounce: 0 } }}> */ }
97- < Tabs . List class = "absolute top-3 right-3 z-10 flex gap-1 rounded-full bg-zinc-150/90 p-1 backdrop-blur-lg dark:bg-black/60 " >
104+ < Tabs . List class = "absolute top-3 right-3 z-10 flex gap-1 rounded-full bg-black/60 p-1 backdrop-blur-lg" >
98105 < Tabs . Trigger
99106 value = "preview"
100107 class = { clsx (
101108 active ( ) !== "preview" && "hover:transition-[color]" ,
102- "relative px-2 py-1 font-medium text-xs/4 text-zinc-600 hover:text-primary aria-selected:text-primary dark:text-muted "
109+ "relative px-2 py-1 font-medium text-muted text- xs/4 hover:text-primary aria-selected:text-primary"
103110 ) }
104111 >
105112 < Show when = { active ( ) === "preview" } >
106113 { /* // Motion.div */ }
107114 < div
108- class = "prefers-dark:!bg-white/15 - z-10 absolute inset-0 size-full bg-white shadow-sm dark: bg-white/25"
115+ class = "- z-10 absolute inset-0 size-full rounded-full bg-white/25 shadow-sm "
109116 style = { { "border-radius" : "999" } }
110117 // layout
111118 // layoutId={`${id}active`}
@@ -117,13 +124,13 @@ function Demo(props: FlowProps<Props>) {
117124 value = "code"
118125 class = { clsx (
119126 active ( ) !== "code" && "hover:transition-[color]" ,
120- "relative px-2 py-1 font-medium text-xs/4 text-zinc-600 hover:text-primary aria-selected:text-primary dark:text-muted "
127+ "relative px-2 py-1 font-medium text-muted text- xs/4 hover:text-primary aria-selected:text-primary"
121128 ) }
122129 >
123130 < Show when = { active ( ) === "code" } >
124131 { /* // Motion.div */ }
125132 < div
126- class = "prefers-dark:!bg-white/15 - z-10 absolute inset-0 size-full bg-white shadow-sm dark: bg-white/25"
133+ class = "- z-10 absolute inset-0 size-full rounded-full bg-white/25 shadow-sm "
127134 style = { { "border-radius" : "999" } }
128135 // layout
129136 // layoutId={`${id}active`}
@@ -134,43 +141,86 @@ function Demo(props: FlowProps<Props>) {
134141 </ Tabs . List >
135142 { /* </MotionConfig> */ }
136143 </ Show >
137- < Tabs . Content
138- value = "preview"
139- class = { clsx (
140- _props . class ,
141- "relative rounded-lg border border-faint data-[state=inactive]:hidden"
142- ) }
144+ < Collapsible
145+ open = { true }
146+ containerClass = { cn ( "rounded-lg border border-faint" , active ( ) === "code" && "bg-[#18181B]" ) }
143147 >
144- < Show when = { renderedTitle ( ) } >
145- < div class = "absolute top-3 left-3" > { renderedTitle ( ) } </ div >
146- </ Show >
148+ < Show when = { active ( ) === "preview" } >
149+ < Tabs . Content value = "preview" class = { clsx ( _props . class , "relative" ) } >
150+ < Show when = { renderedTitle ( ) } >
151+ < div class = "absolute top-3 left-3" > { renderedTitle ( ) } </ div >
152+ </ Show >
147153
148- < div
149- class = { clsx ( _props . minHeight , "flex flex-col items-center justify-center p-5 pb-6" ) }
150- onClick = { handleClick }
151- onMouseDown = { handleMouseDown }
152- >
153- { _props . children }
154- { _props ?. onClick && (
155- < span
156- class = { clsx (
157- "absolute bottom-5 left-0 w-full text-center text-sm text-zinc-400 transition-opacity duration-200 ease-out" ,
158- knowsToClick ( ) && "opacity-0"
154+ < div
155+ class = { clsx ( _props . minHeight , "flex flex-col items-center justify-center p-5 pb-6" ) }
156+ onClick = { handleClick }
157+ onMouseDown = { handleMouseDown }
158+ >
159+ { _props . children }
160+ { _props ?. onClick && (
161+ < span
162+ class = { clsx (
163+ "absolute bottom-5 left-0 w-full text-center text-sm text-zinc-400 transition-opacity duration-200 ease-out" ,
164+ knowsToClick ( ) && "opacity-0"
165+ ) }
166+ >
167+ Click anywhere to change numbers
168+ </ span >
159169 ) }
170+ </ div >
171+ </ Tabs . Content >
172+ </ Show >
173+ < Show when = { active ( ) === "code" && _props . code } >
174+ < Tabs . Content value = "code" class = "relative overflow-hidden p-3 text-sm" >
175+ < MDXContent code = { _props . code ! } />
176+ < button
177+ onClick = { ( ) => {
178+ const code = _props . code || ""
179+ const trimmedCode = code
180+ . replace ( / ^ \s * ` ` ` t s x \s * \n ? / , "" ) // Remove ```tsx from beginning
181+ . replace ( / \n ? \s * ` ` ` \s * $ / , "" ) // Remove ``` from end
182+ copy ( trimmedCode )
183+ } }
184+ class = "absolute right-2 bottom-2 animate-fadeIn rounded-md bg-white/10 p-1 transition-colors hover:bg-white/20"
160185 >
161- Click anywhere to change numbers
162- </ span >
163- ) }
164- </ div >
165- </ Tabs . Content >
166- < Show when = { _props . code } >
167- < Tabs . Content
168- value = "code"
169- class = "relative overflow-hidden rounded-lg border border-faint bg-zinc-950 text-sm"
170- >
171- { _props . code }
172- </ Tabs . Content >
173- </ Show >
186+ < Show
187+ when = { ! copied ( ) }
188+ fallback = {
189+ < svg
190+ class = "h-4 w-4 animate-scale-in text-white"
191+ fill = "none"
192+ stroke = "currentColor"
193+ viewBox = "0 0 24 24"
194+ xmlns = "http://www.w3.org/2000/svg"
195+ >
196+ < path
197+ stroke-linecap = "round"
198+ stroke-linejoin = "round"
199+ stroke-width = "2"
200+ d = "M5 13l4 4L19 7"
201+ />
202+ </ svg >
203+ }
204+ >
205+ < svg
206+ class = "h-4 w-4 animate-scale-in text-white"
207+ fill = "none"
208+ stroke = "currentColor"
209+ viewBox = "0 0 24 24"
210+ xmlns = "http://www.w3.org/2000/svg"
211+ >
212+ < path
213+ stroke-linecap = "round"
214+ stroke-linejoin = "round"
215+ stroke-width = "2"
216+ d = "M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
217+ > </ path >
218+ </ svg >
219+ </ Show >
220+ </ button >
221+ </ Tabs . Content >
222+ </ Show >
223+ </ Collapsible >
174224 </ Tabs >
175225 )
176226}
@@ -254,12 +304,125 @@ export function DemoSwitch(props: ComponentProps<typeof Switch>) {
254304 < Switch . Control
255305 class = { clsx (
256306 props . class ,
257- "group relative flex h-6 w-11 cursor-pointer rounded-full bg-zinc-200 p-0.5 transition-colors duration-200 ease-in-out focus:outline-none data-checked:bg-zinc-950 data-focus:outline-2 data-focus:outline-blue-500 dark:bg-zinc-800 dark:data-checked:bg-zinc-50 "
307+ "group relative flex h-6 w-11 cursor-pointer rounded-full bg-zinc-800 p-0.5 transition-colors duration-200 ease-in-out focus:outline-none data-checked:bg-zinc-50 data-focus:outline-2 data-focus:outline-blue-500"
258308 ) }
259309 >
260- < Switch . Thumb class = "spring-bounce-0 spring-duration-200 pointer-events-none inline-block size-5 rounded-full bg-white shadow-lg ring-0 transition-transform group-data-checked:translate-x-5 dark:bg-zinc-950 " />
310+ < Switch . Thumb class = "spring-bounce-0 spring-duration-200 pointer-events-none inline-block size-5 rounded-full bg-zinc-950 shadow-lg ring-0 transition-transform group-data-checked:translate-x-5" />
261311 </ Switch . Control >
262312 < Switch . Label class = "text-xs" > { props . children as any } </ Switch . Label >
263313 </ Switch >
264314 )
265315}
316+
317+ // NOT A SOLID-UI / SHADCN COMPONENT. CUSTOM BY CARLO
318+ // A11Y NOTES ----------------------------------------------------
319+ // - The extra divs here (outer animating wrapper + inner measure div)
320+ // are **not** exposed to the a11y tree because:
321+ // – we don't add any semantic roles
322+ // – we don't add any labelling attributes
323+ // So screen-readers will only “see” the real interactive controls
324+ // that compose this component (buttons, links, inputs, etc.).
325+ // Multiple layout divs are therefore “noise-free”.
326+ // - The collapsing action itself should be announced by whatever
327+ // trigger toggles the `open` prop (e.g. the parent button should
328+ // have `aria-expanded` and `aria-controls`).
329+ // - If this component is ever used as a disclosure region directly,
330+ // add `id`, `role="region"` and `aria-labelledby` (or `aria-label`)
331+ // on the outer div so assistive tech can associate it with the
332+ // controlling button.
333+ // - `overflow:hidden` on the outer div prevents keyboard focus from
334+ // landing on off-screen descendants when closed, but double-check
335+ // that no forced-focus logic circumvents that.
336+ // - Prefer `prefers-reduced-motion` in consumer code to disable
337+ // the `transition-all` class when the user has requested it.
338+ // - HIDING CONTENT FROM SR WHEN CLOSED: Yes, this is the usual,
339+ // expected pattern. When a disclosure widget is closed we remove
340+ // its descendants from the a11y tree with `aria-hidden` so users
341+ // cannot read/traverse it. (aria-expanded=false already implies
342+ // invisibility for SR users; aria-hidden just makes it official.)
343+ // When open we drop aria-hidden so the region is discoverable again.
344+ // - External trigger a11y docs - added.
345+ // ----------------------------------------------------------------
346+
347+ export interface CollapsibleProps extends JSX . HTMLAttributes < HTMLDivElement > {
348+ open ?: boolean
349+ /**
350+ * Applied to the collapsing wrapper div (the element whose height changes). Generally no need to touch this besides changing the transition duration.
351+ */
352+ containerClass ?: string
353+ /**
354+ * Applied to the content div (the measured inner wrapper)
355+ */
356+ class ?: string
357+ children : JSX . Element
358+ }
359+
360+ /*
361+ * Fluid height collapsible built on native resize events.
362+ * Uses transition-* utilities (duration/ease) – the root parent gets
363+ * a changing style.height that the utility class animates.
364+ * Works exactly like an Accordion panel but without any trigger
365+ * baggage; state is 100% parent-controlled via open={bool}.
366+ *
367+ * A11y Notes for external triggers (Just a minor trade-off from not using an Accordion component).
368+ * If an external button/link controls this panel you should:
369+ * 1. Pass a unique `id` to Collapsible (e.g. id="faq-panel-3").
370+ * 2. On the trigger element, add:
371+ * aria-expanded={open}
372+ * aria-controls="faq-panel-3"
373+ * where `open` is the same boolean you pass to Collapsible.
374+ * 3. Optionally set the `role="region"` prop on Collapsible so
375+ * screen-reader users hear "region" when entering the panel.
376+ * 4. If you want a visible label, add `aria-labelledby`
377+ * to the trigger button (not Collapsible) pointing
378+ * to the ID of the heading that names the panel.
379+ * Example:
380+ * <h3 id="faq-title">Shipping options</h3>
381+ * <button aria-expanded={open} aria-controls="faq-panel-3" aria-labelledby="faq-title">
382+ * …
383+ * </button>
384+ */
385+ export function Collapsible ( props : CollapsibleProps ) {
386+ let innerRef : HTMLDivElement | undefined
387+ let lastHeight = 0
388+
389+ const [ local , others ] = splitProps ( props , [ "open" , "containerClass" , "class" , "children" ] )
390+ const [ height , setHeight ] = createSignal < number | string > ( "auto" )
391+
392+ // Observe the *inner* element’s size so any content change is reflected
393+ const resizeHandler = ( ) => {
394+ if ( innerRef ) {
395+ lastHeight = innerRef . scrollHeight
396+ setHeight ( lastHeight ) // keep latest value when open
397+ }
398+ }
399+
400+ let ro : ResizeObserver
401+ onMount ( ( ) => {
402+ if ( ! innerRef ) return
403+ ro = new ResizeObserver ( resizeHandler )
404+ ro . observe ( innerRef )
405+ resizeHandler ( ) // ensure initial read
406+ } )
407+
408+ onCleanup ( ( ) => ro ?. disconnect ( ) )
409+
410+ // Select what to render based on open state
411+ // (invoke children here once so ResizeObserver sees static children of inner)
412+ const content = children ( ( ) => local . children )
413+
414+ const heightStyle = ( ) => ( local . open ? `${ height ( ) } px` : "0px" )
415+ return (
416+ < div
417+ style = { { height : heightStyle ( ) } }
418+ class = { cn ( "overflow-hidden transition-[width,height] duration-400" , local . containerClass ) }
419+ aria-hidden = { ! local . open }
420+ { ...others }
421+ >
422+ { /* inner wrapper we measure */ }
423+ < div ref = { innerRef } class = { cn ( local . class , "w-full" ) } >
424+ { content ( ) }
425+ </ div >
426+ </ div >
427+ )
428+ }
0 commit comments