@tristan2828/ui-foundation 4.12.0 → 4.14.0

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.
@@ -934,15 +934,26 @@ row`), every editable cell is an editable value, exactly as on a view.
934
934
  one is open, "Saving…" and a refusal in the cell. There's no pencil
935
935
  column and no edit route.
936
936
 
937
- - **A cell** is `<EditableValue label="<Column> of <name>" layout="inline"
938
- edit={editInPlace({ ... })}>`, its shown value the cell as it always
939
- was. The label names the column and the row, so its pencil reads "Edit
940
- Colour of Work" and its control "Colour of Work". The kinds are the
941
- same: `text` (Enter saves), `choice` (the list opens with it, non-modal,
942
- a pick saves), `multi`.
937
+ - **A cell is one of the package's three row-edit cells**, or an
938
+ `<EditableValue label="<Column> of <name>" layout="inline"
939
+ edit={editInPlace({ ... })}>` for anything else. The label names the
940
+ column and the row, so its pencil reads "Edit Colour of Work" and its
941
+ control "Colour of Work".
942
+ - **`NameCell`**: one line; Enter saves, the form's rule (`schema`)
943
+ runs first, a taken name's 422 shows in the cell. `children` is how
944
+ the name shows at rest (an icon beside it).
945
+ - **`ChoiceCell<T>`** (`options`, `render`): the list opens with the
946
+ cell, non-modal; a pick saves, closing it with no pick gives up.
947
+ `render` draws a value at rest, on the trigger and in the list.
948
+ - **`LinkCell`** (`linkText`, `emptyLabel`, `save(link | null)`): a
949
+ link opening in a new tab (said to screen readers), or the not-set
950
+ label; edited as one line. A bare `example.com` gains `https://`,
951
+ another scheme (`javascript:`) is refused in the cell, and a blank
952
+ box saves `null`. Clicking the link follows it; the pencil edits.
943
953
  - **A cell is a component** (`<Entity><Field>Cell`) that calls
944
954
  `useEdit<Entity>Field(row.id)` for its save, the view's single-field
945
- save. A hook can't be called inside a column definition.
955
+ save, and renders the package's cell with it. A hook can't be called
956
+ inside a column definition.
946
957
  - **Stable columns and row ids.** Make the column definitions once
947
958
  (at module level, or `useMemo`) and give
948
959
  `DataTable` a `getRowId`: new definitions every render, or rows keyed
@@ -958,24 +969,29 @@ column and no edit route.
958
969
  and its delete together on a wide screen.
959
970
 
960
971
  ```tsx
961
- // A cell is its own component, so it can call the record's save hook.
972
+ import { ChoiceCell, InlineCreate, LinkCell, NameCell } from '@tristan2828/ui-foundation'
973
+
974
+ // Each cell is its own component, so it can call the record's save hook.
962
975
  function CategoryColourCell({ category }: { category: Category }) {
963
976
  const editField = useEditCategoryField(category.id) // optimistic: false, toastOnError: false
964
977
  return (
965
- <EditableValue label={`Colour of ${category.name}`} layout="inline"
966
- edit={editInPlace({
967
- kind: 'choice',
968
- value: category.colour,
969
- schema: categoryFormSchema.shape.colour,
970
- save: (colour) => editField.mutateAsync({ colour }),
971
- control: (props) => (
972
- <CategoryColourSelect id={props.id} value={props.value} onChange={(colour) => props.commit(colour)}
973
- readOnly={props.disabled} defaultOpen modal={false} onOpenChange={(open) => !open && props.cancel()}
974
- aria-label={props.label} aria-describedby={props.describedBy} />
975
- ),
976
- })}>
977
- <ColourSwatch colour={category.colour} />
978
- </EditableValue>
978
+ <ChoiceCell label={`Colour of ${category.name}`} value={category.colour} options={CATEGORY_COLOURS}
979
+ render={(colour) => <ColourSwatch colour={colour} />}
980
+ save={(colour) => editField.mutateAsync({ colour })} />
981
+ )
982
+ }
983
+ function CategoryNameCell({ category }: { category: Category }) {
984
+ const editField = useEditCategoryField(category.id)
985
+ return (
986
+ <NameCell label={`Name of ${category.name}`} value={category.name} schema={categoryFormSchema.shape.name}
987
+ save={(name) => editField.mutateAsync({ name })} />
988
+ )
989
+ }
990
+ function CategoryWebsiteCell({ category }: { category: Category }) {
991
+ const editField = useEditCategoryField(category.id)
992
+ return (
993
+ <LinkCell label={`Website of ${category.name}`} value={category.website} emptyLabel="No website"
994
+ save={(website) => editField.mutateAsync({ website })} />
979
995
  )
980
996
  }
981
997
 
@@ -983,6 +999,7 @@ function CategoryColourCell({ category }: { category: Category }) {
983
999
  const CATEGORY_COLUMNS: LegacyColumnDef<Category, unknown>[] = [
984
1000
  { id: 'name', header: 'Name', cell: ({ row }) => <CategoryNameCell category={row.original} /> },
985
1001
  { id: 'colour', header: 'Colour', cell: ({ row }) => <CategoryColourCell category={row.original} /> },
1002
+ { id: 'website', header: 'Website', cell: ({ row }) => <CategoryWebsiteCell category={row.original} /> },
986
1003
  { id: 'actions', header: () => <span className="sr-only">Actions</span>, cell: ({ row }) => <DeleteCategoryAction category={row.original} /> },
987
1004
  ]
988
1005
 
@@ -11,6 +11,15 @@ export type NavItem = {
11
11
  export type NavSection = {
12
12
  label: string;
13
13
  items: readonly NavItem[];
14
+ /**
15
+ * The label folds the section away: a button with a chevron. Whether
16
+ * it's open is remembered per browser, by the section's label. The
17
+ * section holding the page you're on is always open, and the icon rail
18
+ * shows every section's items whatever their state.
19
+ */
20
+ collapsible?: boolean;
21
+ /** Whether a collapsible section starts open for someone who has never folded it. Defaults to true. */
22
+ defaultOpen?: boolean;
14
23
  };
15
24
  /** One entry of `AppShell`'s `nav`: a page, or a labelled section of pages. */
16
25
  export type NavEntry = NavItem | NavSection;
@@ -19,6 +28,9 @@ export declare function isNavSection(entry: NavEntry): entry is NavSection;
19
28
  export type NavGroup = {
20
29
  label?: string;
21
30
  items: readonly NavItem[];
31
+ /** From its section: the label folds it (NavSection's `collapsible`). */
32
+ collapsible?: boolean;
33
+ defaultOpen?: boolean;
22
34
  };
23
35
  /**
24
36
  * The sidebar groups for a nav, in order. A flat nav (no sections) is the
@@ -16,7 +16,11 @@ export function navGroups(nav) {
16
16
  if (run.length)
17
17
  groups.push({ items: run });
18
18
  run = [];
19
- groups.push({ label: entry.label, items: entry.items });
19
+ groups.push({
20
+ label: entry.label,
21
+ items: entry.items,
22
+ ...(entry.collapsible && { collapsible: true, defaultOpen: entry.defaultOpen ?? true }),
23
+ });
20
24
  }
21
25
  else {
22
26
  run.push(entry);
@@ -11,7 +11,10 @@ export type AppShellProps = {
11
11
  * pages (`{ label, items }`, one per module, say). A nav with no sections
12
12
  * is the single "Navigation" group. One entry is marked active: the most
13
13
  * specific whose route matches, so '/tasks' and '/tasks/categories' can
14
- * both be listed.
14
+ * both be listed. A section marked `collapsible` folds under its label,
15
+ * remembered per browser (`defaultOpen` for the first visit); the one
16
+ * holding the current page stays open, and the icon rail shows every
17
+ * section's items.
15
18
  */
16
19
  nav: readonly NavEntry[];
17
20
  /**
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { LogOutIcon, MoonIcon, SunIcon } from 'lucide-react';
2
+ import { ChevronRightIcon, LogOutIcon, MoonIcon, SunIcon } from 'lucide-react';
3
3
  import { useTheme } from 'next-themes';
4
- import { useEffect, useState } from 'react';
4
+ import { useEffect, useId, useState } from 'react';
5
5
  import { Link, Navigate, Outlet, useLocation } from 'react-router';
6
6
  import { useAuth } from '../../auth/use-auth.js';
7
7
  import { activeNavTo, navGroups } from './app-shell-nav.js';
@@ -9,6 +9,7 @@ import { versionHref, versionLabel } from './app-shell-version.js';
9
9
  import { ErrorState } from './error-state.js';
10
10
  import { useErrorReporter } from '../../error-reporting/error-reporting-context.js';
11
11
  import { ReportRoutePattern } from '../../error-reporting/error-reporting-provider.js';
12
+ import { cn } from 'cn';
12
13
  import { Button } from '../ui/button.js';
13
14
  import { Skeleton } from '../ui/skeleton.js';
14
15
  import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarInset, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider, SidebarTrigger, useSidebar, } from '../ui/sidebar.js';
@@ -47,6 +48,64 @@ function VersionEntry({ version }) {
47
48
  const className = 'block truncate px-2 type-caption text-sidebar-foreground/70 group-data-[collapsible=icon]:hidden';
48
49
  return href ? (_jsx("a", { href: href, target: "_blank", rel: "noreferrer", className: `${className} hover:underline`, children: label })) : (_jsx("span", { className: className, children: label }));
49
50
  }
51
+ // One group's entries. A plain Link, not NavLink: NavLink marks every
52
+ // matching entry current, and only the most specific one should be
53
+ // (activeNavTo).
54
+ function NavMenu({ items, activeTo }) {
55
+ return (_jsx(SidebarMenu, { children: items.map(({ to, label, icon: Icon }) => {
56
+ const isActive = to === activeTo;
57
+ return (_jsx(SidebarMenuItem, { children: _jsxs(SidebarMenuButton, { isActive: isActive, tooltip: label, render: _jsx(Link, { to: to, "aria-current": isActive ? 'page' : undefined }), children: [_jsx(Icon, {}), _jsx("span", { children: label })] }) }, to));
58
+ }) }));
59
+ }
60
+ // Whether a collapsible section is open, remembered per browser by its
61
+ // label. Storage can be missing or refuse (a private window): then it's
62
+ // remembered for the page's life only.
63
+ const SECTION_KEY = (label) => `ui-foundation:nav-section:${label}`;
64
+ function storedSectionOpen(label) {
65
+ try {
66
+ const stored = window.localStorage.getItem(SECTION_KEY(label));
67
+ return stored === null ? null : stored === 'open';
68
+ }
69
+ catch {
70
+ return null;
71
+ }
72
+ }
73
+ function storeSectionOpen(label, open) {
74
+ try {
75
+ window.localStorage.setItem(SECTION_KEY(label), open ? 'open' : 'closed');
76
+ }
77
+ catch {
78
+ // Not remembered; the state still holds until the page reloads.
79
+ }
80
+ }
81
+ // A section whose label folds it (#152). On the icon rail (the collapsed
82
+ // sidebar on a wide screen) its items always show and its label stays the
83
+ // rail's hidden one, so no page is out of reach and no hidden button
84
+ // takes a tab stop. The section holding the current page is always open:
85
+ // arriving in a closed one opens it and remembers that.
86
+ function CollapsibleNavGroup({ group, activeTo }) {
87
+ const { state, isMobile } = useSidebar();
88
+ const contentId = useId();
89
+ const [stored, setStored] = useState(() => storedSectionOpen(group.label));
90
+ const holdsCurrent = group.items.some((item) => item.to === activeTo);
91
+ // Arriving in a folded section unfolds it, and it stays unfolded after.
92
+ if (holdsCurrent && stored !== true)
93
+ setStored(true);
94
+ useEffect(() => {
95
+ if (holdsCurrent)
96
+ storeSectionOpen(group.label, true);
97
+ }, [holdsCurrent, group.label]);
98
+ if (state === 'collapsed' && !isMobile) {
99
+ return (_jsxs(SidebarGroup, { children: [_jsx(SidebarGroupLabel, { children: group.label }), _jsx(SidebarGroupContent, { children: _jsx(NavMenu, { items: group.items, activeTo: activeTo }) })] }));
100
+ }
101
+ const open = holdsCurrent || (stored ?? group.defaultOpen ?? true);
102
+ return (_jsxs(SidebarGroup, { "data-collapsible-section": open ? 'open' : 'closed', children: [_jsxs(SidebarGroupLabel, { render: _jsx("button", { type: "button", "aria-expanded": open, "aria-controls": contentId, "aria-disabled": holdsCurrent || undefined, onClick: () => {
103
+ if (holdsCurrent)
104
+ return;
105
+ setStored(!open);
106
+ storeSectionOpen(group.label, !open);
107
+ } }), className: "w-full justify-between text-left hover:bg-sidebar-accent hover:text-sidebar-accent-foreground aria-disabled:hover:bg-transparent aria-disabled:hover:text-sidebar-foreground/70", children: [group.label, _jsx(ChevronRightIcon, { "aria-hidden": "true", className: cn('transition-transform', open && 'rotate-90') })] }), _jsx(SidebarGroupContent, { id: contentId, hidden: !open, children: _jsx(NavMenu, { items: group.items, activeTo: activeTo }) })] }));
108
+ }
50
109
  function AppShellSkeleton() {
51
110
  return (_jsxs("div", { className: "flex h-screen w-full gap-4 p-4", "data-state": "loading", children: [_jsx(Skeleton, { className: "h-full w-56 shrink-0" }), _jsxs("div", { className: "flex-1 space-y-4 pt-2", children: [_jsx(Skeleton, { className: "h-8 w-64" }), _jsx(Skeleton, { className: "h-40 w-full" })] })] }));
52
111
  }
@@ -79,13 +138,7 @@ export function AppShell({ title, nav, sidebarExtra, defaultSidebarOpen = true,
79
138
  return _jsx(Navigate, { to: "/login", replace: true, state: isLoggingOut ? undefined : { from } });
80
139
  }
81
140
  const activeTo = activeNavTo(nav, location.pathname);
82
- return (_jsxs(TooltipProvider, { children: [reporter && _jsx(ReportRoutePattern, { reporter: reporter }), _jsxs(SidebarProvider, { defaultOpen: storedSidebarOpen(defaultSidebarOpen), children: [_jsx(CloseMobileSidebarOnNavigate, {}), _jsx("nav", { "aria-label": "Primary", children: _jsxs(Sidebar, { collapsible: "icon", children: [_jsx(SidebarHeader, { children: _jsx("span", { className: "px-2 text-sm font-semibold text-sidebar-foreground group-data-[collapsible=icon]:hidden", children: title }) }), _jsxs(SidebarContent, { children: [navGroups(nav).map((group, groupIndex) => (_jsxs(SidebarGroup, { children: [group.label !== undefined && _jsx(SidebarGroupLabel, { children: group.label }), _jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: group.items.map(({ to, label, icon: Icon }) => {
83
- // A plain Link, not NavLink: NavLink marks every
84
- // matching entry current, and only the most specific
85
- // one should be (activeNavTo).
86
- const isActive = to === activeTo;
87
- return (_jsx(SidebarMenuItem, { children: _jsxs(SidebarMenuButton, { isActive: isActive, tooltip: label, render: _jsx(Link, { to: to, "aria-current": isActive ? 'page' : undefined }), children: [_jsx(Icon, {}), _jsx("span", { children: label })] }) }, to));
88
- }) }) })] }, `${groupIndex}:${group.label ?? ''}`))), sidebarExtra] }), _jsxs(SidebarFooter, { children: [version && _jsx(VersionEntry, { version: version }), _jsxs("div", { className: "flex items-center justify-between gap-2 px-2 group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:px-0", children: [_jsx("span", { className: "truncate type-caption text-sidebar-foreground/70 group-data-[collapsible=icon]:hidden", children: user.name }), _jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": "Log out", onClick: () => {
141
+ return (_jsxs(TooltipProvider, { children: [reporter && _jsx(ReportRoutePattern, { reporter: reporter }), _jsxs(SidebarProvider, { defaultOpen: storedSidebarOpen(defaultSidebarOpen), children: [_jsx(CloseMobileSidebarOnNavigate, {}), _jsx("nav", { "aria-label": "Primary", children: _jsxs(Sidebar, { collapsible: "icon", children: [_jsx(SidebarHeader, { children: _jsx("span", { className: "px-2 text-sm font-semibold text-sidebar-foreground group-data-[collapsible=icon]:hidden", children: title }) }), _jsxs(SidebarContent, { children: [navGroups(nav).map((group, groupIndex) => group.collapsible && group.label !== undefined ? (_jsx(CollapsibleNavGroup, { group: { ...group, label: group.label }, activeTo: activeTo }, `${groupIndex}:${group.label}`)) : (_jsxs(SidebarGroup, { children: [group.label !== undefined && _jsx(SidebarGroupLabel, { children: group.label }), _jsx(SidebarGroupContent, { children: _jsx(NavMenu, { items: group.items, activeTo: activeTo }) })] }, `${groupIndex}:${group.label ?? ''}`))), sidebarExtra] }), _jsxs(SidebarFooter, { children: [version && _jsx(VersionEntry, { version: version }), _jsxs("div", { className: "flex items-center justify-between gap-2 px-2 group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:px-0", children: [_jsx("span", { className: "truncate type-caption text-sidebar-foreground/70 group-data-[collapsible=icon]:hidden", children: user.name }), _jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": "Log out", onClick: () => {
89
142
  setIsLoggingOut(true);
90
143
  // A failed request still ends the session on screen
91
144
  // (AuthProvider), so this stays a deliberate logout: a
@@ -0,0 +1,52 @@
1
+ import type { ReactNode } from 'react';
2
+ import { type EditInPlaceOptions } from './edit-in-place.js';
3
+ type Schema<TValue> = EditInPlaceOptions<unknown, TValue>['schema'];
4
+ export type NameCellProps = {
5
+ /** The cell's name, column and row: "Name of Home". */
6
+ label: string;
7
+ /** The saved name. */
8
+ value: string;
9
+ /** Saves the new name (already checked by `schema`). Rejects with the AppError. */
10
+ save: (name: string) => Promise<unknown>;
11
+ /** The form's rule for the name (`schema.shape.name`): checked before anything is sent. */
12
+ schema?: Schema<string>;
13
+ /** How the name shows at rest (an icon beside it). Defaults to the name. */
14
+ children?: ReactNode;
15
+ /** The input's width class. Defaults to `w-48`. */
16
+ inputClassName?: string;
17
+ };
18
+ /** A name in one line: Enter saves, the form's rule runs first, a taken name's 422 shows in the cell. */
19
+ export declare function NameCell({ label, value, save, schema, children, inputClassName }: NameCellProps): import("react").JSX.Element;
20
+ export type ChoiceCellProps<T extends string> = {
21
+ /** The cell's name, column and row: "Colour of Home". */
22
+ label: string;
23
+ /** The saved value. */
24
+ value: T;
25
+ /** Every value, in the order the list shows them. */
26
+ options: readonly T[];
27
+ /** How a value shows: at rest, on the open trigger and in the list (a swatch and its word). */
28
+ render: (value: T) => ReactNode;
29
+ /** Saves the picked value. Rejects with the AppError. */
30
+ save: (value: T) => Promise<unknown>;
31
+ /** The trigger's width class. Defaults to `w-40`. */
32
+ triggerClassName?: string;
33
+ };
34
+ /** A single choice: its list opens with the cell, a pick saves, closing it with no pick gives up. */
35
+ export declare function ChoiceCell<T extends string>({ label, value, options, render, save, triggerClassName, }: ChoiceCellProps<T>): import("react").JSX.Element;
36
+ export type LinkCellProps = {
37
+ /** The cell's name, column and row: "Website of Home". */
38
+ label: string;
39
+ /** The saved address, or null when there's none. */
40
+ value: string | null;
41
+ /** Saves the address, or null to clear it (the box left blank). Rejects with the AppError. */
42
+ save: (link: string | null) => Promise<unknown>;
43
+ /** The link's text at rest. Defaults to the address without its scheme. */
44
+ linkText?: string | ((href: string) => ReactNode);
45
+ /** What the cell says with no address. Defaults to "Not set". */
46
+ emptyLabel?: string;
47
+ /** The input's width class. Defaults to `w-64`. */
48
+ inputClassName?: string;
49
+ };
50
+ /** An optional link: shown as a link out (or the not-set label), edited as one line; blank clears it. */
51
+ export declare function LinkCell({ label, value, save, linkText, emptyLabel, inputClassName }: LinkCellProps): import("react").JSX.Element;
52
+ export {};
@@ -0,0 +1,63 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { ExternalLinkIcon } from 'lucide-react';
3
+ import { Input } from '../ui/input.js';
4
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select.js';
5
+ import { linkHref } from '../../lib/link-href.js';
6
+ import { editInPlace } from './edit-in-place.js';
7
+ import { EditableValue } from './editable-value.js';
8
+ /** A name in one line: Enter saves, the form's rule runs first, a taken name's 422 shows in the cell. */
9
+ export function NameCell({ label, value, save, schema, children, inputClassName = 'w-48' }) {
10
+ return (_jsx(EditableValue, { label: label, layout: "inline", edit: editInPlace({
11
+ kind: 'text',
12
+ value,
13
+ schema,
14
+ save,
15
+ control: (props) => (_jsx(Input, { id: props.id, className: inputClassName, value: props.value, readOnly: props.disabled, "aria-invalid": props.invalid || undefined, "aria-label": props.label, "aria-describedby": props.describedBy, onChange: (event) => props.onChange(event.target.value) })),
16
+ }), children: children ?? value }));
17
+ }
18
+ /** A single choice: its list opens with the cell, a pick saves, closing it with no pick gives up. */
19
+ export function ChoiceCell({ label, value, options, render, save, triggerClassName = 'w-40', }) {
20
+ return (_jsx(EditableValue, { label: label, layout: "inline", edit: editInPlace({
21
+ kind: 'choice',
22
+ value,
23
+ save,
24
+ control: (props) => (_jsxs(Select, { value: props.value, onValueChange: (next) => {
25
+ if (next)
26
+ props.commit(next);
27
+ }, disabled: props.disabled, defaultOpen: true, modal: false, onOpenChange: (open) => {
28
+ if (!open)
29
+ props.cancel();
30
+ }, children: [_jsx(SelectTrigger, { id: props.id, className: triggerClassName, "aria-invalid": props.invalid || undefined, "aria-label": props.label, "aria-describedby": props.describedBy, children: _jsx(SelectValue, { children: (shown) => render(shown) }) }), _jsx(SelectContent, { children: options.map((option) => (_jsx(SelectItem, { value: option, children: render(option) }, option))) })] })),
31
+ }), children: render(value) }));
32
+ }
33
+ // A web or email address (`example.com` gains https://), blank for none.
34
+ // Another scheme (`javascript:`) is refused before anything is sent.
35
+ const LINK_RULE = {
36
+ safeParse: (value) => {
37
+ const href = linkHref(String(value ?? ''));
38
+ if (href === '')
39
+ return { success: true, data: null };
40
+ if (href === null) {
41
+ return { success: false, error: { issues: [{ message: 'Use a web or email address' }] } };
42
+ }
43
+ return { success: true, data: href };
44
+ },
45
+ };
46
+ function shownLinkText(href, linkText) {
47
+ if (typeof linkText === 'function')
48
+ return linkText(href);
49
+ return linkText ?? href.replace(/^(https?:\/\/|mailto:)/i, '').replace(/\/$/, '');
50
+ }
51
+ /** An optional link: shown as a link out (or the not-set label), edited as one line; blank clears it. */
52
+ export function LinkCell({ label, value, save, linkText, emptyLabel = 'Not set', inputClassName = 'w-64' }) {
53
+ // Only an address the editor would have saved is shown as a link: a
54
+ // stored `javascript:` reads as text, never as something to click.
55
+ const href = value ? linkHref(value) : null;
56
+ return (_jsx(EditableValue, { label: label, layout: "inline", edit: editInPlace({
57
+ kind: 'text',
58
+ value: value ?? '',
59
+ schema: LINK_RULE,
60
+ save,
61
+ control: (props) => (_jsx(Input, { id: props.id, type: "url", inputMode: "url", className: inputClassName, value: props.value, placeholder: "https://", readOnly: props.disabled, "aria-invalid": props.invalid || undefined, "aria-label": props.label, "aria-describedby": props.describedBy, onChange: (event) => props.onChange(event.target.value) })),
62
+ }), children: !value ? (_jsx("span", { className: "text-muted-foreground", children: emptyLabel })) : href ? (_jsxs("a", { href: href, target: "_blank", rel: "noreferrer", className: "rounded-sm text-link underline underline-offset-4 outline-none focus-visible:ring-3 focus-visible:ring-ring/50", children: [shownLinkText(href, linkText), _jsx(ExternalLinkIcon, { "aria-hidden": "true", className: "ml-0.5 inline size-3 align-baseline" }), _jsx("span", { className: "sr-only", children: " (opens in a new tab)" })] })) : (value) }));
63
+ }
package/dist/index.d.ts CHANGED
@@ -29,6 +29,7 @@ export { StepBar, type StepBarProps } from './components/app/step-bar.js';
29
29
  export { RatingStar, type RatingStarProps, type RatingStarValue } from './components/app/rating-star.js';
30
30
  export { DateField, type DateFieldProps } from './components/app/date-field.js';
31
31
  export { formatPlainDate, type PlainDate } from './lib/plain-date.js';
32
+ export { ChoiceCell, LinkCell, NameCell, type ChoiceCellProps, type LinkCellProps, type NameCellProps, } from './components/app/row-edit-cells.js';
32
33
  export { WidgetCard, WidgetList, WidgetListItem, type WidgetCardProps, type WidgetListItemProps, } from './components/app/widget-card.js';
33
34
  export { ThemeProvider } from './components/theme-provider.js';
34
35
  export { LoginRoute, type LoginRouteProps } from './routes/login.js';
package/dist/index.js CHANGED
@@ -34,6 +34,7 @@ export { StepBar } from './components/app/step-bar.js';
34
34
  export { RatingStar } from './components/app/rating-star.js';
35
35
  export { DateField } from './components/app/date-field.js';
36
36
  export { formatPlainDate } from './lib/plain-date.js';
37
+ export { ChoiceCell, LinkCell, NameCell, } from './components/app/row-edit-cells.js';
37
38
  export { WidgetCard, WidgetList, WidgetListItem, } from './components/app/widget-card.js';
38
39
  export { ThemeProvider } from './components/theme-provider.js';
39
40
  // Screens outside the app shell
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tristan2828/ui-foundation",
3
- "version": "4.12.0",
3
+ "version": "4.14.0",
4
4
  "description": "Shared UI foundation for contract-first, database-backed React apps: shadcn primitives, DataTable/EntityForm composites, auth, the gateway error seam, design tokens, lint rules and the AI playbook.",
5
5
  "license": "MIT",
6
6
  "repository": {