@podoba/react 0.0.40 → 0.0.41
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 +3 -3
- package/src/components/animated-summary-text.tsx +98 -0
- package/src/components/brand-page-header.tsx +11 -1
- package/src/components/combobox.tsx +4 -3
- package/src/components/date-field.tsx +5 -4
- package/src/components/date-picker.tsx +3 -2
- package/src/components/field-appearance.ts +7 -0
- package/src/components/input.tsx +3 -3
- package/src/components/multiselect.tsx +3 -2
- package/src/components/number-field.tsx +3 -2
- package/src/components/preview-motion.tsx +13 -5
- package/src/components/radio.tsx +3 -2
- package/src/components/rich-text-editor.tsx +3 -2
- package/src/components/search-field.tsx +2 -1
- package/src/components/section-tabs.tsx +32 -2
- package/src/components/select.tsx +3 -3
- package/src/components/source-icons.tsx +60 -0
- package/src/components/tag-group.tsx +2 -1
- package/src/components/template-catalog-card.tsx +1 -1
- package/src/components/textarea.tsx +3 -3
- package/src/index.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@podoba/react",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.41",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "podoba React components — React Aria Components + Tailwind primitives + layout, built with uic.",
|
|
6
6
|
"repository": {
|
|
@@ -28,8 +28,8 @@
|
|
|
28
28
|
"typecheck": "tsc --build"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@podoba/tokens": "^0.0.
|
|
32
|
-
"@podoba/tailwind": "^0.0.
|
|
31
|
+
"@podoba/tokens": "^0.0.41",
|
|
32
|
+
"@podoba/tailwind": "^0.0.41",
|
|
33
33
|
"react-aria-components": "1.18.0",
|
|
34
34
|
"class-variance-authority": "0.7.1",
|
|
35
35
|
"clsx": "2.1.1",
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AnimatedSummaryText: the gs `SummaryContentTile` sentence reveal (Tasks summary,
|
|
5
|
+
* project and brand dashboard summary tiles).
|
|
6
|
+
*
|
|
7
|
+
* Every character is its own `inline-block` token playing `summary-token-reveal`
|
|
8
|
+
* (680ms `cubic-bezier(0.19, 1, 0.22, 1)`: opacity 0, 8px down and 6px blur, clear
|
|
9
|
+
* by 58%, settled at 100%), delayed `min(index * 22, 1800)ms`. Words are
|
|
10
|
+
* `inline-block whitespace-nowrap` so a word never breaks mid-reveal, and a `\n`
|
|
11
|
+
* in the text becomes a line break (pass an already balanced sentence).
|
|
12
|
+
*
|
|
13
|
+
* Accessibility: the per-character copy is `aria-hidden`; assistive technology
|
|
14
|
+
* reads one visually hidden string instead of single letters. Under
|
|
15
|
+
* `prefers-reduced-motion: reduce` no token animates and the sentence is simply
|
|
16
|
+
* shown. The reveal replays whenever `text` changes.
|
|
17
|
+
*
|
|
18
|
+
* Renders inline (`<span>`): place it inside the caller's paragraph, which owns the
|
|
19
|
+
* type scale, colour and `max-width`. Presentational only: no i18n, no data.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export interface AnimatedSummarySegment {
|
|
23
|
+
text: string
|
|
24
|
+
/** Highlighted runs take `highlightClassName` (gs `summaryHighlight`, primary ink). */
|
|
25
|
+
highlighted?: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface AnimatedSummaryTextProps {
|
|
29
|
+
/** The full sentence. `\n` renders as a line break. */
|
|
30
|
+
text: string
|
|
31
|
+
/**
|
|
32
|
+
* Optional highlighted runs. Their texts joined must equal `text`; when omitted the
|
|
33
|
+
* whole sentence is one plain segment.
|
|
34
|
+
*/
|
|
35
|
+
segments?: ReadonlyArray<AnimatedSummarySegment>
|
|
36
|
+
/** Class for highlighted words (e.g. `text-fg`). */
|
|
37
|
+
highlightClassName?: string
|
|
38
|
+
/** Per-character delay step (source 22ms). */
|
|
39
|
+
stepMs?: number
|
|
40
|
+
/** Delay ceiling (source 1800ms). */
|
|
41
|
+
maxDelayMs?: number
|
|
42
|
+
className?: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const tokenClass = 'inline-block animate-summary-token will-change-[opacity,filter,transform] motion-reduce:animate-none'
|
|
46
|
+
|
|
47
|
+
export function AnimatedSummaryText({
|
|
48
|
+
text,
|
|
49
|
+
segments,
|
|
50
|
+
highlightClassName,
|
|
51
|
+
stepMs = 22,
|
|
52
|
+
maxDelayMs = 1800,
|
|
53
|
+
className,
|
|
54
|
+
}: AnimatedSummaryTextProps) {
|
|
55
|
+
const parts = segments && segments.length > 0 ? segments : [{ text, highlighted: false }]
|
|
56
|
+
let tokenIndex = 0
|
|
57
|
+
const nodes: ReactNode[] = []
|
|
58
|
+
|
|
59
|
+
parts.forEach((segment, segmentIndex) => {
|
|
60
|
+
segment.text.split(/(\n|\s+)/).forEach((part, partIndex) => {
|
|
61
|
+
if (!part) return
|
|
62
|
+
const key = `${segmentIndex}-${partIndex}`
|
|
63
|
+
if (part === '\n') {
|
|
64
|
+
nodes.push(<br key={`${key}-br`} />)
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
if (/^\s+$/.test(part)) {
|
|
68
|
+
nodes.push(part)
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
const wordClass = ['inline-block whitespace-nowrap', segment.highlighted ? highlightClassName : '']
|
|
72
|
+
.filter(Boolean)
|
|
73
|
+
.join(' ')
|
|
74
|
+
nodes.push(
|
|
75
|
+
<span key={key} className={wordClass}>
|
|
76
|
+
{Array.from(part).map((character, characterIndex) => {
|
|
77
|
+
const style: CSSProperties = { animationDelay: `${Math.min(tokenIndex * stepMs, maxDelayMs)}ms` }
|
|
78
|
+
tokenIndex += 1
|
|
79
|
+
return (
|
|
80
|
+
<span key={characterIndex} className={tokenClass} style={style} data-summary-token="">
|
|
81
|
+
{character}
|
|
82
|
+
</span>
|
|
83
|
+
)
|
|
84
|
+
})}
|
|
85
|
+
</span>,
|
|
86
|
+
)
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<span className={className} data-animated-summary="">
|
|
92
|
+
<span className="sr-only">{text.replace(/\s*\n\s*/g, ' ')}</span>
|
|
93
|
+
<span key={text} aria-hidden="true">
|
|
94
|
+
{nodes}
|
|
95
|
+
</span>
|
|
96
|
+
</span>
|
|
97
|
+
)
|
|
98
|
+
}
|
|
@@ -51,6 +51,13 @@ export type BrandPageHeaderProps = {
|
|
|
51
51
|
* navigation link, not ornament (#25).
|
|
52
52
|
*/
|
|
53
53
|
parentLink?: ReactNode
|
|
54
|
+
/**
|
|
55
|
+
* Ink of `parentLink`. `muted` (default) is `fg-muted` (5.98:1). `soft` is the
|
|
56
|
+
* lighter large-text grey `fg-muted-large` (3.4:1 on `surface`, above the WCAG
|
|
57
|
+
* 1.4.3 3:1 floor for this 30px row), the closest AA match to the source's
|
|
58
|
+
* decorative #b3b3b3 parent line. Hover still resolves to `fg`.
|
|
59
|
+
*/
|
|
60
|
+
parentLinkTone?: 'muted' | 'soft'
|
|
54
61
|
/** Optional breadcrumb trail rendered above the greeting. */
|
|
55
62
|
breadcrumbs?: BrandPageHeaderCrumb[]
|
|
56
63
|
/**
|
|
@@ -84,6 +91,7 @@ export function BrandPageHeader({
|
|
|
84
91
|
variant = 'default',
|
|
85
92
|
headingLevel = 1,
|
|
86
93
|
parentLink,
|
|
94
|
+
parentLinkTone = 'muted',
|
|
87
95
|
breadcrumbs,
|
|
88
96
|
cta,
|
|
89
97
|
ctaLabel,
|
|
@@ -299,7 +307,9 @@ export function BrandPageHeader({
|
|
|
299
307
|
// readable `fg-muted` (5.98:1) rather than `fg-subtle`.
|
|
300
308
|
<nav
|
|
301
309
|
aria-label="Breadcrumb"
|
|
302
|
-
className=
|
|
310
|
+
className={`h-8 text-display-large font-medium leading-8 tracking-wide transition-colors [&_a:hover]:text-fg [&_a]:block [&_a]:h-8 [&_a]:no-underline [&_a]:outline-none [&_a:focus-visible]:underline ${
|
|
311
|
+
parentLinkTone === 'soft' ? 'text-fg-muted-large [&_a]:text-fg-muted-large' : 'text-fg-muted [&_a]:text-fg-muted'
|
|
312
|
+
}`}
|
|
303
313
|
>
|
|
304
314
|
{parentLink}
|
|
305
315
|
</nav>
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
} from 'react-aria-components'
|
|
18
18
|
import { uic } from '../utils/uic'
|
|
19
19
|
import { useInFocusOverlay } from './focus-context'
|
|
20
|
+
import { fieldDescriptionClass, fieldErrorClass } from './field-appearance'
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* ComboBox — a filterable single-select: a text input that narrows a listbox as
|
|
@@ -76,7 +77,7 @@ export const ComboBox = <T extends object>({
|
|
|
76
77
|
const inFocus = useInFocusOverlay()
|
|
77
78
|
const { contains } = useFilter({ sensitivity: 'base' })
|
|
78
79
|
const desc = description ? (
|
|
79
|
-
<Text slot="description" className=
|
|
80
|
+
<Text slot="description" className={fieldDescriptionClass}>
|
|
80
81
|
{description}
|
|
81
82
|
</Text>
|
|
82
83
|
) : null
|
|
@@ -108,7 +109,7 @@ export const ComboBox = <T extends object>({
|
|
|
108
109
|
</ListBox>
|
|
109
110
|
</Autocomplete>
|
|
110
111
|
{desc}
|
|
111
|
-
{errorMessage ? <span className=
|
|
112
|
+
{errorMessage ? <span className={fieldErrorClass}>{errorMessage}</span> : null}
|
|
112
113
|
</div>
|
|
113
114
|
)
|
|
114
115
|
}
|
|
@@ -132,7 +133,7 @@ export const ComboBox = <T extends object>({
|
|
|
132
133
|
</RACButton>
|
|
133
134
|
</div>
|
|
134
135
|
{desc}
|
|
135
|
-
<FieldError className=
|
|
136
|
+
<FieldError className={fieldErrorClass}>{errorMessage}</FieldError>
|
|
136
137
|
<Popover className="min-w-[var(--trigger-width)] overflow-hidden rounded-lg bg-surface-card shadow-lg">
|
|
137
138
|
<ListBox
|
|
138
139
|
className="flex max-h-72 flex-col gap-0.5 overflow-auto overscroll-contain p-1 outline-none"
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
type TimeFieldProps as RACTimeFieldProps,
|
|
13
13
|
type TimeValue,
|
|
14
14
|
} from 'react-aria-components'
|
|
15
|
+
import { fieldDescriptionClass, fieldErrorClass } from './field-appearance'
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* DateField / TimeField — segmented, keyboard-first date and time entry (type or
|
|
@@ -43,11 +44,11 @@ export const DateField = <T extends DateValue>({ label, description, errorMessag
|
|
|
43
44
|
<Label className="text-heading5 font-medium text-fg">{label}</Label>
|
|
44
45
|
<DateInput className={dateInputClass}>{(segment) => <DateSegment segment={segment} className={segmentClass} />}</DateInput>
|
|
45
46
|
{description ? (
|
|
46
|
-
<Text slot="description" className=
|
|
47
|
+
<Text slot="description" className={fieldDescriptionClass}>
|
|
47
48
|
{description}
|
|
48
49
|
</Text>
|
|
49
50
|
) : null}
|
|
50
|
-
<FieldError className=
|
|
51
|
+
<FieldError className={fieldErrorClass}>{errorMessage}</FieldError>
|
|
51
52
|
</RACDateField>
|
|
52
53
|
)
|
|
53
54
|
|
|
@@ -62,10 +63,10 @@ export const TimeField = <T extends TimeValue>({ label, description, errorMessag
|
|
|
62
63
|
<Label className="text-heading5 font-medium text-fg">{label}</Label>
|
|
63
64
|
<DateInput className={dateInputClass}>{(segment) => <DateSegment segment={segment} className={segmentClass} />}</DateInput>
|
|
64
65
|
{description ? (
|
|
65
|
-
<Text slot="description" className=
|
|
66
|
+
<Text slot="description" className={fieldDescriptionClass}>
|
|
66
67
|
{description}
|
|
67
68
|
</Text>
|
|
68
69
|
) : null}
|
|
69
|
-
<FieldError className=
|
|
70
|
+
<FieldError className={fieldErrorClass}>{errorMessage}</FieldError>
|
|
70
71
|
</RACTimeField>
|
|
71
72
|
)
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
import { clsx } from 'clsx'
|
|
24
24
|
import { dateInputClass, segmentClass } from './date-field'
|
|
25
25
|
import { useInFocusOverlay } from './focus-context'
|
|
26
|
+
import { fieldDescriptionClass, fieldErrorClass } from './field-appearance'
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
29
|
* DatePicker — a `DateField` with a calendar popover. Built on React Aria
|
|
@@ -106,11 +107,11 @@ export const DatePicker = <T extends DateValue>({ label, description, errorMessa
|
|
|
106
107
|
)}
|
|
107
108
|
</Group>
|
|
108
109
|
{description ? (
|
|
109
|
-
<Text slot="description" className=
|
|
110
|
+
<Text slot="description" className={fieldDescriptionClass}>
|
|
110
111
|
{description}
|
|
111
112
|
</Text>
|
|
112
113
|
) : null}
|
|
113
|
-
<FieldError className=
|
|
114
|
+
<FieldError className={fieldErrorClass}>{errorMessage}</FieldError>
|
|
114
115
|
{inFocus ? (
|
|
115
116
|
<div className="mt-2 -ml-2">
|
|
116
117
|
<CalendarBody />
|
|
@@ -15,3 +15,10 @@ export const filledFieldClasses =
|
|
|
15
15
|
'focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-2 ' +
|
|
16
16
|
'data-[invalid]:ring-1 data-[invalid]:ring-danger data-[invalid]:outline-danger ' +
|
|
17
17
|
'data-[disabled]:bg-surface-muted data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed'
|
|
18
|
+
|
|
19
|
+
/** gs `.helperText` (`--font-size-label-2`): 14px/18px under a field. The source ink
|
|
20
|
+
* `#7d786f` fails WCAG AA, so it keeps the readable `fg-muted`. */
|
|
21
|
+
export const fieldDescriptionClass = 'text-small text-fg-muted'
|
|
22
|
+
|
|
23
|
+
/** gs `.errorText`: 16px, line-height normal, error red (`danger`, 4.83:1 on white). */
|
|
24
|
+
export const fieldErrorClass = 'text-body leading-[normal] text-danger'
|
package/src/components/input.tsx
CHANGED
|
@@ -8,7 +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
|
+
import { fieldDescriptionClass, fieldErrorClass, filledFieldClasses, type FieldAppearance } from './field-appearance'
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Input — labelled single-line text field.
|
|
@@ -78,10 +78,10 @@ export const Input = ({ label, description, errorMessage, placeholder, size, app
|
|
|
78
78
|
<Label className="text-panel-heading font-medium text-fg">{label}</Label>
|
|
79
79
|
<StyledInput className={inputClassName} placeholder={placeholder} appearance={appearance} fieldSize={size ?? (appearance === 'filled' ? 'filled' : undefined)} />
|
|
80
80
|
{description ? (
|
|
81
|
-
<Text slot="description" className=
|
|
81
|
+
<Text slot="description" className={fieldDescriptionClass}>
|
|
82
82
|
{description}
|
|
83
83
|
</Text>
|
|
84
84
|
) : null}
|
|
85
|
-
<FieldError className=
|
|
85
|
+
<FieldError className={fieldErrorClass}>{errorMessage}</FieldError>
|
|
86
86
|
</TextField>
|
|
87
87
|
)
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
import { clsx } from 'clsx'
|
|
15
15
|
import { uic } from '../utils/uic'
|
|
16
16
|
import { useInFocusOverlay } from './focus-context'
|
|
17
|
+
import { fieldDescriptionClass, fieldErrorClass } from './field-appearance'
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* MultiSelect — a dropdown that selects several options at once.
|
|
@@ -142,8 +143,8 @@ export const MultiSelect = ({
|
|
|
142
143
|
list
|
|
143
144
|
)
|
|
144
145
|
|
|
145
|
-
const desc = description ? <span className=
|
|
146
|
-
const err = isInvalid && errorMessage ? <span className=
|
|
146
|
+
const desc = description ? <span className={fieldDescriptionClass}>{description}</span> : null
|
|
147
|
+
const err = isInvalid && errorMessage ? <span className={fieldErrorClass}>{errorMessage}</span> : null
|
|
147
148
|
|
|
148
149
|
return (
|
|
149
150
|
<div className={clsx('flex flex-col gap-2', className)}>
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
type NumberFieldProps as RACNumberFieldProps,
|
|
10
10
|
Text,
|
|
11
11
|
} from 'react-aria-components'
|
|
12
|
+
import { fieldDescriptionClass, fieldErrorClass } from './field-appearance'
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* NumberField — numeric input with steppers, min/max and locale-aware formatting
|
|
@@ -51,10 +52,10 @@ export const NumberField = ({ label, description, errorMessage, placeholder, ...
|
|
|
51
52
|
</RACButton>
|
|
52
53
|
</Group>
|
|
53
54
|
{description ? (
|
|
54
|
-
<Text slot="description" className=
|
|
55
|
+
<Text slot="description" className={fieldDescriptionClass}>
|
|
55
56
|
{description}
|
|
56
57
|
</Text>
|
|
57
58
|
) : null}
|
|
58
|
-
<FieldError className=
|
|
59
|
+
<FieldError className={fieldErrorClass}>{errorMessage}</FieldError>
|
|
59
60
|
</RACNumberField>
|
|
60
61
|
)
|
|
@@ -15,7 +15,10 @@ import { type CSSProperties, type ReactNode, useEffect, useRef, useState } from
|
|
|
15
15
|
* All of it is ornamental: every layer is `aria-hidden`, nothing here carries content,
|
|
16
16
|
* and under `prefers-reduced-motion: reduce` neither the timed states nor any motion
|
|
17
17
|
* run (the skeleton shows the source static gradient, the reveal never engages).
|
|
18
|
-
*
|
|
18
|
+
* Skeleton colours: the light theme keeps the source literals (#ffffff base, #c7fee0
|
|
19
|
+
* glow) exactly; under `[data-theme="dark"]` the base follows `surface` and the glow
|
|
20
|
+
* becomes a brand-green tint of it, so a loading preview is not a white block on the
|
|
21
|
+
* dark shell.
|
|
19
22
|
*/
|
|
20
23
|
|
|
21
24
|
function usePrefersReducedMotion(): boolean {
|
|
@@ -69,9 +72,14 @@ export function useMinimumVisible(active: boolean, minMs = 500): boolean {
|
|
|
69
72
|
return reduce ? active : held
|
|
70
73
|
}
|
|
71
74
|
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
+
const SKELETON_BASE = 'var(--preview-skeleton-base)'
|
|
76
|
+
const SKELETON_GLOW = 'var(--preview-skeleton-glow)'
|
|
77
|
+
const SKELETON_ANIMATED = `linear-gradient(80deg, ${SKELETON_BASE} 0%, ${SKELETON_BASE} 12.5%, ${SKELETON_GLOW} 27.5%, ${SKELETON_BASE} 41.5%, ${SKELETON_BASE} 50%, ${SKELETON_BASE} 62.5%, ${SKELETON_GLOW} 77.5%, ${SKELETON_BASE} 91.5%, ${SKELETON_BASE} 100%)`
|
|
78
|
+
const SKELETON_STATIC = `linear-gradient(80deg, ${SKELETON_BASE} 25%, ${SKELETON_GLOW} 55%, ${SKELETON_BASE} 83%)`
|
|
79
|
+
// Light: the source literals. Dark: theme `surface` with an 18% brand-green glow.
|
|
80
|
+
const SKELETON_THEME =
|
|
81
|
+
'[--preview-skeleton-base:#ffffff] [--preview-skeleton-glow:#c7fee0] ' +
|
|
82
|
+
'dark:[--preview-skeleton-base:var(--color-surface)] dark:[--preview-skeleton-glow:color-mix(in_srgb,var(--color-brand-green)_18%,var(--color-surface))]'
|
|
75
83
|
|
|
76
84
|
export interface PreviewSkeletonProps {
|
|
77
85
|
/** Accessible status text (translated by the caller), e.g. "Rendering preview". */
|
|
@@ -91,7 +99,7 @@ export function PreviewSkeleton({ label, className }: PreviewSkeletonProps) {
|
|
|
91
99
|
aria-label={label}
|
|
92
100
|
aria-hidden={label ? undefined : true}
|
|
93
101
|
data-preview-skeleton=""
|
|
94
|
-
className={['absolute inset-0 overflow-hidden', reduce ? '' : 'animate-preview-skeleton-shimmer', className]
|
|
102
|
+
className={['absolute inset-0 overflow-hidden', SKELETON_THEME, reduce ? '' : 'animate-preview-skeleton-shimmer', className]
|
|
95
103
|
.filter(Boolean)
|
|
96
104
|
.join(' ')}
|
|
97
105
|
style={style}
|
package/src/components/radio.tsx
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type RadioProps as RACRadioProps,
|
|
9
9
|
Text,
|
|
10
10
|
} from 'react-aria-components'
|
|
11
|
+
import { fieldDescriptionClass, fieldErrorClass } from './field-appearance'
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* RadioGroup + Radio — labelled single-choice control.
|
|
@@ -34,11 +35,11 @@ export const RadioGroup = ({ label, description, errorMessage, children, ...prop
|
|
|
34
35
|
{label ? <Label className="text-small font-medium text-fg">{label}</Label> : null}
|
|
35
36
|
<div className="flex flex-col gap-3">{children}</div>
|
|
36
37
|
{description ? (
|
|
37
|
-
<Text slot="description" className=
|
|
38
|
+
<Text slot="description" className={fieldDescriptionClass}>
|
|
38
39
|
{description}
|
|
39
40
|
</Text>
|
|
40
41
|
) : null}
|
|
41
|
-
<FieldError className=
|
|
42
|
+
<FieldError className={fieldErrorClass}>{errorMessage}</FieldError>
|
|
42
43
|
</RACRadioGroup>
|
|
43
44
|
)
|
|
44
45
|
|
|
@@ -2,6 +2,7 @@ import { type ClipboardEvent as ReactClipboardEvent, type ReactNode, useEffect,
|
|
|
2
2
|
import { clsx } from 'clsx'
|
|
3
3
|
import { SAFE_LINK_HINT, safeLinkUrl } from '../utils/safe-link-url'
|
|
4
4
|
import { useInFocusOverlay } from './focus-context'
|
|
5
|
+
import { fieldDescriptionClass, fieldErrorClass } from './field-appearance'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* RichTextEditor — a dependency-free contentEditable WYSIWYG that emits an HTML
|
|
@@ -155,8 +156,8 @@ export function RichTextEditor({
|
|
|
155
156
|
onPaste={onPaste}
|
|
156
157
|
/>
|
|
157
158
|
</div>
|
|
158
|
-
{description ? <span className=
|
|
159
|
-
{isInvalid && errorMessage ? <span className=
|
|
159
|
+
{description ? <span className={fieldDescriptionClass}>{description}</span> : null}
|
|
160
|
+
{isInvalid && errorMessage ? <span className={fieldErrorClass}>{errorMessage}</span> : null}
|
|
160
161
|
</div>
|
|
161
162
|
)
|
|
162
163
|
}
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type SearchFieldProps as RACSearchFieldProps,
|
|
8
8
|
Text,
|
|
9
9
|
} from 'react-aria-components'
|
|
10
|
+
import { fieldDescriptionClass } from './field-appearance'
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* SearchField — a text input for search, with a clear (✕) button that appears
|
|
@@ -58,7 +59,7 @@ export const SearchField = ({ label, description, placeholder, ...props }: Searc
|
|
|
58
59
|
</RACButton>
|
|
59
60
|
</div>
|
|
60
61
|
{description ? (
|
|
61
|
-
<Text slot="description" className=
|
|
62
|
+
<Text slot="description" className={fieldDescriptionClass}>
|
|
62
63
|
{description}
|
|
63
64
|
</Text>
|
|
64
65
|
) : null}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ReactNode } from 'react'
|
|
2
2
|
import { Button } from './button'
|
|
3
|
+
import { SwitchViewIcon } from './source-icons'
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* SectionTabs — horizontal section/filter tab row (port of gs-platform
|
|
@@ -22,6 +23,15 @@ export type SectionTab = {
|
|
|
22
23
|
label: ReactNode
|
|
23
24
|
/** Disabled tabs render dimmed with a "Soon" badge and cannot be selected. */
|
|
24
25
|
disabled?: boolean
|
|
26
|
+
/**
|
|
27
|
+
* gs `switchable` tab: pressing the already selected tab switches the view
|
|
28
|
+
* (e.g. Tasks list and board) instead of re-selecting it. While selected it shows
|
|
29
|
+
* the source "switch view" burst before the label, revealed on hover and keyboard
|
|
30
|
+
* focus (width 0 to 14px, opacity 0 to 1, 120ms `ease`).
|
|
31
|
+
*/
|
|
32
|
+
switchable?: boolean
|
|
33
|
+
/** Native tooltip on a selected switchable tab (translated, e.g. "Switch view"). */
|
|
34
|
+
switchHintLabel?: string
|
|
25
35
|
}
|
|
26
36
|
|
|
27
37
|
export type SectionTabsProps = {
|
|
@@ -63,6 +73,14 @@ const tabBase =
|
|
|
63
73
|
|
|
64
74
|
const tabActive = 'bg-surface-muted text-fg'
|
|
65
75
|
|
|
76
|
+
// gs `.tabButton` (switchable form): 12px gap, so the selected tab is 12px wider even
|
|
77
|
+
// while the 0px icon is hidden (measured 77px at rest, 91px on hover for "All (18)").
|
|
78
|
+
const tabSwitchable = 'group/switch'
|
|
79
|
+
const tabSwitchIcon =
|
|
80
|
+
'h-3.5 w-0 shrink-0 overflow-hidden opacity-0 transition-[width,opacity] duration-120 ease-[ease] motion-reduce:transition-none ' +
|
|
81
|
+
'group-data-[hovered]/switch:w-3.5 group-data-[hovered]/switch:opacity-100 ' +
|
|
82
|
+
'group-data-[focus-visible]/switch:w-3.5 group-data-[focus-visible]/switch:opacity-100'
|
|
83
|
+
|
|
66
84
|
export function SectionTabs({
|
|
67
85
|
tabs,
|
|
68
86
|
active,
|
|
@@ -105,6 +123,7 @@ export function SectionTabs({
|
|
|
105
123
|
) : null}
|
|
106
124
|
{tabs.map((tab) => {
|
|
107
125
|
const selected = active === tab.key
|
|
126
|
+
const switchHint = Boolean(tab.switchable && selected && !tab.disabled)
|
|
108
127
|
return (
|
|
109
128
|
<Button
|
|
110
129
|
key={tab.key}
|
|
@@ -112,9 +131,20 @@ export function SectionTabs({
|
|
|
112
131
|
isDisabled={tab.disabled}
|
|
113
132
|
aria-pressed={selected}
|
|
114
133
|
onPress={() => onChange(tab.key)}
|
|
115
|
-
|
|
134
|
+
data-switchable={switchHint || undefined}
|
|
135
|
+
className={[tabBase, selected && !tab.disabled ? tabActive : '', switchHint ? tabSwitchable : '']
|
|
136
|
+
.filter(Boolean)
|
|
137
|
+
.join(' ')}
|
|
116
138
|
>
|
|
117
|
-
|
|
139
|
+
{switchHint ? (
|
|
140
|
+
// React Aria's Button drops `title`, so the native hint rides on the content.
|
|
141
|
+
<span title={tab.switchHintLabel} className="inline-flex items-center gap-3">
|
|
142
|
+
<SwitchViewIcon className={tabSwitchIcon} />
|
|
143
|
+
<span>{tab.label}</span>
|
|
144
|
+
</span>
|
|
145
|
+
) : (
|
|
146
|
+
<span>{tab.label}</span>
|
|
147
|
+
)}
|
|
118
148
|
{tab.disabled ? (
|
|
119
149
|
<span className="inline-flex items-center rounded-full bg-surface-muted px-1.5 py-0.5 text-micro font-medium leading-none text-fg-muted">
|
|
120
150
|
{soonLabel}
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
} from 'react-aria-components'
|
|
15
15
|
import { uic } from '../utils/uic'
|
|
16
16
|
import { useInFocusOverlay } from './focus-context'
|
|
17
|
-
import type
|
|
17
|
+
import { fieldDescriptionClass, fieldErrorClass, type FieldAppearance } from './field-appearance'
|
|
18
18
|
|
|
19
19
|
const SelectAppearanceContext = createContext<FieldAppearance>('outlined')
|
|
20
20
|
const filledPopoverStyle: CSSProperties & { '--select-popup-max-width': string } = {
|
|
@@ -143,11 +143,11 @@ export const Select = <T extends object>({
|
|
|
143
143
|
</SelectListBox>
|
|
144
144
|
)
|
|
145
145
|
const desc = description ? (
|
|
146
|
-
<Text slot="description" className=
|
|
146
|
+
<Text slot="description" className={fieldDescriptionClass}>
|
|
147
147
|
{description}
|
|
148
148
|
</Text>
|
|
149
149
|
) : null
|
|
150
|
-
const err = <FieldError className=
|
|
150
|
+
const err = <FieldError className={fieldErrorClass}>{errorMessage}</FieldError>
|
|
151
151
|
|
|
152
152
|
return (
|
|
153
153
|
<SelectAppearanceContext.Provider value={appearance}>
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { IconProps } from './icons'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Original Manager glyphs that the curated 24px line set in `icons.tsx` has no
|
|
5
|
+
* equivalent for. The Radix ones are Radix Icons 1.3.2 (MIT) on their 15px grid,
|
|
6
|
+
* filled with `currentColor`, exactly as the Manager rendered them (the document
|
|
7
|
+
* library Scope / Role / Status column headers). `SwitchViewIcon` is the Manager's
|
|
8
|
+
* 16px "switch view" burst shown on a selected switchable section tab.
|
|
9
|
+
*
|
|
10
|
+
* All are decorative (`aria-hidden`); size via width/height or `className`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Radix `LayersIcon` (document library "Scope" header). */
|
|
14
|
+
export const LayersIcon = (props: IconProps) => (
|
|
15
|
+
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true" {...props}>
|
|
16
|
+
<path
|
|
17
|
+
d="M7.75432 0.819537C7.59742 0.726821 7.4025 0.726821 7.24559 0.819537L1.74559 4.06954C1.59336 4.15949 1.49996 4.32317 1.49996 4.5C1.49996 4.67683 1.59336 4.84051 1.74559 4.93046L7.24559 8.18046C7.4025 8.27318 7.59742 8.27318 7.75432 8.18046L13.2543 4.93046C13.4066 4.84051 13.5 4.67683 13.5 4.5C13.5 4.32317 13.4066 4.15949 13.2543 4.06954L7.75432 0.819537ZM7.49996 7.16923L2.9828 4.5L7.49996 1.83077L12.0171 4.5L7.49996 7.16923ZM1.5695 7.49564C1.70998 7.2579 2.01659 7.17906 2.25432 7.31954L7.49996 10.4192L12.7456 7.31954C12.9833 7.17906 13.2899 7.2579 13.4304 7.49564C13.5709 7.73337 13.4921 8.03998 13.2543 8.18046L7.75432 11.4305C7.59742 11.5232 7.4025 11.5232 7.24559 11.4305L1.74559 8.18046C1.50786 8.03998 1.42901 7.73337 1.5695 7.49564ZM1.56949 10.4956C1.70998 10.2579 2.01658 10.1791 2.25432 10.3195L7.49996 13.4192L12.7456 10.3195C12.9833 10.1791 13.2899 10.2579 13.4304 10.4956C13.5709 10.7334 13.4921 11.04 13.2543 11.1805L7.75432 14.4305C7.59742 14.5232 7.4025 14.5232 7.24559 14.4305L1.74559 11.1805C1.50785 11.04 1.42901 10.7334 1.56949 10.4956Z"
|
|
18
|
+
fill="currentColor"
|
|
19
|
+
fillRule="evenodd"
|
|
20
|
+
clipRule="evenodd"
|
|
21
|
+
/>
|
|
22
|
+
</svg>
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
/** Radix `BadgeIcon` (document library "Role" header). */
|
|
26
|
+
export const BadgeIcon = (props: IconProps) => (
|
|
27
|
+
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true" {...props}>
|
|
28
|
+
<path
|
|
29
|
+
d="M3.5 6H11.5C12.3284 6 13 6.67157 13 7.5C13 8.32843 12.3284 9 11.5 9H3.5C2.67157 9 2 8.32843 2 7.5C2 6.67157 2.67157 6 3.5 6ZM1 7.5C1 6.11929 2.11929 5 3.5 5H11.5C12.8807 5 14 6.11929 14 7.5C14 8.88071 12.8807 10 11.5 10H3.5C2.11929 10 1 8.88071 1 7.5ZM4.5 7C4.22386 7 4 7.22386 4 7.5C4 7.77614 4.22386 8 4.5 8H10.5C10.7761 8 11 7.77614 11 7.5C11 7.22386 10.7761 7 10.5 7H4.5Z"
|
|
30
|
+
fill="currentColor"
|
|
31
|
+
fillRule="evenodd"
|
|
32
|
+
clipRule="evenodd"
|
|
33
|
+
/>
|
|
34
|
+
</svg>
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
/** Radix `CheckCircledIcon` (document library "Status" header). */
|
|
38
|
+
export const CheckCircledIcon = (props: IconProps) => (
|
|
39
|
+
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true" {...props}>
|
|
40
|
+
<path
|
|
41
|
+
d="M7.49991 0.877045C3.84222 0.877045 0.877075 3.84219 0.877075 7.49988C0.877075 11.1575 3.84222 14.1227 7.49991 14.1227C11.1576 14.1227 14.1227 11.1575 14.1227 7.49988C14.1227 3.84219 11.1576 0.877045 7.49991 0.877045ZM1.82708 7.49988C1.82708 4.36686 4.36689 1.82704 7.49991 1.82704C10.6329 1.82704 13.1727 4.36686 13.1727 7.49988C13.1727 10.6329 10.6329 13.1727 7.49991 13.1727C4.36689 13.1727 1.82708 10.6329 1.82708 7.49988ZM10.1589 5.53774C10.3178 5.31191 10.2636 5.00001 10.0378 4.84109C9.81194 4.68217 9.50004 4.73642 9.34112 4.96225L6.51977 8.97154L5.35681 7.78706C5.16334 7.59002 4.84677 7.58711 4.64973 7.78058C4.45268 7.97404 4.44978 8.29061 4.64325 8.48765L6.22658 10.1003C6.33054 10.2062 6.47617 10.2604 6.62407 10.2483C6.77197 10.2363 6.90686 10.1591 6.99226 10.0377L10.1589 5.53774Z"
|
|
42
|
+
fill="currentColor"
|
|
43
|
+
fillRule="evenodd"
|
|
44
|
+
clipRule="evenodd"
|
|
45
|
+
/>
|
|
46
|
+
</svg>
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
/** Manager "switch view" burst (16px grid, 1.5px round stroke). */
|
|
50
|
+
export const SwitchViewIcon = (props: IconProps) => (
|
|
51
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true" {...props}>
|
|
52
|
+
<path d="M8 2V6M8 10V14M2 8H6M10 8H14" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
53
|
+
<path
|
|
54
|
+
d="M11.657 4.343L13.071 2.929M2.929 13.071L4.343 11.657M13.071 13.071L11.657 11.657M4.343 4.343L2.929 2.929"
|
|
55
|
+
stroke="currentColor"
|
|
56
|
+
strokeWidth="1.5"
|
|
57
|
+
strokeLinecap="round"
|
|
58
|
+
/>
|
|
59
|
+
</svg>
|
|
60
|
+
)
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
TagList,
|
|
10
10
|
Text,
|
|
11
11
|
} from 'react-aria-components'
|
|
12
|
+
import { fieldDescriptionClass } from './field-appearance'
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* TagGroup + Tag — a set of chips (labels, filters, or a removable tag input).
|
|
@@ -26,7 +27,7 @@ export const TagGroup = ({ label, description, children, ...props }: TagGroupPro
|
|
|
26
27
|
{label ? <Label className="text-heading5 font-medium text-fg">{label}</Label> : null}
|
|
27
28
|
<TagList className="flex flex-wrap gap-2 outline-none">{children}</TagList>
|
|
28
29
|
{description ? (
|
|
29
|
-
<Text slot="description" className=
|
|
30
|
+
<Text slot="description" className={fieldDescriptionClass}>
|
|
30
31
|
{description}
|
|
31
32
|
</Text>
|
|
32
33
|
) : null}
|
|
@@ -37,7 +37,7 @@ export const CatalogSummaryGrid = uic('div', { displayName: 'CatalogSummaryGrid'
|
|
|
37
37
|
const SummaryHead = uic('div', { displayName: 'CatalogSummaryHead', baseClass: 'flex w-full min-w-0 items-center justify-between gap-3' })
|
|
38
38
|
export const CatalogSummaryBadge = uic('span', { displayName: 'CatalogSummaryBadge', baseClass: 'ml-3 flex h-6 shrink-0 items-center rounded-2xl px-2.5 text-micro font-medium leading-5', style: { fontFeatureSettings: '"liga" off, "clig" off' }, variants: { color: { green: 'bg-brand-green text-fg-on-brand', grey: 'bg-surface-muted text-(--color-neutral-600)' } }, defaultVariants: { color: 'grey' } })
|
|
39
39
|
const SummaryTitle = uic('h2', { displayName: 'CatalogSummaryTitle', baseClass: 'm-0 min-w-0 truncate text-heading5 font-medium' })
|
|
40
|
-
const SummaryTail = uic('div', { displayName: 'CatalogSummaryTail', baseClass: 'mt-auto flex flex-col gap-1 text-small font-normal text-fg-
|
|
40
|
+
const SummaryTail = uic('div', { displayName: 'CatalogSummaryTail', baseClass: 'mt-auto flex flex-col gap-1 text-small font-normal text-fg-muted' })
|
|
41
41
|
/** Source gs Tile image cover: caller artwork under a transparent → 30% → 50% black gradient. */
|
|
42
42
|
function CatalogCover({ preview }: { preview: ReactNode }) {
|
|
43
43
|
return <>
|
|
@@ -8,7 +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
|
+
import { fieldDescriptionClass, fieldErrorClass, filledFieldClasses, type FieldAppearance } from './field-appearance'
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Textarea — labelled multi-line text field.
|
|
@@ -64,10 +64,10 @@ export const Textarea = ({ label, description, errorMessage, placeholder, rows,
|
|
|
64
64
|
<Label className="text-panel-heading font-medium text-fg">{label}</Label>
|
|
65
65
|
<StyledTextArea className={textAreaClassName} placeholder={placeholder} rows={rows} appearance={appearance} />
|
|
66
66
|
{description ? (
|
|
67
|
-
<Text slot="description" className=
|
|
67
|
+
<Text slot="description" className={fieldDescriptionClass}>
|
|
68
68
|
{description}
|
|
69
69
|
</Text>
|
|
70
70
|
) : null}
|
|
71
|
-
<FieldError className=
|
|
71
|
+
<FieldError className={fieldErrorClass}>{errorMessage}</FieldError>
|
|
72
72
|
</TextField>
|
|
73
73
|
)
|
package/src/index.ts
CHANGED
|
@@ -33,6 +33,8 @@ export * from "./components/document-upload-panel";
|
|
|
33
33
|
export * from "./components/csv-binding-presentation";
|
|
34
34
|
export * from "./components/dialog-action-button";
|
|
35
35
|
export * from "./components/reload-icon";
|
|
36
|
+
export * from "./components/source-icons";
|
|
37
|
+
export * from "./components/animated-summary-text";
|
|
36
38
|
export * from "./components/date-field";
|
|
37
39
|
export * from "./components/date-picker";
|
|
38
40
|
export * from "./components/dialog";
|