@nocobase/app-template-default 1.0.0-beta.76 → 1.0.0-beta.77

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.
@@ -17,6 +17,8 @@ export interface DefaultAccessChoice {
17
17
  export interface DefaultAccessScopeControl {
18
18
  readonly id: string;
19
19
  readonly action: string;
20
+ /** Stable operation identity; titles alone may be shared by different actions. */
21
+ readonly actionId?: string;
20
22
  readonly scope: string;
21
23
  readonly value: string;
22
24
  readonly options: readonly DefaultAccessChoice[];
@@ -37,6 +39,8 @@ export interface DefaultAccessEditorProps {
37
39
  readonly resource: string;
38
40
  readonly chooseResource: string;
39
41
  readonly action: string;
42
+ /** Stable operation identity; titles alone may be shared by different actions. */
43
+ readonly actionId?: string;
40
44
  readonly scope: string;
41
45
  readonly noScopes: string;
42
46
  };
@@ -61,6 +65,16 @@ export function DefaultAccessEditor({
61
65
  onScopeChange,
62
66
  }: DefaultAccessEditorProps): ReactElement {
63
67
  const id = useId();
68
+ const groups = new Map<
69
+ string,
70
+ { action: string; scopes: DefaultAccessScopeControl[] }
71
+ >();
72
+ for (const scope of scopes) {
73
+ const key = scope.actionId ?? scope.action;
74
+ const group = groups.get(key);
75
+ if (group) group.scopes.push(scope);
76
+ else groups.set(key, { action: scope.action, scopes: [scope] });
77
+ }
64
78
  return (
65
79
  <div className='flex min-w-0 flex-col gap-6'>
66
80
  <div className='grid gap-4 sm:grid-cols-2'>
@@ -119,63 +133,76 @@ export function DefaultAccessEditor({
119
133
  )}
120
134
  </div>
121
135
  </div>
122
- <div className='overflow-hidden rounded-lg border'>
136
+ <div className='grid min-w-0 gap-3'>
123
137
  {scopes.length ? (
124
- scopes.map((scope) => (
125
- <div
126
- key={scope.id}
127
- className='grid min-w-0 gap-3 border-b p-4 last:border-b-0 sm:grid-cols-3'
138
+ [...groups].map(([key, group]) => (
139
+ <section
140
+ key={key}
141
+ aria-labelledby={`${id}-action-${encodeURIComponent(key)}`}
142
+ className='grid min-w-0 gap-3 rounded-lg border bg-card p-3 sm:p-4'
128
143
  >
129
- <div className='min-w-0 break-words text-sm font-medium'>
130
- {scope.action}
131
- </div>
132
- <div className='flex min-w-0 flex-col gap-2 sm:col-span-2'>
133
- <Label htmlFor={`${id}-${scope.id}`}>
134
- {scope.scope || labels.scope}
135
- </Label>
136
- <Select
137
- value={scope.value}
138
- items={scope.options}
139
- disabled={readOnly}
140
- onValueChange={(value) => {
141
- if (value !== null) onScopeChange(scope.id, value);
142
- }}
143
- >
144
- <SelectTrigger
145
- id={`${id}-${scope.id}`}
146
- className='w-full'
147
- aria-label={`${scope.action} · ${scope.scope || labels.scope}`}
148
- aria-invalid={Boolean(scope.error)}
149
- aria-describedby={
150
- scope.error ? `${id}-${scope.id}-error` : undefined
151
- }
152
- >
153
- <SelectValue />
154
- </SelectTrigger>
155
- <SelectContent className='w-auto max-w-[min(var(--container-sm),var(--available-width))] min-w-(--anchor-width) [&_[data-slot=select-item]>:first-child]:whitespace-normal'>
156
- <SelectGroup>
157
- {scope.options.map((option) => (
158
- <SelectItem key={option.value} value={option.value}>
159
- {option.label}
160
- </SelectItem>
161
- ))}
162
- </SelectGroup>
163
- </SelectContent>
164
- </Select>
165
- {scope.error && (
166
- <p
167
- id={`${id}-${scope.id}-error`}
168
- className='text-sm text-destructive'
169
- >
170
- {scope.error}
171
- </p>
172
- )}
173
- {scope.configuration}
144
+ <h3
145
+ id={`${id}-action-${encodeURIComponent(key)}`}
146
+ className='text-sm font-medium break-words'
147
+ >
148
+ {group.action}
149
+ </h3>
150
+ <div className='grid min-w-0 grid-cols-[repeat(auto-fit,minmax(min(100%,16rem),1fr))] items-start gap-3'>
151
+ {group.scopes.map((scope) => (
152
+ <div key={scope.id} className='grid min-w-0 gap-2'>
153
+ <Label
154
+ htmlFor={`${id}-${scope.id}`}
155
+ className='text-xs text-muted-foreground'
156
+ >
157
+ {scope.scope || labels.scope}
158
+ </Label>
159
+ <Select
160
+ value={scope.value}
161
+ items={scope.options}
162
+ disabled={readOnly}
163
+ onValueChange={(value) => {
164
+ if (value !== null) onScopeChange(scope.id, value);
165
+ }}
166
+ >
167
+ <SelectTrigger
168
+ id={`${id}-${scope.id}`}
169
+ className='w-full'
170
+ aria-label={`${scope.action} · ${scope.scope || labels.scope}`}
171
+ aria-invalid={Boolean(scope.error)}
172
+ aria-describedby={
173
+ scope.error ? `${id}-${scope.id}-error` : undefined
174
+ }
175
+ >
176
+ <SelectValue />
177
+ </SelectTrigger>
178
+ <SelectContent className='w-auto max-w-[min(var(--container-sm),var(--available-width))] min-w-(--anchor-width) [&_[data-slot=select-item]>:first-child]:whitespace-normal'>
179
+ <SelectGroup>
180
+ {scope.options.map((option) => (
181
+ <SelectItem key={option.value} value={option.value}>
182
+ {option.label}
183
+ </SelectItem>
184
+ ))}
185
+ </SelectGroup>
186
+ </SelectContent>
187
+ </Select>
188
+ {scope.error && (
189
+ <p
190
+ id={`${id}-${scope.id}-error`}
191
+ className='text-sm text-destructive'
192
+ >
193
+ {scope.error}
194
+ </p>
195
+ )}
196
+ {scope.configuration}
197
+ </div>
198
+ ))}
174
199
  </div>
175
- </div>
200
+ </section>
176
201
  ))
177
202
  ) : (
178
- <p className='p-4 text-sm text-muted-foreground'>{labels.noScopes}</p>
203
+ <p className='rounded-lg border p-4 text-sm text-muted-foreground'>
204
+ {labels.noScopes}
205
+ </p>
179
206
  )}
180
207
  </div>
181
208
  </div>
@@ -0,0 +1,200 @@
1
+ import {
2
+ useEffect,
3
+ useRef,
4
+ useState,
5
+ type CSSProperties,
6
+ type KeyboardEvent,
7
+ type MouseEvent,
8
+ type ReactElement,
9
+ type ReactNode,
10
+ } from 'react';
11
+ import { cn } from 'cn';
12
+
13
+ import {
14
+ Popover,
15
+ PopoverContent,
16
+ PopoverHeader,
17
+ PopoverTitle,
18
+ PopoverTrigger,
19
+ } from '#components/ui/popover';
20
+
21
+ // The last line fades out rather than ending in an ellipsis mid-sentence.
22
+ const FADE = '[mask-image:linear-gradient(to_bottom,black_55%,transparent)]';
23
+ // Cut at a height of whole lines rather than with line-clamp, which would add an ellipsis under the fade.
24
+ const CLAMP = 'overflow-hidden max-h-[calc(var(--long-text-lines)*1lh)]';
25
+
26
+ /**
27
+ * Whether the element's content is taller than the lines it shows, measured again on every resize. The element comes
28
+ * through a callback ref, so measuring follows it when it is replaced, as LongTextPreview's is once it overflows and
29
+ * moves into the pop-up's trigger.
30
+ */
31
+ function useOverflows(
32
+ watch: unknown,
33
+ ): [(element: HTMLDivElement | null) => void, boolean | undefined] {
34
+ const [element, setElement] = useState<HTMLDivElement | null>(null);
35
+ // Undefined until measured, so a remounted element does not first report that it fits.
36
+ const [overflows, setOverflows] = useState<boolean | undefined>(undefined);
37
+ useEffect(() => {
38
+ if (!element || typeof ResizeObserver === 'undefined') return;
39
+ const measure = (): void =>
40
+ setOverflows(element.scrollHeight > element.clientHeight + 1);
41
+ // The observer reports once when it starts, then on every resize.
42
+ const observer = new ResizeObserver(measure);
43
+ observer.observe(element);
44
+ return () => observer.disconnect();
45
+ }, [element, watch]);
46
+ return [setElement, overflows];
47
+ }
48
+
49
+ export interface LongTextProps {
50
+ /** The text, or rendered content such as Markdown. A string keeps its line breaks. */
51
+ readonly children: ReactNode;
52
+ /** How many lines show while folded. */
53
+ readonly lines?: number;
54
+ readonly className?: string;
55
+ }
56
+
57
+ /**
58
+ * A long text where there is room for it, such as the description under a page's title or a comment: up to `lines`
59
+ * lines, the last fading out while there is more, unfolding in place on a click and folding again on the next. It
60
+ * reacts only when it overflows; a double-click or a text selection does not fold it, and Enter or Space works as a
61
+ * click.
62
+ */
63
+ export function LongText({
64
+ children,
65
+ lines = 5,
66
+ className,
67
+ }: LongTextProps): ReactElement {
68
+ const [open, setOpen] = useState(false);
69
+ const [ref, measured] = useOverflows(open);
70
+ const overflows = measured === true;
71
+ const foldable = overflows || open;
72
+ const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
73
+ useEffect(() => () => clearTimeout(timerRef.current), []);
74
+ const toggle = (): void => setOpen((current) => !current);
75
+ return (
76
+ <div
77
+ ref={ref}
78
+ role={foldable ? 'button' : undefined}
79
+ tabIndex={foldable ? 0 : undefined}
80
+ aria-expanded={foldable ? open : undefined}
81
+ style={{ '--long-text-lines': lines } as CSSProperties}
82
+ className={cn(
83
+ 'leading-relaxed break-words whitespace-pre-line',
84
+ foldable && 'cursor-pointer',
85
+ !open && CLAMP,
86
+ !open && overflows && FADE,
87
+ className,
88
+ )}
89
+ onClick={(event: MouseEvent<HTMLDivElement>) => {
90
+ // A double-click selects a word rather than folding twice, and a selection is not a click.
91
+ if (!foldable || event.detail > 1) return;
92
+ if (!window.getSelection()?.isCollapsed) return;
93
+ clearTimeout(timerRef.current);
94
+ timerRef.current = setTimeout(toggle, 220);
95
+ }}
96
+ onDoubleClick={() => clearTimeout(timerRef.current)}
97
+ onKeyDown={(event: KeyboardEvent<HTMLDivElement>) => {
98
+ if (!foldable || event.target !== event.currentTarget) return;
99
+ if (event.key !== 'Enter' && event.key !== ' ') return;
100
+ event.preventDefault();
101
+ toggle();
102
+ }}
103
+ >
104
+ {children}
105
+ </div>
106
+ );
107
+ }
108
+
109
+ export interface FadingTextProps {
110
+ /** The text, or rendered content. A string keeps its line breaks. */
111
+ readonly children: ReactNode;
112
+ /** How many lines show. */
113
+ readonly lines?: number;
114
+ readonly className?: string;
115
+ /** Told whether the text overflows, each time that changes. */
116
+ readonly onOverflowChange?: (overflows: boolean) => void;
117
+ }
118
+
119
+ /**
120
+ * The part both components are drawn with, for a consumer that opens the text its own way, such as a field editing it
121
+ * in a pop-up: up to `lines` lines, the last fading out while there is more, and nothing that reacts to a click.
122
+ */
123
+ export function FadingText({
124
+ children,
125
+ lines = 3,
126
+ className,
127
+ onOverflowChange,
128
+ }: FadingTextProps): ReactElement {
129
+ const [ref, overflows] = useOverflows(children);
130
+ useEffect(() => {
131
+ if (overflows !== undefined) onOverflowChange?.(overflows);
132
+ }, [overflows, onOverflowChange]);
133
+ return (
134
+ <div
135
+ ref={ref}
136
+ style={{ '--long-text-lines': lines } as CSSProperties}
137
+ className={cn(
138
+ 'text-left leading-relaxed break-words whitespace-pre-line',
139
+ CLAMP,
140
+ overflows && FADE,
141
+ className,
142
+ )}
143
+ >
144
+ {children}
145
+ </div>
146
+ );
147
+ }
148
+
149
+ export interface LongTextPreviewProps {
150
+ /** The text, or rendered content. A string keeps its line breaks. */
151
+ readonly children: ReactNode;
152
+ /** What the text is, such as "Introduction": the heading of the pop-up that shows it whole. */
153
+ readonly label: ReactNode;
154
+ /** How many lines show in the column. */
155
+ readonly lines?: number;
156
+ readonly className?: string;
157
+ }
158
+
159
+ /**
160
+ * A long text in a narrow column, such as a record's introduction or address beside its page: up to `lines` lines in
161
+ * muted text, the last fading out while there is more, the whole text opening in a pop-up beside it on a click, so
162
+ * the column never grows and pushes what is under it. It reacts only when it overflows.
163
+ */
164
+ export function LongTextPreview({
165
+ children,
166
+ label,
167
+ lines = 3,
168
+ className,
169
+ }: LongTextPreviewProps): ReactElement {
170
+ const [overflows, setOverflows] = useState(false);
171
+ const preview = (
172
+ <FadingText
173
+ lines={lines}
174
+ className={cn('text-muted-foreground', className)}
175
+ onOverflowChange={setOverflows}
176
+ >
177
+ {children}
178
+ </FadingText>
179
+ );
180
+ if (!overflows) return preview;
181
+ return (
182
+ <Popover>
183
+ <PopoverTrigger className='block w-full cursor-pointer rounded-md text-left'>
184
+ {preview}
185
+ </PopoverTrigger>
186
+ <PopoverContent
187
+ align='start'
188
+ side='left'
189
+ className='max-h-[min(36rem,70vh)] w-[28rem] max-w-[calc(100vw-2rem)] overflow-y-auto'
190
+ >
191
+ <PopoverHeader>
192
+ <PopoverTitle>{label}</PopoverTitle>
193
+ </PopoverHeader>
194
+ <div className='text-sm leading-relaxed break-words whitespace-pre-line'>
195
+ {children}
196
+ </div>
197
+ </PopoverContent>
198
+ </Popover>
199
+ );
200
+ }