@domicile-desktop/component-library 0.0.0-alpha-ddde2bfbbaad → 0.0.0-alpha-da1cf1c5441c

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -2,8 +2,8 @@
2
2
  "dependencies": {
3
3
  "@base-ui/react": "^1.8.0",
4
4
  "@cprussin/option-result": "^2.0.1",
5
- "@domicile-desktop/sdk": "0.0.0-alpha-ddde2bfbbaad",
6
- "@domicile-desktop/system-apps": "0.0.0-alpha-ddde2bfbbaad",
5
+ "@domicile-desktop/sdk": "0.0.0-alpha-da1cf1c5441c",
6
+ "@domicile-desktop/system-apps": "0.0.0-alpha-da1cf1c5441c",
7
7
  "@phosphor-icons/react": "^2.1.10"
8
8
  },
9
9
  "description": "React components and the Panda CSS design system that Domicile's shells are built from.",
@@ -30,6 +30,7 @@
30
30
  },
31
31
  "exports": {
32
32
  "./Accordion": "./src/Accordion/Accordion.tsx",
33
+ "./AppChooser": "./src/AppChooser/AppChooser.tsx",
33
34
  "./Autocomplete": "./src/Autocomplete/Autocomplete.tsx",
34
35
  "./Avatar": "./src/Avatar/Avatar.tsx",
35
36
  "./Button": "./src/Button/Button.tsx",
@@ -102,5 +103,5 @@
102
103
  "test:unit": "bun test"
103
104
  },
104
105
  "type": "module",
105
- "version": "0.0.0-alpha-ddde2bfbbaad"
106
+ "version": "0.0.0-alpha-da1cf1c5441c"
106
107
  }
@@ -0,0 +1,186 @@
1
+ import type { System } from "@domicile-desktop/sdk/system";
2
+ import { AppWindowIcon } from "@phosphor-icons/react/dist/ssr/AppWindow";
3
+ import { useId, useState } from "react";
4
+ import { css } from "../../styled-system/css";
5
+ import { flex, hstack } from "../../styled-system/patterns";
6
+ import { Button } from "../Button/Button";
7
+ import { keepInView, stepOf, steppedTo } from "../list-walk";
8
+ import { ModalDialog } from "../ModalDialog/ModalDialog";
9
+ import { preselected } from "./app-choices";
10
+ import { useDescribedChoices } from "./useDescribedChoices";
11
+
12
+ const ICON_SIZE = 24;
13
+
14
+ type Props = {
15
+ /** Desktop file IDs without `.desktop`, in the order offered. */
16
+ choices: readonly string[];
17
+ /** The type opened, whose defaults start picked; none when `undefined`. */
18
+ contentType: string | undefined;
19
+ /** A line under the title, such as who asks. */
20
+ description?: string | undefined;
21
+ /** The application chosen last time, which starts picked. */
22
+ lastChoice?: string | undefined;
23
+ onCancel: () => void;
24
+ /** Called with the ID of the application to open with. */
25
+ onChoose: (id: string) => void;
26
+ screen: string | undefined;
27
+ /** Where the applications' desktop entries are read. */
28
+ system: System;
29
+ title: string;
30
+ };
31
+
32
+ /**
33
+ * Pick an application to open a file or URI with. The last choice, else the
34
+ * type's default, else the first offered, starts picked. Dismissing it
35
+ * cancels.
36
+ */
37
+ export const AppChooser = ({
38
+ choices,
39
+ contentType,
40
+ description,
41
+ lastChoice,
42
+ onCancel,
43
+ onChoose,
44
+ screen,
45
+ system,
46
+ title,
47
+ }: Props) => {
48
+ const listId = useId();
49
+ const described = useDescribedChoices(system, choices, contentType);
50
+ const [picked, setPicked] = useState<string | undefined>();
51
+ const apps = described?.apps ?? [];
52
+ const first =
53
+ described === undefined
54
+ ? undefined
55
+ : preselected(choices, lastChoice, described.defaults);
56
+ // A pick the caller has since taken away no longer counts.
57
+ const chosen =
58
+ picked !== undefined && choices.includes(picked) ? picked : first;
59
+ const at = apps.findIndex(({ id }) => id === chosen);
60
+ const choose = (id: string | undefined) => {
61
+ if (id !== undefined) {
62
+ onChoose(id);
63
+ }
64
+ };
65
+ return (
66
+ <ModalDialog
67
+ closeButton={false}
68
+ footer={
69
+ <>
70
+ <Button onClick={onCancel} variant="outline">
71
+ Cancel
72
+ </Button>
73
+ <Button
74
+ disabled={chosen === undefined}
75
+ onClick={() => {
76
+ choose(chosen);
77
+ }}
78
+ >
79
+ Open
80
+ </Button>
81
+ </>
82
+ }
83
+ onOpenChange={(open) => {
84
+ if (!open) {
85
+ onCancel();
86
+ }
87
+ }}
88
+ open
89
+ screen={screen}
90
+ title={title}
91
+ >
92
+ {description !== undefined && (
93
+ <p className={descriptionStyles}>{description}</p>
94
+ )}
95
+ <div
96
+ aria-activedescendant={at === -1 ? undefined : optionId(listId, at)}
97
+ aria-label="Applications"
98
+ className={listStyles}
99
+ onKeyDown={(event) => {
100
+ const step = stepOf(event);
101
+ const next = apps[steppedTo(at, step ?? 0, apps.length)];
102
+ if (event.key === "Enter") {
103
+ event.preventDefault();
104
+ choose(chosen);
105
+ } else if (step !== undefined && next !== undefined) {
106
+ event.preventDefault();
107
+ setPicked(next.id);
108
+ }
109
+ }}
110
+ role="listbox"
111
+ tabIndex={0}
112
+ >
113
+ {apps.map((app, index) => (
114
+ // biome-ignore lint/a11y/useKeyWithClickEvents: the listbox handles the keyboard via `aria-activedescendant`
115
+ <div
116
+ aria-selected={app.id === chosen}
117
+ className={optionStyles}
118
+ id={optionId(listId, index)}
119
+ key={app.id}
120
+ onClick={() => {
121
+ setPicked(app.id);
122
+ }}
123
+ onDoubleClick={() => {
124
+ choose(app.id);
125
+ }}
126
+ ref={app.id === chosen ? keepInView : undefined}
127
+ role="option"
128
+ tabIndex={-1}
129
+ >
130
+ {app.icon === undefined ? (
131
+ <AppWindowIcon size={ICON_SIZE} />
132
+ ) : (
133
+ <img
134
+ alt=""
135
+ className={iconStyles}
136
+ height={ICON_SIZE}
137
+ src={app.icon}
138
+ width={ICON_SIZE}
139
+ />
140
+ )}
141
+ <span className={nameStyles}>{app.name}</span>
142
+ </div>
143
+ ))}
144
+ </div>
145
+ </ModalDialog>
146
+ );
147
+ };
148
+
149
+ const descriptionStyles = css({ color: "muted", fontSize: "sm", margin: 0 });
150
+
151
+ const listStyles = flex({
152
+ direction: "column",
153
+ gap: 0.5,
154
+ marginBlockStart: 3,
155
+ maxBlockSize: 80,
156
+ overflowY: "auto",
157
+ });
158
+
159
+ // As the file picker's rows.
160
+ const optionStyles = hstack({
161
+ "&[aria-selected=true]": {
162
+ backgroundImage:
163
+ "linear-gradient(to right, color-mix(in oklab, {colors.accent} 30%, transparent), color-mix(in oklab, {colors.accent} 8%, transparent))",
164
+ },
165
+ borderRadius: "md",
166
+ color: "foreground",
167
+ cursor: "pointer",
168
+ gap: 3,
169
+ paddingBlock: 1.5,
170
+ paddingInline: 2,
171
+ transition: "background-color {durations.fast} {easings.out}",
172
+ userSelect: "none",
173
+ });
174
+
175
+ const iconStyles = css({ flexShrink: 0, objectFit: "contain" });
176
+
177
+ const nameStyles = css({
178
+ fontSize: "sm",
179
+ fontWeight: "medium",
180
+ overflow: "hidden",
181
+ textOverflow: "ellipsis",
182
+ whiteSpace: "nowrap",
183
+ });
184
+
185
+ const optionId = (listId: string, index: number): string =>
186
+ `${listId}-${String(index)}`;
@@ -4,17 +4,7 @@ import type {
4
4
  } from "@domicile-desktop/sdk/portal";
5
5
  import { PortalAnswer as Answer } from "@domicile-desktop/sdk/portal";
6
6
  import type { System } from "@domicile-desktop/sdk/system";
7
- import { AppWindowIcon } from "@phosphor-icons/react/dist/ssr/AppWindow";
8
- import { useId, useState } from "react";
9
- import { css } from "../../styled-system/css";
10
- import { flex, hstack } from "../../styled-system/patterns";
11
- import { Button } from "../Button/Button";
12
- import { keepInView, stepOf, steppedTo } from "../list-walk";
13
- import { ModalDialog } from "../ModalDialog/ModalDialog";
14
- import { preselected } from "./app-choices";
15
- import { useDescribedChoices } from "./useDescribedChoices";
16
-
17
- const ICON_SIZE = 24;
7
+ import { AppChooser } from "../AppChooser/AppChooser";
18
8
 
19
9
  type Props = {
20
10
  answer: (answer: PortalAnswer) => void;
@@ -26,162 +16,33 @@ type Props = {
26
16
  system: System;
27
17
  };
28
18
 
29
- /**
30
- * Pick an application to open a file or URI with. The last choice, else the
31
- * type's default, starts picked. Dismissing it cancels.
32
- */
19
+ /** The AppChooser portal's {@link AppChooser}. Dismissing it cancels. */
33
20
  export const AppChooserDialog = ({
34
21
  answer,
35
22
  asker,
36
23
  body,
37
24
  screen,
38
25
  system,
39
- }: Props) => {
40
- const listId = useId();
41
- const described = useDescribedChoices(system, body.choices, body.contentType);
42
- const [picked, setPicked] = useState<string | undefined>();
43
- const apps = described?.apps ?? [];
44
- const first =
45
- described === undefined
46
- ? undefined
47
- : preselected(body.choices, body.lastChoice, described.defaults);
48
- // A pick the application has since taken away no longer counts.
49
- const chosen =
50
- picked !== undefined && body.choices.includes(picked) ? picked : first;
51
- const at = apps.findIndex(({ id }) => id === chosen);
52
- const choose = (id: string | undefined) => {
53
- if (id !== undefined) {
26
+ }: Props) => (
27
+ <AppChooser
28
+ choices={body.choices}
29
+ contentType={body.contentType}
30
+ description={`${asker} asks`}
31
+ lastChoice={body.lastChoice}
32
+ onCancel={() => {
33
+ answer(Answer.Canceled());
34
+ }}
35
+ onChoose={(id) => {
54
36
  answer(Answer.AppChooser(id));
55
- }
56
- };
57
- return (
58
- <ModalDialog
59
- closeButton={false}
60
- footer={
61
- <>
62
- <Button
63
- onClick={() => {
64
- answer(Answer.Canceled());
65
- }}
66
- variant="outline"
67
- >
68
- Cancel
69
- </Button>
70
- <Button
71
- disabled={chosen === undefined}
72
- onClick={() => {
73
- choose(chosen);
74
- }}
75
- >
76
- Open
77
- </Button>
78
- </>
79
- }
80
- onOpenChange={(open) => {
81
- if (!open) {
82
- answer(Answer.Canceled());
83
- }
84
- }}
85
- open
86
- screen={screen}
87
- title={titleOf(body)}
88
- >
89
- <p className={askerStyles}>{`${asker} asks`}</p>
90
- <div
91
- aria-activedescendant={at === -1 ? undefined : optionId(listId, at)}
92
- aria-label="Applications"
93
- className={listStyles}
94
- onKeyDown={(event) => {
95
- const step = stepOf(event);
96
- const next = apps[steppedTo(at, step ?? 0, apps.length)];
97
- if (event.key === "Enter") {
98
- event.preventDefault();
99
- choose(chosen);
100
- } else if (step !== undefined && next !== undefined) {
101
- event.preventDefault();
102
- setPicked(next.id);
103
- }
104
- }}
105
- role="listbox"
106
- tabIndex={0}
107
- >
108
- {apps.map((app, index) => (
109
- // biome-ignore lint/a11y/useKeyWithClickEvents: the listbox handles the keyboard via `aria-activedescendant`
110
- <div
111
- aria-selected={app.id === chosen}
112
- className={optionStyles}
113
- id={optionId(listId, index)}
114
- key={app.id}
115
- onClick={() => {
116
- setPicked(app.id);
117
- }}
118
- onDoubleClick={() => {
119
- choose(app.id);
120
- }}
121
- ref={app.id === chosen ? keepInView : undefined}
122
- role="option"
123
- tabIndex={-1}
124
- >
125
- {app.icon === undefined ? (
126
- <AppWindowIcon size={ICON_SIZE} />
127
- ) : (
128
- <img
129
- alt=""
130
- className={iconStyles}
131
- height={ICON_SIZE}
132
- src={app.icon}
133
- width={ICON_SIZE}
134
- />
135
- )}
136
- <span className={nameStyles}>{app.name}</span>
137
- </div>
138
- ))}
139
- </div>
140
- </ModalDialog>
141
- );
142
- };
143
-
144
- const askerStyles = css({ color: "muted", fontSize: "sm", margin: 0 });
145
-
146
- const listStyles = flex({
147
- direction: "column",
148
- gap: 0.5,
149
- marginBlockStart: 3,
150
- maxBlockSize: 80,
151
- overflowY: "auto",
152
- });
153
-
154
- // As the file picker's rows.
155
- const optionStyles = hstack({
156
- "&[aria-selected=true]": {
157
- backgroundImage:
158
- "linear-gradient(to right, color-mix(in oklab, {colors.accent} 30%, transparent), color-mix(in oklab, {colors.accent} 8%, transparent))",
159
- },
160
- borderRadius: "md",
161
- color: "foreground",
162
- cursor: "pointer",
163
- gap: 3,
164
- paddingBlock: 1.5,
165
- paddingInline: 2,
166
- transition: "background-color {durations.fast} {easings.out}",
167
- userSelect: "none",
168
- });
169
-
170
- const iconStyles = css({ flexShrink: 0, objectFit: "contain" });
171
-
172
- const nameStyles = css({
173
- fontSize: "sm",
174
- fontWeight: "medium",
175
- overflow: "hidden",
176
- textOverflow: "ellipsis",
177
- whiteSpace: "nowrap",
178
- });
37
+ }}
38
+ screen={screen}
39
+ system={system}
40
+ title={titleOf(body)}
41
+ />
42
+ );
179
43
 
180
44
  /** What the dialog opens, as its title names it. */
181
45
  const titleOf = ({ filename, uri }: AppChooserBody): string => {
182
46
  const opened = filename ?? uri;
183
47
  return opened === undefined ? "Open with" : `Open ${opened} with`;
184
48
  };
185
-
186
- const optionId = (listId: string, index: number): string =>
187
- `${listId}-${String(index)}`;