tosijs-ui 1.5.18 → 1.5.21

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/dist/menu.d.ts CHANGED
@@ -27,7 +27,8 @@ export interface SubMenu {
27
27
  dropAction?: (dataTransfer: DataTransfer) => void;
28
28
  }
29
29
  export type MenuSeparator = null;
30
- export type MenuItem = MenuAction | SubMenu | MenuSeparator;
30
+ export type MenuElement = () => HTMLElement;
31
+ export type MenuItem = MenuAction | SubMenu | MenuSeparator | MenuElement;
31
32
  export declare const resolveMenuItems: (provider: MenuItemsProvider) => MenuItem[];
32
33
  export declare const filterForDrop: (items: MenuItem[], dataTypes: readonly string[], hideDisabled?: boolean) => MenuItem[];
33
34
  export declare const filterForClick: (items: MenuItem[], hideDisabled?: boolean) => MenuItem[];
package/dist/menu.js CHANGED
@@ -235,12 +235,14 @@ export interface PopMenuOptions {
235
235
 
236
236
  ## MenuItem
237
237
 
238
- A `MenuItem` can be one of three things:
238
+ A `MenuItem` can be one of four things:
239
239
 
240
240
  - `null` denotes a separator
241
241
  - `MenuAction` denotes a labeled button or `<a>` tag based on whether the `action` provided
242
242
  is a url (string) or an event handler (function).
243
243
  - `SubMenu` is a submenu.
244
+ - A `() => HTMLElement` function returns a custom element to embed inline in
245
+ the menu (see `MenuElement` below).
244
246
 
245
247
  ### MenuAction
246
248
 
@@ -272,6 +274,54 @@ interface SubMenu {
272
274
  }
273
275
  ```
274
276
 
277
+ ### MenuElement
278
+
279
+ For embedding a custom widget inline in a menu — e.g. a `<tosi-segmented>` for
280
+ quick option-picking — pass a function that returns an `HTMLElement`:
281
+
282
+ ```
283
+ type MenuElement = () => HTMLElement
284
+ ```
285
+
286
+ The returned element is added as-is and tagged with the `tosi-menu-element`
287
+ class, which sets `min-height` to match standard menu items so the row
288
+ aligns visually. The widget is responsible for its own click/focus behaviour.
289
+
290
+ ```js
291
+ import { popMenu, tosiSegmented } from 'tosijs-ui'
292
+ import { elements } from 'tosijs'
293
+
294
+ const { button } = elements
295
+
296
+ let view = 'list'
297
+
298
+ const btn = button('View options')
299
+ btn.addEventListener('click', () => {
300
+ popMenu({
301
+ target: btn,
302
+ menuItems: [
303
+ { caption: 'Refresh', icon: 'refreshCcw', action() {} },
304
+ null,
305
+ () => tosiSegmented({
306
+ choices: 'list,grid,table',
307
+ value: view,
308
+ style: { margin: '0 1em' },
309
+ onChange(event) {
310
+ view = event.target.value
311
+ },
312
+ // stop the menu's outer onClick from closing it when the user
313
+ // picks a segment
314
+ onClick(event) { event.stopPropagation() },
315
+ }),
316
+ null,
317
+ { caption: 'Settings…', icon: 'settings', action() {} },
318
+ ]
319
+ })
320
+ })
321
+
322
+ preview.append(btn)
323
+ ```
324
+
275
325
  ### Keyboard Shortcuts
276
326
 
277
327
  If a menu is embodied in a `<tosi-menu>` it is supported by keyboard
@@ -736,6 +786,10 @@ export const filterForDrop = (items, dataTypes, hideDisabled = false) => {
736
786
  filtered.push(item);
737
787
  continue;
738
788
  }
789
+ if (typeof item === 'function') {
790
+ filtered.push(item);
791
+ continue;
792
+ }
739
793
  const { acceptsDrop } = item;
740
794
  if (!acceptsDrop) {
741
795
  if (!hideDisabled) {
@@ -777,6 +831,10 @@ export const filterForClick = (items, hideDisabled = false) => {
777
831
  filtered.push(item);
778
832
  continue;
779
833
  }
834
+ if (typeof item === 'function') {
835
+ filtered.push(item);
836
+ continue;
837
+ }
780
838
  const action = item.action;
781
839
  const menuItemsProvider = item.menuItems;
782
840
  if (action || menuItemsProvider) {
@@ -870,6 +928,9 @@ StyleSheet('xin-menu-helper', {
870
928
  background: varDefault.menuSeparatorColor('#2224'),
871
929
  margin: varDefault.menuSeparatorMargin('8px 0'),
872
930
  },
931
+ '.xin-menu-element, .tosi-menu-element': {
932
+ minHeight: varDefault.menuItemHeight('48px'),
933
+ },
873
934
  '.xin-menu-item, .tosi-menu-item': menuItemStyles,
874
935
  '.xin-menu-item, .xin-menu-item > span, .tosi-menu-item, .tosi-menu-item > span': menuItemColorStyles,
875
936
  '.xin-menu-with-icons .xin-menu-item, .tosi-menu-with-icons .tosi-menu-item': {
@@ -917,19 +978,18 @@ export const createMenuAction = (item, options) => {
917
978
  let menuItem;
918
979
  const props = item.properties || {};
919
980
  if (typeof item?.action === 'string') {
920
- menuItem = a({
921
- class: 'xin-menu-item tosi-menu-item',
981
+ menuItem = a(props, {
922
982
  role: itemRole,
923
983
  href: item.action,
924
- }, props, icon, options.localized ? span(localize(item.caption)) : span(item.caption), span(item.shortcut ? displayShortcut(item.shortcut) : ' '));
984
+ }, icon, options.localized ? span(localize(item.caption)) : span(item.caption), span(item.shortcut ? displayShortcut(item.shortcut) : ' '));
925
985
  }
926
986
  else {
927
- menuItem = button({
928
- class: 'xin-menu-item tosi-menu-item',
987
+ menuItem = button(props, {
929
988
  role: itemRole,
930
989
  onClick: item.action,
931
- }, props, icon, options.localized ? span(localize(item.caption)) : span(item.caption), span(item.shortcut ? displayShortcut(item.shortcut) : ' '));
990
+ }, icon, options.localized ? span(localize(item.caption)) : span(item.caption), span(item.shortcut ? displayShortcut(item.shortcut) : ' '));
932
991
  }
992
+ menuItem.classList.add('xin-menu-item', 'tosi-menu-item');
933
993
  menuItem.classList.toggle('xin-menu-item-checked', checked !== false);
934
994
  menuItem.classList.toggle('tosi-menu-item-checked', checked !== false);
935
995
  if (item.tooltip) {
@@ -950,8 +1010,7 @@ export const createDropMenuItem = (item, options) => {
950
1010
  icon = icons[icon]();
951
1011
  }
952
1012
  const props = item.properties || {};
953
- const menuItem = button({
954
- class: 'xin-menu-item tosi-menu-item',
1013
+ const menuItem = button(props, {
955
1014
  onDragenter(event) {
956
1015
  clearDropGraceTimer();
957
1016
  menuItem.classList.add('xin-drop-over', 'tosi-drop-over');
@@ -978,7 +1037,8 @@ export const createDropMenuItem = (item, options) => {
978
1037
  }
979
1038
  removeLastMenu(0);
980
1039
  },
981
- }, props, icon, options.localized ? span(localize(item.caption)) : span(item.caption), span(' '));
1040
+ }, icon, options.localized ? span(localize(item.caption)) : span(item.caption), span(' '));
1041
+ menuItem.classList.add('xin-menu-item', 'tosi-menu-item');
982
1042
  if (item.tooltip) {
983
1043
  menuItem.dataset.tooltip = item.tooltip;
984
1044
  }
@@ -1000,8 +1060,7 @@ export const createSubMenu = (item, options) => {
1000
1060
  let disclosureTimer = null;
1001
1061
  let disclosed = false;
1002
1062
  const props = item.properties || {};
1003
- const submenuItem = button({
1004
- class: 'xin-menu-item tosi-menu-item',
1063
+ const submenuItem = button(props, {
1005
1064
  disabled: !(!item.enabled || item.enabled()),
1006
1065
  onClick(event) {
1007
1066
  if (options._dropMode)
@@ -1093,7 +1152,8 @@ export const createSubMenu = (item, options) => {
1093
1152
  }
1094
1153
  removeLastMenu(0);
1095
1154
  },
1096
- }, props, icon, options.localized ? span(localize(item.caption)) : span(item.caption), icons.chevronRight({ style: { justifySelf: 'flex-end' } }));
1155
+ }, icon, options.localized ? span(localize(item.caption)) : span(item.caption), icons.chevronRight({ style: { justifySelf: 'flex-end' } }));
1156
+ submenuItem.classList.add('xin-menu-item', 'tosi-menu-item');
1097
1157
  if (item.tooltip) {
1098
1158
  submenuItem.dataset.tooltip = item.tooltip;
1099
1159
  }
@@ -1106,6 +1166,11 @@ export const createMenuItem = (item, options) => {
1106
1166
  if (item === null) {
1107
1167
  return span({ class: 'xin-menu-separator tosi-menu-separator' });
1108
1168
  }
1169
+ else if (typeof item === 'function') {
1170
+ const el = item();
1171
+ el.classList.add('xin-menu-element', 'tosi-menu-element');
1172
+ return el;
1173
+ }
1109
1174
  else if (options._dropMode) {
1110
1175
  const sub = item;
1111
1176
  const hasChildren = sub.menuItems && resolveMenuItems(sub.menuItems).length > 0;
@@ -1142,7 +1207,9 @@ export const createMenuItem = (item, options) => {
1142
1207
  };
1143
1208
  export const menu = (options) => {
1144
1209
  const { target, width, menuItems, role = 'menu' } = options;
1145
- const hasIcons = menuItems.find((item) => item?.icon || item?.checked);
1210
+ const hasIcons = menuItems.find((item) => item != null &&
1211
+ typeof item !== 'function' &&
1212
+ (item.icon || item.checked));
1146
1213
  const menuDepth = options.submenuDepth || 0;
1147
1214
  const menuDiv = div({
1148
1215
  class: hasIcons
@@ -1296,6 +1363,8 @@ export function findShortcutAction(items, event, path = []) {
1296
1363
  for (const item of items) {
1297
1364
  if (!item)
1298
1365
  continue;
1366
+ if (typeof item === 'function')
1367
+ continue;
1299
1368
  const { shortcut } = item;
1300
1369
  const { menuItems } = item;
1301
1370
  if (shortcut) {
package/dist/select.js CHANGED
@@ -399,7 +399,7 @@ export class TosiSelect extends Component {
399
399
  return options;
400
400
  }
401
401
  const showOption = (option) => {
402
- if (option === null) {
402
+ if (option === null || typeof option === 'function') {
403
403
  return true;
404
404
  }
405
405
  else if (option.menuItems) {
package/dist/tag-list.js CHANGED
@@ -26,9 +26,9 @@ as a comma-delimited string or an array of strings).
26
26
  <b>Editable</b>
27
27
  <tosi-tag-list
28
28
  class="editable-tag-list"
29
- value="belongs,also belongs,custom"
29
+ value="belongs,also belongs,has\, comma,custom"
30
30
  editable
31
- available-tags="belongs,also belongs,not initially chosen"
31
+ available-tags="belongs,also belongs,has\, comma,not initially chosen"
32
32
  ></tosi-tag-list>
33
33
  </label>
34
34
  <br>
@@ -77,13 +77,29 @@ test('first tag-list has correct tags', () => {
77
77
  test('editable tag-list has editable attribute', () => {
78
78
  expect(tagLists[2].editable).toBe(true)
79
79
  })
80
+ test('a comma inside a tag survives the value round-trip', () => {
81
+ const tl = document.createElement('tosi-tag-list')
82
+ tl.tags = ['New York, NY', 'Boston']
83
+ // the literal comma is escaped in `value` so it is not a delimiter
84
+ expect(tl.value).toBe('New York\\, NY,Boston')
85
+ expect(tl.tags.length).toBe(2)
86
+ expect(tl.tags).toContain('New York, NY')
87
+ })
88
+ test('an escaped comma in a value string parses as one tag', () => {
89
+ const tl = document.createElement('tosi-tag-list')
90
+ tl.value = 'New York\\, NY,Boston'
91
+ expect(tl.tags.length).toBe(2)
92
+ expect(tl.tags).toContain('New York, NY')
93
+ })
80
94
  ```
81
95
 
82
96
  ## Properties
83
97
 
84
98
  ### `value`: string | string[]
85
99
 
86
- A list of tags
100
+ A comma-delimited list of tags. A tag that itself contains a comma must
101
+ escape it as `\,` — e.g. `value="New York\, NY,Boston"` is two tags. The
102
+ `tags` accessor handles this escaping for you in both directions.
87
103
 
88
104
  ### `tags`: string[]
89
105
 
@@ -97,7 +113,8 @@ A read-only property giving the value as an array.
97
113
  ### `available-tags`: string | string[]
98
114
 
99
115
  A list of tags that will be displayed in the popup menu by default. The popup menu
100
- will always display custom tags (allowing their removal).
116
+ will always display custom tags (allowing their removal). As with `value`, a
117
+ comma inside a tag must be escaped as `\,` when set via the attribute string.
101
118
 
102
119
  ### `editable`: boolean
103
120
 
@@ -115,6 +132,12 @@ import { Component as WebComponent, elements, vars, varDefault, deprecated, } fr
115
132
  import { popMenu } from './menu';
116
133
  import { icons } from './icons';
117
134
  const { div, input, span, button } = elements;
135
+ // Tags are serialised as a comma-delimited string (the form `value`). A
136
+ // literal comma inside a tag is escaped as `\,` so it survives the
137
+ // split/join round-trip — both in programmatic values and in the
138
+ // `value` / `available-tags` HTML attributes.
139
+ const splitTags = (str) => str.split(/(?<!\\),/).map((tag) => tag.trim().replace(/\\,/g, ','));
140
+ const joinTags = (tags) => tags.map((tag) => tag.replace(/,/g, '\\,')).join(',');
118
141
  export class TosiTag extends WebComponent {
119
142
  static preferredTagName = 'tosi-tag';
120
143
  static lightStyleSpec = {
@@ -248,13 +271,10 @@ export class TosiTagList extends WebComponent {
248
271
  value = '';
249
272
  // tags parses value into array
250
273
  get tags() {
251
- return this.value
252
- .split(',')
253
- .map((tag) => tag.trim())
254
- .filter((tag) => tag !== '');
274
+ return splitTags(this.value).filter((tag) => tag !== '');
255
275
  }
256
276
  set tags(v) {
257
- this.value = v.join(',');
277
+ this.value = joinTags(v);
258
278
  }
259
279
  _availableTags = [];
260
280
  get availableTags() {
@@ -269,12 +289,9 @@ export class TosiTagList extends WebComponent {
269
289
  }
270
290
  this.queueRender();
271
291
  }
272
- // Parse available-tags string (comma-delimited)
292
+ // Parse available-tags string (comma-delimited; `\,` is a literal comma).
273
293
  static parseAvailableTagsString(tagsStr) {
274
- return tagsStr.split(',').map((tag) => {
275
- const trimmed = tag.trim();
276
- return trimmed === '' ? null : trimmed;
277
- });
294
+ return splitTags(tagsStr).map((tag) => (tag === '' ? null : tag));
278
295
  }
279
296
  connectedCallback() {
280
297
  super.connectedCallback();