minka-ds 0.3.11 → 0.4.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/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "minka-ds",
3
- "version": "0.3.11",
3
+ "version": "0.4.0",
4
4
  "description": "Minka product design system — tokenized component library",
5
5
  "license": "MIT",
6
6
  "files": [
7
7
  "src",
8
- "tokens"
8
+ "tokens",
9
+ "!src/textures/*.svg"
9
10
  ],
10
11
  "exports": {
11
12
  ".": "./src/index.ts",
@@ -0,0 +1,47 @@
1
+ import * as React from "react"
2
+ import { cn } from "../../lib/utils"
3
+
4
+ const SIZE: Record<NonNullable<AvatarProps["size"]>, string> = {
5
+ sm: "size-7 text-caption-serif",
6
+ md: "size-9 text-body-sm-serif",
7
+ lg: "size-12 text-body-serif",
8
+ }
9
+
10
+ interface AvatarProps {
11
+ /** Image URL. When absent, initials (or a fallback) are shown. */
12
+ src?: string
13
+ /** Full name — used for alt text and to derive initials. */
14
+ name?: string
15
+ /** Explicit initials override; otherwise derived from `name`. */
16
+ initials?: string
17
+ size?: "sm" | "md" | "lg"
18
+ /** Background for the initials state. Defaults to a brand color. */
19
+ background?: string
20
+ className?: string
21
+ }
22
+
23
+ function deriveInitials(name?: string): string {
24
+ if (!name) return "?"
25
+ return name.trim().split(/\s+/).map(p => p[0]).join("").slice(0, 2).toUpperCase()
26
+ }
27
+
28
+ function Avatar({ src, name, initials, size = "md", background, className }: AvatarProps) {
29
+ return (
30
+ <div
31
+ data-slot="avatar"
32
+ className={cn(
33
+ "shrink-0 rounded-full flex items-center justify-center overflow-hidden text-[var(--color-text-inverse)]",
34
+ SIZE[size],
35
+ className
36
+ )}
37
+ style={{ background: src ? undefined : (background ?? "var(--color-brand-blue)") }}
38
+ >
39
+ {src
40
+ ? <img src={src} alt={name ?? ""} className="size-full object-cover" />
41
+ : <span className="leading-none">{initials ?? deriveInitials(name)}</span>}
42
+ </div>
43
+ )
44
+ }
45
+
46
+ export { Avatar }
47
+ export type { AvatarProps }
@@ -0,0 +1,106 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { cn } from "../../lib/utils"
5
+
6
+ interface InputOTPProps {
7
+ /** Number of digit boxes. */
8
+ length?: number
9
+ value: string
10
+ onChange: (value: string) => void
11
+ /** Fires when all boxes are filled. */
12
+ onComplete?: (value: string) => void
13
+ invalid?: boolean
14
+ disabled?: boolean
15
+ autoFocus?: boolean
16
+ className?: string
17
+ }
18
+
19
+ function InputOTP({
20
+ length = 6,
21
+ value,
22
+ onChange,
23
+ onComplete,
24
+ invalid,
25
+ disabled,
26
+ autoFocus,
27
+ className,
28
+ }: InputOTPProps) {
29
+ const refs = React.useRef<(HTMLInputElement | null)[]>([])
30
+ const digits = Array.from({ length }, (_, i) => value[i] ?? "")
31
+
32
+ function setAt(index: number, char: string) {
33
+ const next = digits.slice()
34
+ next[index] = char
35
+ const joined = next.join("").slice(0, length)
36
+ onChange(joined)
37
+ if (joined.length === length) onComplete?.(joined)
38
+ }
39
+
40
+ function handleChange(index: number, raw: string) {
41
+ const char = raw.replace(/\D/g, "").slice(-1) // keep last typed digit
42
+ if (!char) return
43
+ setAt(index, char)
44
+ if (index < length - 1) refs.current[index + 1]?.focus()
45
+ }
46
+
47
+ function handleKeyDown(index: number, e: React.KeyboardEvent<HTMLInputElement>) {
48
+ if (e.key === "Backspace") {
49
+ e.preventDefault()
50
+ if (digits[index]) {
51
+ setAt(index, "")
52
+ } else if (index > 0) {
53
+ refs.current[index - 1]?.focus()
54
+ setAt(index - 1, "")
55
+ }
56
+ } else if (e.key === "ArrowLeft" && index > 0) {
57
+ e.preventDefault()
58
+ refs.current[index - 1]?.focus()
59
+ } else if (e.key === "ArrowRight" && index < length - 1) {
60
+ e.preventDefault()
61
+ refs.current[index + 1]?.focus()
62
+ }
63
+ }
64
+
65
+ function handlePaste(e: React.ClipboardEvent) {
66
+ e.preventDefault()
67
+ const pasted = e.clipboardData.getData("text").replace(/\D/g, "").slice(0, length)
68
+ if (!pasted) return
69
+ onChange(pasted)
70
+ if (pasted.length === length) onComplete?.(pasted)
71
+ refs.current[Math.min(pasted.length, length - 1)]?.focus()
72
+ }
73
+
74
+ return (
75
+ <div data-slot="input-otp" className={cn("flex items-center gap-2", className)} onPaste={handlePaste}>
76
+ {digits.map((d, i) => (
77
+ <input
78
+ key={i}
79
+ ref={el => { refs.current[i] = el }}
80
+ type="text"
81
+ inputMode="numeric"
82
+ autoComplete={i === 0 ? "one-time-code" : "off"}
83
+ maxLength={1}
84
+ value={d}
85
+ disabled={disabled}
86
+ autoFocus={autoFocus && i === 0}
87
+ aria-invalid={invalid || undefined}
88
+ aria-label={`Digit ${i + 1}`}
89
+ onChange={e => handleChange(i, e.target.value)}
90
+ onKeyDown={e => handleKeyDown(i, e)}
91
+ onFocus={e => e.target.select()}
92
+ className={cn(
93
+ "size-11 text-center text-heading-4 [border-radius:var(--radius-input)] border bg-[var(--color-bg-raised)] outline-none transition-[color,box-shadow]",
94
+ "border-[var(--color-border-default)]",
95
+ "focus-visible:border-[var(--color-border-focus)] focus-visible:ring-[3px] focus-visible:ring-[var(--color-border-focus)]/50",
96
+ "aria-invalid:border-[var(--color-border-error)] aria-invalid:ring-[3px] aria-invalid:ring-[var(--color-border-error)]/20",
97
+ "disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
98
+ )}
99
+ />
100
+ ))}
101
+ </div>
102
+ )
103
+ }
104
+
105
+ export { InputOTP }
106
+ export type { InputOTPProps }
@@ -7,6 +7,7 @@ import { Slot } from "radix-ui"
7
7
 
8
8
  import { useIsMobile } from "../../hooks/use-mobile"
9
9
  import { cn } from "../../lib/utils"
10
+ import { Avatar } from "./avatar"
10
11
  import { Button } from "./button"
11
12
  import { Input } from "./input"
12
13
  import { Separator } from "./separator"
@@ -368,6 +369,38 @@ function SidebarSeparator({
368
369
  )
369
370
  }
370
371
 
372
+ // Footer user block: avatar + name/role, with an optional trailing action
373
+ // (e.g. a kebab dropdown trigger). Composed for the sidebar footer.
374
+ function SidebarUser({
375
+ name,
376
+ role,
377
+ avatarSrc,
378
+ avatarBackground,
379
+ action,
380
+ className,
381
+ }: {
382
+ name: string
383
+ role?: string
384
+ avatarSrc?: string
385
+ avatarBackground?: string
386
+ action?: React.ReactNode
387
+ className?: string
388
+ }) {
389
+ return (
390
+ <div
391
+ data-slot="sidebar-user"
392
+ className={cn("flex items-center gap-2.5 px-2 py-1.5", className)}
393
+ >
394
+ <Avatar name={name} src={avatarSrc} background={avatarBackground} />
395
+ <div className="flex flex-col gap-0.5 flex-1 min-w-0">
396
+ <span className="text-body-sm text-[var(--color-text-default)] truncate">{name}</span>
397
+ {role && <span className="text-caption-light text-[var(--color-text-muted)] truncate">{role}</span>}
398
+ </div>
399
+ {action}
400
+ </div>
401
+ )
402
+ }
403
+
371
404
  function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
372
405
  return (
373
406
  <div
@@ -722,5 +755,6 @@ export {
722
755
  SidebarRail,
723
756
  SidebarSeparator,
724
757
  SidebarTrigger,
758
+ SidebarUser,
725
759
  useSidebar,
726
760
  }
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@ export { usePlatform } from "./hooks/use-platform"
6
6
 
7
7
  // Components
8
8
  export * from "./components/ui/alert"
9
+ export * from "./components/ui/avatar"
9
10
  export * from "./components/ui/badge"
10
11
  export * from "./components/ui/breadcrumb"
11
12
  export * from "./components/ui/calendar"
@@ -26,6 +27,7 @@ export * from "./components/ui/filter-combobox"
26
27
  export * from "./components/ui/search-bar"
27
28
  export * from "./components/ui/input"
28
29
  export * from "./components/ui/input-group"
30
+ export * from "./components/ui/input-otp"
29
31
  export * from "./components/ui/kbd"
30
32
  export * from "./components/ui/label"
31
33
  export * from "./components/ui/pagination"
@@ -41,3 +43,6 @@ export * from "./components/ui/tab-count"
41
43
  export * from "./components/ui/tabs"
42
44
  export * from "./components/ui/textarea"
43
45
  export * from "./components/ui/tooltip"
46
+
47
+ // Brand textures + logo
48
+ export * from "./textures"
@@ -0,0 +1,60 @@
1
+ import * as React from "react"
2
+ import { cn } from "../lib/utils"
3
+ import { TEXTURES, type TextureName } from "./index"
4
+
5
+ /** The five sanctioned brand pairs. */
6
+ export type BrandPair =
7
+ | "yellow-darkforest"
8
+ | "rose-coral"
9
+ | "blue-navy"
10
+ | "beige-bronze"
11
+ | "gray-black"
12
+
13
+ export interface BrandTextureProps extends React.HTMLAttributes<HTMLDivElement> {
14
+ /** Which texture motif to render. */
15
+ name: TextureName
16
+ /** Which brand pair colors it. */
17
+ pair: BrandPair
18
+ /**
19
+ * Swap which member is ink vs background. By default the pair's dark member
20
+ * is the ink (shapes) and the light member is the background.
21
+ */
22
+ reverse?: boolean
23
+ /**
24
+ * How the motif fills the box. `cover` (default) crops to fill — right for
25
+ * edge-to-edge patterns; `contain` fits the whole motif without cropping —
26
+ * right for the logo or any motif that must stay intact.
27
+ */
28
+ fit?: "cover" | "contain"
29
+ }
30
+
31
+ /**
32
+ * Renders a brand texture colored by a sanctioned pair. Sets --texture-ink and
33
+ * --texture-bg on a wrapper; the inlined SVG reads them. The wrapper clips the
34
+ * texture, so size it via className (e.g. `h-40 w-full`).
35
+ */
36
+ function BrandTexture({ name, pair, reverse, fit = "cover", className, style, ...props }: BrandTextureProps) {
37
+ const Texture = TEXTURES[name]
38
+ const light = `var(--color-pair-${pair}-light)`
39
+ const dark = `var(--color-pair-${pair}-dark)`
40
+
41
+ const vars = {
42
+ "--texture-ink": reverse ? light : dark,
43
+ "--texture-bg": reverse ? dark : light,
44
+ backgroundColor: "var(--texture-bg)",
45
+ } as React.CSSProperties
46
+
47
+ const par = fit === "contain" ? "xMidYMid meet" : "xMidYMid slice"
48
+
49
+ return (
50
+ <div
51
+ className={cn("relative overflow-hidden", className)}
52
+ style={{ ...vars, ...style }}
53
+ {...props}
54
+ >
55
+ <Texture className="absolute inset-0 size-full" preserveAspectRatio={par} />
56
+ </div>
57
+ )
58
+ }
59
+
60
+ export { BrandTexture }