@podoba/react 0.0.32 → 0.0.35

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 (70) hide show
  1. package/package.json +3 -3
  2. package/src/components/asset-masonry-grid.examples.tsx +9 -0
  3. package/src/components/asset-masonry-grid.tsx +62 -0
  4. package/src/components/asset-selection-surface.examples.tsx +32 -0
  5. package/src/components/asset-selection-surface.tsx +184 -0
  6. package/src/components/brand-page-header.tsx +297 -67
  7. package/src/components/button.tsx +27 -4
  8. package/src/components/combobox.tsx +1 -1
  9. package/src/components/compact-action-button.examples.tsx +7 -0
  10. package/src/components/compact-action-button.tsx +21 -0
  11. package/src/components/compact-settings-dialog.examples.tsx +15 -0
  12. package/src/components/compact-settings-dialog.tsx +48 -0
  13. package/src/components/context-action-glyph.examples.tsx +9 -0
  14. package/src/components/context-action-glyph.tsx +61 -0
  15. package/src/components/context-menu.tsx +191 -107
  16. package/src/components/context-search-panel.examples.tsx +9 -0
  17. package/src/components/context-search-panel.tsx +50 -0
  18. package/src/components/csv-binding-presentation.examples.tsx +14 -0
  19. package/src/components/csv-binding-presentation.tsx +40 -0
  20. package/src/components/cta-pill.tsx +19 -4
  21. package/src/components/dashboard-grid.tsx +1 -1
  22. package/src/components/date-picker.tsx +5 -3
  23. package/src/components/date-selection-calendar.examples.tsx +8 -0
  24. package/src/components/date-selection-calendar.tsx +47 -0
  25. package/src/components/delivery/send-to-print-modal.tsx +351 -84
  26. package/src/components/dialog-action-button.examples.tsx +7 -0
  27. package/src/components/dialog-action-button.tsx +22 -0
  28. package/src/components/dialog.tsx +32 -12
  29. package/src/components/document-upload-panel.examples.tsx +12 -0
  30. package/src/components/document-upload-panel.tsx +48 -0
  31. package/src/components/dropdown-menu.tsx +5 -3
  32. package/src/components/empty-panel-action.examples.tsx +7 -0
  33. package/src/components/empty-panel-action.tsx +8 -0
  34. package/src/components/field-appearance.ts +14 -0
  35. package/src/components/focus-field.tsx +3 -3
  36. package/src/components/icons.tsx +30 -0
  37. package/src/components/input.examples.tsx +8 -0
  38. package/src/components/input.tsx +13 -5
  39. package/src/components/media-asset-panel.examples.tsx +11 -0
  40. package/src/components/media-asset-panel.tsx +58 -0
  41. package/src/components/media-gallery.examples.tsx +12 -0
  42. package/src/components/media-gallery.tsx +53 -0
  43. package/src/components/media-settings-dialog.examples.tsx +21 -0
  44. package/src/components/media-settings-dialog.tsx +83 -0
  45. package/src/components/preview-info-card.examples.tsx +18 -0
  46. package/src/components/preview-info-card.tsx +27 -0
  47. package/src/components/reload-icon.examples.tsx +7 -0
  48. package/src/components/reload-icon.tsx +6 -0
  49. package/src/components/request-changes-modal.examples.tsx +26 -0
  50. package/src/components/request-changes-modal.tsx +36 -19
  51. package/src/components/rich-text-editor.tsx +1 -1
  52. package/src/components/select.examples.tsx +17 -0
  53. package/src/components/select.tsx +78 -22
  54. package/src/components/settings-dialog-surface.examples.tsx +44 -0
  55. package/src/components/settings-dialog-surface.tsx +240 -0
  56. package/src/components/side-panel.examples.tsx +80 -0
  57. package/src/components/side-panel.tsx +140 -0
  58. package/src/components/subtle.tsx +64 -8
  59. package/src/components/table.examples.tsx +9 -0
  60. package/src/components/table.tsx +29 -12
  61. package/src/components/template-catalog-card.examples.tsx +25 -0
  62. package/src/components/template-catalog-card.tsx +178 -0
  63. package/src/components/text.tsx +14 -1
  64. package/src/components/textarea.examples.tsx +8 -0
  65. package/src/components/textarea.tsx +16 -6
  66. package/src/components/tile.tsx +16 -17
  67. package/src/editor/block-editor.tsx +56 -14
  68. package/src/index.ts +20 -0
  69. package/src/layout/app-shell.tsx +13 -9
  70. package/src/layout/topbar.tsx +9 -12
@@ -0,0 +1,48 @@
1
+ import type { DragEventHandler, ReactNode } from 'react'
2
+ import { uic } from '../utils/uic'
3
+ import { DialogActionButton } from './dialog-action-button'
4
+ import { Tile } from './tile'
5
+
6
+ const Surface = uic('div', {
7
+ displayName: 'DocumentUploadSurface',
8
+ baseClass: 'relative h-95 w-full',
9
+ })
10
+
11
+ /** Source upload tile: label at top, action centred in the WHOLE tile, helper at bottom.
12
+ * File ownership, validation and persistence belong to the caller. The real button
13
+ * provides the keyboard alternative to native file drag/drop.
14
+ */
15
+ export function DocumentUploadPanel({
16
+ title, actionLabel, helperText, isDragging = false, isDisabled = false,
17
+ onAction, onDrop, onDragEnter, onDragOver, onDragLeave,
18
+ ...rest
19
+ }: {
20
+ title: ReactNode
21
+ actionLabel: string
22
+ helperText?: string
23
+ isDragging?: boolean
24
+ isDisabled?: boolean
25
+ onAction?: () => void
26
+ onDrop?: DragEventHandler<HTMLDivElement>
27
+ onDragEnter?: DragEventHandler<HTMLDivElement>
28
+ onDragOver?: DragEventHandler<HTMLDivElement>
29
+ onDragLeave?: DragEventHandler<HTMLDivElement>
30
+ 'data-testid'?: string
31
+ }) {
32
+ return <Surface {...rest} onDrop={onDrop} onDragEnter={onDragEnter} onDragOver={onDragOver} onDragLeave={onDragLeave}>
33
+ <Tile theme="light" className={isDragging && !isDisabled ? 'ring-2 ring-inset ring-fg-subtle' : undefined}
34
+ eyebrow={<span className="relative z-10 mb-2 text-body font-medium leading-5">{title}</span>}
35
+ footer={helperText ? <p className="relative z-10 m-0 whitespace-pre-line text-small font-normal text-fg-workflow-muted" style={{ lineHeight: 'normal' }}>
36
+ {/* Original Tile's `.tile .tail *` wins over the upload helper classes:
37
+ BOTH lines inherit the regular muted supporting text. */}
38
+ {helperText}
39
+ </p> : undefined}
40
+ />
41
+ <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
42
+ <DialogActionButton type="button" isDisabled={isDisabled} onPress={onAction}
43
+ className="pointer-events-auto h-14 w-48 min-w-48 py-0">
44
+ {actionLabel}
45
+ </DialogActionButton>
46
+ </div>
47
+ </Surface>
48
+ }
@@ -43,7 +43,8 @@ import { uic } from '../utils/uic'
43
43
  * tokens): content bg white → `surface` · 1px `#eceae1` border → `border` · 8px
44
44
  * radius → `rounded-lg` · `shadow-lg` · item 12/16px padding → `py-3 px-4` · 6px
45
45
  * item radius → `rounded-md` · item hover `#f7f6f2` → `surface-card` · item text
46
- * `#0d0d0d` → `fg` · section label `#aba89c` 13px → `text-fg-subtle text-compact`
46
+ * `#0d0d0d` → `fg` · section label `#aba89c` 13px → `text-fg-muted text-compact`
47
+ * (#25: the source grey is 2.10:1 on `surface`; a section label is read, not ornament)
47
48
  * · separator `#eceae1` → `border`. The destructive item uses `text-danger`
48
49
  * (`#dc2626`) — a light-surface red that reads correctly here (unlike the dark
49
50
  * ContextMenu, which needs the lighter `#ffb5b5`).
@@ -82,7 +83,8 @@ export const DropdownMenuSeparator = uic(RACSeparator, {
82
83
  export interface DropdownMenuSectionProps<T extends object> extends Omit<RACMenuSectionProps<T>, 'children'> {
83
84
  /**
84
85
  * Section label (gs `.label`). Rendered as a non-interactive `Header` — 13px
85
- * medium, `fg-subtle` — above the section's items.
86
+ * medium, `fg-muted` — above the section's items. (The source grey `fg-subtle`
87
+ * is 2.10:1 on `surface`; a section label is read, not ornament — #25.)
86
88
  */
87
89
  label?: ReactNode
88
90
  /** Static section items (each a {@link DropdownMenuItem}). */
@@ -98,7 +100,7 @@ export function DropdownMenuSection<T extends object>({
98
100
  return (
99
101
  <RACMenuSection {...props}>
100
102
  {label ? (
101
- <Header className="px-4 py-3 text-compact font-medium text-fg-subtle">{label}</Header>
103
+ <Header className="px-4 py-3 text-compact font-medium text-fg-muted">{label}</Header>
102
104
  ) : null}
103
105
  {children}
104
106
  </RACMenuSection>
@@ -0,0 +1,7 @@
1
+ import { EmptyPanelAction } from './empty-panel-action'
2
+ export const examples = {
3
+ default: () => <EmptyPanelAction>Add deadline</EmptyPanelAction>,
4
+ variants: () => <EmptyPanelAction>Select assets</EmptyPanelAction>,
5
+ states: () => <><EmptyPanelAction isDisabled>Add deadline</EmptyPanelAction><EmptyPanelAction isPending>Loading</EmptyPanelAction></>,
6
+ }
7
+ export const meta = { category: 'Composite', description: 'Source dashboard empty action: 56px height, 192px minimum width and 16/20 medium text. Uses RAC focus, disabled and pending states.' }
@@ -0,0 +1,8 @@
1
+ import { uic } from '../utils/uic'
2
+ import { DialogActionButton } from './dialog-action-button'
3
+
4
+ /** Centered action for an empty dashboard panel; keeps the source UI action palette. */
5
+ export const EmptyPanelAction = uic(DialogActionButton, {
6
+ displayName: 'EmptyPanelAction',
7
+ baseClass: 'h-14 min-w-48 px-6 py-0 text-body font-medium leading-5',
8
+ })
@@ -0,0 +1,14 @@
1
+ export const FIELD_APPEARANCES = ['outlined', 'filled'] as const
2
+ export type FieldAppearance = (typeof FIELD_APPEARANCES)[number]
3
+
4
+ /** Borderless Manager field skin. Hover yields to focus; focus uses an offset
5
+ * outline instead of combining the browser outline with a second inset ring.
6
+ * Placeholder/error colors retain the library's accessible semantic tokens.
7
+ */
8
+ export const filledFieldClasses =
9
+ 'border-0 bg-surface-card text-small font-normal leading-4.5 text-fg ' +
10
+ 'outline-none transition-colors duration-200 motion-reduce:transition-none placeholder:text-fg-muted placeholder:font-normal ' +
11
+ 'data-[hovered]:bg-surface-muted data-[focused]:bg-surface-card ' +
12
+ 'focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-2 ' +
13
+ 'data-[invalid]:ring-1 data-[invalid]:ring-danger data-[invalid]:outline-danger ' +
14
+ 'data-[disabled]:bg-surface-muted data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed'
@@ -85,8 +85,8 @@ export function FocusFields({ children, className }: { children: ReactNode; clas
85
85
 
86
86
  // In the overlay, enlarge and de-chrome text controls into a bare headline editor.
87
87
  const OVERLAY_EDITOR =
88
- '[&_input]:w-full [&_input]:border-0 [&_input]:bg-transparent [&_input]:p-0 [&_input]:text-display [&_input]:font-medium [&_input]:text-fg [&_input]:outline-none [&_input]:shadow-none [&_input]:ring-0 [&_input]:placeholder:text-fg-subtle ' +
89
- '[&_textarea]:min-h-40 [&_textarea]:w-full [&_textarea]:resize-none [&_textarea]:border-0 [&_textarea]:bg-transparent [&_textarea]:p-0 [&_textarea]:text-display [&_textarea]:font-medium [&_textarea]:text-fg [&_textarea]:outline-none [&_textarea]:placeholder:text-fg-subtle'
88
+ '[&_input]:w-full [&_input]:border-0 [&_input]:bg-transparent [&_input]:p-0 [&_input]:text-display [&_input]:font-medium [&_input]:text-fg [&_input]:outline-none [&_input]:shadow-none [&_input]:ring-0 [&_input]:placeholder:text-fg-muted ' +
89
+ '[&_textarea]:min-h-40 [&_textarea]:w-full [&_textarea]:resize-none [&_textarea]:border-0 [&_textarea]:bg-transparent [&_textarea]:p-0 [&_textarea]:text-display [&_textarea]:font-medium [&_textarea]:text-fg [&_textarea]:outline-none [&_textarea]:placeholder:text-fg-muted'
90
90
 
91
91
  function IconBadge({ icon }: { icon: ReactNode }) {
92
92
  return (
@@ -166,7 +166,7 @@ export function FocusField({
166
166
  <div className="min-w-0 flex-1">
167
167
  <div className="text-small text-fg-muted">{label}</div>
168
168
  <div className="mt-1 truncate text-body text-fg">
169
- {hasValue ? preview : <span className="text-fg-subtle">{placeholder}</span>}
169
+ {hasValue ? preview : <span className="text-fg-muted">{placeholder}</span>}
170
170
  </div>
171
171
  </div>
172
172
  </div>
@@ -78,6 +78,36 @@ export const ExternalLinkIcon = (p: IconProps) => (
78
78
  <path d="M15 3h6v6M21 3l-9 9M10 5H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-5" />
79
79
  </svg>
80
80
  )
81
+ export const PencilIcon = (p: IconProps) => (
82
+ <svg {...STROKE} {...p}>
83
+ <path d="M12 20h9" />
84
+ <path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L8 18l-4 1 1-4z" />
85
+ </svg>
86
+ )
87
+ export const CopyIcon = (p: IconProps) => (
88
+ <svg {...STROKE} {...p}>
89
+ <rect x="9" y="9" width="11" height="11" rx="2" />
90
+ <path d="M15 9V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h3" />
91
+ </svg>
92
+ )
93
+ export const ShareIcon = (p: IconProps) => (
94
+ <svg {...STROKE} {...p}>
95
+ <circle cx="18" cy="5" r="3" />
96
+ <circle cx="6" cy="12" r="3" />
97
+ <circle cx="18" cy="19" r="3" />
98
+ <path d="m8.6 10.5 6.8-4M8.6 13.5l6.8 4" />
99
+ </svg>
100
+ )
101
+ export const PinIcon = (p: IconProps) => (
102
+ <svg {...STROKE} {...p}>
103
+ <path d="m9 3 6 6M10 8l-5 5 6 1 1 6 5-5M2 22l6-6" />
104
+ </svg>
105
+ )
106
+ export const ArchiveIcon = (p: IconProps) => (
107
+ <svg {...STROKE} {...p}>
108
+ <path d="M4 7v13h16V7M3 3h18v4H3zM9 11h6" />
109
+ </svg>
110
+ )
81
111
  export const ExpandIcon = (p: IconProps) => (
82
112
  <svg {...STROKE} {...p}><path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7" /></svg>
83
113
  )
@@ -0,0 +1,8 @@
1
+ import { Input } from './input'
2
+
3
+ export const examples = {
4
+ default: () => <Input label="Project name" placeholder="Annual report" />,
5
+ variants: () => <><Input label="Outlined" /><Input appearance="filled" label="Filled" placeholder="Annual report" /></>,
6
+ states: () => <><Input appearance="filled" label="Disabled" defaultValue="Saved content" isDisabled /><Input appearance="filled" label="Invalid" isInvalid errorMessage="Check the value." /></>,
7
+ }
8
+ export const meta = { category: 'Form', description: 'Labelled input with outlined and Manager-style filled appearances, disabled/error states and keyboard focus.' }
@@ -8,6 +8,7 @@ import {
8
8
  type TextFieldProps,
9
9
  } from 'react-aria-components'
10
10
  import { uic } from '../utils/uic'
11
+ import { filledFieldClasses, type FieldAppearance } from './field-appearance'
11
12
 
12
13
  /**
13
14
  * Input — labelled single-line text field.
@@ -28,29 +29,36 @@ const StyledInput = uic(RACInput, {
28
29
  // fg-subtle · focus #75e7b8 → brand-green · error → danger. This is the shared
29
30
  // filled-field skin (textarea / combobox / date-field / number-field /
30
31
  // search-field); `fieldSize` adds the single-line height on top.
31
- baseClass:
32
- 'w-full rounded-lg border border-border bg-surface px-4 text-small text-fg ' +
32
+ baseClass: 'w-full rounded-lg px-4',
33
+ variants: {
34
+ appearance: {
35
+ filled: filledFieldClasses,
36
+ outlined: 'border border-border bg-surface text-small text-fg ' +
33
37
  'outline-none transition-colors duration-200 placeholder:text-fg-muted ' +
34
38
  'data-[hovered]:border-fg-subtle ' +
35
39
  'data-[focused]:border-brand-green data-[focused]:ring-2 data-[focused]:ring-ring ' +
36
40
  'data-[invalid]:border-danger data-[invalid]:ring-2 data-[invalid]:ring-danger ' +
37
41
  'data-[disabled]:bg-surface-muted data-[disabled]:opacity-60 data-[disabled]:pointer-events-none',
38
- variants: {
42
+ },
39
43
  // `fieldSize` (not `size`) to avoid colliding with the native <input size>
40
44
  // attribute, which RAC's Input inherits (a numeric prop).
41
45
  fieldSize: {
42
46
  sm: 'h-8',
43
47
  md: 'h-10',
48
+ filled: 'h-10.5 py-3',
44
49
  lg: 'h-12',
45
50
  tall: 'h-control-tall',
46
51
  },
47
52
  },
48
53
  defaultVariants: {
54
+ appearance: 'outlined',
49
55
  fieldSize: 'md',
50
56
  },
51
57
  })
52
58
 
53
59
  export type InputProps = TextFieldProps & {
60
+ /** Filled matches the original Manager dialog fields; outlined remains the default. */
61
+ appearance?: FieldAppearance
54
62
  /** Visible field label (required for accessibility). */
55
63
  label: ReactNode
56
64
  /** Helper text rendered under the field. */
@@ -65,10 +73,10 @@ export type InputProps = TextFieldProps & {
65
73
  inputClassName?: string
66
74
  }
67
75
 
68
- export const Input = ({ label, description, errorMessage, placeholder, size, rootClassName, inputClassName, ...props }: InputProps) => (
76
+ export const Input = ({ label, description, errorMessage, placeholder, size, appearance = 'outlined', rootClassName, inputClassName, ...props }: InputProps) => (
69
77
  <TextField {...props} className={`flex w-full flex-col gap-3 ${rootClassName ?? ''}`}>
70
78
  <Label className="text-panel-heading font-medium text-fg">{label}</Label>
71
- <StyledInput className={inputClassName} placeholder={placeholder} fieldSize={size} />
79
+ <StyledInput className={inputClassName} placeholder={placeholder} appearance={appearance} fieldSize={size ?? (appearance === 'filled' ? 'filled' : undefined)} />
72
80
  {description ? (
73
81
  <Text slot="description" className="text-label text-fg-muted">
74
82
  {description}
@@ -0,0 +1,11 @@
1
+ import { MediaAssetPanel } from './media-asset-panel'
2
+ const labels = { heading: 'Assets', emptyDescription: 'No assets yet.', emptyHint: 'Select assets to begin.', selectLabel: 'Select assets', previousLabel: 'Previous asset', nextLabel: 'Next asset' }
3
+ const items = ['First', 'Second'].map(id => ({ id, content: <div className="grid h-full place-items-center bg-surface-muted">{id}</div> }))
4
+ export const examples = {
5
+ default: () => <MediaAssetPanel {...labels} items={items} mode="media" empty={false} onSelect={() => undefined} />,
6
+ variants: () => <MediaAssetPanel {...labels} items={items} mode="carousel" empty={false} onSelect={() => undefined} />,
7
+ states: () => <MediaAssetPanel {...labels} items={[]} mode="media" empty onSelect={() => undefined} />,
8
+ readOnly: () => <MediaAssetPanel {...labels} items={items} mode="grid" empty={false} />,
9
+ reference: () => <MediaAssetPanel {...labels} items={items.slice(0, 1)} mode="media" empty={false} identity={{ title: 'Annual report', caption: '1 asset reference', count: 1 }} onSelect={() => undefined} />,
10
+ }
11
+ export const meta = { category: 'Composite', description: 'Asset preview panel with centered empty action and independent carousel controls.' }
@@ -0,0 +1,58 @@
1
+ import type { ReactNode } from 'react'
2
+ import { Button as AriaButton } from 'react-aria-components'
3
+ import { uic } from '../utils/uic'
4
+ import { Button } from './button'
5
+ import { MediaGallery, type MediaGalleryItem } from './media-gallery'
6
+
7
+ export const MEDIA_ASSET_PANEL_MODES = ['media', 'grid', 'carousel'] as const
8
+ export type MediaAssetPanelMode = (typeof MEDIA_ASSET_PANEL_MODES)[number]
9
+ export type MediaAssetPanelProps = {
10
+ items: readonly MediaGalleryItem[]
11
+ mode: MediaAssetPanelMode
12
+ empty: boolean
13
+ heading: ReactNode
14
+ emptyDescription: ReactNode
15
+ emptyHint: ReactNode
16
+ selectLabel: string
17
+ previousLabel: string
18
+ nextLabel: string
19
+ onSelect?: () => void
20
+ /** Optional reference identity; omitted for a media-only gallery. */
21
+ identity?: { title: ReactNode; caption: ReactNode; count: ReactNode }
22
+ className?: string
23
+ }
24
+ const Surface = uic('div', {
25
+ displayName: 'MediaAssetPanel',
26
+ baseClass: 'group/media-panel relative isolate h-95 w-full shrink-0 overflow-hidden rounded-lg bg-surface-card text-fg',
27
+ })
28
+ const PanelAction = uic(AriaButton, {
29
+ displayName: 'MediaAssetPanelAction',
30
+ baseClass: 'absolute inset-0 z-10 cursor-pointer rounded-lg border-0 bg-transparent p-0 outline-none data-[focus-visible]:ring-2 data-[focus-visible]:ring-inset data-[focus-visible]:ring-ring',
31
+ })
32
+
33
+ /** Full-bleed media with independent selection and carousel controls. */
34
+ export function MediaAssetPanel({ items, mode, empty, heading, emptyDescription, emptyHint, selectLabel, previousLabel, nextLabel, onSelect, identity, className }: MediaAssetPanelProps) {
35
+ return <Surface className={className}>
36
+ {onSelect ? empty
37
+ ? <button type="button" aria-label={selectLabel} aria-hidden="true" tabIndex={-1} onClick={onSelect} className="absolute inset-0 z-10 cursor-pointer rounded-lg border-0 bg-transparent p-0" />
38
+ : <PanelAction aria-label={selectLabel} onPress={onSelect} /> : null}
39
+ {empty ? <>
40
+ <div className="pointer-events-none absolute inset-x-4 top-4 z-20 text-body font-medium leading-5">{heading}</div>
41
+ {onSelect ? <div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
42
+ <Button className="pointer-events-auto h-14 w-48 p-0" onPress={onSelect}>{selectLabel}</Button>
43
+ </div> : null}
44
+ <p className="pointer-events-none absolute inset-x-4 bottom-4 z-20 m-0 text-small">
45
+ <span className="font-medium text-fg">{emptyDescription}</span><br />
46
+ <span className="text-fg-muted">{emptyHint}</span>
47
+ </p>
48
+ </> : <MediaGallery items={mode === 'media' ? items.slice(0, 1) : items} mode={mode === 'carousel' ? 'carousel' : 'grid'} previousLabel={previousLabel} nextLabel={nextLabel} className="pointer-events-none" />}
49
+ {!empty && identity ? <>
50
+ <div aria-hidden="true" className="pointer-events-none absolute inset-y-0 left-0 z-[1] hidden w-[min(56%,34rem)] bg-[linear-gradient(90deg,rgba(0,0,0,0.52)_0%,rgba(0,0,0,0.28)_58%,transparent_100%)] group-has-[img]/media-panel:block" />
51
+ <div className="pointer-events-none absolute inset-4 z-[2] grid grid-rows-[auto_minmax(0,1fr)_auto] gap-6 text-fg group-has-[img]/media-panel:text-white group-has-[img]/media-panel:[text-shadow:0_1px_3px_rgba(0,0,0,0.5)]">
52
+ <div className="mb-2 flex items-start justify-between gap-2 text-body font-medium leading-5"><span>{heading}</span><span className="rounded-full bg-surface-muted px-2.5 py-1 text-caption text-fg [text-shadow:none]">{identity.count}</span></div>
53
+ <p className="m-0 max-w-[19ch] pt-2 text-heading1 font-medium leading-[30px]">{identity.title}</p>
54
+ <p className="m-0 text-small opacity-80">{identity.caption}</p>
55
+ </div>
56
+ </> : null}
57
+ </Surface>
58
+ }
@@ -0,0 +1,12 @@
1
+ import { MediaGallery } from './media-gallery'
2
+
3
+ const sample = (label: string) => <div className="grid h-full min-h-24 place-items-center bg-surface-card text-label text-fg">{label}</div>
4
+ const items = ['One', 'Two', 'Three', 'Four'].map(label => ({ id: label.toLowerCase(), content: sample(label) }))
5
+
6
+ export const examples = {
7
+ default: () => <MediaGallery items={items.slice(0, 1)} mode="grid" previousLabel="Previous" nextLabel="Next" className="h-48" />,
8
+ grid: () => <MediaGallery items={items} mode="grid" previousLabel="Previous" nextLabel="Next" className="h-48" />,
9
+ carousel: () => <MediaGallery items={items.slice(0, 3)} mode="carousel" previousLabel="Previous image" nextLabel="Next image" className="h-48" />,
10
+ empty: () => <div className="h-48"><MediaGallery items={[]} mode="grid" previousLabel="Previous" nextLabel="Next" /></div>,
11
+ }
12
+ export const meta = { category: 'Composite', description: 'Media grid and carousel with consumer-rendered previews. Coarse-pointer controls use 44px targets.' }
@@ -0,0 +1,53 @@
1
+ import { useEffect, useState, type ReactNode, type SyntheticEvent } from 'react'
2
+ import { Button as AriaButton } from 'react-aria-components'
3
+ import { uic } from '../utils/uic'
4
+
5
+ export const MEDIA_GALLERY_MODES = ['grid', 'carousel'] as const
6
+ export type MediaGalleryMode = (typeof MEDIA_GALLERY_MODES)[number]
7
+ export type MediaGalleryItem = { id: string; content: ReactNode }
8
+ export type MediaGalleryProps = {
9
+ items: readonly MediaGalleryItem[]
10
+ mode: MediaGalleryMode
11
+ previousLabel: string
12
+ nextLabel: string
13
+ className?: string
14
+ }
15
+
16
+ const GalleryRoot = uic('div', {
17
+ baseClass: 'relative block h-full w-full overflow-hidden bg-surface-muted',
18
+ displayName: 'MediaGallery',
19
+ })
20
+ const itemClass = 'block h-full min-h-0 w-full overflow-hidden [&>img]:block [&>img]:h-full [&>img]:w-full [&>img]:object-cover [&>video]:block [&>video]:h-full [&>video]:w-full [&>video]:object-cover'
21
+ const stopParentActivation = (event: SyntheticEvent): void => event.stopPropagation()
22
+ const GalleryControl = uic(AriaButton, {
23
+ baseClass: 'pointer-events-auto inline-flex size-10 items-center justify-center rounded-full border-0 bg-surface p-0 text-title leading-none text-fg cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 pointer-coarse:size-11',
24
+ displayName: 'MediaGalleryControl',
25
+ })
26
+
27
+ export function MediaGallery({ items, mode, previousLabel, nextLabel, className }: MediaGalleryProps) {
28
+ const firstId = items[0]?.id ?? null
29
+ const [activeId, setActiveId] = useState<string | null>(firstId)
30
+ useEffect(() => {
31
+ setActiveId(current => current && items.some(item => item.id === current) ? current : firstId)
32
+ }, [firstId, items])
33
+ if (items.length === 0) return null
34
+ if (mode === 'grid') {
35
+ const visibleItems = items.slice(0, 4)
36
+ return <GalleryRoot className={['grid gap-0.5', visibleItems.length === 1 ? 'grid-cols-1 grid-rows-1' : visibleItems.length === 2 ? 'grid-cols-2 grid-rows-1' : 'grid-cols-2 grid-rows-2', className].filter(Boolean).join(' ')}>
37
+ {visibleItems.map(item => <div key={item.id} className={itemClass}>{item.content}</div>)}
38
+ </GalleryRoot>
39
+ }
40
+ const activeIndex = Math.max(0, items.findIndex(item => item.id === activeId))
41
+ const activeItem = items[activeIndex] ?? items[0]
42
+ // `items` is non-empty past the early return above, but that does not narrow an
43
+ // indexed access, so make the empty case explicit rather than asserting.
44
+ if (!activeItem) return null
45
+ const selectRelative = (offset: number) => setActiveId(items[(activeIndex + offset + items.length) % items.length]?.id ?? firstId)
46
+ return <GalleryRoot className={className}>
47
+ <div className={itemClass}>{activeItem.content}</div>
48
+ {items.length > 1 ? <div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-between p-4">
49
+ <GalleryControl aria-label={previousLabel} onClick={stopParentActivation} onPress={() => selectRelative(-1)}><span aria-hidden="true">‹</span></GalleryControl>
50
+ <GalleryControl aria-label={nextLabel} onClick={stopParentActivation} onPress={() => selectRelative(1)}><span aria-hidden="true">›</span></GalleryControl>
51
+ </div> : null}
52
+ </GalleryRoot>
53
+ }
@@ -0,0 +1,21 @@
1
+ import { useState } from 'react'
2
+
3
+ import { MediaSettingsDialog, type MediaSettingsLabels, type MediaSettingsValue } from './media-settings-dialog'
4
+
5
+ const labels: MediaSettingsLabels = {
6
+ title: 'Media settings', displayTitle: 'Display', displayHint: 'Choose how this reference appears.', displayMode: 'Display mode', media: 'Media', gallery: 'Gallery', widthTitle: 'Width', widthHint: 'Choose the content width.', mediaWidth: 'Media width', textWidth: 'Text', wide: 'Wide', fullWidth: 'Full width', layoutTitle: 'Layout', layoutHint: 'Choose the gallery layout.', galleryLayout: 'Gallery layout', carousel: 'Carousel', grid: 'Grid', cancel: 'Cancel', save: 'Save', close: 'Close',
7
+ }
8
+ const initial: MediaSettingsValue = { displayMode: 'media', mediaWidth: 'text', galleryLayout: 'grid' }
9
+
10
+ function Example({ value = initial }: { value?: MediaSettingsValue }) {
11
+ const [open, setOpen] = useState(true)
12
+ const [current, setCurrent] = useState(value)
13
+ return <MediaSettingsDialog isOpen={open} onClose={() => setOpen(false)} onSave={next => { setCurrent(next); setOpen(false) }} value={current} labels={labels} />
14
+ }
15
+
16
+ export const examples = {
17
+ default: () => <Example />,
18
+ gallery: () => <Example value={{ ...initial, displayMode: 'gallery' }} />,
19
+ disabled: () => <MediaSettingsDialog isOpen onClose={() => undefined} onSave={() => undefined} value={initial} labels={labels} isDisabled />,
20
+ }
21
+ export const meta = { category: 'Composite', description: 'Media presentation settings with discardable draft and conditional fields.' }
@@ -0,0 +1,83 @@
1
+ import { useState, type ReactNode } from 'react'
2
+ import { Button } from './button'
3
+ import { SettingsDialogSurface } from './settings-dialog-surface'
4
+ import { Select, SelectItem } from './select'
5
+ import { uic } from '../utils/uic'
6
+
7
+ export const MEDIA_SETTINGS_DISPLAY_MODES = ['media', 'gallery'] as const
8
+ export const MEDIA_SETTINGS_WIDTHS = ['text', 'wide', 'full'] as const
9
+ export const MEDIA_SETTINGS_LAYOUTS = ['carousel', 'grid'] as const
10
+ export type MediaSettingsValue = {
11
+ displayMode: (typeof MEDIA_SETTINGS_DISPLAY_MODES)[number]
12
+ mediaWidth: (typeof MEDIA_SETTINGS_WIDTHS)[number]
13
+ galleryLayout: (typeof MEDIA_SETTINGS_LAYOUTS)[number]
14
+ }
15
+
16
+ export type MediaSettingsLabels = {
17
+ title: ReactNode
18
+ displayTitle: ReactNode
19
+ displayHint: ReactNode
20
+ displayMode: ReactNode
21
+ media: ReactNode
22
+ gallery: ReactNode
23
+ widthTitle: ReactNode
24
+ widthHint: ReactNode
25
+ mediaWidth: ReactNode
26
+ textWidth: ReactNode
27
+ wide: ReactNode
28
+ fullWidth: ReactNode
29
+ layoutTitle: ReactNode
30
+ layoutHint: ReactNode
31
+ galleryLayout: ReactNode
32
+ carousel: ReactNode
33
+ grid: ReactNode
34
+ cancel: ReactNode
35
+ save: ReactNode
36
+ close: string
37
+ }
38
+
39
+ export type MediaSettingsDialogProps = {
40
+ isOpen: boolean
41
+ onClose: () => void
42
+ onSave: (value: MediaSettingsValue) => void
43
+ value: MediaSettingsValue
44
+ isDisabled?: boolean
45
+ labels: MediaSettingsLabels
46
+ }
47
+
48
+ const Field = uic('section', { displayName: 'MediaSettingsField', baseClass: 'flex flex-col gap-0 [&>div]:mt-6' })
49
+ const FieldTitle = uic('h2', { displayName: 'MediaSettingsFieldTitle', baseClass: 'm-0 text-panel-heading font-medium text-fg' })
50
+ const Hint = uic('p', { displayName: 'MediaSettingsHint', baseClass: 'm-0 text-small leading-4.5 font-normal text-fg-workflow-muted' })
51
+
52
+ function MediaSettingsDialogInner({ value, labels, isDisabled, onClose, onSave }: Omit<MediaSettingsDialogProps, 'isOpen'>) {
53
+ const [draft, setDraft] = useState(value)
54
+ const update = <K extends keyof MediaSettingsValue>(key: K, next: MediaSettingsValue[K]) => setDraft(current => ({ ...current, [key]: next }))
55
+ return (
56
+ <SettingsDialogSurface variant="edit" isOpen onOpenChange={open => { if (!open) onClose() }} title={labels.title} closeLabel={labels.close}
57
+ footer={<><Button variant="secondary" onPress={onClose}>{labels.cancel}</Button><Button onPress={() => { if (!isDisabled) onSave(draft) }} isDisabled={isDisabled}>{labels.save}</Button></>}
58
+ >
59
+ <Field>
60
+ <FieldTitle>{labels.displayTitle}</FieldTitle><Hint>{labels.displayHint}</Hint>
61
+ <Select label={labels.displayMode} placeholder="" selectedKey={draft.displayMode} onSelectionChange={key => update('displayMode', String(key) as MediaSettingsValue['displayMode'])} isDisabled={isDisabled}>
62
+ <SelectItem id="media">{labels.media}</SelectItem><SelectItem id="gallery">{labels.gallery}</SelectItem>
63
+ </Select>
64
+ </Field>
65
+ {draft.displayMode === 'media' ? <Field key="width">
66
+ <FieldTitle>{labels.widthTitle}</FieldTitle><Hint>{labels.widthHint}</Hint>
67
+ <Select label={labels.mediaWidth} placeholder="" selectedKey={draft.mediaWidth} onSelectionChange={key => update('mediaWidth', String(key) as MediaSettingsValue['mediaWidth'])} isDisabled={isDisabled}>
68
+ <SelectItem id="text">{labels.textWidth}</SelectItem><SelectItem id="wide">{labels.wide}</SelectItem><SelectItem id="full">{labels.fullWidth}</SelectItem>
69
+ </Select>
70
+ </Field> : <Field key="layout">
71
+ <FieldTitle>{labels.layoutTitle}</FieldTitle><Hint>{labels.layoutHint}</Hint>
72
+ <Select label={labels.galleryLayout} placeholder="" selectedKey={draft.galleryLayout} onSelectionChange={key => update('galleryLayout', String(key) as MediaSettingsValue['galleryLayout'])} isDisabled={isDisabled}>
73
+ <SelectItem id="carousel">{labels.carousel}</SelectItem><SelectItem id="grid">{labels.grid}</SelectItem>
74
+ </Select>
75
+ </Field>}
76
+ </SettingsDialogSurface>
77
+ )
78
+ }
79
+
80
+ export function MediaSettingsDialog({ isOpen, ...props }: MediaSettingsDialogProps) {
81
+ if (!isOpen) return null
82
+ return <MediaSettingsDialogInner {...props} />
83
+ }
@@ -0,0 +1,18 @@
1
+ import { PreviewInfoCard, PreviewInfoRow, PreviewInfoLabel, PreviewInfoCount, PreviewInfoTitle, PreviewInfoActions } from './preview-info-card'
2
+ import { Button } from './button'
3
+
4
+ const Card = ({ disabled = false, long = false }) => (
5
+ <div className="relative h-48">
6
+ <PreviewInfoCard>
7
+ <PreviewInfoRow><PreviewInfoLabel>{long ? 'A long section name that must not displace the counter' : 'Section'}</PreviewInfoLabel><PreviewInfoCount>1/3</PreviewInfoCount></PreviewInfoRow>
8
+ <PreviewInfoTitle>Template preview</PreviewInfoTitle>
9
+ <PreviewInfoActions><Button isDisabled={disabled}>Open editor</Button></PreviewInfoActions>
10
+ </PreviewInfoCard>
11
+ </div>
12
+ )
13
+ export const examples = {
14
+ default: () => <Card />,
15
+ variants: () => <Card long />,
16
+ states: () => <Card disabled />,
17
+ }
18
+ export const meta = { category: 'Composite', description: 'Domain-free preview information overlay with original Manager spacing. Navigation and workflow actions are supplied by the consumer.' }
@@ -0,0 +1,27 @@
1
+ import { uic } from '../utils/uic'
2
+
3
+ // Shared, domain-free information overlay. Position belongs to the preview pane.
4
+ export const PreviewInfoCard = uic('div', {
5
+ displayName: 'PreviewInfoCard',
6
+ baseClass: 'pointer-events-none absolute bottom-0 left-0 z-10 flex w-80 max-w-full flex-col gap-3 rounded-md bg-surface p-4',
7
+ })
8
+ export const PreviewInfoRow = uic('div', {
9
+ displayName: 'PreviewInfoRow',
10
+ baseClass: 'flex min-h-6 items-center justify-between gap-2',
11
+ })
12
+ export const PreviewInfoLabel = uic('span', {
13
+ displayName: 'PreviewInfoLabel',
14
+ baseClass: 'min-w-0 truncate text-label font-normal leading-5 text-fg-muted',
15
+ })
16
+ export const PreviewInfoCount = uic('span', {
17
+ displayName: 'PreviewInfoCount',
18
+ baseClass: 'shrink-0 rounded-full bg-surface-muted px-2 py-0.5 text-micro font-medium leading-5 tracking-tight text-fg',
19
+ })
20
+ export const PreviewInfoTitle = uic('h3', {
21
+ displayName: 'PreviewInfoTitle',
22
+ baseClass: 'm-0 truncate text-compact font-medium leading-4 text-fg',
23
+ })
24
+ export const PreviewInfoActions = uic('div', {
25
+ displayName: 'PreviewInfoActions',
26
+ baseClass: 'pointer-events-auto flex flex-wrap gap-2 pt-1',
27
+ })
@@ -0,0 +1,7 @@
1
+ import { ReloadIcon } from './reload-icon'
2
+ export const examples = {
3
+ default: () => <ReloadIcon />,
4
+ labeled: () => <span><ReloadIcon />Import CSV</span>,
5
+ muted: () => <span className="text-fg-muted"><ReloadIcon /></span>,
6
+ }
7
+ export const meta = { category: 'Primitives', description: 'Decorative original 15px reload glyph; the parent action supplies its accessible name.' }
@@ -0,0 +1,6 @@
1
+ import type { IconProps } from './icons'
2
+
3
+ /** Original Manager context glyph: Radix ReloadIcon 1.3.2 (MIT), 15px grid. */
4
+ export const ReloadIcon = (props: IconProps) => <svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true" {...props}>
5
+ <path d="M1.84998 7.49998C1.84998 4.66458 4.05979 1.84998 7.49998 1.84998C10.2783 1.84998 11.6515 3.9064 12.2367 5H10.5C10.2239 5 10 5.22386 10 5.5C10 5.77614 10.2239 6 10.5 6H13.5C13.7761 6 14 5.77614 14 5.5V2.5C14 2.22386 13.7761 2 13.5 2C13.2239 2 13 2.22386 13 2.5V4.31318C12.2955 3.07126 10.6659 0.849976 7.49998 0.849976C3.43716 0.849976 0.849976 4.18537 0.849976 7.49998C0.849976 10.8146 3.43716 14.15 7.49998 14.15C9.44382 14.15 11.0622 13.3808 12.2145 12.2084C12.8315 11.5806 13.3133 10.839 13.6418 10.0407C13.7469 9.78536 13.6251 9.49315 13.3698 9.38806C13.1144 9.28296 12.8222 9.40478 12.7171 9.66014C12.4363 10.3425 12.0251 10.9745 11.5013 11.5074C10.5295 12.4963 9.16504 13.15 7.49998 13.15C4.05979 13.15 1.84998 10.3354 1.84998 7.49998Z" fill="currentColor" fillRule="evenodd" clipRule="evenodd" />
6
+ </svg>
@@ -0,0 +1,26 @@
1
+ import { useState } from 'react'
2
+ import { Button } from './button'
3
+ import { RequestChangesModal } from './request-changes-modal'
4
+
5
+ function Example({ pending = false, error }: { pending?: boolean; error?: string }) {
6
+ const [open, setOpen] = useState(false)
7
+ return <>
8
+ <Button onPress={() => setOpen(true)}>Request changes</Button>
9
+ {open ? <RequestChangesModal
10
+ isOpen onOpenChange={setOpen} isPending={pending} error={error}
11
+ onConfirm={() => setOpen(false)}
12
+ labels={{
13
+ title: 'Request changes', body: 'Describe what needs to change before the next review.',
14
+ noteLabel: 'Requested changes', notePlaceholder: 'Describe the requested changes…',
15
+ cancel: 'Cancel', confirm: 'Request changes', submitting: 'Submitting…',
16
+ }}
17
+ /> : null}
18
+ </>
19
+ }
20
+
21
+ export const examples = {
22
+ default: () => <Example />,
23
+ pending: () => <Example pending />,
24
+ error: () => <Example error="The decision could not be saved. Please try again." />,
25
+ }
26
+ export const meta = { category: 'Composite', description: 'Required review note with shared filled field, focus management, pending dismissal lock and retry feedback.' }