@podoba/react 0.0.34 → 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 (67) 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 +60 -29
  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/dashboard-grid.tsx +1 -1
  21. package/src/components/date-picker.tsx +5 -3
  22. package/src/components/date-selection-calendar.examples.tsx +8 -0
  23. package/src/components/date-selection-calendar.tsx +47 -0
  24. package/src/components/delivery/send-to-print-modal.tsx +351 -84
  25. package/src/components/dialog-action-button.examples.tsx +7 -0
  26. package/src/components/dialog-action-button.tsx +22 -0
  27. package/src/components/dialog.tsx +32 -12
  28. package/src/components/document-upload-panel.examples.tsx +12 -0
  29. package/src/components/document-upload-panel.tsx +48 -0
  30. package/src/components/dropdown-menu.tsx +5 -3
  31. package/src/components/empty-panel-action.examples.tsx +7 -0
  32. package/src/components/empty-panel-action.tsx +8 -0
  33. package/src/components/field-appearance.ts +14 -0
  34. package/src/components/focus-field.tsx +3 -3
  35. package/src/components/icons.tsx +30 -0
  36. package/src/components/input.examples.tsx +8 -0
  37. package/src/components/input.tsx +13 -5
  38. package/src/components/media-asset-panel.examples.tsx +11 -0
  39. package/src/components/media-asset-panel.tsx +58 -0
  40. package/src/components/media-gallery.examples.tsx +12 -0
  41. package/src/components/media-gallery.tsx +53 -0
  42. package/src/components/media-settings-dialog.examples.tsx +21 -0
  43. package/src/components/media-settings-dialog.tsx +83 -0
  44. package/src/components/preview-info-card.examples.tsx +18 -0
  45. package/src/components/preview-info-card.tsx +27 -0
  46. package/src/components/reload-icon.examples.tsx +7 -0
  47. package/src/components/reload-icon.tsx +6 -0
  48. package/src/components/request-changes-modal.examples.tsx +26 -0
  49. package/src/components/request-changes-modal.tsx +36 -19
  50. package/src/components/rich-text-editor.tsx +1 -1
  51. package/src/components/select.examples.tsx +17 -0
  52. package/src/components/select.tsx +78 -22
  53. package/src/components/settings-dialog-surface.examples.tsx +44 -0
  54. package/src/components/settings-dialog-surface.tsx +240 -0
  55. package/src/components/subtle.tsx +64 -8
  56. package/src/components/table.examples.tsx +9 -0
  57. package/src/components/table.tsx +29 -12
  58. package/src/components/template-catalog-card.examples.tsx +25 -0
  59. package/src/components/template-catalog-card.tsx +178 -0
  60. package/src/components/text.tsx +14 -1
  61. package/src/components/textarea.examples.tsx +8 -0
  62. package/src/components/textarea.tsx +16 -6
  63. package/src/components/tile.tsx +16 -17
  64. package/src/editor/block-editor.tsx +56 -14
  65. package/src/index.ts +19 -0
  66. package/src/layout/app-shell.tsx +13 -9
  67. package/src/layout/topbar.tsx +9 -12
@@ -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.' }
@@ -11,12 +11,16 @@
11
11
  // follow-up `changes` task returned by the command.
12
12
  //
13
13
  // a11y: the textarea is label-associated (htmlFor/id), marked `required` +
14
- // `aria-invalid` while empty; the server error is role="alert"; confirm shows a
15
- // pending state and is disabled while in flight or while the note is empty.
14
+ // the server error is role="alert"; confirm shows a pending state and is disabled
15
+ // while in flight or while the note is empty. The note is marked `isRequired`
16
+ // (→ aria-required), NOT aria-invalid-while-empty as the raw textarea used to be:
17
+ // an untouched required field is incomplete, not invalid, and RAC coupling
18
+ // `isInvalid` to the danger ring would alarm the field before anyone typed.
16
19
 
17
- import { useId, useState } from 'react'
20
+ import { useLayoutEffect, useRef, useState } from 'react'
18
21
  import { Button } from './button'
19
22
  import { Dialog } from './dialog'
23
+ import { Textarea } from './textarea'
20
24
 
21
25
  /** Strings the request-changes modal renders — supplied by the app (i18n). */
22
26
  export interface RequestChangesModalLabels {
@@ -63,14 +67,24 @@ export function RequestChangesModal({
63
67
  labels,
64
68
  'data-testid': testId,
65
69
  }: RequestChangesModalProps): React.ReactNode {
66
- const noteId = useId()
67
70
  // CONTROLLED textarea: the confirm button's disabled state depends on whether
68
71
  // the note is non-empty, so the value drives dependent UI (unlike approve).
69
72
  const [note, setNote] = useState('')
73
+ const noteRootRef = useRef<HTMLDivElement>(null)
70
74
  const isEmpty = note.trim().length === 0
71
75
  const canConfirm = !isEmpty && !isPending
72
76
 
77
+ useLayoutEffect(() => {
78
+ if (!isOpen || isPending) return
79
+ noteRootRef.current?.querySelector('textarea')?.focus()
80
+ const frame = requestAnimationFrame(() => {
81
+ noteRootRef.current?.querySelector('textarea')?.focus()
82
+ })
83
+ return () => cancelAnimationFrame(frame)
84
+ }, [isOpen, isPending])
85
+
73
86
  const handleConfirm = (): void => {
87
+ if (isPending) return
74
88
  const trimmed = note.trim()
75
89
  if (trimmed.length === 0) return
76
90
  onConfirm(trimmed)
@@ -82,27 +96,30 @@ export function RequestChangesModal({
82
96
  data-testid={testId ?? 'request-changes-modal'}
83
97
  isOpen={isOpen}
84
98
  onOpenChange={(open) => {
99
+ if (isPending) return
85
100
  if (!open) setNote('')
86
101
  onOpenChange(open)
87
102
  }}
103
+ isDismissable={!isPending}
104
+ closeLabel={labels.cancel}
88
105
  >
89
106
  <div className="flex flex-col gap-3">
90
107
  <p className="text-small text-fg-muted">{labels.body}</p>
91
- <label htmlFor={noteId} className="text-small font-medium text-fg">
92
- {labels.noteLabel}
93
- </label>
94
- <textarea
95
- id={noteId}
96
- data-testid="request-changes-note"
97
- value={note}
98
- onChange={(event) => setNote(event.target.value)}
99
- maxLength={10_000}
100
- rows={4}
101
- required
102
- aria-invalid={isEmpty ? true : undefined}
103
- placeholder={labels.notePlaceholder}
104
- className="w-full rounded-md border border-border bg-surface px-3 py-2 text-small text-fg outline-none transition-colors placeholder:text-fg-muted focus-visible:ring-2 focus-visible:ring-ring"
105
- />
108
+ <div ref={noteRootRef}>
109
+ <Textarea
110
+ autoFocus
111
+ appearance="filled"
112
+ label={labels.noteLabel}
113
+ data-testid="request-changes-note"
114
+ value={note}
115
+ isDisabled={isPending}
116
+ onChange={setNote}
117
+ maxLength={10_000}
118
+ rows={4}
119
+ isRequired
120
+ placeholder={labels.notePlaceholder}
121
+ />
122
+ </div>
106
123
  {error ? (
107
124
  <p data-testid="request-changes-error" role="alert" className="text-small text-danger">
108
125
  {error}
@@ -145,7 +145,7 @@ export function RichTextEditor({
145
145
  </div>
146
146
  <div
147
147
  ref={ref}
148
- className="prose prose-sm max-w-none px-4 py-3.5 text-small leading-relaxed text-fg outline-none empty:before:text-fg-subtle empty:before:content-[attr(data-placeholder)]"
148
+ className="prose prose-sm max-w-none px-4 py-3.5 text-small leading-relaxed text-fg outline-none empty:before:text-fg-muted empty:before:content-[attr(data-placeholder)]"
149
149
  style={{ minHeight: bodyMinHeight }}
150
150
  contentEditable
151
151
  suppressContentEditableWarning
@@ -0,0 +1,17 @@
1
+ import { Select, SelectItem } from './select'
2
+ import type { FieldAppearance } from './field-appearance'
3
+
4
+ function Example({ appearance = 'outlined', disabled = false, invalid = false }: { appearance?: FieldAppearance; disabled?: boolean; invalid?: boolean }) {
5
+ return <Select appearance={appearance} label="Content behavior" placeholder="Choose behavior" isDisabled={disabled}
6
+ isInvalid={invalid} errorMessage="Choose a behavior." defaultSelectedKey={invalid ? undefined : 'shared'}>
7
+ <SelectItem id="shared">Shared across outputs</SelectItem>
8
+ <SelectItem id="separate">Edited separately per output</SelectItem>
9
+ <SelectItem id="unavailable" isDisabled>Unavailable behavior</SelectItem>
10
+ </Select>
11
+ }
12
+ export const examples = {
13
+ default: () => <Example />,
14
+ variants: () => <><Example /><Example appearance="filled" /></>,
15
+ states: () => <><Example appearance="filled" disabled /><Example appearance="filled" invalid /></>,
16
+ }
17
+ export const meta = { category: 'Form', description: 'Outlined and Manager-style filled selects with keyboard/typeahead navigation. Filled options retain a keyboard outline and larger narrow-screen hit targets for accessibility.' }
@@ -1,4 +1,4 @@
1
- import type { ReactNode } from 'react'
1
+ import { createContext, useContext, type CSSProperties, type ReactNode } from 'react'
2
2
  import {
3
3
  Button as RACButton,
4
4
  FieldError,
@@ -14,6 +14,12 @@ import {
14
14
  } from 'react-aria-components'
15
15
  import { uic } from '../utils/uic'
16
16
  import { useInFocusOverlay } from './focus-context'
17
+ import type { FieldAppearance } from './field-appearance'
18
+
19
+ const SelectAppearanceContext = createContext<FieldAppearance>('outlined')
20
+ const filledPopoverStyle: CSSProperties & { '--select-popup-max-width': string } = {
21
+ '--select-popup-max-width': 'calc(100vw - var(--spacing) * 6)',
22
+ }
17
23
 
18
24
  /**
19
25
  * Select — accessible dropdown built on React Aria Components `Select`.
@@ -29,32 +35,77 @@ import { useInFocusOverlay } from './focus-context'
29
35
  */
30
36
  const SelectTrigger = uic(RACButton, {
31
37
  displayName: 'SelectTrigger',
32
- // gs source control geometry (58px tall, 20px inline padding, 8px radius) with
33
- // the shared bordered fill: gs draws it borderless on cream, but our Card is
34
- // also surface-card and dark theme collapses surface-card onto surface, so a
35
- // borderless cream trigger disappears. White fill + border keeps it visible and
36
- // consistent with the other form controls; hover darkens the border.
38
+ // Filled follows the Manager's borderless control; outlined preserves the
39
+ // existing default for consumers which have not opted into that appearance.
37
40
  baseClass:
38
- 'flex h-control-tall w-full items-center justify-between gap-2.5 rounded-lg border border-border bg-surface px-5 ' +
39
- 'text-small text-fg outline-none transition-colors ' +
41
+ 'flex w-full items-center justify-between gap-2.5 rounded-lg px-5 text-small text-fg outline-none transition-colors',
42
+ variants: {
43
+ appearance: {
44
+ filled: 'min-h-control-tall border-0 bg-surface-card py-5 font-normal leading-4.5 duration-200 motion-reduce:transition-none ' +
45
+ 'data-[hovered]:bg-surface-muted group-data-[open]:bg-surface-card ' +
46
+ 'data-[focus-visible]:outline-2 data-[focus-visible]:outline-ring data-[focus-visible]:outline-offset-2 ' +
47
+ 'group-data-[invalid]:ring-1 group-data-[invalid]:ring-danger ' +
48
+ 'data-[disabled]:bg-surface-card data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed',
49
+ outlined: 'h-control-tall border border-border bg-surface ' +
40
50
  'data-[hovered]:border-fg-subtle ' +
41
51
  'data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring ' +
42
52
  'group-data-[invalid]:border-danger group-data-[invalid]:ring-2 group-data-[invalid]:ring-danger ' +
43
53
  'data-[disabled]:bg-surface-muted data-[disabled]:opacity-60 data-[disabled]:pointer-events-none',
54
+ },
55
+ },
56
+ defaultVariants: { appearance: 'outlined' },
44
57
  })
45
58
 
46
- export const SelectItem = uic(ListBoxItem, {
59
+ const StyledSelectItem = uic(ListBoxItem, {
47
60
  displayName: 'SelectItem',
48
- // gs item: 0/20px padding (px-5, no vertical pad), 6px radius, selected =
49
- // medium weight. We add a `surface-muted` focus background (gs highlights with
50
- // weight only) so keyboard focus stays clearly visible on the cream content.
61
+ // Filled highlights through weight, as in Manager. Retain a keyboard outline
62
+ // and larger narrow-screen hit targets as explicit accessibility exceptions.
51
63
  baseClass:
52
- 'flex cursor-pointer select-none items-center rounded-md px-3 py-2 text-small text-fg outline-none ' +
64
+ 'flex cursor-pointer select-none items-center rounded-md text-small text-fg outline-none data-[disabled]:opacity-50 data-[disabled]:pointer-events-none',
65
+ variants: {
66
+ appearance: {
67
+ filled: 'min-w-0 overflow-hidden px-5 py-0 font-normal leading-4.5 max-md:min-h-11 ' +
68
+ 'data-[hovered]:font-medium data-[focused]:font-medium data-[selected]:font-medium ' +
69
+ 'data-[focus-visible]:outline-2 data-[focus-visible]:outline-ring data-[focus-visible]:-outline-offset-2',
70
+ outlined: 'px-3 py-2 ' +
53
71
  'data-[hovered]:bg-surface-muted data-[focused]:bg-surface-muted data-[selected]:font-medium ' +
54
72
  'data-[disabled]:opacity-50 data-[disabled]:pointer-events-none',
55
- }) as (props: ListBoxItemProps) => ReactNode
73
+ },
74
+ },
75
+ defaultVariants: { appearance: 'outlined' },
76
+ }) as (props: ListBoxItemProps & { appearance?: FieldAppearance }) => ReactNode
77
+
78
+ export const SelectItem = (props: ListBoxItemProps): ReactNode => {
79
+ const appearance = useContext(SelectAppearanceContext)
80
+ if (appearance === 'outlined') return <StyledSelectItem {...props} appearance={appearance} />
81
+ // Manager's ItemText is a single ellipsized line. Keep the full value in the
82
+ // label/textValue so keyboard search and assistive technology don't see a cut-off label.
83
+ const { children, textValue, ...rest } = props
84
+ return <StyledSelectItem {...rest} appearance={appearance} textValue={textValue ?? (typeof children === 'string' ? children : undefined)}>
85
+ {state => <Text slot="label" className="min-w-0 truncate">{typeof children === 'function' ? children(state) : children}</Text>}
86
+ </StyledSelectItem>
87
+ }
88
+
89
+ const SelectListBox = uic(ListBox, {
90
+ displayName: 'SelectListBox',
91
+ baseClass: 'flex flex-col overflow-auto overscroll-contain outline-none',
92
+ variants: { appearance: { outlined: 'max-h-72 gap-0.5 p-1', filled: 'gap-3 px-0 py-5' } },
93
+ defaultVariants: { appearance: 'outlined' },
94
+ })
95
+ const SelectPopover = uic(Popover, {
96
+ displayName: 'SelectPopover',
97
+ baseClass: 'overflow-hidden rounded-lg bg-surface-card shadow-lg',
98
+ variants: { appearance: {
99
+ outlined: 'min-w-(--trigger-width)',
100
+ filled: 'w-(--trigger-width) max-w-(--select-popup-max-width)',
101
+ } },
102
+ defaultVariants: { appearance: 'outlined' },
103
+ })
56
104
 
57
105
  export type SelectProps<T extends object> = RACSelectProps<T> & {
106
+ appearance?: FieldAppearance
107
+ /** Keep the accessible label without reserving an empty label row. */
108
+ isLabelHidden?: boolean
58
109
  /** Visible label (required for accessibility). */
59
110
  label: ReactNode
60
111
  description?: ReactNode
@@ -80,14 +131,16 @@ export const Select = <T extends object>({
80
131
  children,
81
132
  rootClassName,
82
133
  triggerClassName,
134
+ appearance = 'outlined',
135
+ isLabelHidden = false,
83
136
  ...props
84
137
  }: SelectProps<T>) => {
85
138
  // In a focus overlay, show the options inline (seamless) instead of a popover.
86
139
  const inFocus = useInFocusOverlay()
87
140
  const listbox = (
88
- <ListBox className="flex max-h-72 flex-col gap-0.5 overflow-auto overscroll-contain p-1 outline-none">
141
+ <SelectListBox appearance={appearance} style={appearance === 'filled' ? { maxHeight: 'inherit' } : undefined}>
89
142
  {children}
90
- </ListBox>
143
+ </SelectListBox>
91
144
  )
92
145
  const desc = description ? (
93
146
  <Text slot="description" className="text-label text-fg-muted">
@@ -97,8 +150,9 @@ export const Select = <T extends object>({
97
150
  const err = <FieldError className="text-label text-danger">{errorMessage}</FieldError>
98
151
 
99
152
  return (
153
+ <SelectAppearanceContext.Provider value={appearance}>
100
154
  <RACSelect {...props} placeholder={placeholder} className={`group flex flex-col gap-3 ${rootClassName ?? ''}`}>
101
- <Label className="text-panel-heading font-medium text-fg">{label}</Label>
155
+ <Label className={isLabelHidden ? 'sr-only' : 'text-panel-heading font-medium text-fg'}>{label}</Label>
102
156
  {inFocus ? (
103
157
  <>
104
158
  {listbox}
@@ -107,7 +161,7 @@ export const Select = <T extends object>({
107
161
  </>
108
162
  ) : (
109
163
  <>
110
- <SelectTrigger className={triggerClassName}>
164
+ <SelectTrigger className={triggerClassName} appearance={appearance}>
111
165
  <SelectValue className="data-[placeholder]:text-fg-muted" />
112
166
  {/* gs chevron: 9.5px caret, dark (neutral-400 → fg), non-interactive. */}
113
167
  <svg
@@ -123,13 +177,15 @@ export const Select = <T extends object>({
123
177
  </SelectTrigger>
124
178
  {desc}
125
179
  {err}
126
- {/* Cream fill, 8px radius, shadow-lg, NO border. 4px inset so each option's
127
- highlight sits as a padded pill; small gap for an even list rhythm. */}
128
- <Popover className="min-w-[var(--trigger-width)] overflow-hidden rounded-lg bg-surface-card shadow-lg">
180
+ {/* Filled uses the trigger width and Manager's 4px popup offset. */}
181
+ <SelectPopover appearance={appearance} offset={appearance === 'filled' ? 4 : undefined}
182
+ containerPadding={appearance === 'filled' ? 12 : undefined}
183
+ style={appearance === 'filled' ? filledPopoverStyle : undefined}>
129
184
  {listbox}
130
- </Popover>
185
+ </SelectPopover>
131
186
  </>
132
187
  )}
133
188
  </RACSelect>
189
+ </SelectAppearanceContext.Provider>
134
190
  )
135
191
  }
@@ -0,0 +1,44 @@
1
+ import { useState } from 'react'
2
+ import { Button } from './button'
3
+ import { Input } from './input'
4
+ import { WizardForm, WizardBrief, WizardFormDialog, ProductionSettingsDialog, ProductionSettingsAction, SettingsDialogDisclosure, SettingsDialogProductionColumns } from './settings-dialog-surface'
5
+ import { DialogActionButton } from './dialog-action-button'
6
+ import { SettingsDialogSurface, SettingsDialogIdentity, SettingsDialogAction, SettingsDialogEmphasis, SettingsDialogDescription, SettingsDialogLabel, SettingsDialogHint, type SettingsDialogVariant } from './settings-dialog-surface'
7
+
8
+ function Example({ variant = 'create', pending = false, error = false, wide = false }: { variant?: SettingsDialogVariant; pending?: boolean; error?: boolean; wide?: boolean }) {
9
+ const [open, setOpen] = useState(false)
10
+ const Action = variant === 'basic' ? DialogActionButton : SettingsDialogAction
11
+ return <>
12
+ <Button onPress={() => setOpen(true)}>Open {variant} dialog{pending ? ' (pending)' : error ? ' (error)' : ''}</Button>
13
+ <SettingsDialogSurface variant={variant} isOpen={open} onOpenChange={setOpen} isPending={pending}
14
+ isWide={wide} description={variant === 'csv' ? 'Review the column-to-field mapping before importing.' : undefined}
15
+ label="Settings example" closeLabel="Close settings"
16
+ title={variant === 'basic' ? <><SettingsDialogEmphasis>{'Set the project name\nand describe '}</SettingsDialogEmphasis>the brief.</> : variant === 'catalog' ? <>Choose an output template<br />to add to this section.</> : <>Create <span className="text-fg">a new section</span> for your <span className="text-fg">production workspace</span>.</>}
17
+ footer={<><Action variant="secondary" isDisabled={pending} onPress={() => setOpen(false)}>Cancel</Action><Action isPending={pending} isDisabled={pending}>Save</Action></>}
18
+ >
19
+ {variant === 'basic' ? <SettingsDialogDescription><SettingsDialogLabel>Basic information</SettingsDialogLabel><SettingsDialogHint>Keep the project name clear and add a short brief so the team understands what this work is about.</SettingsDialogHint></SettingsDialogDescription> : null}
20
+ <SettingsDialogIdentity><Input appearance="filled" label="Section name" defaultValue="Spring campaign" isDisabled={pending} /></SettingsDialogIdentity>
21
+ {error ? <p role="alert" className="text-small text-danger">The changes could not be saved. Please try again.</p> : null}
22
+ </SettingsDialogSurface>
23
+ </>
24
+ }
25
+ function ProductionExample() {
26
+ const [open, setOpen] = useState(false)
27
+ return <><Button onPress={() => setOpen(true)}>Open production settings</Button><ProductionSettingsDialog isOpen={open} onOpenChange={setOpen} prefix="Template settings" title="Poster" description="Configure production details." closeLabel="Close settings" preview={<span>Artwork preview</span>} footer={<><ProductionSettingsAction variant="secondary" onPress={() => setOpen(false)}>Close</ProductionSettingsAction><ProductionSettingsAction isDisabled>Save</ProductionSettingsAction></>}><SettingsDialogDisclosure title="General"><Input label="Name" appearance="filled" defaultValue="Poster" /></SettingsDialogDisclosure></ProductionSettingsDialog></>
28
+ }
29
+ function WizardExample({ expanded = false, pending = false }: { expanded?: boolean; pending?: boolean }) {
30
+ const [open, setOpen] = useState(false)
31
+ return <><Button onPress={() => setOpen(true)}>Open wizard {expanded ? 'catalog' : 'form'}{pending ? ' pending' : ''}</Button><WizardFormDialog isOpen={open} onOpenChange={setOpen} isExpanded={expanded} isPending={pending} closeLabel="Close wizard" title={<>Create a <SettingsDialogEmphasis>new preset</SettingsDialogEmphasis><br />and define its details.</>} footer={<><Button variant="secondary" isDisabled={pending} onPress={() => setOpen(false)}>Cancel</Button><Button isPending={pending} isDisabled={pending}>Next</Button></>}><WizardForm><Input label="Name" appearance="filled" isDisabled={pending} defaultValue="Campaign" /><WizardBrief label="Scenario brief">Prepare the selected templates and shared content.</WizardBrief></WizardForm></WizardFormDialog></>
32
+ }
33
+ export const examples = {
34
+ wizard: () => <WizardExample />,
35
+ wizardSizes: () => <><WizardExample /><WizardExample expanded /></>,
36
+ wizardPending: () => <WizardExample pending />,
37
+ production: () => <ProductionExample />,
38
+ default: () => <Example />,
39
+ productionColumns: () => <SettingsDialogProductionColumns><Input label="Material" appearance="filled" /><Input label="Surface finish" appearance="filled" /></SettingsDialogProductionColumns>,
40
+ disclosures: () => <><SettingsDialogDisclosure title="Settings" description="Campaign templates"><Input label="Name" appearance="filled" defaultValue="Poster" /></SettingsDialogDisclosure><SettingsDialogDisclosure title="Print" description="Available for Print templates" defaultExpanded={false}><Input label="Material" appearance="filled" isDisabled /></SettingsDialogDisclosure></>,
41
+ variants: () => <><Example variant="create" /><Example variant="edit" /><Example variant="catalog" /><Example variant="csv" /><Example variant="csv" wide /><Example variant="basic" /></>,
42
+ states: () => <><Example pending /><Example error /><Example variant="basic" pending /><Example variant="basic" error /></>,
43
+ }
44
+ export const meta = { category: 'Layout', description: 'Responsive settings and catalog frames with fixed headings/actions and a scrolling form body.' }