matterviz 0.3.0 → 0.3.2

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.
Files changed (286) hide show
  1. package/dist/FilePicker.svelte +37 -20
  2. package/dist/Icon.svelte +2 -2
  3. package/dist/MillerIndexInput.svelte +60 -0
  4. package/dist/MillerIndexInput.svelte.d.ts +7 -0
  5. package/dist/app.css +38 -2
  6. package/dist/brillouin/BrillouinZone.svelte +20 -62
  7. package/dist/brillouin/BrillouinZone.svelte.d.ts +1 -1
  8. package/dist/brillouin/BrillouinZoneExportPane.svelte +12 -20
  9. package/dist/brillouin/BrillouinZoneScene.svelte +2 -2
  10. package/dist/brillouin/BrillouinZoneScene.svelte.d.ts +1 -1
  11. package/dist/chempot-diagram/ChemPotDiagram.svelte +192 -0
  12. package/dist/chempot-diagram/ChemPotDiagram.svelte.d.ts +13 -0
  13. package/dist/chempot-diagram/ChemPotDiagram2D.svelte +677 -0
  14. package/dist/chempot-diagram/ChemPotDiagram2D.svelte.d.ts +16 -0
  15. package/dist/chempot-diagram/ChemPotDiagram3D.svelte +2688 -0
  16. package/dist/chempot-diagram/ChemPotDiagram3D.svelte.d.ts +16 -0
  17. package/dist/chempot-diagram/ChemPotScene3D.svelte +8 -0
  18. package/dist/chempot-diagram/ChemPotScene3D.svelte.d.ts +7 -0
  19. package/dist/chempot-diagram/color.d.ts +10 -0
  20. package/dist/chempot-diagram/color.js +33 -0
  21. package/dist/chempot-diagram/compute.d.ts +38 -0
  22. package/dist/chempot-diagram/compute.js +650 -0
  23. package/dist/chempot-diagram/index.d.ts +5 -0
  24. package/dist/chempot-diagram/index.js +5 -0
  25. package/dist/chempot-diagram/pointer.d.ts +16 -0
  26. package/dist/chempot-diagram/pointer.js +40 -0
  27. package/dist/chempot-diagram/temperature.d.ts +15 -0
  28. package/dist/chempot-diagram/temperature.js +37 -0
  29. package/dist/chempot-diagram/types.d.ts +83 -0
  30. package/dist/chempot-diagram/types.js +27 -0
  31. package/dist/colors/index.d.ts +3 -1
  32. package/dist/colors/index.js +4 -0
  33. package/dist/composition/BarChart.svelte +13 -22
  34. package/dist/composition/BubbleChart.svelte +5 -3
  35. package/dist/composition/FormulaFilter.svelte +770 -90
  36. package/dist/composition/FormulaFilter.svelte.d.ts +37 -1
  37. package/dist/composition/PieChart.svelte +43 -18
  38. package/dist/composition/PieChart.svelte.d.ts +1 -1
  39. package/dist/constants.d.ts +1 -0
  40. package/dist/constants.js +2 -0
  41. package/dist/convex-hull/ConvexHull.svelte +14 -1
  42. package/dist/convex-hull/ConvexHull.svelte.d.ts +1 -1
  43. package/dist/convex-hull/ConvexHull2D.svelte +14 -45
  44. package/dist/convex-hull/ConvexHull2D.svelte.d.ts +1 -1
  45. package/dist/convex-hull/ConvexHull3D.svelte +396 -134
  46. package/dist/convex-hull/ConvexHull3D.svelte.d.ts +1 -1
  47. package/dist/convex-hull/ConvexHull4D.svelte +93 -42
  48. package/dist/convex-hull/ConvexHull4D.svelte.d.ts +1 -1
  49. package/dist/convex-hull/ConvexHullControls.svelte +94 -31
  50. package/dist/convex-hull/ConvexHullControls.svelte.d.ts +4 -2
  51. package/dist/convex-hull/ConvexHullStats.svelte +697 -128
  52. package/dist/convex-hull/ConvexHullStats.svelte.d.ts +6 -1
  53. package/dist/convex-hull/ConvexHullTooltip.svelte +1 -0
  54. package/dist/convex-hull/GasPressureControls.svelte +72 -38
  55. package/dist/convex-hull/GasPressureControls.svelte.d.ts +2 -1
  56. package/dist/convex-hull/TemperatureSlider.svelte +46 -19
  57. package/dist/convex-hull/TemperatureSlider.svelte.d.ts +2 -1
  58. package/dist/convex-hull/demo-temperature.d.ts +6 -0
  59. package/dist/convex-hull/demo-temperature.js +36 -0
  60. package/dist/convex-hull/gas-thermodynamics.js +16 -5
  61. package/dist/convex-hull/helpers.d.ts +7 -1
  62. package/dist/convex-hull/helpers.js +45 -15
  63. package/dist/convex-hull/index.d.ts +15 -1
  64. package/dist/convex-hull/index.js +1 -0
  65. package/dist/convex-hull/thermodynamics.d.ts +8 -21
  66. package/dist/convex-hull/thermodynamics.js +106 -17
  67. package/dist/convex-hull/types.d.ts +7 -0
  68. package/dist/convex-hull/types.js +11 -0
  69. package/dist/coordination/CoordinationBarPlot.svelte +29 -46
  70. package/dist/element/BohrAtom.svelte +1 -1
  71. package/dist/element/data.js +2 -14
  72. package/dist/element/data.json.gz +0 -0
  73. package/dist/element/index.d.ts +1 -1
  74. package/dist/element/index.js +1 -0
  75. package/dist/element/types.d.ts +1 -0
  76. package/dist/fermi-surface/FermiSurface.svelte +21 -65
  77. package/dist/fermi-surface/FermiSurface.svelte.d.ts +1 -1
  78. package/dist/fermi-surface/FermiSurfaceControls.svelte.d.ts +1 -1
  79. package/dist/fermi-surface/FermiSurfaceScene.svelte +1 -1
  80. package/dist/fermi-surface/FermiSurfaceScene.svelte.d.ts +1 -1
  81. package/dist/fermi-surface/compute.js +1 -21
  82. package/dist/fermi-surface/marching-cubes.d.ts +2 -13
  83. package/dist/fermi-surface/marching-cubes.js +2 -519
  84. package/dist/fermi-surface/parse.js +17 -23
  85. package/dist/heatmap-matrix/HeatmapMatrix.svelte +1273 -0
  86. package/dist/heatmap-matrix/HeatmapMatrix.svelte.d.ts +110 -0
  87. package/dist/heatmap-matrix/HeatmapMatrixControls.svelte +171 -0
  88. package/dist/heatmap-matrix/HeatmapMatrixControls.svelte.d.ts +31 -0
  89. package/dist/heatmap-matrix/index.d.ts +53 -0
  90. package/dist/heatmap-matrix/index.js +100 -0
  91. package/dist/heatmap-matrix/shared.d.ts +2 -0
  92. package/dist/heatmap-matrix/shared.js +4 -0
  93. package/dist/icons.d.ts +119 -0
  94. package/dist/icons.js +119 -0
  95. package/dist/index.d.ts +6 -1
  96. package/dist/index.js +6 -1
  97. package/dist/io/export.js +15 -3
  98. package/dist/io/file-drop.d.ts +7 -0
  99. package/dist/io/file-drop.js +43 -0
  100. package/dist/io/index.d.ts +2 -2
  101. package/dist/io/index.js +2 -112
  102. package/dist/io/types.d.ts +1 -0
  103. package/dist/io/url-drop.d.ts +2 -0
  104. package/dist/io/url-drop.js +118 -0
  105. package/dist/isosurface/Isosurface.svelte +231 -0
  106. package/dist/isosurface/Isosurface.svelte.d.ts +8 -0
  107. package/dist/isosurface/IsosurfaceControls.svelte +273 -0
  108. package/dist/isosurface/IsosurfaceControls.svelte.d.ts +9 -0
  109. package/dist/isosurface/index.d.ts +5 -0
  110. package/dist/isosurface/index.js +6 -0
  111. package/dist/isosurface/parse.d.ts +6 -0
  112. package/dist/isosurface/parse.js +548 -0
  113. package/dist/isosurface/slice.d.ts +11 -0
  114. package/dist/isosurface/slice.js +145 -0
  115. package/dist/isosurface/types.d.ts +55 -0
  116. package/dist/isosurface/types.js +178 -0
  117. package/dist/labels.d.ts +2 -1
  118. package/dist/labels.js +1 -0
  119. package/dist/layout/InfoTag.svelte +62 -62
  120. package/dist/layout/SubpageGrid.svelte +74 -0
  121. package/dist/layout/SubpageGrid.svelte.d.ts +14 -0
  122. package/dist/layout/index.d.ts +1 -0
  123. package/dist/layout/index.js +1 -0
  124. package/dist/layout/json-tree/JsonNode.svelte +226 -53
  125. package/dist/layout/json-tree/JsonTree.svelte +425 -51
  126. package/dist/layout/json-tree/JsonTree.svelte.d.ts +1 -1
  127. package/dist/layout/json-tree/JsonValue.svelte +218 -97
  128. package/dist/layout/json-tree/types.d.ts +27 -2
  129. package/dist/layout/json-tree/utils.d.ts +14 -1
  130. package/dist/layout/json-tree/utils.js +254 -0
  131. package/dist/marching-cubes.d.ts +14 -0
  132. package/dist/marching-cubes.js +519 -0
  133. package/dist/math.d.ts +8 -0
  134. package/dist/math.js +374 -7
  135. package/dist/overlays/ContextMenu.svelte +3 -2
  136. package/dist/overlays/DraggablePane.svelte +163 -58
  137. package/dist/overlays/DraggablePane.svelte.d.ts +2 -0
  138. package/dist/phase-diagram/IsobaricBinaryPhaseDiagram.svelte +232 -77
  139. package/dist/phase-diagram/IsobaricBinaryPhaseDiagram.svelte.d.ts +6 -2
  140. package/dist/phase-diagram/PhaseDiagramControls.svelte +32 -11
  141. package/dist/phase-diagram/PhaseDiagramControls.svelte.d.ts +3 -2
  142. package/dist/phase-diagram/PhaseDiagramEditorPane.svelte +103 -0
  143. package/dist/phase-diagram/PhaseDiagramEditorPane.svelte.d.ts +15 -0
  144. package/dist/phase-diagram/PhaseDiagramExportPane.svelte +102 -95
  145. package/dist/phase-diagram/PhaseDiagramExportPane.svelte.d.ts +7 -0
  146. package/dist/phase-diagram/PhaseDiagramTooltip.svelte +100 -26
  147. package/dist/phase-diagram/PhaseDiagramTooltip.svelte.d.ts +6 -3
  148. package/dist/phase-diagram/index.d.ts +2 -0
  149. package/dist/phase-diagram/index.js +2 -0
  150. package/dist/phase-diagram/svg-to-diagram.d.ts +2 -0
  151. package/dist/phase-diagram/svg-to-diagram.js +865 -0
  152. package/dist/phase-diagram/types.d.ts +10 -0
  153. package/dist/phase-diagram/utils.d.ts +7 -4
  154. package/dist/phase-diagram/utils.js +149 -59
  155. package/dist/plot/AxisLabel.svelte +26 -0
  156. package/dist/plot/AxisLabel.svelte.d.ts +16 -0
  157. package/dist/plot/BarPlot.svelte +473 -228
  158. package/dist/plot/BarPlot.svelte.d.ts +3 -3
  159. package/dist/plot/BarPlotControls.svelte +3 -2
  160. package/dist/plot/BarPlotControls.svelte.d.ts +1 -1
  161. package/dist/plot/ColorBar.svelte +54 -54
  162. package/dist/plot/ColorBar.svelte.d.ts +1 -1
  163. package/dist/plot/ElementScatter.svelte +4 -3
  164. package/dist/plot/FillArea.svelte +4 -1
  165. package/dist/plot/Histogram.svelte +320 -230
  166. package/dist/plot/Histogram.svelte.d.ts +2 -2
  167. package/dist/plot/HistogramControls.svelte +29 -10
  168. package/dist/plot/HistogramControls.svelte.d.ts +6 -2
  169. package/dist/plot/InteractiveAxisLabel.svelte.d.ts +2 -2
  170. package/dist/plot/PlotControls.svelte +109 -27
  171. package/dist/plot/PlotControls.svelte.d.ts +1 -1
  172. package/dist/plot/PlotLegend.svelte +1 -1
  173. package/dist/plot/PortalSelect.svelte +2 -1
  174. package/dist/plot/ReferenceLine.svelte +2 -1
  175. package/dist/plot/ReferenceLine.svelte.d.ts +1 -0
  176. package/dist/plot/ReferencePlane.svelte +1 -3
  177. package/dist/plot/ScatterPlot.svelte +343 -209
  178. package/dist/plot/ScatterPlot.svelte.d.ts +3 -3
  179. package/dist/plot/ScatterPlot3D.svelte.d.ts +2 -2
  180. package/dist/plot/ScatterPlot3DControls.svelte +203 -250
  181. package/dist/plot/ScatterPlot3DScene.svelte +4 -7
  182. package/dist/plot/ScatterPlot3DScene.svelte.d.ts +2 -2
  183. package/dist/plot/ScatterPlotControls.svelte +95 -55
  184. package/dist/plot/ScatterPlotControls.svelte.d.ts +1 -1
  185. package/dist/plot/ZeroLines.svelte +44 -0
  186. package/dist/plot/ZeroLines.svelte.d.ts +32 -0
  187. package/dist/plot/ZoomRect.svelte +21 -0
  188. package/dist/plot/ZoomRect.svelte.d.ts +8 -0
  189. package/dist/plot/axis-utils.d.ts +1 -1
  190. package/dist/plot/data-cleaning.js +1 -5
  191. package/dist/plot/index.d.ts +6 -2
  192. package/dist/plot/index.js +6 -2
  193. package/dist/plot/interactions.d.ts +8 -10
  194. package/dist/plot/interactions.js +10 -19
  195. package/dist/plot/layout.d.ts +7 -1
  196. package/dist/plot/layout.js +12 -4
  197. package/dist/plot/reference-line.d.ts +4 -21
  198. package/dist/plot/reference-line.js +7 -81
  199. package/dist/plot/types.d.ts +42 -17
  200. package/dist/plot/types.js +10 -0
  201. package/dist/plot/utils/label-placement.js +14 -11
  202. package/dist/plot/utils.d.ts +1 -0
  203. package/dist/plot/utils.js +14 -0
  204. package/dist/rdf/RdfPlot.svelte +55 -66
  205. package/dist/rdf/RdfPlot.svelte.d.ts +1 -1
  206. package/dist/rdf/index.d.ts +1 -1
  207. package/dist/rdf/index.js +1 -1
  208. package/dist/settings.d.ts +5 -0
  209. package/dist/settings.js +37 -3
  210. package/dist/spectral/Bands.svelte +515 -143
  211. package/dist/spectral/Bands.svelte.d.ts +22 -2
  212. package/dist/spectral/helpers.d.ts +23 -1
  213. package/dist/spectral/helpers.js +65 -9
  214. package/dist/spectral/types.d.ts +2 -0
  215. package/dist/structure/AtomLegend.svelte +31 -10
  216. package/dist/structure/AtomLegend.svelte.d.ts +1 -1
  217. package/dist/structure/CellSelect.svelte +92 -22
  218. package/dist/structure/Lattice.svelte +2 -0
  219. package/dist/structure/Structure.svelte +716 -173
  220. package/dist/structure/Structure.svelte.d.ts +7 -2
  221. package/dist/structure/StructureControls.svelte +26 -14
  222. package/dist/structure/StructureControls.svelte.d.ts +5 -1
  223. package/dist/structure/StructureInfoPane.svelte +7 -1
  224. package/dist/structure/StructureScene.svelte +386 -95
  225. package/dist/structure/StructureScene.svelte.d.ts +15 -4
  226. package/dist/structure/atom-properties.d.ts +6 -2
  227. package/dist/structure/atom-properties.js +38 -25
  228. package/dist/structure/export.js +10 -7
  229. package/dist/structure/ferrox-wasm-types.d.ts +3 -2
  230. package/dist/structure/ferrox-wasm-types.js +0 -3
  231. package/dist/structure/ferrox-wasm.d.ts +3 -2
  232. package/dist/structure/ferrox-wasm.js +1 -2
  233. package/dist/structure/index.d.ts +7 -0
  234. package/dist/structure/index.js +22 -0
  235. package/dist/structure/parse.js +19 -16
  236. package/dist/structure/partial-occupancy.d.ts +25 -0
  237. package/dist/structure/partial-occupancy.js +102 -0
  238. package/dist/structure/validation.js +6 -3
  239. package/dist/symmetry/SymmetryStats.svelte +18 -4
  240. package/dist/symmetry/WyckoffTable.svelte +18 -10
  241. package/dist/symmetry/index.d.ts +7 -4
  242. package/dist/symmetry/index.js +83 -18
  243. package/dist/table/HeatmapTable.svelte +468 -69
  244. package/dist/table/HeatmapTable.svelte.d.ts +13 -1
  245. package/dist/table/ToggleMenu.svelte +291 -44
  246. package/dist/table/ToggleMenu.svelte.d.ts +4 -1
  247. package/dist/table/index.d.ts +3 -0
  248. package/dist/tooltip/index.d.ts +1 -1
  249. package/dist/tooltip/index.js +1 -0
  250. package/dist/trajectory/Trajectory.svelte +147 -145
  251. package/dist/trajectory/TrajectoryExportPane.svelte +13 -9
  252. package/dist/trajectory/TrajectoryExportPane.svelte.d.ts +1 -1
  253. package/dist/trajectory/constants.d.ts +6 -0
  254. package/dist/trajectory/constants.js +7 -0
  255. package/dist/trajectory/extract.js +3 -5
  256. package/dist/trajectory/format-detect.d.ts +9 -0
  257. package/dist/trajectory/format-detect.js +76 -0
  258. package/dist/trajectory/frame-reader.d.ts +17 -0
  259. package/dist/trajectory/frame-reader.js +339 -0
  260. package/dist/trajectory/helpers.d.ts +15 -0
  261. package/dist/trajectory/helpers.js +187 -0
  262. package/dist/trajectory/index.d.ts +1 -0
  263. package/dist/trajectory/index.js +11 -4
  264. package/dist/trajectory/parse/ase.d.ts +2 -0
  265. package/dist/trajectory/parse/ase.js +76 -0
  266. package/dist/trajectory/parse/hdf5.d.ts +2 -0
  267. package/dist/trajectory/parse/hdf5.js +121 -0
  268. package/dist/trajectory/parse/index.d.ts +12 -0
  269. package/dist/trajectory/parse/index.js +304 -0
  270. package/dist/trajectory/parse/lammps.d.ts +5 -0
  271. package/dist/trajectory/parse/lammps.js +169 -0
  272. package/dist/trajectory/parse/vasp.d.ts +2 -0
  273. package/dist/trajectory/parse/vasp.js +65 -0
  274. package/dist/trajectory/parse/xyz.d.ts +2 -0
  275. package/dist/trajectory/parse/xyz.js +109 -0
  276. package/dist/trajectory/types.d.ts +11 -0
  277. package/dist/trajectory/types.js +1 -0
  278. package/dist/utils.d.ts +2 -0
  279. package/dist/utils.js +4 -0
  280. package/dist/xrd/XrdPlot.svelte +6 -4
  281. package/dist/xrd/calc-xrd.js +0 -1
  282. package/package.json +33 -23
  283. package/readme.md +4 -4
  284. package/dist/trajectory/parse.d.ts +0 -42
  285. package/dist/trajectory/parse.js +0 -1267
  286. /package/dist/element/{data.json.d.ts → data.json.gz.d.ts} +0 -0
@@ -1,40 +1,195 @@
1
1
  <script lang="ts">import Icon from '../Icon.svelte';
2
+ import { get_alphabetical_formula } from './format';
3
+ import { ELEM_SYMBOLS } from '../labels';
2
4
  import { tooltip } from 'svelte-multiselect';
3
- import { extract_formula_elements, has_wildcards, normalize_element_symbols, parse_formula_with_wildcards, } from './parse';
4
- const SEARCH_EXAMPLES = [
5
+ import { extract_formula_elements, has_wildcards, normalize_element_symbols, parse_formula, parse_formula_with_wildcards, } from './parse';
6
+ const DEFAULT_SEARCH_EXAMPLES = [
5
7
  {
6
- label: `Contains elements`,
7
- description: `Materials containing at least these elements (may have others). Use * for any element.`,
8
- examples: [`Li,Fe`, `Si,O`, `Li,*,*`],
8
+ label: `Has elements`,
9
+ description: `Materials containing these elements. Operators/ranges: +Li,-O,Fe:1-2. Use * for any element.`,
10
+ examples: [`Li,Fe`, `+Li,-O`, `Li,*,*`],
9
11
  },
10
12
  {
11
13
  label: `Chemical system`,
12
- description: `Materials with only these elements (no others). Use * for any element.`,
14
+ description: `Materials with only these elements (no others). Wildcards/ranges supported.`,
13
15
  examples: [`Li-Fe-O`, `Li-Fe-*-*`, `*-*-O`],
14
16
  },
15
17
  {
16
18
  label: `Exact formula`,
17
- description: `Materials with this exact stoichiometry. Use * for any element.`,
19
+ description: `Materials with this exact stoichiometry. Unicode paste, wildcards, and canonicalization supported.`,
18
20
  examples: [`LiFePO4`, `LiFe*2*`, `*2O3`],
19
21
  },
20
22
  ];
21
- let { value = $bindable(``), search_mode = $bindable(`elements`), input_element = $bindable(null), show_clear_button = true, show_examples = true, disabled = false, onchange, onclear, ...rest } = $props();
23
+ const SUBSCRIPT_TO_ASCII = {
24
+ [`\u2080`]: `0`,
25
+ [`\u2081`]: `1`,
26
+ [`\u2082`]: `2`,
27
+ [`\u2083`]: `3`,
28
+ [`\u2084`]: `4`,
29
+ [`\u2085`]: `5`,
30
+ [`\u2086`]: `6`,
31
+ [`\u2087`]: `7`,
32
+ [`\u2088`]: `8`,
33
+ [`\u2089`]: `9`,
34
+ };
35
+ const SUPERSCRIPT_TO_ASCII = {
36
+ [`\u2070`]: `0`,
37
+ [`\u00B9`]: `1`,
38
+ [`\u00B2`]: `2`,
39
+ [`\u00B3`]: `3`,
40
+ [`\u2074`]: `4`,
41
+ [`\u2075`]: `5`,
42
+ [`\u2076`]: `6`,
43
+ [`\u2077`]: `7`,
44
+ [`\u2078`]: `8`,
45
+ [`\u2079`]: `9`,
46
+ [`\u207A`]: `+`,
47
+ [`\u207B`]: `-`,
48
+ };
49
+ let { value = $bindable(``), search_mode = $bindable(`elements`), input_element = $bindable(null), show_clear_button = true, show_examples = true, show_mode_lock = true, show_chip_editor = true, normalize_exact = true, examples = DEFAULT_SEARCH_EXAMPLES, disabled = false, mode_locked = $bindable(false), max_history = 5, // Max recent inputs to remember; 0 disables history dropdown
50
+ history_key = `formula-filter-history`, // localStorage key for persisting history
51
+ validate, onparse, on_validation, onchange, onclear, ...rest } = $props();
22
52
  let input_value = $state(value);
23
53
  let examples_open = $state(false);
54
+ let history_open = $state(false);
24
55
  let wrapper = $state(null);
25
56
  let examples_wrapper = $state(null);
26
57
  let focused_item_idx = $state(-1);
58
+ let focused_history_idx = $state(-1);
27
59
  let anchor_left = $state(false);
60
+ let history_query = $state(``);
61
+ let validation = $state({ state: `valid`, message: null });
28
62
  // Flatten examples for keyboard navigation
29
- const all_examples = SEARCH_EXAMPLES.flatMap((cat) => cat.examples);
63
+ let all_examples = $derived(examples.flatMap((cat) => cat.examples));
64
+ // === History Management ===
65
+ const has_storage = typeof localStorage !== `undefined`;
66
+ const history_pins_key = $derived(`${history_key}-pins`);
67
+ function load_history() {
68
+ if (max_history <= 0 || !has_storage)
69
+ return [];
70
+ try {
71
+ const raw = localStorage.getItem(history_key);
72
+ if (!raw)
73
+ return [];
74
+ const parsed = JSON.parse(raw);
75
+ if (!Array.isArray(parsed))
76
+ return [];
77
+ return parsed.filter((item) => typeof item === `string`).slice(0, max_history);
78
+ }
79
+ catch {
80
+ return [];
81
+ }
82
+ }
83
+ function save_history(entries) {
84
+ if (max_history <= 0 || !has_storage)
85
+ return;
86
+ try {
87
+ localStorage.setItem(history_key, JSON.stringify(entries.slice(0, max_history)));
88
+ }
89
+ catch {
90
+ // localStorage may be unavailable (e.g. private browsing)
91
+ }
92
+ }
93
+ function load_pinned() {
94
+ if (max_history <= 0 || !has_storage)
95
+ return [];
96
+ try {
97
+ const raw = localStorage.getItem(history_pins_key);
98
+ if (!raw)
99
+ return [];
100
+ const parsed = JSON.parse(raw);
101
+ if (!Array.isArray(parsed))
102
+ return [];
103
+ return parsed.filter((item) => typeof item === `string`);
104
+ }
105
+ catch {
106
+ return [];
107
+ }
108
+ }
109
+ function save_pinned(entries) {
110
+ if (max_history <= 0 || !has_storage)
111
+ return;
112
+ try {
113
+ localStorage.setItem(history_pins_key, JSON.stringify(entries));
114
+ }
115
+ catch {
116
+ // localStorage may be unavailable
117
+ }
118
+ }
119
+ let history = $state(load_history());
120
+ let pinned_history = $state(load_pinned());
121
+ function add_to_history(entry) {
122
+ if (max_history <= 0 || !entry.trim())
123
+ return;
124
+ // Remove duplicate if present, then prepend
125
+ const filtered = history.filter((item) => item !== entry);
126
+ history = [entry, ...filtered].slice(0, max_history);
127
+ // Keep pin state for retained entries only
128
+ pinned_history = pinned_history.filter((item) => history.includes(item));
129
+ save_history(history);
130
+ save_pinned(pinned_history);
131
+ }
132
+ function remove_from_history(entry) {
133
+ history = history.filter((item) => item !== entry);
134
+ pinned_history = pinned_history.filter((item) => item !== entry);
135
+ save_history(history);
136
+ save_pinned(pinned_history);
137
+ // Clamp focused index to prevent out-of-bounds access on Enter
138
+ if (history.length === 0)
139
+ history_open = false;
140
+ else if (focused_history_idx >= visible_history.length) {
141
+ focused_history_idx = visible_history.length - 1;
142
+ }
143
+ }
144
+ function toggle_pin_history(entry) {
145
+ pinned_history = pinned_history.includes(entry)
146
+ ? pinned_history.filter((item) => item !== entry)
147
+ : [entry, ...pinned_history.filter((item) => item !== entry)];
148
+ save_pinned(pinned_history);
149
+ }
150
+ function clear_history() {
151
+ history = [];
152
+ pinned_history = [];
153
+ save_history(history);
154
+ save_pinned(pinned_history);
155
+ close_history();
156
+ }
157
+ function is_pinned(entry) {
158
+ return pinned_history.includes(entry);
159
+ }
160
+ // Filtered history: exclude current value to avoid redundant suggestion
161
+ let visible_history = $derived.by(() => {
162
+ const filtered = history
163
+ .filter((item) => item !== value)
164
+ .filter((item) => item.toLowerCase().includes(history_query.toLowerCase().trim()));
165
+ const pinned = filtered.filter((item) => pinned_history.includes(item));
166
+ const unpinned = filtered.filter((item) => !pinned_history.includes(item));
167
+ return [...pinned, ...unpinned];
168
+ });
169
+ function close_history() {
170
+ history_open = false;
171
+ history_query = ``;
172
+ focused_history_idx = -1;
173
+ }
174
+ function open_history() {
175
+ if (max_history <= 0 || visible_history.length === 0 || examples_open)
176
+ return;
177
+ history_open = true;
178
+ history_query = ``;
179
+ focused_history_idx = -1;
180
+ }
30
181
  function handle_document_click(event) {
31
- if (!wrapper || !examples_open)
182
+ if (!wrapper || (!examples_open && !history_open))
32
183
  return;
33
184
  const target = event.target;
34
185
  if (!(target instanceof Node))
35
186
  return;
36
- if (!wrapper.contains(target))
37
- close_examples();
187
+ if (!wrapper.contains(target)) {
188
+ if (examples_open)
189
+ close_examples();
190
+ if (history_open)
191
+ close_history();
192
+ }
38
193
  }
39
194
  function close_examples(restore_focus = true) {
40
195
  examples_open = false;
@@ -46,14 +201,15 @@ function close_examples(restore_focus = true) {
46
201
  // and re-infer mode accordingly. Without this, mode would only be set on first render.
47
202
  let last_synced = $state(null);
48
203
  $effect(() => {
49
- input_value = value;
50
204
  if (value !== last_synced) {
51
205
  last_synced = value;
52
- if (value) {
206
+ input_value = value;
207
+ if (value && !mode_locked) {
53
208
  const inferred = infer_mode(value);
54
209
  if (inferred !== search_mode)
55
210
  search_mode = inferred;
56
211
  }
212
+ run_validation(value, search_mode);
57
213
  }
58
214
  });
59
215
  // Detect if dropdown would exit viewport on the right and adjust anchor
@@ -74,14 +230,210 @@ function infer_mode(input) {
74
230
  const trimmed = input.trim();
75
231
  if (!trimmed)
76
232
  return `elements`;
233
+ if (/^[+\-!]\s*\w/.test(trimmed))
234
+ return `elements`;
235
+ if (trimmed.includes(`+`) || trimmed.includes(`!`))
236
+ return `elements`;
237
+ if (trimmed.includes(`:`))
238
+ return trimmed.includes(`-`) ? `chemsys` : `elements`;
77
239
  if (trimmed.includes(`,`))
78
- return `elements`; // Li,Fe,O → contains elements
240
+ return `elements`; // Li,Fe,O → has elements
79
241
  if (trimmed.includes(`-`))
80
242
  return `chemsys`; // Li-Fe-O → chemical system
81
243
  return `exact`; // LiFePO4 → exact formula
82
244
  }
83
245
  // Cycle through modes: elements → chemsys → exact → elements
84
246
  const MODE_CYCLE = [`elements`, `chemsys`, `exact`];
247
+ function normalize_unicode_formula(input) {
248
+ let normalized = input;
249
+ for (const [subscript, ascii] of Object.entries(SUBSCRIPT_TO_ASCII)) {
250
+ normalized = normalized.replaceAll(subscript, ascii);
251
+ }
252
+ for (const [superscript, ascii] of Object.entries(SUPERSCRIPT_TO_ASCII)) {
253
+ normalized = normalized.replaceAll(superscript, ascii);
254
+ }
255
+ return normalized
256
+ .replaceAll(`·`, ``)
257
+ .replaceAll(`⋅`, ``)
258
+ .replaceAll(`−`, `-`)
259
+ .replace(/\s+/g, ``);
260
+ }
261
+ function normalize_exact_formula(input) {
262
+ const sanitized_input = normalize_unicode_formula(input.trim());
263
+ if (!sanitize_exact_formula(sanitized_input).is_valid)
264
+ return sanitized_input;
265
+ if (!has_wildcards(sanitized_input)) {
266
+ const canonical = get_alphabetical_formula(sanitized_input, true, ``);
267
+ return canonical || sanitized_input;
268
+ }
269
+ try {
270
+ const tokens = parse_formula_with_wildcards(sanitized_input);
271
+ const explicit = tokens
272
+ .filter((token) => token.element !== null)
273
+ .map((token) => ({ element: token.element, count: token.count }));
274
+ const wildcard_tokens = tokens.filter((token) => token.element === null);
275
+ // Merge explicit element counts before sorting.
276
+ const merged_explicit = [];
277
+ for (const token of explicit) {
278
+ const existing = merged_explicit.find((item) => item.element === token.element);
279
+ if (existing)
280
+ existing.count += token.count;
281
+ else
282
+ merged_explicit.push(token);
283
+ }
284
+ const sorted_explicit = merged_explicit.sort((elem_a, elem_b) => elem_a.element.localeCompare(elem_b.element));
285
+ const wildcard_str = wildcard_tokens.map((token) => token.count > 1 ? `*${token.count}` : `*`).join(``);
286
+ const explicit_str = sorted_explicit.map((token) => token.count > 1 ? `${token.element}${token.count}` : token.element).join(``);
287
+ return `${explicit_str}${wildcard_str}`;
288
+ }
289
+ catch {
290
+ return sanitized_input;
291
+ }
292
+ }
293
+ function is_valid_constraint(constraint) {
294
+ if (!constraint)
295
+ return true;
296
+ return /^\d+$/.test(constraint) || /^\d+-\d+$/.test(constraint) ||
297
+ /^(>=|<=|>|<)\d+$/.test(constraint);
298
+ }
299
+ function strip_operator_prefix(token) {
300
+ const operator = token.startsWith(`-`) || token.startsWith(`!`)
301
+ ? `exclude`
302
+ : `include`;
303
+ const value = token.startsWith(`+`) || token.startsWith(`-`) || token.startsWith(`!`)
304
+ ? token.slice(1)
305
+ : token;
306
+ return { operator, value };
307
+ }
308
+ function serialize_token(token) {
309
+ const prefix = token.operator === `exclude` ? `-` : ``;
310
+ const suffix = token.constraint ? `:${token.constraint}` : ``;
311
+ return `${prefix}${token.element}${suffix}`;
312
+ }
313
+ function token_chip_label(token) {
314
+ const prefix = token.operator === `exclude` ? `-` : `+`;
315
+ const suffix = token.constraint ? `:${token.constraint}` : ``;
316
+ return `${prefix}${token.element}${suffix}`;
317
+ }
318
+ function parse_token(raw_token) {
319
+ const token = raw_token.trim();
320
+ const { operator, value: without_operator } = strip_operator_prefix(token);
321
+ const [element_part, constraint] = without_operator.split(`:`);
322
+ const element = element_part.trim();
323
+ const is_wildcard = element === `*`;
324
+ const is_valid_element = is_wildcard ||
325
+ ELEM_SYMBOLS.includes(element);
326
+ const normalized_constraint = constraint?.trim() || null;
327
+ const is_valid = is_valid_element && (normalized_constraint === null ||
328
+ is_valid_constraint(normalized_constraint));
329
+ return {
330
+ raw: raw_token,
331
+ element,
332
+ operator,
333
+ constraint: normalized_constraint,
334
+ is_wildcard,
335
+ is_valid,
336
+ };
337
+ }
338
+ function tokenize_query(input, mode) {
339
+ const trimmed = input.trim();
340
+ if (!trimmed)
341
+ return [];
342
+ if (mode === `exact`) {
343
+ return [{
344
+ raw: trimmed,
345
+ element: trimmed,
346
+ operator: `include`,
347
+ constraint: null,
348
+ is_wildcard: has_wildcards(trimmed),
349
+ is_valid: sanitize_exact_formula(trimmed).is_valid,
350
+ }];
351
+ }
352
+ const normalized = mode === `chemsys` ? trimmed.replaceAll(`,`, `-`) : trimmed;
353
+ const tokens = mode === `chemsys`
354
+ // Keep range constraints like Fe:1-2 intact while splitting token separators.
355
+ ? normalized.split(/-(?!\d)/)
356
+ : normalized.split(`,`);
357
+ return tokens
358
+ .map((token) => token.trim())
359
+ .filter(Boolean)
360
+ .map(parse_token);
361
+ }
362
+ function sanitize_exact_formula(input) {
363
+ const trimmed = input.trim();
364
+ if (!trimmed)
365
+ return { is_valid: true, error_message: null };
366
+ try {
367
+ if (has_wildcards(trimmed)) {
368
+ parse_formula_with_wildcards(trimmed);
369
+ }
370
+ else {
371
+ parse_formula(trimmed);
372
+ }
373
+ return { is_valid: true, error_message: null };
374
+ }
375
+ catch (error) {
376
+ const message = error instanceof Error ? error.message : `Invalid exact formula`;
377
+ return { is_valid: false, error_message: message };
378
+ }
379
+ }
380
+ function normalize_tokenized_input(input, mode) {
381
+ const separator = mode === `chemsys` ? `-` : `,`;
382
+ const parsed_tokens = tokenize_query(input, mode);
383
+ if (parsed_tokens.length === 0)
384
+ return ``;
385
+ const normalized_tokens = parsed_tokens
386
+ .filter((token) => token.is_valid)
387
+ .map((token) => ({
388
+ ...token,
389
+ element: token.is_wildcard
390
+ ? `*`
391
+ : normalize_element_symbols(token.element).at(0) || token.element,
392
+ }))
393
+ .sort((token_a, token_b) => {
394
+ if (token_a.operator !== token_b.operator) {
395
+ return token_a.operator === `include` ? -1 : 1;
396
+ }
397
+ if (token_a.is_wildcard !== token_b.is_wildcard) {
398
+ return token_a.is_wildcard ? 1 : -1;
399
+ }
400
+ return token_a.element.localeCompare(token_b.element);
401
+ });
402
+ return normalized_tokens
403
+ .map(serialize_token)
404
+ .join(separator);
405
+ }
406
+ function parse_query(normalized_value, mode) {
407
+ const tokens = tokenize_query(normalized_value, mode);
408
+ const first_invalid_token = tokens.find((token) => !token.is_valid);
409
+ const exact_validation = mode === `exact`
410
+ ? sanitize_exact_formula(normalized_value)
411
+ : {
412
+ is_valid: !first_invalid_token,
413
+ error_message: first_invalid_token
414
+ ? `Invalid token: ${first_invalid_token.raw}`
415
+ : null,
416
+ };
417
+ return {
418
+ value: normalized_value,
419
+ normalized_value,
420
+ search_mode: mode,
421
+ tokens,
422
+ has_wildcards: tokens.some((token) => token.is_wildcard),
423
+ is_valid: exact_validation.is_valid,
424
+ error_message: exact_validation.error_message,
425
+ };
426
+ }
427
+ function run_validation(next_value, next_mode) {
428
+ const parsed = parse_query(next_value, next_mode);
429
+ onparse?.(parsed);
430
+ const default_validation = parsed.is_valid
431
+ ? { state: `valid`, message: null }
432
+ : { state: `invalid`, message: parsed.error_message ?? `Invalid filter query` };
433
+ const custom_validation = validate?.(next_value, next_mode, parsed);
434
+ validation = custom_validation ?? default_validation;
435
+ on_validation?.(validation);
436
+ }
85
437
  // Extract elements from any input format (formula, comma-separated, dash-separated)
86
438
  // Always returns elements in alphabetical order for consistency, preserving wildcards (*)
87
439
  function extract_elements(input) {
@@ -102,9 +454,13 @@ function extract_elements(input) {
102
454
  // For formulas with wildcards, we can't parse them normally
103
455
  if (has_wildcards(trimmed)) { // Use shared utility and extract unique elements
104
456
  const tokens = parse_formula_with_wildcards(trimmed);
105
- const elements = [
106
- ...new Set(tokens.filter((token) => token.element !== null).map((token) => token.element)),
107
- ].sort();
457
+ const unique_elements = [];
458
+ for (const token of tokens) {
459
+ if (token.element !== null && !unique_elements.includes(token.element)) {
460
+ unique_elements.push(token.element);
461
+ }
462
+ }
463
+ const elements = unique_elements.sort();
108
464
  const wildcards = tokens.filter((token) => token.element === null).map(() => `*`);
109
465
  return [...elements, ...wildcards];
110
466
  }
@@ -127,6 +483,8 @@ function format_for_mode(elements, mode) {
127
483
  return elements.join(``);
128
484
  }
129
485
  function cycle_mode() {
486
+ if (mode_locked)
487
+ return;
130
488
  const current_idx = MODE_CYCLE.indexOf(search_mode);
131
489
  const next_idx = (current_idx + 1) % MODE_CYCLE.length;
132
490
  const next_mode = MODE_CYCLE[next_idx];
@@ -135,57 +493,89 @@ function cycle_mode() {
135
493
  const reformatted = format_for_mode(elements, next_mode);
136
494
  search_mode = next_mode;
137
495
  last_synced = value = input_value = reformatted; // update last_synced to prevent effect re-inference
496
+ run_validation(reformatted, next_mode);
138
497
  onchange?.(reformatted, next_mode);
139
498
  }
140
- function set_value(new_value) {
141
- const mode = infer_mode(new_value);
499
+ function set_value(new_value, forced_mode) {
500
+ const mode = forced_mode ?? (mode_locked ? search_mode : infer_mode(new_value));
142
501
  last_synced = value = input_value = new_value; // update last_synced to prevent effect re-inference
143
502
  search_mode = mode;
503
+ if (new_value.trim())
504
+ add_to_history(new_value);
505
+ close_history();
506
+ run_validation(value, mode);
144
507
  onchange?.(value, mode);
145
508
  }
146
509
  function sync_value() {
147
- const trimmed = input_value.trim();
510
+ const trimmed = normalize_unicode_formula(input_value).trim();
148
511
  if (!trimmed)
149
512
  return set_value(``);
150
- const mode = infer_mode(trimmed);
151
- if (mode === `exact`)
152
- return set_value(trimmed);
153
- // Normalize element symbols for elements/chemsys modes, preserving wildcards
154
- const separator = mode === `chemsys` ? `-` : `,`;
155
- const parts = trimmed.replace(/[-,]/g, `,`).split(`,`).map((str) => str.trim())
156
- .filter(Boolean);
157
- // Separate wildcards from regular elements
158
- const wildcards = parts.filter((part) => part === `*`);
159
- const regular_parts = parts.filter((part) => part !== `*`);
160
- // Normalize regular elements, sort alphabetically, and append wildcards
161
- const normalized = [
162
- ...normalize_element_symbols(regular_parts.join(`,`)).sort(),
163
- ...wildcards,
164
- ];
165
- set_value(normalized.join(separator));
513
+ const mode = mode_locked ? search_mode : infer_mode(trimmed);
514
+ if (mode === `exact`) {
515
+ const exact_value = normalize_exact ? normalize_exact_formula(trimmed) : trimmed;
516
+ return set_value(exact_value, mode);
517
+ }
518
+ const parsed = parse_query(trimmed, mode);
519
+ if (!parsed.is_valid) {
520
+ // Preserve user input on invalid tokens instead of silently dropping them.
521
+ input_value = trimmed;
522
+ run_validation(trimmed, mode);
523
+ return;
524
+ }
525
+ const normalized = normalize_tokenized_input(trimmed, mode);
526
+ set_value(normalized, mode);
166
527
  }
167
528
  function onkeydown(event) {
168
529
  if (event.key === `Enter`) {
169
530
  event.preventDefault();
170
- sync_value();
531
+ if (history_open && focused_history_idx >= 0) {
532
+ set_value(visible_history[focused_history_idx]);
533
+ }
534
+ else {
535
+ sync_value();
536
+ }
171
537
  }
172
538
  else if (event.key === `Escape`) {
173
- if (examples_open)
539
+ if (history_open)
540
+ close_history();
541
+ else if (examples_open)
174
542
  examples_open = false;
175
543
  else if (input_value)
176
544
  clear_filter();
177
545
  }
546
+ else if (history_open && visible_history.length > 0) {
547
+ const len = visible_history.length;
548
+ if (event.key === `ArrowDown`) {
549
+ event.preventDefault();
550
+ focused_history_idx = (focused_history_idx + 1) % len;
551
+ }
552
+ else if (event.key === `ArrowUp`) {
553
+ event.preventDefault();
554
+ focused_history_idx = focused_history_idx <= 0
555
+ ? len - 1
556
+ : focused_history_idx - 1;
557
+ }
558
+ }
559
+ }
560
+ function oninput() {
561
+ if (history_open) {
562
+ history_query = input_value;
563
+ focused_history_idx = visible_history.length > 0 ? 0 : -1;
564
+ }
565
+ const mode = mode_locked ? search_mode : infer_mode(input_value);
566
+ run_validation(input_value, mode);
178
567
  }
179
568
  function clear_filter() {
180
569
  onclear?.();
181
570
  set_value(``);
182
571
  }
183
572
  function apply_example(example) {
184
- set_value(example);
573
+ set_value(example, mode_locked ? search_mode : infer_mode(example));
185
574
  close_examples();
186
575
  }
187
576
  function toggle_examples(event) {
188
577
  event.stopPropagation();
578
+ close_history();
189
579
  examples_open = !examples_open;
190
580
  focused_item_idx = examples_open ? 0 : -1;
191
581
  if (examples_open)
@@ -211,6 +601,19 @@ function handle_menu_keydown(event) {
211
601
  key_actions[event.key]();
212
602
  }
213
603
  }
604
+ function toggle_mode_lock() {
605
+ mode_locked = !mode_locked;
606
+ }
607
+ function remove_token(token_idx) {
608
+ if (search_mode === `exact`)
609
+ return;
610
+ const separator = search_mode === `chemsys` ? `-` : `,`;
611
+ const tokens = tokenize_query(input_value, search_mode)
612
+ .filter((_, idx) => idx !== token_idx);
613
+ const next_value = tokens.map(serialize_token).join(separator);
614
+ input_value = next_value;
615
+ set_value(next_value, search_mode);
616
+ }
214
617
  // Focus the active menu item when index changes
215
618
  $effect(() => {
216
619
  if (!examples_open || focused_item_idx < 0)
@@ -224,11 +627,13 @@ let placeholder = $derived(search_mode === `chemsys`
224
627
  ? `LiFePO4 or LiFe*2*`
225
628
  : `Li,Fe,O or Li,*,*`);
226
629
  const MODE_LABELS = {
227
- elements: `contains elements`,
630
+ elements: `has elements`,
228
631
  chemsys: `chemical system`,
229
632
  exact: `exact formula`,
230
633
  };
231
634
  let mode_hint = $derived(MODE_LABELS[search_mode]);
635
+ let parsed_tokens = $derived(tokenize_query(input_value, search_mode));
636
+ let show_chip_row = $derived(show_chip_editor && search_mode !== `exact` && parsed_tokens.length > 0);
232
637
  // Preview of next mode cycle step for tooltip
233
638
  let next_mode = $derived.by(() => {
234
639
  const next = MODE_CYCLE[(MODE_CYCLE.indexOf(search_mode) + 1) % MODE_CYCLE.length];
@@ -240,34 +645,133 @@ let next_mode = $derived.by(() => {
240
645
 
241
646
  <svelte:document onclick={handle_document_click} />
242
647
 
243
- <div class="formula-filter" bind:this={wrapper} class:disabled {...rest}>
648
+ <div
649
+ class="formula-filter"
650
+ bind:this={wrapper}
651
+ class:disabled
652
+ class:invalid={validation.state === `invalid`}
653
+ class:warning={validation.state === `warning`}
654
+ {...rest}
655
+ >
244
656
  <input
245
657
  bind:this={input_element}
246
658
  bind:value={input_value}
247
- onblur={sync_value}
659
+ onblur={() => {
660
+ // mousedown preventDefault on history items prevents blur, so this only
661
+ // fires when focus genuinely leaves (tab out, click outside, etc.)
662
+ // sync_value → set_value → close_history, so no separate close needed
663
+ sync_value()
664
+ }}
665
+ onfocus={open_history}
666
+ {oninput}
667
+ onpaste={() => {
668
+ requestAnimationFrame(() => {
669
+ input_value = normalize_unicode_formula(input_value)
670
+ oninput()
671
+ })
672
+ }}
248
673
  {onkeydown}
249
674
  {placeholder}
250
675
  {disabled}
251
676
  aria-label="Formula filter"
252
677
  />
678
+ {#if history_open && visible_history.length > 0}
679
+ <div class="history-dropdown" role="listbox" aria-label="Recent searches">
680
+ <div class="history-header-row">
681
+ <span class="history-header">Recent</span>
682
+ <button
683
+ type="button"
684
+ class="history-clear-all"
685
+ title="Clear history"
686
+ aria-label="Clear all history"
687
+ onmousedown={(event) => {
688
+ event.preventDefault()
689
+ clear_history()
690
+ }}
691
+ >
692
+ Clear
693
+ </button>
694
+ </div>
695
+ {#each visible_history as entry, idx (entry)}
696
+ <div class="history-item" class:focused={idx === focused_history_idx}>
697
+ <button
698
+ type="button"
699
+ class="history-value"
700
+ role="option"
701
+ aria-selected={idx === focused_history_idx}
702
+ onmousedown={(event) => {
703
+ event.preventDefault()
704
+ set_value(entry)
705
+ }}
706
+ >
707
+ {entry}
708
+ </button>
709
+ <button
710
+ type="button"
711
+ class="history-pin"
712
+ title={is_pinned(entry) ? `Unpin entry` : `Pin entry`}
713
+ aria-label={is_pinned(entry) ? `Unpin ${entry}` : `Pin ${entry}`}
714
+ onmousedown={(event) => {
715
+ event.preventDefault()
716
+ toggle_pin_history(entry)
717
+ }}
718
+ >
719
+ <Icon
720
+ icon={is_pinned(entry) ? `Star` : `Circle`}
721
+ style="width: 0.8em; height: 0.8em"
722
+ />
723
+ </button>
724
+ <button
725
+ type="button"
726
+ class="history-remove"
727
+ title="Remove from history"
728
+ aria-label="Remove {entry} from history"
729
+ onmousedown={(event) => {
730
+ event.preventDefault()
731
+ remove_from_history(entry)
732
+ }}
733
+ >
734
+ <Icon icon="Close" style="width: 0.7em; height: 0.7em" />
735
+ </button>
736
+ </div>
737
+ {/each}
738
+ </div>
739
+ {/if}
253
740
  {#if input_value}
254
741
  <button
255
742
  type="button"
256
743
  class="mode-hint clickable"
744
+ class:locked={mode_locked}
257
745
  onclick={cycle_mode}
258
- title="Click to switch to '{next_mode.mode}' → {next_mode.value}"
259
- {@attach tooltip({ style: `font-size: 0.6em; padding: 1pt 5pt;` })}
746
+ title={mode_locked
747
+ ? `Mode is locked`
748
+ : `Click to switch to '${next_mode.mode}' → ${next_mode.value}`}
749
+ {@attach tooltip()}
260
750
  aria-label="Change search mode"
261
751
  >
262
752
  {mode_hint}
263
753
  </button>
264
754
  {/if}
755
+ {#if show_mode_lock && !disabled}
756
+ <button
757
+ type="button"
758
+ class="icon-btn lock-btn"
759
+ class:active={mode_locked}
760
+ onclick={toggle_mode_lock}
761
+ title={mode_locked ? `Unlock mode inference` : `Lock current mode`}
762
+ {@attach tooltip()}
763
+ aria-label={mode_locked ? `Unlock mode` : `Lock mode`}
764
+ >
765
+ <Icon icon={mode_locked ? `Lock` : `Unlock`} style="width: 1em; height: 1em" />
766
+ </button>
767
+ {/if}
265
768
  {#if show_clear_button && value && !disabled}
266
769
  <button
267
770
  type="button"
268
771
  class="icon-btn clear-btn"
269
772
  onclick={clear_filter}
270
773
  title="Clear (Escape)"
774
+ {@attach tooltip()}
271
775
  aria-label="Clear filter"
272
776
  >
273
777
  <Icon icon="Close" style="width: 1em; height: 1em" />
@@ -295,7 +799,7 @@ let next_mode = $derived.by(() => {
295
799
  tabindex="-1"
296
800
  onkeydown={handle_menu_keydown}
297
801
  >
298
- {#each SEARCH_EXAMPLES as category (category.label)}
802
+ {#each examples as category (category.label)}
299
803
  <div class="example-category">
300
804
  <div class="category-label">{category.label}:</div>
301
805
  <div class="example-tags">
@@ -320,24 +824,58 @@ let next_mode = $derived.by(() => {
320
824
  </div>
321
825
  {/if}
322
826
  </div>
827
+ {#if show_chip_row}
828
+ <div class="token-chip-row">
829
+ {#each parsed_tokens as
830
+ token,
831
+ idx
832
+ (`${token.operator}:${token.element}:${token.constraint ?? ``}:${idx}`)
833
+ }
834
+ <button
835
+ type="button"
836
+ class="token-chip"
837
+ class:exclude={token.operator === `exclude`}
838
+ class:invalid={!token.is_valid}
839
+ onclick={() => remove_token(idx)}
840
+ title="Click to remove token"
841
+ aria-label="Remove token {token.raw}"
842
+ >
843
+ {token_chip_label(token)}
844
+ </button>
845
+ {/each}
846
+ </div>
847
+ {/if}
848
+ {#if validation.message}
849
+ <div class="validation-message" class:invalid={validation.state === `invalid`}>
850
+ {validation.message}
851
+ </div>
852
+ {/if}
323
853
 
324
854
  <style>
325
855
  .formula-filter {
326
856
  position: relative;
327
857
  display: flex;
328
858
  align-items: center;
329
- gap: 6pt;
330
- padding: 4pt 8pt;
331
- border-radius: 6px;
332
- background: var(--filter-bg, rgba(128, 128, 128, 0.05));
859
+ gap: var(--formula-filter-gap, 1pt);
860
+ padding: var(--formula-filter-padding, 4pt 8pt);
861
+ border-radius: var(--formula-filter-border-radius, var(--border-radius, 3pt));
862
+ background: var(--formula-filter-bg, rgba(128, 128, 128, 0.05));
333
863
  transition: background 0.15s;
334
- }
335
- .formula-filter:focus-within {
336
- background: rgba(77, 182, 255, 0.08);
337
- }
338
- .formula-filter.disabled {
339
- opacity: 0.5;
340
- pointer-events: none;
864
+ &.invalid {
865
+ outline: 1px solid rgba(239, 68, 68, 0.65);
866
+ background: rgba(239, 68, 68, 0.08);
867
+ }
868
+ &.warning {
869
+ outline: 1px solid rgba(245, 158, 11, 0.6);
870
+ background: rgba(245, 158, 11, 0.08);
871
+ }
872
+ &:focus-within {
873
+ background: rgba(77, 182, 255, 0.08);
874
+ }
875
+ &.disabled {
876
+ opacity: 0.5;
877
+ pointer-events: none;
878
+ }
341
879
  }
342
880
  input {
343
881
  flex: 1;
@@ -348,33 +886,117 @@ let next_mode = $derived.by(() => {
348
886
  padding: 2pt 0;
349
887
  outline: none;
350
888
  font-family: var(--mono-font, monospace);
351
- }
352
- input::placeholder {
353
- opacity: 0.4;
889
+ &::placeholder {
890
+ opacity: 0.4;
891
+ }
354
892
  }
355
893
  .mode-hint {
356
894
  opacity: 0.5;
357
895
  white-space: nowrap;
896
+ &.clickable {
897
+ display: inline-flex;
898
+ align-items: center;
899
+ gap: 2pt;
900
+ background: rgba(77, 182, 255, 0.1);
901
+ border: 1px solid rgba(77, 182, 255, 0.25);
902
+ border-radius: 4px;
903
+ padding: 1pt 5pt;
904
+ cursor: pointer;
905
+ color: var(--highlight, #4db6ff);
906
+ opacity: 0.8;
907
+ transition: opacity 0.15s, background 0.15s;
908
+ &:hover {
909
+ opacity: 1;
910
+ background: rgba(77, 182, 255, 0.2);
911
+ border-color: rgba(77, 182, 255, 0.4);
912
+ }
913
+ &.locked {
914
+ cursor: not-allowed;
915
+ opacity: 0.5;
916
+ }
917
+ }
358
918
  }
359
- .mode-hint.clickable {
360
- display: inline-flex;
919
+ .icon-btn {
920
+ display: flex;
361
921
  align-items: center;
362
- gap: 2pt;
363
- background: rgba(77, 182, 255, 0.1);
364
- border: 1px solid rgba(77, 182, 255, 0.25);
365
- border-radius: 4px;
366
- padding: 1pt 5pt;
922
+ justify-content: center;
923
+ background: none;
924
+ border: none;
367
925
  cursor: pointer;
368
- color: var(--highlight, #4db6ff);
369
- opacity: 0.8;
370
- transition: opacity 0.15s, background 0.15s;
926
+ padding: 3pt;
927
+ border-radius: 50%;
928
+ color: inherit;
929
+ opacity: 0.4;
930
+ &:hover {
931
+ opacity: 1;
932
+ background: rgba(128, 128, 128, 0.15);
933
+ }
934
+ &.active {
935
+ opacity: 1;
936
+ color: var(--highlight, #4db6ff);
937
+ }
938
+ }
939
+ .history-dropdown {
940
+ position: absolute;
941
+ top: calc(100% + 2pt);
942
+ left: 0;
943
+ right: 0;
944
+ z-index: 101;
945
+ background: var(--dropdown-bg, var(--surface-bg, #fff));
946
+ border: 1px solid var(--dropdown-border, rgba(128, 128, 128, 0.2));
947
+ border-radius: 8px;
948
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
949
+ padding: 4pt 0;
950
+ display: flex;
951
+ flex-direction: column;
371
952
  }
372
- .mode-hint.clickable:hover {
373
- opacity: 1;
374
- background: rgba(77, 182, 255, 0.2);
375
- border-color: rgba(77, 182, 255, 0.4);
953
+ .history-header {
954
+ font-size: 0.7em;
955
+ font-weight: 600;
956
+ opacity: 0.45;
957
+ padding: 2pt 10pt 4pt;
958
+ text-transform: uppercase;
959
+ letter-spacing: 0.5px;
376
960
  }
377
- .icon-btn {
961
+ .history-header-row {
962
+ display: flex;
963
+ align-items: center;
964
+ justify-content: space-between;
965
+ gap: 6pt;
966
+ padding-right: 6pt;
967
+ }
968
+ .history-clear-all {
969
+ border: none;
970
+ background: transparent;
971
+ cursor: pointer;
972
+ font-size: 0.75em;
973
+ opacity: 0.6;
974
+ &:hover {
975
+ opacity: 1;
976
+ }
977
+ }
978
+ .history-item {
979
+ display: flex;
980
+ align-items: center;
981
+ padding: 0 4pt 0 0;
982
+ &:is(.focused, :hover) {
983
+ background: rgba(77, 182, 255, 0.08);
984
+ }
985
+ }
986
+ .history-value {
987
+ flex: 1;
988
+ text-align: left;
989
+ background: none;
990
+ border: none;
991
+ cursor: pointer;
992
+ padding: 4pt 10pt;
993
+ font-family: var(--mono-font, monospace);
994
+ font-size: 0.88em;
995
+ color: inherit;
996
+ }
997
+ .history-remove {
998
+ min-width: 24px;
999
+ min-height: 24px;
378
1000
  display: flex;
379
1001
  align-items: center;
380
1002
  justify-content: center;
@@ -383,16 +1005,28 @@ let next_mode = $derived.by(() => {
383
1005
  cursor: pointer;
384
1006
  padding: 3pt;
385
1007
  border-radius: 50%;
1008
+ opacity: 0.3;
386
1009
  color: inherit;
387
- opacity: 0.4;
388
- }
389
- .icon-btn:hover {
390
- opacity: 1;
391
- background: rgba(128, 128, 128, 0.15);
1010
+ &:hover {
1011
+ opacity: 0.8;
1012
+ background: rgba(128, 128, 128, 0.15);
1013
+ }
392
1014
  }
393
- .icon-btn.active {
394
- opacity: 1;
395
- color: var(--highlight, #4db6ff);
1015
+ .history-pin {
1016
+ display: flex;
1017
+ align-items: center;
1018
+ justify-content: center;
1019
+ background: none;
1020
+ border: none;
1021
+ cursor: pointer;
1022
+ padding: 3pt;
1023
+ border-radius: 50%;
1024
+ opacity: 0.3;
1025
+ color: inherit;
1026
+ &:hover {
1027
+ opacity: 0.8;
1028
+ background: rgba(128, 128, 128, 0.15);
1029
+ }
396
1030
  }
397
1031
  .examples-wrapper {
398
1032
  position: relative;
@@ -412,10 +1046,10 @@ let next_mode = $derived.by(() => {
412
1046
  display: flex;
413
1047
  flex-direction: column;
414
1048
  gap: 6pt;
415
- }
416
- .examples-dropdown.anchor-left {
417
- right: auto;
418
- left: 0;
1049
+ &.anchor-left {
1050
+ right: auto;
1051
+ left: 0;
1052
+ }
419
1053
  }
420
1054
  .example-category {
421
1055
  display: flex;
@@ -443,9 +1077,55 @@ let next_mode = $derived.by(() => {
443
1077
  font-family: var(--mono-font, monospace);
444
1078
  color: var(--highlight, #4db6ff);
445
1079
  cursor: pointer;
1080
+ &:hover {
1081
+ background: rgba(77, 182, 255, 0.2);
1082
+ border-color: rgba(77, 182, 255, 0.5);
1083
+ }
1084
+ }
1085
+ .token-chip-row {
1086
+ margin-top: 4pt;
1087
+ display: flex;
1088
+ flex-wrap: wrap;
1089
+ gap: 4pt;
446
1090
  }
447
- .example-tag:hover {
448
- background: rgba(77, 182, 255, 0.2);
449
- border-color: rgba(77, 182, 255, 0.5);
1091
+ .token-chip {
1092
+ border: 1px solid rgba(77, 182, 255, 0.35);
1093
+ background: rgba(77, 182, 255, 0.12);
1094
+ border-radius: 4px;
1095
+ font-family: var(--mono-font, monospace);
1096
+ font-size: 0.78em;
1097
+ padding: 2pt 6pt;
1098
+ cursor: pointer;
1099
+ color: inherit;
1100
+ &.exclude {
1101
+ border-color: rgba(239, 68, 68, 0.35);
1102
+ background: rgba(239, 68, 68, 0.12);
1103
+ }
1104
+ &.invalid {
1105
+ border-color: rgba(239, 68, 68, 0.65);
1106
+ }
1107
+ }
1108
+ .validation-message {
1109
+ margin-top: 4pt;
1110
+ font-size: 0.74em;
1111
+ opacity: 0.75;
1112
+ &.invalid {
1113
+ color: rgb(239, 68, 68);
1114
+ opacity: 0.95;
1115
+ }
1116
+ }
1117
+ @media (max-width: 700px) {
1118
+ .icon-btn {
1119
+ min-width: 28px;
1120
+ min-height: 28px;
1121
+ padding: 5pt;
1122
+ }
1123
+ :is(.history-remove, .history-pin) {
1124
+ min-width: 28px;
1125
+ min-height: 28px;
1126
+ }
1127
+ .history-value {
1128
+ padding: 6pt 10pt;
1129
+ }
450
1130
  }
451
1131
  </style>