@apliteni/apliteni-ui 0.41.4 → 0.42.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.
- package/package.json +1 -1
- package/react/README.md +30 -2
- package/react/dist/index.css +28 -0
- package/react/dist/index.d.ts +17 -1
- package/react/dist/index.js +26 -0
- package/src/assets/brand.js +4 -4
- package/src/assets/icons.js +2 -2
- package/src/components/back.js +1 -1
- package/src/components/command-palette.js +6 -6
- package/src/components/drawer.js +1 -1
- package/src/components/dropdown.js +8 -8
- package/src/components/footer.js +6 -6
- package/src/components/index.js +18 -18
- package/src/components/loading.js +4 -4
- package/src/components/nav.js +6 -6
- package/src/components/pagination.js +2 -2
- package/src/components/shell.js +2 -2
- package/src/components/success.js +3 -3
- package/src/components/table-values.js +3 -3
- package/src/components/tabs.js +6 -6
- package/src/components/topbar.js +4 -4
- package/src/html.js +23 -0
package/package.json
CHANGED
package/react/README.md
CHANGED
|
@@ -73,8 +73,7 @@ focus is already back on the opener and the dialog takes no clicks.
|
|
|
73
73
|
A panel that slides in from an edge of the screen over a scrim. It renders the vanilla
|
|
74
74
|
`drawer()` markup, class for class, so it looks and moves like the kit's drawer, and like
|
|
75
75
|
`Pagination` its styles come from `@apliteni/apliteni-ui/css` rather than from this bundle.
|
|
76
|
-
Group
|
|
77
|
-
and value rows — rather than in cards; the rules are on Guidelines / Drawers.
|
|
76
|
+
Group its contents with `DrawerSection` and `KeyValueList`; see Guidelines / Drawers.
|
|
78
77
|
|
|
79
78
|
```tsx
|
|
80
79
|
<Drawer open={open} title="Transaction" onClose={() => setOpen(false)}
|
|
@@ -292,3 +291,32 @@ Clearing the page-jump box, or typing into it and then pressing a step, does not
|
|
|
292
291
|
page.
|
|
293
292
|
|
|
294
293
|
`pageSize` defaults to `DEFAULT_PAGE_SIZE` (100). **Breaking:** it used to default to 4.
|
|
294
|
+
|
|
295
|
+
## KeyValueList and DrawerSection
|
|
296
|
+
|
|
297
|
+
KeyValueList displays one record’s facts with labels beside their values. Pass rows
|
|
298
|
+
with a label and a React value. Set `columns={2}` to show two label-value pairs on
|
|
299
|
+
each line. Null and undefined values are displayed as an em dash. Boolean values
|
|
300
|
+
are displayed as `false` or `true`, zero remains visible, and strings are preserved.
|
|
301
|
+
Set `redacted` on a row to display “Hidden” with an eye-off icon without rendering
|
|
302
|
+
the value. Below 560px, each label appears above its value, and the layout
|
|
303
|
+
changes to one column.
|
|
304
|
+
|
|
305
|
+
DrawerSection groups content under a heading and adds a hairline between adjacent
|
|
306
|
+
sections. Its `headingLevel` defaults to 3. Import both the kit CSS and the React
|
|
307
|
+
CSS. Use a table for figures that readers need to compare down a column.
|
|
308
|
+
|
|
309
|
+
```tsx
|
|
310
|
+
import { DrawerSection, KeyValueList } from '@apliteni/apliteni-ui/react';
|
|
311
|
+
import '@apliteni/apliteni-ui/css';
|
|
312
|
+
import '@apliteni/apliteni-ui/react/css';
|
|
313
|
+
|
|
314
|
+
<DrawerSection title="Transaction">
|
|
315
|
+
<KeyValueList rows={[
|
|
316
|
+
{ label: 'Reference', value: <a href="/invoices/1001">INV-1001</a> },
|
|
317
|
+
{ label: 'Amount', value: '€ 1,240.00' },
|
|
318
|
+
{ label: 'Account', redacted: true },
|
|
319
|
+
{ label: 'Note' },
|
|
320
|
+
]} />
|
|
321
|
+
</DrawerSection>
|
|
322
|
+
```
|
package/react/dist/index.css
CHANGED
|
@@ -114,3 +114,31 @@
|
|
|
114
114
|
transition: none;
|
|
115
115
|
}
|
|
116
116
|
}
|
|
117
|
+
|
|
118
|
+
/* src/KeyValueList.css */
|
|
119
|
+
.ui-drawer__rows.ui-kv {
|
|
120
|
+
grid-template-columns: calc(var(--space-6) * 5) minmax(0, 1fr);
|
|
121
|
+
}
|
|
122
|
+
.ui-drawer__rows.ui-kv--two-columns {
|
|
123
|
+
grid-template-columns: repeat(2, calc(var(--space-6) * 5) minmax(0, 1fr));
|
|
124
|
+
}
|
|
125
|
+
.ui-kv__hidden {
|
|
126
|
+
display: inline-flex;
|
|
127
|
+
align-items: center;
|
|
128
|
+
gap: var(--space-1);
|
|
129
|
+
color: var(--text);
|
|
130
|
+
}
|
|
131
|
+
.ui-kv__hidden svg {
|
|
132
|
+
width: var(--space-4);
|
|
133
|
+
height: var(--space-4);
|
|
134
|
+
stroke-width: 2.25;
|
|
135
|
+
}
|
|
136
|
+
@media (max-width: 560px) {
|
|
137
|
+
.ui-drawer__rows.ui-kv {
|
|
138
|
+
grid-template-columns: minmax(0, 1fr);
|
|
139
|
+
row-gap: var(--space-3);
|
|
140
|
+
}
|
|
141
|
+
.ui-kv > .ui-drawer__row {
|
|
142
|
+
display: block;
|
|
143
|
+
}
|
|
144
|
+
}
|
package/react/dist/index.d.ts
CHANGED
|
@@ -425,7 +425,23 @@ type FilterBarProps = {
|
|
|
425
425
|
};
|
|
426
426
|
declare function FilterBar(props: FilterBarProps): react.JSX.Element;
|
|
427
427
|
|
|
428
|
+
type KeyValueRow = {
|
|
429
|
+
label: string;
|
|
430
|
+
value?: ReactNode;
|
|
431
|
+
redacted?: boolean;
|
|
432
|
+
};
|
|
433
|
+
type KeyValueListProps = Omit<ComponentPropsWithoutRef<'dl'>, 'children'> & {
|
|
434
|
+
rows: readonly KeyValueRow[];
|
|
435
|
+
columns?: 1 | 2;
|
|
436
|
+
};
|
|
437
|
+
declare function KeyValueList({ rows, columns, className, ...rest }: KeyValueListProps): react.JSX.Element;
|
|
438
|
+
type DrawerSectionProps = Omit<ComponentPropsWithoutRef<'section'>, 'title'> & {
|
|
439
|
+
title?: ReactNode;
|
|
440
|
+
headingLevel?: 2 | 3 | 4 | 5 | 6;
|
|
441
|
+
};
|
|
442
|
+
declare function DrawerSection({ title, headingLevel, className, children, ...rest }: DrawerSectionProps): react.JSX.Element;
|
|
443
|
+
|
|
428
444
|
declare const PAGE_SIZES: readonly number[];
|
|
429
445
|
declare const DEFAULT_PAGE_SIZE: number;
|
|
430
446
|
|
|
431
|
-
export { BackLink, type BackLinkOwnProps, type BackLinkProps, Badge, BusyRegion, type BusyRegionProps, Button, type ButtonProps, Card, type Column, type CommandGroup, type CommandItem, CommandPalette, type CommandPaletteProps, DEFAULT_PAGE_SIZE, DataTable, type DataTableProps, DeltaValue, type DeltaValueProps, Denied, type DeniedProps, Drawer, type DrawerProps, Dropdown, type DropdownBadge, type DropdownEntry, type DropdownItem, type DropdownProps, type DropdownRowProps, type DropdownSearch, type DropdownSection, type DropdownSeparator, type Filter, FilterBar, type FilterBarProps, Icon, Modal, type ModalProps, NumericValue, type NumericValueProps, PAGE_SIZES, Pagination, type PaginationProps, RowIdentity, type RowIdentityProps, type SegmentOption, Segmented, type SegmentedProps, Skeleton, type SkeletonProps, SkeletonTable, type SkeletonTableProps, StatBand, type StatBandProps, type StatDelta, type StatFigure, type StatTone, type StatVariant, type TableSort, sortTableRows };
|
|
447
|
+
export { BackLink, type BackLinkOwnProps, type BackLinkProps, Badge, BusyRegion, type BusyRegionProps, Button, type ButtonProps, Card, type Column, type CommandGroup, type CommandItem, CommandPalette, type CommandPaletteProps, DEFAULT_PAGE_SIZE, DataTable, type DataTableProps, DeltaValue, type DeltaValueProps, Denied, type DeniedProps, Drawer, type DrawerProps, DrawerSection, type DrawerSectionProps, Dropdown, type DropdownBadge, type DropdownEntry, type DropdownItem, type DropdownProps, type DropdownRowProps, type DropdownSearch, type DropdownSection, type DropdownSeparator, type Filter, FilterBar, type FilterBarProps, Icon, KeyValueList, type KeyValueListProps, type KeyValueRow, Modal, type ModalProps, NumericValue, type NumericValueProps, PAGE_SIZES, Pagination, type PaginationProps, RowIdentity, type RowIdentityProps, type SegmentOption, Segmented, type SegmentedProps, Skeleton, type SkeletonProps, SkeletonTable, type SkeletonTableProps, StatBand, type StatBandProps, type StatDelta, type StatFigure, type StatTone, type StatVariant, type TableSort, sortTableRows };
|
package/react/dist/index.js
CHANGED
|
@@ -1685,6 +1685,30 @@ function FilterBar(props) {
|
|
|
1685
1685
|
return /* @__PURE__ */ jsx16("div", { ref: host, dangerouslySetInnerHTML: initial.current });
|
|
1686
1686
|
}
|
|
1687
1687
|
|
|
1688
|
+
// src/KeyValueList.tsx
|
|
1689
|
+
import { jsx as jsx17, jsxs as jsxs13 } from "react/jsx-runtime";
|
|
1690
|
+
function detail({ value, redacted }) {
|
|
1691
|
+
if (redacted) return /* @__PURE__ */ jsxs13("span", { className: "ui-kv__hidden", children: [
|
|
1692
|
+
/* @__PURE__ */ jsx17(Icon, { name: "eyeOff" }),
|
|
1693
|
+
"Hidden"
|
|
1694
|
+
] });
|
|
1695
|
+
if (value == null) return "\u2014";
|
|
1696
|
+
return typeof value === "boolean" ? String(value) : value;
|
|
1697
|
+
}
|
|
1698
|
+
function KeyValueList({ rows, columns = 1, className, ...rest }) {
|
|
1699
|
+
return /* @__PURE__ */ jsx17("dl", { ...rest, className: ["ui-drawer__rows", "ui-kv", columns === 2 && "ui-kv--two-columns", className].filter(Boolean).join(" "), children: rows.map((row, index) => /* @__PURE__ */ jsxs13("div", { className: "ui-drawer__row", children: [
|
|
1700
|
+
/* @__PURE__ */ jsx17("dt", { children: row.label }),
|
|
1701
|
+
/* @__PURE__ */ jsx17("dd", { children: detail(row) })
|
|
1702
|
+
] }, index)) });
|
|
1703
|
+
}
|
|
1704
|
+
function DrawerSection({ title, headingLevel = 3, className, children, ...rest }) {
|
|
1705
|
+
const Heading = `h${headingLevel}`;
|
|
1706
|
+
return /* @__PURE__ */ jsxs13("section", { ...rest, className: ["ui-drawer__section", className].filter(Boolean).join(" "), children: [
|
|
1707
|
+
title != null && /* @__PURE__ */ jsx17(Heading, { className: "ui-drawer__section-title", children: title }),
|
|
1708
|
+
children
|
|
1709
|
+
] });
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1688
1712
|
// src/index.ts
|
|
1689
1713
|
var PAGE_SIZES = KIT_PAGE_SIZES;
|
|
1690
1714
|
var DEFAULT_PAGE_SIZE3 = KIT_DEFAULT_PAGE_SIZE;
|
|
@@ -1700,9 +1724,11 @@ export {
|
|
|
1700
1724
|
DeltaValue,
|
|
1701
1725
|
Denied,
|
|
1702
1726
|
Drawer,
|
|
1727
|
+
DrawerSection,
|
|
1703
1728
|
Dropdown,
|
|
1704
1729
|
FilterBar,
|
|
1705
1730
|
Icon,
|
|
1731
|
+
KeyValueList,
|
|
1706
1732
|
Modal,
|
|
1707
1733
|
NumericValue,
|
|
1708
1734
|
PAGE_SIZES,
|
package/src/assets/brand.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Apliteni seedling brand mark + wordmark.
|
|
2
2
|
// `p` prefixes gradient ids so multiple marks on one page never collide.
|
|
3
|
-
|
|
4
|
-
export const seedling = (p = 'lg', size = 21) => `<svg viewBox="0 0 38 36" width="${size}" height="${Math.round(size * 36 / 38)}" aria-hidden="true"><defs><linearGradient id="${p}1" x1="1" x2="0" y1="0.248" y2="0.752"><stop offset="0" stop-color="rgb(140,198,63)"/><stop offset="1" stop-color="rgb(0,146,69)"/></linearGradient><linearGradient id="${p}2" x1="1" x2="0" y1="0.497" y2="0.503"><stop offset="0" stop-color="rgb(140,198,63)"/><stop offset="1" stop-color="rgb(0,146,69)"/></linearGradient></defs><g transform="translate(1 1)"><path d="M 23.115 33.95 C 22.982 34.379 23.862 31.827 24.095 30.048 C 25.239 21.404 23.879 13.04 15.283 6.191 C 11.018 2.783 5.476 0.988 0 1.235 C 1.974 1.647 3.17 3.705 3.484 5.697 C 3.8 7.673 3.468 9.697 3.7 11.69 C 4.165 15.691 6.953 19.264 10.57 21.108 C 13.84 22.771 17.689 23.1 21.306 22.359 C 21.306 22.359 17.772 13.501 11.234 10.801 C 11.234 10.801 20.062 13.451 22.617 21.981 C 24.31 27.694 23.115 33.951 23.115 33.951 Z" fill="url(#${p}1)"/><path d="M 24.509 17.09 C 24.495 17.045 24.478 17.002 24.459 16.959 C 25.322 13.469 28.425 10.109 30.582 9.089 C 28.392 9.171 24.708 12.546 23.562 14.621 C 22.857 13.042 21.972 11.551 20.924 10.175 C 24.84 6.026 33.851 6.026 35.527 0 C 36.157 3.886 36.157 9.632 34.532 13.221 C 32.988 16.613 29.752 21.454 25.355 20.878 C 25.181 19.594 24.898 18.327 24.509 17.091 Z" fill="url(#${p}2)"/></g></svg>`;
|
|
3
|
+
import { esc, trustedAttr, safeUrl } from '../html.js';
|
|
4
|
+
export const seedling = (p = 'lg', size = 21) => `<svg viewBox="0 0 38 36" width="${esc(size)}" height="${Math.round(size * 36 / 38)}" aria-hidden="true"><defs><linearGradient id="${esc(p)}1" x1="1" x2="0" y1="0.248" y2="0.752"><stop offset="0" stop-color="rgb(140,198,63)"/><stop offset="1" stop-color="rgb(0,146,69)"/></linearGradient><linearGradient id="${esc(p)}2" x1="1" x2="0" y1="0.497" y2="0.503"><stop offset="0" stop-color="rgb(140,198,63)"/><stop offset="1" stop-color="rgb(0,146,69)"/></linearGradient></defs><g transform="translate(1 1)"><path d="M 23.115 33.95 C 22.982 34.379 23.862 31.827 24.095 30.048 C 25.239 21.404 23.879 13.04 15.283 6.191 C 11.018 2.783 5.476 0.988 0 1.235 C 1.974 1.647 3.17 3.705 3.484 5.697 C 3.8 7.673 3.468 9.697 3.7 11.69 C 4.165 15.691 6.953 19.264 10.57 21.108 C 13.84 22.771 17.689 23.1 21.306 22.359 C 21.306 22.359 17.772 13.501 11.234 10.801 C 11.234 10.801 20.062 13.451 22.617 21.981 C 24.31 27.694 23.115 33.951 23.115 33.951 Z" fill="url(#${esc(p)}1)"/><path d="M 24.509 17.09 C 24.495 17.045 24.478 17.002 24.459 16.959 C 25.322 13.469 28.425 10.109 30.582 9.089 C 28.392 9.171 24.708 12.546 23.562 14.621 C 22.857 13.042 21.972 11.551 20.924 10.175 C 24.84 6.026 33.851 6.026 35.527 0 C 36.157 3.886 36.157 9.632 34.532 13.221 C 32.988 16.613 29.752 21.454 25.355 20.878 C 25.181 19.594 24.898 18.327 24.509 17.091 Z" fill="url(#${esc(p)}2)"/></g></svg>`;
|
|
5
5
|
|
|
6
6
|
// The Apliteni mark — a rounded "sub-theme prism" of the four ready-made accents
|
|
7
7
|
// (Nebula / Phoenix / Ocean / Emerald). This is the current mark used everywhere
|
|
@@ -9,10 +9,10 @@ export const seedling = (p = 'lg', size = 21) => `<svg viewBox="0 0 38 36" width
|
|
|
9
9
|
// is kept only for backward compatibility. `p` prefixes the clip id so multiple
|
|
10
10
|
// marks on one page never collide.
|
|
11
11
|
export const prism = (p = 'pr', size = 26) =>
|
|
12
|
-
`<svg viewBox="0 0 32 32" width="${size}" height="${size}" aria-hidden="true"><defs><clipPath id="${p}"><rect x="1" y="1" width="30" height="30" rx="9"/></clipPath></defs><g clip-path="url(#${p})"><rect x="1" y="1" width="15" height="15" fill="#9b5dff"/><rect x="16" y="1" width="15" height="15" fill="#ff6a3d"/><rect x="1" y="16" width="15" height="15" fill="#3b9dff"/><rect x="16" y="16" width="15" height="15" fill="#16c98a"/></g></svg>`;
|
|
12
|
+
`<svg viewBox="0 0 32 32" width="${esc(size)}" height="${esc(size)}" aria-hidden="true"><defs><clipPath id="${esc(p)}"><rect x="1" y="1" width="30" height="30" rx="9"/></clipPath></defs><g clip-path="url(#${esc(p)})"><rect x="1" y="1" width="15" height="15" fill="#9b5dff"/><rect x="16" y="1" width="15" height="15" fill="#ff6a3d"/><rect x="1" y="16" width="15" height="15" fill="#3b9dff"/><rect x="16" y="16" width="15" height="15" fill="#16c98a"/></g></svg>`;
|
|
13
13
|
|
|
14
14
|
// Brand lockup: the prism mark + product word. `word` defaults to "Strategy"
|
|
15
15
|
// (the strategy portal). Uses the prism — the current Apliteni mark — not the
|
|
16
16
|
// legacy seedling (still exported above for anything that needs it).
|
|
17
17
|
export const brand = ({ p = 'lg', word = 'Strategy', size = 22, href = '/' } = {}) =>
|
|
18
|
-
`<a class="brand" href="${href}" aria-label="Apliteni ${word}">${prism(p, size)}<span>${word}</span></a>`;
|
|
18
|
+
`<a class="brand" href="${esc(safeUrl(href))}" aria-label="Apliteni ${trustedAttr(word)}">${prism(p, size)}<span>${word}</span></a>`;
|
package/src/assets/icons.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
//
|
|
9
9
|
// Naming, grouping and provenance, and the gates that hold them:
|
|
10
10
|
// why: CONTRIBUTING.md#add-a-glyph
|
|
11
|
-
|
|
11
|
+
import { esc } from '../html.js';
|
|
12
12
|
const NAV = {
|
|
13
13
|
chevronDown: '<path d="M6 9l6 6 6-6" stroke-linecap="round" stroke-linejoin="round"/>',
|
|
14
14
|
chevronUp: '<polyline points="18 15 12 9 6 15"/>',
|
|
@@ -134,7 +134,7 @@ export const iconCategories = [
|
|
|
134
134
|
// purpose; a named graphic would let a caller hang a control's only accessible
|
|
135
135
|
// name off a decorative glyph, which is what we just fixed.
|
|
136
136
|
export const icon = (name, cls = '') =>
|
|
137
|
-
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"${cls ? ` class="${cls}"` : ''}>${ICONS[name] || ''}</svg>`;
|
|
137
|
+
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"${cls ? ` class="${esc(cls)}"` : ''}>${ICONS[name] || ''}</svg>`;
|
|
138
138
|
|
|
139
139
|
export const iconNames = Object.keys(ICONS);
|
|
140
140
|
|
package/src/components/back.js
CHANGED
|
@@ -23,7 +23,7 @@ const BARE = 'Back';
|
|
|
23
23
|
// C0 controls and spaces from the front and removes every tab, LF and CR wherever
|
|
24
24
|
// they sit, so "java\tscript:" is still javascript:. The check reads the address the
|
|
25
25
|
// same way (WHATWG URL Standard, basic URL parser).
|
|
26
|
-
const SCRIPTED = /^javascript:/i;
|
|
26
|
+
const SCRIPTED = /^(?:javascript|data|vbscript):/i;
|
|
27
27
|
const LEADING = /^[\u0000-\u0020]+/;
|
|
28
28
|
const TAB_OR_NEWLINE = /[\t\n\r]/g;
|
|
29
29
|
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// why: docs/specification.md#the-command-palette
|
|
11
11
|
import { esc, icon } from './index.js';
|
|
12
12
|
import { OVERLAY_LAYER, adoptOverlay, popOverlay, pushOverlay, returnFocus, syncOverlays } from './overlay.js';
|
|
13
|
-
|
|
13
|
+
import { safeUrl } from '../html.js';
|
|
14
14
|
const cx = (...a) => a.filter(Boolean).join(' ');
|
|
15
15
|
|
|
16
16
|
let _uid = 0;
|
|
@@ -204,12 +204,12 @@ function paletteItem(it, uid, index, active) {
|
|
|
204
204
|
`class="${cx('ui-cmdk__item', it.danger && !disabled && 'is-danger', disabled && 'is-disabled', active && 'is-active')}"`,
|
|
205
205
|
'role="option"',
|
|
206
206
|
'tabindex="-1"',
|
|
207
|
-
`id="${uid}-o${index}"`,
|
|
207
|
+
`id="${esc(uid)}-o${esc(index)}"`,
|
|
208
208
|
'data-cmdk-item',
|
|
209
|
-
`data-i="${index}"`,
|
|
209
|
+
`data-i="${esc(index)}"`,
|
|
210
210
|
searchAttrs(it),
|
|
211
211
|
it.id != null ? `data-id="${esc(it.id)}"` : '',
|
|
212
|
-
it.href && !disabled ? `data-href="${esc(it.href)}"` : '',
|
|
212
|
+
it.href && !disabled ? `data-href="${esc(safeUrl(it.href))}"` : '',
|
|
213
213
|
it.confirm && !disabled ? `data-confirm-open="${esc(it.confirm)}"` : '',
|
|
214
214
|
it.confirm && !disabled ? 'aria-haspopup="dialog"' : '',
|
|
215
215
|
disabled ? 'aria-disabled="true"' : '',
|
|
@@ -260,10 +260,10 @@ export function commandPaletteList(groups = [], { uid = nextId(), from = 0 } = {
|
|
|
260
260
|
if (!items.length) return '';
|
|
261
261
|
const headId = g.label ? `${uid}-g${gi}` : null;
|
|
262
262
|
const head = headId
|
|
263
|
-
? `<div class="ui-cmdk__group-head" id="${headId}">${esc(g.label)}</div>` : '';
|
|
263
|
+
? `<div class="ui-cmdk__group-head" id="${esc(headId)}">${esc(g.label)}</div>` : '';
|
|
264
264
|
const rows = items.map((it) => paletteItem(it, uid, n, n++ === activeAt)).join('');
|
|
265
265
|
return `<div class="ui-cmdk__group" role="group" data-cmdk-group data-i="${gi}"`
|
|
266
|
-
+ `${headId ? ` aria-labelledby="${headId}"` : ''}>${head}${rows}</div>`;
|
|
266
|
+
+ `${headId ? ` aria-labelledby="${esc(headId)}"` : ''}>${head}${rows}</div>`;
|
|
267
267
|
}).join('');
|
|
268
268
|
}
|
|
269
269
|
|
package/src/components/drawer.js
CHANGED
|
@@ -60,7 +60,7 @@ export function drawer({
|
|
|
60
60
|
const footEl = footer ? `<footer class="ui-drawer__footer">${footer}</footer>` : '';
|
|
61
61
|
|
|
62
62
|
const rootCls = cx('ui-drawer', `ui-drawer--${side}`, `ui-drawer--${size}`, (open || specimen) && 'is-open');
|
|
63
|
-
return `<div class="${rootCls}"${specimen ? '' : ' data-drawer'} data-drawer-side="${esc(side)}"`
|
|
63
|
+
return `<div class="${esc(rootCls)}"${specimen ? '' : ' data-drawer'} data-drawer-side="${esc(side)}"`
|
|
64
64
|
+ `${dismissible ? '' : ' data-drawer-static'}${id ? ` id="${esc(id)}"` : ''}>`
|
|
65
65
|
+ `<div class="ui-drawer__scrim" data-drawer-scrim></div>`
|
|
66
66
|
+ `<aside class="ui-drawer__panel" role="dialog"${specimen ? '' : ' aria-modal="true"'} ${nameAttr} tabindex="-1" data-drawer-panel>`
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// container.innerHTML = dropdown({ label: 'version:', value: '…', items });
|
|
14
14
|
// wireDropdown(container); // or let wireTopbar() do it
|
|
15
15
|
import { esc, icon } from './index.js';
|
|
16
|
-
|
|
16
|
+
import { safeUrl } from '../html.js';
|
|
17
17
|
const cx = (...a) => a.filter(Boolean).join(' ');
|
|
18
18
|
|
|
19
19
|
// A trailing status badge. `badge` is the text shown, in the case it is written
|
|
@@ -24,7 +24,7 @@ function ddBadge(badge) {
|
|
|
24
24
|
let tone = typeof badge === 'string' ? '' : (badge.tone || '');
|
|
25
25
|
if (!tone) tone = /^live$/i.test(text) ? 'live'
|
|
26
26
|
: /^(?:off|unset|disabled|archive|archived)$/i.test(text) ? 'state' : 'neutral';
|
|
27
|
-
return `<span class="${cx('ui-dropdown__badge', `is-${tone}`)}">${esc(text)}</span>`;
|
|
27
|
+
return `<span class="${esc(cx('ui-dropdown__badge', `is-${tone}`))}">${esc(text)}</span>`;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
// One item row. `listbox` picks role=option (selectable) vs role=menuitem (action).
|
|
@@ -41,14 +41,14 @@ function ddItem(it, listbox, ext) {
|
|
|
41
41
|
const badge = ddBadge(it.badge);
|
|
42
42
|
const tick = listbox ? `<span class="ui-dropdown__tick" aria-hidden="true">${icon('check')}</span>` : '';
|
|
43
43
|
const attrs = [
|
|
44
|
-
`class="${cx('ui-dropdown__item', selected && 'is-selected', disabled && 'is-disabled', it.danger && 'is-danger')}"`,
|
|
44
|
+
`class="${esc(cx('ui-dropdown__item', selected && 'is-selected', disabled && 'is-disabled', it.danger && 'is-danger'))}"`,
|
|
45
45
|
'data-dd-item',
|
|
46
46
|
`role="${role}"`,
|
|
47
47
|
'tabindex="-1"',
|
|
48
48
|
it.value != null ? `data-value="${esc(it.value)}"` : '',
|
|
49
49
|
listbox ? `aria-selected="${selected ? 'true' : 'false'}"` : '',
|
|
50
50
|
disabled ? 'aria-disabled="true"' : '',
|
|
51
|
-
asLink ? `href="${esc(it.href)}"` : '',
|
|
51
|
+
asLink ? `href="${esc(safeUrl(it.href))}"` : '',
|
|
52
52
|
asLink && it.target ? `target="${esc(it.target)}"` : '',
|
|
53
53
|
ext?.id ? `id="${esc(ext.id)}"` : '',
|
|
54
54
|
ext?.hidden ? 'hidden' : '',
|
|
@@ -189,7 +189,7 @@ export function dropdown({
|
|
|
189
189
|
: `${label ? `<span class="ui-dropdown__pre">${esc(label)}</span>` : ''}` +
|
|
190
190
|
`<span class="ui-dropdown__value">${esc(cur != null ? cur : placeholder)}</span>`;
|
|
191
191
|
const triggerAttrs = [
|
|
192
|
-
`class="${cx('ui-dropdown__trigger', triggerClass)}"`,
|
|
192
|
+
`class="${esc(cx('ui-dropdown__trigger', triggerClass))}"`,
|
|
193
193
|
'type="button"',
|
|
194
194
|
'data-dropdown-trigger',
|
|
195
195
|
`aria-haspopup="${sx ? 'dialog' : listRole}"`,
|
|
@@ -200,7 +200,7 @@ export function dropdown({
|
|
|
200
200
|
// `is-open` beside `open` because the descendant selector the panel normally
|
|
201
201
|
// takes its open state from stops matching once wireDropdown() portals it.
|
|
202
202
|
const panelAttrs = [
|
|
203
|
-
`class="${cx(
|
|
203
|
+
`class="${esc(cx(
|
|
204
204
|
'ui-dropdown__panel',
|
|
205
205
|
align === 'end' && 'is-end',
|
|
206
206
|
direction === 'up' && 'is-up',
|
|
@@ -209,7 +209,7 @@ export function dropdown({
|
|
|
209
209
|
portal && 'ui-dropdown__panel--portal',
|
|
210
210
|
portal && open && 'is-open',
|
|
211
211
|
panelClass,
|
|
212
|
-
)}"`,
|
|
212
|
+
))}"`,
|
|
213
213
|
'data-dropdown-panel',
|
|
214
214
|
// With search the panel holds a field and a list, which a listbox may not.
|
|
215
215
|
`role="${sx ? 'dialog' : listRole}"`,
|
|
@@ -227,7 +227,7 @@ export function dropdown({
|
|
|
227
227
|
+ (direction === 'auto' ? ' data-dropdown-direction="auto"' : '')
|
|
228
228
|
+ (portal ? ' data-dropdown-portal' : '');
|
|
229
229
|
|
|
230
|
-
return `<div class="${cx('ui-dropdown', open && 'open')}" ${ddAttrs}${id ? ` id="${esc(id)}"` : ''}>` +
|
|
230
|
+
return `<div class="${esc(cx('ui-dropdown', open && 'open'))}" ${ddAttrs}${id ? ` id="${esc(id)}"` : ''}>` +
|
|
231
231
|
`<button ${triggerAttrs}>${trig}${chevron ? '<span class="ui-dropdown__chevron" aria-hidden="true"></span>' : ''}</button>` +
|
|
232
232
|
`<div ${panelAttrs}>${header}`
|
|
233
233
|
+ `${sx ? ddSearchBody({ items, sections }, sx, name, scroll) : ddBody({ items, sections }, isSelect)}`
|
package/src/components/footer.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// app — compact, in-product (tight padding, surface background)
|
|
8
8
|
import { brand as brandLockup } from '../assets/brand.js';
|
|
9
9
|
import { esc } from './index.js';
|
|
10
|
-
|
|
10
|
+
import { safeUrl } from '../html.js';
|
|
11
11
|
const cx = (...a) => a.filter(Boolean).join(' ');
|
|
12
12
|
|
|
13
13
|
// Minimal brand-glyph set for the optional social row. Feather-ish where generic,
|
|
@@ -23,13 +23,13 @@ const SOCIAL = {
|
|
|
23
23
|
function socialLink({ label, href = '#', icon: ic = 'github' } = {}) {
|
|
24
24
|
const glyph = SOCIAL[ic] || SOCIAL.github;
|
|
25
25
|
const fill = ic === 'github' || ic === 'x' ? 'currentColor' : 'none';
|
|
26
|
-
return `<a class="ui-footer__social" href="${href}" aria-label="${esc(label || ic)}">` +
|
|
26
|
+
return `<a class="ui-footer__social" href="${esc(safeUrl(href))}" aria-label="${esc(label || ic)}">` +
|
|
27
27
|
`<svg viewBox="0 0 24 24" fill="${fill}" aria-hidden="true">${glyph}</svg></a>`;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
function linkList(links = []) {
|
|
31
31
|
return `<ul class="ui-footer__links">${links.map(({ label, href = '#', target }) =>
|
|
32
|
-
`<li><a href="${href}"${target ? ` target="${target}" rel="noreferrer"` : ''}>${esc(label)}</a></li>`).join('')}</ul>`;
|
|
32
|
+
`<li><a href="${esc(safeUrl(href))}"${target ? ` target="${esc(target)}" rel="noreferrer"` : ''}>${esc(label)}</a></li>`).join('')}</ul>`;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
// The column title is an h2: it names a top-level section of the page's end
|
|
@@ -47,7 +47,7 @@ function column({ title, links = [] } = {}) {
|
|
|
47
47
|
function legalBar({ legal, legalLinks = [], switcher = '' } = {}) {
|
|
48
48
|
const links = legalLinks.length
|
|
49
49
|
? `<nav class="ui-footer__legal-links" aria-label="Legal">` +
|
|
50
|
-
legalLinks.map(({ label, href = '#' }) => `<a href="${href}">${esc(label)}</a>`).join('') + `</nav>`
|
|
50
|
+
legalLinks.map(({ label, href = '#' }) => `<a href="${esc(safeUrl(href))}">${esc(label)}</a>`).join('') + `</nav>`
|
|
51
51
|
: '';
|
|
52
52
|
const sw = switcher ? `<div class="ui-footer__switcher">${switcher}</div>` : '';
|
|
53
53
|
return `<div class="ui-footer__bar">` +
|
|
@@ -77,7 +77,7 @@ export function footer({
|
|
|
77
77
|
|
|
78
78
|
// slim + app: a single legal row, no brand block or columns.
|
|
79
79
|
if (variant === 'slim' || variant === 'app') {
|
|
80
|
-
return `<footer class="${cls}" role="contentinfo"><div class="ui-footer__in">` +
|
|
80
|
+
return `<footer class="${esc(cls)}" role="contentinfo"><div class="ui-footer__in">` +
|
|
81
81
|
legalBar({ legal, legalLinks, switcher }) +
|
|
82
82
|
`</div></footer>`;
|
|
83
83
|
}
|
|
@@ -93,7 +93,7 @@ export function footer({
|
|
|
93
93
|
const nav = columns.length
|
|
94
94
|
? `<nav class="ui-footer__nav" aria-label="Footer">${columns.map(column).join('')}</nav>` : '';
|
|
95
95
|
|
|
96
|
-
return `<footer class="${cls}" role="contentinfo"><div class="ui-footer__in">` +
|
|
96
|
+
return `<footer class="${esc(cls)}" role="contentinfo"><div class="ui-footer__in">` +
|
|
97
97
|
`<div class="ui-footer__top">${brandBlock}${nav}</div>` +
|
|
98
98
|
legalBar({ legal, legalLinks, switcher }) +
|
|
99
99
|
`</div></footer>`;
|
package/src/components/index.js
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
import { icon } from '../assets/icons.js';
|
|
4
4
|
import { illo } from '../assets/illustrations.js';
|
|
5
5
|
import { successCheck } from './success.js';
|
|
6
|
-
|
|
6
|
+
import { esc, safeUrl } from '../html.js';
|
|
7
|
+
export { esc } from '../html.js';
|
|
7
8
|
const cx = (...a) => a.filter(Boolean).join(' ');
|
|
8
|
-
export const esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, (c) => HTML_ENTITIES[c]);
|
|
9
9
|
// ---- Button --------------------------------------------------------------
|
|
10
10
|
// `iconSvg` is a raw leading-icon SVG string (trusted markup, not escaped) for
|
|
11
11
|
// branded glyphs the kit's icon set doesn't own — e.g. a Google "G". It takes
|
|
@@ -37,20 +37,20 @@ export function button({
|
|
|
37
37
|
// Static busy ⇒ disabled; setButtonBusy wires focus-preserving activation guards.
|
|
38
38
|
const name = String(label == null ? '' : label).trim() || ic || 'Button';
|
|
39
39
|
const named = iconOnly ? ` aria-label="${esc(name)}" title="${esc(name)}"` : '';
|
|
40
|
-
const attrs = `class="${cls}"${disabled || busy ? ' disabled aria-disabled="true"' : ''}${busy ? ' aria-busy="true"' : ''}${disabled && busy ? ' data-btn-disabled' : ''}${named}`;
|
|
40
|
+
const attrs = `class="${esc(cls)}"${disabled || busy ? ' disabled aria-disabled="true"' : ''}${busy ? ' aria-busy="true"' : ''}${disabled && busy ? ' data-btn-disabled' : ''}${named}`;
|
|
41
41
|
const control = href
|
|
42
|
-
? `<a href="${href}" ${attrs}>${inner}</a>`
|
|
43
|
-
: `<button type="${type}" ${attrs}>${inner}</button>`;
|
|
42
|
+
? `<a href="${esc(safeUrl(href))}" ${attrs}>${inner}</a>`
|
|
43
|
+
: `<button type="${esc(type)}" ${attrs}>${inner}</button>`;
|
|
44
44
|
return busy ? `${control}<span class="ui-sr ui-btn__status" role="status" aria-live="polite"></span>` : control;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
// ---- Badge / Pill --------------------------------------------------------
|
|
48
48
|
export function badge(label, variant = 'neutral') {
|
|
49
49
|
const v = variant === 'neutral' ? '' : `ui-badge--${variant}`;
|
|
50
|
-
return `<span class="${cx('ui-badge', v)}">${esc(label)}</span>`;
|
|
50
|
+
return `<span class="${esc(cx('ui-badge', v))}">${esc(label)}</span>`;
|
|
51
51
|
}
|
|
52
52
|
export function pill(label, variant) {
|
|
53
|
-
return `<span class="${cx('ui-pill', variant && `ui-pill--${variant}`)}">${esc(label)}</span>`;
|
|
53
|
+
return `<span class="${esc(cx('ui-pill', variant && `ui-pill--${variant}`))}">${esc(label)}</span>`;
|
|
54
54
|
}
|
|
55
55
|
export function statusDot(live = false) {
|
|
56
56
|
return `<span class="${cx('ui-dot', live && 'is-live')}"></span>`;
|
|
@@ -66,7 +66,7 @@ export function card({ title, sub, body = '', variant, pad, icon: ic, level = 2
|
|
|
66
66
|
const head = title
|
|
67
67
|
? `<${h} class="ui-card__title">${ic ? `<span class="ui-card__icon">${icon(ic)}</span>` : ''}${title}</${h}>${sub ? `<div class="ui-card__sub">${sub}</div>` : ''}`
|
|
68
68
|
: '';
|
|
69
|
-
return `<div class="${cls}">${head}${body}</div>`;
|
|
69
|
+
return `<div class="${esc(cls)}">${head}${body}</div>`;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
// ---- Segmented control ---------------------------------------------------
|
|
@@ -93,7 +93,7 @@ export function segmented({ options = [], active = 0, size, block, name = 'seg',
|
|
|
93
93
|
return `<button type="button"${disabled || o.disabled ? ' disabled' : ''} aria-pressed="${on}" tabindex="${i === rove ? '0' : '-1'}"`
|
|
94
94
|
+ ` data-value="${esc(val)}"${on ? ' class="is-active"' : ''}>${esc(label)}</button>`;
|
|
95
95
|
}).join('');
|
|
96
|
-
return `<div class="${cls}" role="toolbar" aria-label="${esc(ariaLabel)}" data-seg="${name}">${btns}</div>`;
|
|
96
|
+
return `<div class="${esc(cls)}" role="toolbar" aria-label="${esc(ariaLabel)}" data-seg="${esc(name)}">${btns}</div>`;
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
// ---- Accent picker -------------------------------------------------------
|
|
@@ -176,7 +176,7 @@ export function field({ label, hint, error, control = '', id, required = false }
|
|
|
176
176
|
required: required || null,
|
|
177
177
|
});
|
|
178
178
|
const lab = label
|
|
179
|
-
? `<label class="ui-field__label"${forId ? ` for="${forId}"` : ''}>${esc(label)}`
|
|
179
|
+
? `<label class="ui-field__label"${forId ? ` for="${id ? esc(id) : forId}"` : ''}>${esc(label)}`
|
|
180
180
|
+ `${required ? '<span class="ui-field__req" aria-hidden="true">*</span>' : ''}</label>`
|
|
181
181
|
: '';
|
|
182
182
|
const foot = error
|
|
@@ -188,14 +188,14 @@ export function field({ label, hint, error, control = '', id, required = false }
|
|
|
188
188
|
// `invalid` paints the control red AND says so in aria-invalid — the red on its
|
|
189
189
|
// own is a state only a sighted user can read.
|
|
190
190
|
export function input({ type = 'text', placeholder = '', value = '', icon: ic, invalid, disabled, required, name, id, ariaLabel } = {}) {
|
|
191
|
-
const attrs = `${id ? ` id="${esc(id)}"` : ''}${name ? ` name="${name}"` : ''}${ariaLabel ? ` aria-label="${esc(ariaLabel)}"` : ''}`
|
|
191
|
+
const attrs = `${id ? ` id="${esc(id)}"` : ''}${name ? ` name="${esc(name)}"` : ''}${ariaLabel ? ` aria-label="${esc(ariaLabel)}"` : ''}`
|
|
192
192
|
+ `${invalid ? ' aria-invalid="true"' : ''}${required ? ' required' : ''}${disabled ? ' disabled' : ''}`;
|
|
193
|
-
const el = `<input class="${cx('ui-input', invalid && 'is-invalid')}" type="${type}" placeholder="${esc(placeholder)}" value="${esc(value)}"${attrs}>`;
|
|
193
|
+
const el = `<input class="${cx('ui-input', invalid && 'is-invalid')}" type="${esc(type)}" placeholder="${esc(placeholder)}" value="${esc(value)}"${attrs}>`;
|
|
194
194
|
if (!ic) return el;
|
|
195
195
|
return `<div class="ui-input-group"><span class="ui-input-group__icon">${icon(ic)}</span>${el}</div>`;
|
|
196
196
|
}
|
|
197
197
|
export function textarea({ placeholder = '', value = '', rows = 4, name, id, ariaLabel } = {}) {
|
|
198
|
-
return `<textarea class="ui-textarea" rows="${rows}" placeholder="${esc(placeholder)}"${id ? ` id="${esc(id)}"` : ''}${name ? ` name="${name}"` : ''}${ariaLabel ? ` aria-label="${esc(ariaLabel)}"` : ''}>${esc(value)}</textarea>`;
|
|
198
|
+
return `<textarea class="ui-textarea" rows="${esc(rows)}" placeholder="${esc(placeholder)}"${id ? ` id="${esc(id)}"` : ''}${name ? ` name="${esc(name)}"` : ''}${ariaLabel ? ` aria-label="${esc(ariaLabel)}"` : ''}>${esc(value)}</textarea>`;
|
|
199
199
|
}
|
|
200
200
|
// Native <select>. Pass a `label` via field() or an `ariaLabel` for a bare one —
|
|
201
201
|
// a select with neither has no accessible name.
|
|
@@ -206,20 +206,20 @@ export function select({ options = [], value, name, id, ariaLabel, disabled } =
|
|
|
206
206
|
const sel = value != null && String(val) === String(value);
|
|
207
207
|
return `<option value="${esc(val)}"${sel ? ' selected' : ''}>${esc(label)}</option>`;
|
|
208
208
|
}).join('');
|
|
209
|
-
return `<select class="ui-select"${id ? ` id="${esc(id)}"` : ''}${name ? ` name="${name}"` : ''}${ariaLabel ? ` aria-label="${esc(ariaLabel)}"` : ''}${disabled ? ' disabled' : ''}>${opts}</select>`;
|
|
209
|
+
return `<select class="ui-select"${id ? ` id="${esc(id)}"` : ''}${name ? ` name="${esc(name)}"` : ''}${ariaLabel ? ` aria-label="${esc(ariaLabel)}"` : ''}${disabled ? ' disabled' : ''}>${opts}</select>`;
|
|
210
210
|
}
|
|
211
211
|
export function checkbox({ label, checked, type = 'checkbox', name } = {}) {
|
|
212
|
-
return `<label class="ui-check"><input type="${type}"${name ? ` name="${name}"` : ''}${checked ? ' checked' : ''}><span>${label}</span></label>`;
|
|
212
|
+
return `<label class="ui-check"><input type="${esc(type)}"${name ? ` name="${esc(name)}"` : ''}${checked ? ' checked' : ''}><span>${label}</span></label>`;
|
|
213
213
|
}
|
|
214
214
|
// `label` becomes the input's accessible name (a bare switch has no visible text,
|
|
215
215
|
// so it needs one). Defaults to "Toggle" so a control is never left unlabelled.
|
|
216
216
|
export function switchToggle({ checked = false, disabled = false, name, label = 'Toggle' } = {}) {
|
|
217
|
-
return `<label class="ui-switch"><input type="checkbox"${name ? ` name="${name}"` : ''}${checked ? ' checked' : ''}${disabled ? ' disabled' : ''} aria-label="${esc(label)}"><span class="ui-switch__track"></span></label>`;
|
|
217
|
+
return `<label class="ui-switch"><input type="checkbox"${name ? ` name="${esc(name)}"` : ''}${checked ? ' checked' : ''}${disabled ? ' disabled' : ''} aria-label="${esc(label)}"><span class="ui-switch__track"></span></label>`;
|
|
218
218
|
}
|
|
219
219
|
|
|
220
220
|
// ---- Callout / toast / success ------------------------------------------
|
|
221
221
|
export function callout({ variant, icon: ic = 'info', body } = {}) {
|
|
222
|
-
return `<div class="${cx('ui-callout', variant && `ui-callout--${variant}`)}"><span class="ui-callout__icon">${icon(ic)}</span><div>${body}</div></div>`;
|
|
222
|
+
return `<div class="${esc(cx('ui-callout', variant && `ui-callout--${variant}`))}"><span class="ui-callout__icon">${icon(ic)}</span><div>${body}</div></div>`;
|
|
223
223
|
}
|
|
224
224
|
// Default icon per status — overridable with `icon`. The circle family is not
|
|
225
225
|
// decoration: a circled glyph is a STATE the system reports, a bare one is an
|
|
@@ -250,7 +250,7 @@ export function toast({
|
|
|
250
250
|
const bodyHtml = compact || !body ? '' : `<div class="ui-toast__text">${esc(body)}</div>`;
|
|
251
251
|
const dur = typeof timer === 'number' ? ` style="--toast-dur:${timer}s"` : '';
|
|
252
252
|
const timerBar = timer ? '<span class="ui-toast__timer" data-toast-timer></span>' : '';
|
|
253
|
-
return `<div class="${cls}" role="status" aria-live="polite"${dur}>`
|
|
253
|
+
return `<div class="${esc(cls)}" role="status" aria-live="polite"${dur}>`
|
|
254
254
|
+ `<span class="ui-toast__icon">${icon(ic || TOAST_ICON[variant] || 'info')}</span>`
|
|
255
255
|
+ `<div class="ui-toast__body">${title ? `<div class="ui-toast__title">${esc(title)}</div>` : ''}${bodyHtml}</div>`
|
|
256
256
|
+ `${actBtn}${closeBtn}${timerBar}</div>`;
|
|
@@ -28,7 +28,7 @@ export function skeleton({ lines = 3, width, height, radius, className = '' } =
|
|
|
28
28
|
};
|
|
29
29
|
const bars = Array.from({ length: n }, (_, i) =>
|
|
30
30
|
`<span class="ui-skel__bar m-skeleton"${styleFor(i)}></span>`).join('');
|
|
31
|
-
return `<div class="${cx('ui-skel', className)}" aria-hidden="true">${bars}</div>`;
|
|
31
|
+
return `<div class="${esc(cx('ui-skel', className))}" aria-hidden="true">${bars}</div>`;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
// A table's worth of skeleton — `rows` × `cols` of bar, laid out on a grid so
|
|
@@ -36,7 +36,7 @@ export function skeleton({ lines = 3, width, height, radius, className = '' } =
|
|
|
36
36
|
// load a table are the common case, and hand-rolling this per screen is how
|
|
37
37
|
// four slightly different loading tables get shipped.
|
|
38
38
|
export function skeletonTable({ rows = 5, cols = 4, head = true } = {}) {
|
|
39
|
-
const row = (cls) => `<div class="${cls}">`
|
|
39
|
+
const row = (cls) => `<div class="${esc(cls)}">`
|
|
40
40
|
+ Array.from({ length: Math.max(1, cols | 0) }, () => '<span class="ui-skel__bar m-skeleton"></span>').join('')
|
|
41
41
|
+ '</div>';
|
|
42
42
|
const body = Array.from({ length: Math.max(1, rows | 0) }, () => row('ui-skel__row')).join('');
|
|
@@ -56,7 +56,7 @@ export function busyRegion({
|
|
|
56
56
|
body, lines = 3, className = '',
|
|
57
57
|
} = {}) {
|
|
58
58
|
const inner = body != null ? body : skeleton({ lines });
|
|
59
|
-
return `<div class="${cx('ui-busy', className)}" data-busy`
|
|
59
|
+
return `<div class="${esc(cx('ui-busy', className))}" data-busy`
|
|
60
60
|
+ ` data-busy-label="${esc(label)}" data-busy-ready="${esc(readyLabel)}"`
|
|
61
61
|
+ ` role="status" aria-live="polite" aria-busy="${busy ? 'true' : 'false'}">`
|
|
62
62
|
+ `<span class="ui-sr" data-busy-msg>${esc(busy ? label : readyLabel)}</span>`
|
|
@@ -119,7 +119,7 @@ export function deniedState({
|
|
|
119
119
|
const actionsEl = actions.length
|
|
120
120
|
? `<div class="ui-denied__actions">${actions.map((a) => button({ size: 'md', ...a })).join('')}</div>`
|
|
121
121
|
: '';
|
|
122
|
-
return `<div class="${cx('ui-denied', className)}">`
|
|
122
|
+
return `<div class="${esc(cx('ui-denied', className))}">`
|
|
123
123
|
+ `<div class="ui-denied__seal" aria-hidden="true">${icon(ic)}</div>`
|
|
124
124
|
+ `<div class="ui-denied__title">${esc(title)}</div>`
|
|
125
125
|
+ `${sub ? `<div class="ui-denied__sub">${esc(sub)}</div>` : ''}`
|
package/src/components/nav.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// is for). Only the collapsible sidebar groups need JS; wire with wireNav().
|
|
9
9
|
import { esc, icon } from './index.js';
|
|
10
10
|
import { playEntrance } from '../motion.js';
|
|
11
|
-
|
|
11
|
+
import { safeUrl } from '../html.js';
|
|
12
12
|
const cx = (...a) => a.filter(Boolean).join(' ');
|
|
13
13
|
|
|
14
14
|
// Unique-per-render ids so a section heading can label its own list. Module
|
|
@@ -28,7 +28,7 @@ function navBadge(badge) {
|
|
|
28
28
|
const text = badgeText(badge);
|
|
29
29
|
if (!text) return '';
|
|
30
30
|
const tone = typeof badge === 'object' && badge.tone ? badge.tone : 'neutral';
|
|
31
|
-
return `<span class="${cx('ui-nav__badge', `is-${tone}`)}">${esc(text)}</span>`;
|
|
31
|
+
return `<span class="${esc(cx('ui-nav__badge', `is-${tone}`))}">${esc(text)}</span>`;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
// An item's accessible name. It is emitted at every width, not only when
|
|
@@ -59,7 +59,7 @@ function sideLeaf(it, active, { collapsed, sub, current = 'page' } = {}) {
|
|
|
59
59
|
}
|
|
60
60
|
const attrs = [
|
|
61
61
|
`class="${cls}"`,
|
|
62
|
-
`href="${esc(it.href || '#' + (it.id ?? ''))}"`,
|
|
62
|
+
`href="${esc(safeUrl(it.href || '#' + (it.id ?? '')))}"`,
|
|
63
63
|
it.target ? `target="${esc(it.target)}"` : '',
|
|
64
64
|
on ? `aria-current="${current}"` : '',
|
|
65
65
|
name.trim(),
|
|
@@ -140,13 +140,13 @@ export function navTabs({
|
|
|
140
140
|
}
|
|
141
141
|
const attrs = [
|
|
142
142
|
`class="${cx('ui-nav__tab', on && 'is-active')}"`,
|
|
143
|
-
`href="${esc(it.href || '#' + (it.id ?? ''))}"`,
|
|
143
|
+
`href="${esc(safeUrl(it.href || '#' + (it.id ?? '')))}"`,
|
|
144
144
|
it.target ? `target="${esc(it.target)}"` : '',
|
|
145
145
|
on ? 'aria-current="page"' : '',
|
|
146
146
|
].filter(Boolean).join(' ');
|
|
147
147
|
return `<a ${attrs}>${inner}</a>`;
|
|
148
148
|
}).join('');
|
|
149
|
-
return `<nav class="${cx('ui-nav', 'ui-nav--tabs', `is-${variant}`)}"` +
|
|
149
|
+
return `<nav class="${esc(cx('ui-nav', 'ui-nav--tabs', `is-${variant}`))}"` +
|
|
150
150
|
` aria-label="${esc(ariaLabel)}"${id ? ` id="${esc(id)}"` : ''}>${tabs}</nav>`;
|
|
151
151
|
}
|
|
152
152
|
|
|
@@ -161,7 +161,7 @@ export function breadcrumbs({ items = [], ariaLabel = 'Breadcrumb', id } = {}) {
|
|
|
161
161
|
const inner = `${lead}${label}`;
|
|
162
162
|
const isLast = i === last;
|
|
163
163
|
const cell = (!isLast && it.href)
|
|
164
|
-
? `<a class="ui-nav__crumb" href="${esc(it.href)}"${it.target ? ` target="${esc(it.target)}"` : ''}>${inner}</a>`
|
|
164
|
+
? `<a class="ui-nav__crumb" href="${esc(safeUrl(it.href))}"${it.target ? ` target="${esc(it.target)}"` : ''}>${inner}</a>`
|
|
165
165
|
: `<span class="${cx('ui-nav__crumb', isLast && 'is-current')}"${isLast ? ' aria-current="page"' : ''}>${inner}</span>`;
|
|
166
166
|
return `<li class="ui-nav__crumb-item">${cell}</li>`;
|
|
167
167
|
}).join('');
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// fetched. The component then knows no last page, so it draws Prev and Next and
|
|
9
9
|
// nothing else — see `ui-pager--open` below.
|
|
10
10
|
import { esc } from './index.js';
|
|
11
|
-
|
|
11
|
+
import { safeUrl } from '../html.js';
|
|
12
12
|
// The sizes a table offers, and the one it starts on. Named here so no call site
|
|
13
13
|
// writes either number: both moved once already and would have moved in thirteen
|
|
14
14
|
// files. MUI's DataGrid ships exactly this pair (default 100, options 25/50/100),
|
|
@@ -74,7 +74,7 @@ const fmt = (n) => n.toLocaleString('en-US');
|
|
|
74
74
|
function control({ cls, page, label, href, disabled = false, current = false }) {
|
|
75
75
|
const attrs = `class="${cls}" data-page="${page}"${current ? ' aria-current="page"' : ''}`;
|
|
76
76
|
return href && !disabled
|
|
77
|
-
? `<a href="${esc(href(page))}" ${attrs}>${esc(label)}</a>`
|
|
77
|
+
? `<a href="${esc(safeUrl(href(page)))}" ${attrs}>${esc(label)}</a>`
|
|
78
78
|
: `<button type="button" ${attrs}${disabled ? ' disabled aria-disabled="true"' : ''}>${esc(label)}</button>`;
|
|
79
79
|
}
|
|
80
80
|
|
package/src/components/shell.js
CHANGED
|
@@ -10,7 +10,7 @@ import { backLink } from './back.js';
|
|
|
10
10
|
import { prism } from '../assets/brand.js';
|
|
11
11
|
import { ACCOUNT_NAV, toMenuTuple, initials } from './account-nav.js';
|
|
12
12
|
import { paletteHotkey } from './command-palette.js';
|
|
13
|
-
|
|
13
|
+
import { safeUrl } from '../html.js';
|
|
14
14
|
// The one account navigation definition lives in account-nav.js because topbar.js needs
|
|
15
15
|
// it too; re-exported here so the name docs/library.md publishes keeps working.
|
|
16
16
|
export { ACCOUNT_NAV };
|
|
@@ -294,7 +294,7 @@ export function appShell(options = {}) {
|
|
|
294
294
|
// the name is written out — the mark itself is aria-hidden. The banded layout's own
|
|
295
295
|
// bar says no word, so there the rail keeps the lockup: that layout's head band IS
|
|
296
296
|
// the product's mark. why: docs/specification.md#the-page-shell
|
|
297
|
-
const brand = compat ? '' : `<a class="ui-app__brand" href="${esc(brandHref)}" aria-label="${esc(word)}">`
|
|
297
|
+
const brand = compat ? '' : `<a class="ui-app__brand" href="${esc(safeUrl(brandHref))}" aria-label="${esc(word)}">`
|
|
298
298
|
+ `${prism(`appb-${++_shellUid}`, 24)}<span>${esc(word)}</span></a>`;
|
|
299
299
|
// A <div>, not an <aside>: <aside> is the `complementary` landmark, and this holds the
|
|
300
300
|
// page's primary navigation and the signed-in reader. The <nav> inside it is already
|
|
@@ -46,9 +46,9 @@ function confettiField() {
|
|
|
46
46
|
// Optional auto-redirect countdown. Markup-only here (a conic ring sweeps and a
|
|
47
47
|
// static number shows); wireSuccess() turns it into a live ticking counter.
|
|
48
48
|
function countdownEl({ seconds = 5, label = 'Redirecting' } = {}) {
|
|
49
|
-
return `<div class="ui-sx__count" data-sx-count style="--sx-secs:${seconds}s">
|
|
49
|
+
return `<div class="ui-sx__count" data-sx-count style="--sx-secs:${esc(seconds)}s">
|
|
50
50
|
<span class="ui-sx__count-ring" aria-hidden="true"></span>
|
|
51
|
-
<span class="ui-sx__count-text">${esc(label)} in <b data-sx-num>${seconds}</b>s</span>
|
|
51
|
+
<span class="ui-sx__count-text">${esc(label)} in <b data-sx-num>${esc(seconds)}</b>s</span>
|
|
52
52
|
</div>`;
|
|
53
53
|
}
|
|
54
54
|
|
|
@@ -103,7 +103,7 @@ export function success({
|
|
|
103
103
|
: '';
|
|
104
104
|
const countEl = countdown ? countdownEl(countdown) : '';
|
|
105
105
|
|
|
106
|
-
return `<div class="${cls}" role="status" aria-live="polite">
|
|
106
|
+
return `<div class="${esc(cls)}" role="status" aria-live="polite">
|
|
107
107
|
${bd}
|
|
108
108
|
${confetti ? confettiField() : ''}
|
|
109
109
|
<div class="ui-sx__inner">
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { esc } from './index.js';
|
|
2
|
-
|
|
2
|
+
import { safeUrl } from '../html.js';
|
|
3
3
|
export function numericValue({ value, unit = '', missing = 'Not available' } = {}) {
|
|
4
4
|
if (value == null || value === '') return `<span class="ui-value" aria-label="${esc(missing)}">—</span>`;
|
|
5
5
|
return `<span class="ui-value">${esc(value)}${unit ? `<span class="ui-value__unit">${esc(unit)}</span>` : ''}</span>`;
|
|
@@ -13,8 +13,8 @@ export function deltaValue({ value, tone = 'neutral', basisId, missing = 'No ear
|
|
|
13
13
|
|
|
14
14
|
export function rowIdentity({ symbol = '', name = '', logo, href } = {}) {
|
|
15
15
|
const tag = href ? 'a' : 'span';
|
|
16
|
-
return `<${tag} class="ui-identity"${href ? ` href="${esc(href)}"` : ''}>`
|
|
17
|
-
+ `<span class="ui-identity__logo" aria-hidden="true"><span>${esc(symbol.slice(0, 1))}</span>${logo ? `<img src="${esc(logo)}" alt="">` : ''}</span>`
|
|
16
|
+
return `<${tag} class="ui-identity"${href ? ` href="${esc(safeUrl(href))}"` : ''}>`
|
|
17
|
+
+ `<span class="ui-identity__logo" aria-hidden="true"><span>${esc(symbol.slice(0, 1))}</span>${logo ? `<img src="${esc(safeUrl(logo, ''))}" alt="">` : ''}</span>`
|
|
18
18
|
+ `<span class="ui-identity__symbol">${esc(symbol)}</span><span class="ui-identity__name">${esc(name)}</span></${tag}>`;
|
|
19
19
|
}
|
|
20
20
|
|
package/src/components/tabs.js
CHANGED
|
@@ -15,14 +15,14 @@
|
|
|
15
15
|
// aria-selected, and aria-controls / aria-labelledby wiring. `name` must be unique
|
|
16
16
|
// per tabs instance on a page (it seeds the tab/panel ids).
|
|
17
17
|
import { playEntrance } from '../motion.js';
|
|
18
|
-
|
|
18
|
+
import { esc } from '../html.js';
|
|
19
19
|
export function tabs({ items = [], active = 0, name = 'tabs', ariaLabel = 'Tabs', className = '' } = {}) {
|
|
20
20
|
const cls = ['ui-tabs', className].filter(Boolean).join(' ');
|
|
21
21
|
const list = items
|
|
22
22
|
.map((it, i) => {
|
|
23
23
|
const on = i === active;
|
|
24
24
|
return `<button type="button" class="ui-tabs__tab${on ? ' is-active' : ''}" role="tab"`
|
|
25
|
-
+ ` id="${name}-tab-${i}" aria-controls="${name}-panel-${i}"`
|
|
25
|
+
+ ` id="${esc(name)}-tab-${i}" aria-controls="${esc(name)}-panel-${i}"`
|
|
26
26
|
+ ` aria-selected="${on ? 'true' : 'false'}" tabindex="${on ? '0' : '-1'}">${it.label}</button>`;
|
|
27
27
|
})
|
|
28
28
|
.join('');
|
|
@@ -33,12 +33,12 @@ export function tabs({ items = [], active = 0, name = 'tabs', ariaLabel = 'Tabs'
|
|
|
33
33
|
// focusable content of its own, and most panels here are plain text. Without
|
|
34
34
|
// it a keyboard user reaches the tab strip and then cannot reach what it
|
|
35
35
|
// switched to — Tab jumps straight past the panel to whatever follows.
|
|
36
|
-
return `<div class="ui-tabs__panel" role="tabpanel" id="${name}-panel-${i}"`
|
|
37
|
-
+ ` aria-labelledby="${name}-tab-${i}" tabindex="0"${on ? '' : ' hidden'}>${it.panel || ''}</div>`;
|
|
36
|
+
return `<div class="ui-tabs__panel" role="tabpanel" id="${esc(name)}-panel-${i}"`
|
|
37
|
+
+ ` aria-labelledby="${esc(name)}-tab-${i}" tabindex="0"${on ? '' : ' hidden'}>${it.panel || ''}</div>`;
|
|
38
38
|
})
|
|
39
39
|
.join('');
|
|
40
|
-
return `<div class="${cls}" data-tabs>`
|
|
41
|
-
+ `<div class="ui-tabs__list" role="tablist" aria-label="${ariaLabel}">${list}</div>`
|
|
40
|
+
return `<div class="${esc(cls)}" data-tabs>`
|
|
41
|
+
+ `<div class="ui-tabs__list" role="tablist" aria-label="${esc(ariaLabel)}">${list}</div>`
|
|
42
42
|
+ `<div class="ui-tabs__panels">${panels}</div>`
|
|
43
43
|
+ `</div>`;
|
|
44
44
|
}
|
package/src/components/topbar.js
CHANGED
|
@@ -5,7 +5,7 @@ import { icon, sun, moon } from '../assets/icons.js';
|
|
|
5
5
|
import { esc } from './index.js';
|
|
6
6
|
import { wireDropdown } from './dropdown.js';
|
|
7
7
|
import { accountMenuNav, initials, toMenuTuple } from './account-nav.js';
|
|
8
|
-
|
|
8
|
+
import { trustedAttr, trustedUrl } from '../html.js';
|
|
9
9
|
const THEME_KEY = 'apliteni-strategy-theme';
|
|
10
10
|
|
|
11
11
|
// A stateful control in this kit reports the state it is IN, never the state a
|
|
@@ -60,7 +60,7 @@ export function versionSwitcher(versions = [], activeIdx = 0) {
|
|
|
60
60
|
// `nav` mirrors the account sidebar, DERIVED from the one ACCOUNT_NAV definition
|
|
61
61
|
// rather than restated: a second literal agreed with it by hand about the icon
|
|
62
62
|
// and disagreed about the encoding, which is the drift #127 was filed about.
|
|
63
|
-
//
|
|
63
|
+
// Body slots below are trusted HTML; attribute copies retain encoded entities.
|
|
64
64
|
//
|
|
65
65
|
// `initials` is the avatar. A derived value has to be derived BEFORE the
|
|
66
66
|
// escaping — `<Ada>` and `<Ada>` do not begin with the same character — so
|
|
@@ -84,13 +84,13 @@ export function accountMenu({
|
|
|
84
84
|
const items = (Array.isArray(nav) ? nav : accountMenuNav())
|
|
85
85
|
.map((n) => (Array.isArray(n) ? n : toMenuTuple(n)));
|
|
86
86
|
const it = ([id, ic, label, href, target]) =>
|
|
87
|
-
`<a href="${href || '#' + id}"${target ? ` target="${target}"` : ''} data-dd-item tabindex="-1"${active === id ? ' class="cur"' : ''} role="menuitem">${icon(ic)}${label}</a>`;
|
|
87
|
+
`<a href="${trustedUrl(href || '#' + id)}"${target ? ` target="${trustedAttr(target)}"` : ''} data-dd-item tabindex="-1"${active === id ? ' class="cur"' : ''} role="menuitem">${icon(ic)}${label}</a>`;
|
|
88
88
|
// `on` so the menu is visible in Storybook / standalone use (no /auth/me gate).
|
|
89
89
|
// Consumes the shared dropdown wiring via the generic [data-dropdown] hooks.
|
|
90
90
|
return `<div class="acct on" data-dropdown>` +
|
|
91
91
|
`<button class="avatar" data-dropdown-trigger aria-haspopup="menu" aria-expanded="false" aria-label="Account">${ini}</button>` +
|
|
92
92
|
`<div class="amenu" data-dropdown-panel role="menu">` +
|
|
93
|
-
`<div class="ahead"><span class="avatar">${ini}</span><span class="aw"><span class="anm">${name}</span><span class="aem" title="${email}">${email}</span></span></div>` +
|
|
93
|
+
`<div class="ahead"><span class="avatar">${ini}</span><span class="aw"><span class="anm">${name}</span><span class="aem" title="${trustedAttr(email)}">${email}</span></span></div>` +
|
|
94
94
|
items.map(it).join('') +
|
|
95
95
|
`<div class="asep"></div><a class="aout" href="#logout" data-dd-item tabindex="-1" role="menuitem">${icon('logout')}Sign out</a>` +
|
|
96
96
|
`</div></div>`;
|
package/src/html.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Shared HTML boundaries; public esc remains exported from components/index.js.
|
|
2
|
+
const ENTITIES = { '&': '&', '<': '<', '>': '>', '"': '"' };
|
|
3
|
+
export const esc = value => String(value == null ? '' : value).replace(/[&<>"]/g, c => ENTITIES[c]);
|
|
4
|
+
|
|
5
|
+
// Legacy topbar/brand slots accept already escaped HTML. Preserve their entities.
|
|
6
|
+
export const trustedAttr = value => String(value ?? '').replace(/[<>"]/g, c => ENTITIES[c]);
|
|
7
|
+
|
|
8
|
+
// why: docs/specification.md#vanilla-html-boundaries
|
|
9
|
+
export function safeUrl(value, fallback = '#') {
|
|
10
|
+
const raw = String(value ?? '');
|
|
11
|
+
const scheme = raw.replace(/[\t\n\r]/g, '').replace(/^[\u0000-\u0020]+/, '');
|
|
12
|
+
return /^(?:javascript|data|vbscript):/i.test(scheme) ? fallback : raw;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function trustedUrl(value) {
|
|
16
|
+
// Legacy attributes are already HTML encoded, so check their decoded scheme.
|
|
17
|
+
const decoded = String(value ?? '').replace(/&#(x[\da-f]+|\d+);?|&(colon|Tab|NewLine);/gi, (match, number, name) => {
|
|
18
|
+
if (!number) return { colon: ':', tab: '\t', newline: '\n' }[name.toLowerCase()];
|
|
19
|
+
const code = number[0].toLowerCase() === 'x' ? parseInt(number.slice(1), 16) : Number(number);
|
|
20
|
+
return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : '\ufffd';
|
|
21
|
+
});
|
|
22
|
+
return safeUrl(decoded) === '#' ? '#' : trustedAttr(value);
|
|
23
|
+
}
|